From b3dac8f61cbce4fe90928d29310eae220472910b Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 21:27:12 +0200 Subject: [PATCH] feat(combat): implement HEAVY_STRIKE, SHIELD_BASH, DEFEND, POTION, and monster telegraphing --- .../src/combat/combat-engine.service.spec.ts | 164 ++++++++++++++++- apps/api/src/combat/combat-engine.service.ts | 173 ++++++++++++++---- apps/api/src/combat/combat.service.spec.ts | 2 +- 3 files changed, 302 insertions(+), 37 deletions(-) diff --git a/apps/api/src/combat/combat-engine.service.spec.ts b/apps/api/src/combat/combat-engine.service.spec.ts index b94732d..ecb9091 100644 --- a/apps/api/src/combat/combat-engine.service.spec.ts +++ b/apps/api/src/combat/combat-engine.service.spec.ts @@ -101,7 +101,169 @@ describe('CombatEngineService', () => { it('throws UnsupportedCombatActionError for an action it does not implement', () => { expect(() => - engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }), + engine.resolveAction(baseState(), { action: 'FLEE' as CombatAction }), ).toThrow(UnsupportedCombatActionError); }); + + it('HEAVY_STRIKE deals 160% damage to the monster', () => { + const result = engine.resolveAction(baseState(), { action: CombatAction.HEAVY_STRIKE }); + + // raw = 14; 14 * 1.6 = 22.4 -> rounds to 22 + expect(result.events[0]).toEqual({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: 22, + }); + expect(result.state.monster.currentHp).toBe(45 - 22); + }); + + it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => { + const result = engine.resolveAction(baseState(), { action: CombatAction.SHIELD_BASH }); + + // raw = 14; 14 * 0.7 = 9.8 -> rounds to 10 + expect(result.events[0]).toEqual({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: 10, + }); + expect(result.events.some((event) => event.type === CombatEventType.INTERRUPT)).toBe(false); + }); + + it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => { + const state = baseState({ + monster: { + currentHp: 45, + maxHp: 45, + stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, + }, + }); + + const result = engine.resolveAction(state, { action: CombatAction.SHIELD_BASH }); + + expect(result.events).toEqual([ + { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 10 }, + { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT }, + ]); + expect(result.state.monster.stats.pendingAction).toBeUndefined(); + expect(result.state.player.currentHp).toBe(100); + expect(result.state.round).toBe(2); + }); + + it('DEFEND deals no damage and halves the monster normal attack this round', () => { + const result = engine.resolveAction(baseState(), { action: CombatAction.DEFEND }); + + expect(result.state.monster.currentHp).toBe(45); + // raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2 + expect(result.events).toEqual([ + { source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND }, + { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 2 }, + ]); + expect(result.state.player.currentHp).toBe(98); + }); + + it('POTION heals 35% of max HP, decrements potionsRemaining, and the monster still acts', () => { + const state = baseState({ + player: { + currentHp: 60, + maxHp: 100, + stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 2 }, + }, + }); + + const result = engine.resolveAction(state, { action: CombatAction.POTION }); + + // 35% of 100 = 35 + expect(result.events[0]).toEqual({ + source: Combatant.PLAYER, + target: Combatant.PLAYER, + type: CombatEventType.HEAL, + amount: 35, + }); + expect(result.events[1]).toEqual({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: 5, + }); + // 60 + 35 healed - 5 monster hit = 90 + expect(result.state.player.currentHp).toBe(90); + expect(result.state.player.stats.potionsRemaining).toBe(1); + }); + + it('caps POTION healing at the maximum HP', () => { + const state = baseState({ + player: { + currentHp: 90, + maxHp: 100, + stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 1 }, + }, + }); + + const result = engine.resolveAction(state, { action: CombatAction.POTION }); + + // 35% of 100 = 35, but only 10 HP is missing + expect(result.events[0]).toEqual({ + source: Combatant.PLAYER, + target: Combatant.PLAYER, + type: CombatEventType.HEAL, + amount: 10, + }); + }); + + it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => { + const round3State = baseState({ round: 3 }); + + const telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK }); + + expect(telegraphResult.state.player.currentHp).toBe(100); + expect(telegraphResult.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK'); + expect(telegraphResult.events[1]).toEqual({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.TELEGRAPH, + }); + expect(telegraphResult.state.round).toBe(4); + + const resolveResult = engine.resolveAction(telegraphResult.state, { action: CombatAction.ATTACK }); + + // raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7 + expect(resolveResult.events[1]).toEqual({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: 7, + }); + expect(resolveResult.state.monster.stats.pendingAction).toBeUndefined(); + expect(resolveResult.state.player.currentHp).toBe(100 - 7); + }); + + it('does not resolve a pending Heavy Attack when the monster is killed this round', () => { + const state = baseState({ + monster: { + currentHp: 10, + maxHp: 45, + stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, + }, + }); + + const result = engine.resolveAction(state, { action: CombatAction.ATTACK }); + + expect(result.state.status).toBe(CombatStatus.WON); + expect(result.state.player.currentHp).toBe(100); + expect(result.events).toEqual([ + { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 }, + { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON }, + ]); + }); + + it('is deterministic for HEAVY_STRIKE as well', () => { + const state = baseState(); + + const first = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE }); + const second = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE }); + + expect(first).toEqual(second); + }); }); diff --git a/apps/api/src/combat/combat-engine.service.ts b/apps/api/src/combat/combat-engine.service.ts index a7e1725..17ec90f 100644 --- a/apps/api/src/combat/combat-engine.service.ts +++ b/apps/api/src/combat/combat-engine.service.ts @@ -4,6 +4,7 @@ import { calculateDamage } from './combat-damage'; import { Combatant } from './combatant.enum'; import { CombatActionInput, + CombatEngineCombatant, CombatEngineEvent, CombatEngineResult, CombatEngineState, @@ -17,62 +18,122 @@ export class UnsupportedCombatActionError extends Error { } } +// 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.resolveAttack(state); + 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 resolveAttack(state: CombatEngineState): CombatEngineResult { + private resolvePlayerStrike(state: CombatEngineState, multiplier: number): CombatEngineResult { + const player = this.cloneCombatant(state.player); + const monster = this.cloneCombatant(state.monster); const events: CombatEngineEvent[] = []; - const player = { ...state.player }; - const monster = { ...state.monster }; - const playerDamage = calculateDamage(player.stats, monster.stats.armor); - monster.currentHp = Math.max(0, monster.currentHp - playerDamage); + 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: playerDamage, + amount: damage, }); - if (monster.currentHp <= 0) { - events.push({ - source: Combatant.PLAYER, - target: Combatant.MONSTER, - type: CombatEventType.COMBAT_WON, - }); - return { - state: { ...state, player, monster, status: CombatStatus.WON }, - events, - }; + 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 }); } - const monsterDamage = calculateDamage(monster.stats, player.stats.armor); - player.currentHp = Math.max(0, player.currentHp - monsterDamage); - events.push({ - source: Combatant.MONSTER, - target: Combatant.PLAYER, - type: CombatEventType.DAMAGE, - amount: monsterDamage, - }); + return this.finishRound(state, player, monster, events, false, interrupted); + } - 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, - }; + 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 }); + return { state: { ...state, player, monster, 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 { @@ -80,4 +141,46 @@ export class CombatEngineService { 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 } }; + } } diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index ffef68b..9acf50a 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -829,7 +829,7 @@ describe('CombatService', () => { huntEncounterId: ENCOUNTER_ID, monsterDefinitionId: MONSTER_ID, status: CombatStatus.ACTIVE, - round: 3, + round: 2, playerMaxHp: 100, playerCurrentHp: 1, monsterMaxHp: 45,