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 } }; } }