188 lines
6.6 KiB
TypeScript
188 lines
6.6 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { CombatAction } from './combat-action.enum';
|
|
import { calculateDamage } from './combat-damage';
|
|
import { Combatant } from './combatant.enum';
|
|
import {
|
|
CombatActionInput,
|
|
CombatEngineCombatant,
|
|
CombatEngineEvent,
|
|
CombatEngineResult,
|
|
CombatEngineState,
|
|
} from './combat-engine.types';
|
|
import { CombatEventType } from './combat-event-type.enum';
|
|
import { CombatStatus } from './combat-status.enum';
|
|
|
|
export class UnsupportedCombatActionError extends Error {
|
|
constructor(action: string) {
|
|
super(`Unsupported combat action: ${action}`);
|
|
}
|
|
}
|
|
|
|
// The monster telegraphs a Heavy Attack instead of striking every third
|
|
// round it acts, then resolves it the round after unless SHIELD_BASH
|
|
// interrupts it. A fixed cadence (not RNG) keeps combat deterministic
|
|
// (Playable Slice 0.6 spec §10/§11).
|
|
const TELEGRAPH_ROUND_INTERVAL = 3;
|
|
const HEAVY_ATTACK_MULTIPLIER = 1.6;
|
|
const SHIELD_BASH_MULTIPLIER = 0.7;
|
|
const DEFEND_MITIGATION_MULTIPLIER = 0.5;
|
|
const POTION_HEAL_FRACTION = 0.35;
|
|
|
|
@Injectable()
|
|
export class CombatEngineService {
|
|
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
|
|
switch (input.action) {
|
|
case CombatAction.ATTACK:
|
|
return this.resolvePlayerStrike(state, 1);
|
|
case CombatAction.HEAVY_STRIKE:
|
|
return this.resolvePlayerStrike(state, HEAVY_ATTACK_MULTIPLIER);
|
|
case CombatAction.SHIELD_BASH:
|
|
return this.resolveShieldBash(state);
|
|
case CombatAction.DEFEND:
|
|
return this.resolveDefend(state);
|
|
case CombatAction.POTION:
|
|
return this.resolvePotion(state);
|
|
default:
|
|
throw new UnsupportedCombatActionError(input.action);
|
|
}
|
|
}
|
|
|
|
private resolvePlayerStrike(state: CombatEngineState, multiplier: number): CombatEngineResult {
|
|
const player = this.cloneCombatant(state.player);
|
|
const monster = this.cloneCombatant(state.monster);
|
|
const events: CombatEngineEvent[] = [];
|
|
|
|
const damage = calculateDamage(player.stats, monster.stats.armor, multiplier);
|
|
monster.currentHp = Math.max(0, monster.currentHp - damage);
|
|
events.push({
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: damage,
|
|
});
|
|
|
|
return this.finishRound(state, player, monster, events, false);
|
|
}
|
|
|
|
private resolveShieldBash(state: CombatEngineState): CombatEngineResult {
|
|
const player = this.cloneCombatant(state.player);
|
|
const monster = this.cloneCombatant(state.monster);
|
|
const events: CombatEngineEvent[] = [];
|
|
|
|
const damage = calculateDamage(player.stats, monster.stats.armor, SHIELD_BASH_MULTIPLIER);
|
|
monster.currentHp = Math.max(0, monster.currentHp - damage);
|
|
events.push({
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: damage,
|
|
});
|
|
|
|
let interrupted = false;
|
|
if (monster.stats.pendingAction) {
|
|
monster.stats.pendingAction = undefined;
|
|
interrupted = true;
|
|
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT });
|
|
}
|
|
|
|
return this.finishRound(state, player, monster, events, false, interrupted);
|
|
}
|
|
|
|
private resolveDefend(state: CombatEngineState): CombatEngineResult {
|
|
const player = this.cloneCombatant(state.player);
|
|
const monster = this.cloneCombatant(state.monster);
|
|
const events: CombatEngineEvent[] = [
|
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
|
|
];
|
|
|
|
return this.finishRound(state, player, monster, events, true);
|
|
}
|
|
|
|
private resolvePotion(state: CombatEngineState): CombatEngineResult {
|
|
const player = this.cloneCombatant(state.player);
|
|
const monster = this.cloneCombatant(state.monster);
|
|
|
|
const rawHeal = Math.round(player.maxHp * POTION_HEAL_FRACTION);
|
|
const healed = Math.min(rawHeal, player.maxHp - player.currentHp);
|
|
player.currentHp += healed;
|
|
player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1;
|
|
|
|
const events: CombatEngineEvent[] = [
|
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.HEAL, amount: healed },
|
|
];
|
|
|
|
return this.finishRound(state, player, monster, events, false);
|
|
}
|
|
|
|
private finishRound(
|
|
state: CombatEngineState,
|
|
player: CombatEngineCombatant,
|
|
monster: CombatEngineCombatant,
|
|
events: CombatEngineEvent[],
|
|
defended: boolean,
|
|
interrupted = false,
|
|
): CombatEngineResult {
|
|
if (monster.currentHp <= 0) {
|
|
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON });
|
|
const defeatedMonster = { ...monster, stats: { ...monster.stats, pendingAction: undefined } };
|
|
return { state: { ...state, player, monster: defeatedMonster, status: CombatStatus.WON }, events };
|
|
}
|
|
|
|
if (!interrupted) {
|
|
this.resolveMonsterTurn(state.round, monster, player, defended, events);
|
|
|
|
if (player.currentHp <= 0) {
|
|
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST });
|
|
return { state: { ...state, player, monster, status: CombatStatus.LOST }, events };
|
|
}
|
|
}
|
|
|
|
return {
|
|
state: { ...state, player, monster, status: CombatStatus.ACTIVE, round: state.round + 1 },
|
|
events,
|
|
};
|
|
}
|
|
|
|
private resolveMonsterTurn(
|
|
round: number,
|
|
monster: CombatEngineCombatant,
|
|
player: CombatEngineCombatant,
|
|
defended: boolean,
|
|
events: CombatEngineEvent[],
|
|
): void {
|
|
const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1;
|
|
|
|
if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
|
|
monster.stats.pendingAction = undefined;
|
|
const damage = calculateDamage(monster.stats, player.stats.armor, HEAVY_ATTACK_MULTIPLIER * defendMultiplier);
|
|
player.currentHp = Math.max(0, player.currentHp - damage);
|
|
events.push({
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: damage,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (round % TELEGRAPH_ROUND_INTERVAL === 0) {
|
|
monster.stats.pendingAction = 'HEAVY_ATTACK';
|
|
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.TELEGRAPH });
|
|
return;
|
|
}
|
|
|
|
const damage = calculateDamage(monster.stats, player.stats.armor, defendMultiplier);
|
|
player.currentHp = Math.max(0, player.currentHp - damage);
|
|
events.push({
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: damage,
|
|
});
|
|
}
|
|
|
|
private cloneCombatant(combatant: CombatEngineCombatant): CombatEngineCombatant {
|
|
return { ...combatant, stats: { ...combatant.stats } };
|
|
}
|
|
}
|