From edae1af39adf4d400c3c62ad1fe4c36742d3cecb Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 19 Aug 2026 15:43:24 +0200 Subject: [PATCH] feat(combat): add deterministic combat engine for ATTACK resolution --- .../src/combat/combat-engine.service.spec.ts | 107 ++++++++++++++++++ apps/api/src/combat/combat-engine.service.ts | 83 ++++++++++++++ apps/api/src/combat/combat-engine.types.ts | 39 +++++++ 3 files changed, 229 insertions(+) create mode 100644 apps/api/src/combat/combat-engine.service.spec.ts create mode 100644 apps/api/src/combat/combat-engine.service.ts create mode 100644 apps/api/src/combat/combat-engine.types.ts diff --git a/apps/api/src/combat/combat-engine.service.spec.ts b/apps/api/src/combat/combat-engine.service.spec.ts new file mode 100644 index 0000000..b94732d --- /dev/null +++ b/apps/api/src/combat/combat-engine.service.spec.ts @@ -0,0 +1,107 @@ +import { CombatAction } from './combat-action.enum'; +import { CombatEngineService, UnsupportedCombatActionError } from './combat-engine.service'; +import { CombatEngineState } from './combat-engine.types'; +import { CombatEventType } from './combat-event-type.enum'; +import { CombatStatus } from './combat-status.enum'; +import { Combatant } from './combatant.enum'; + +function baseState(overrides: Partial = {}): CombatEngineState { + return { + status: CombatStatus.ACTIVE, + round: 1, + player: { + currentHp: 100, + maxHp: 100, + stats: { attack: 6, weaponDamage: 8, armor: 6 }, + }, + monster: { + currentHp: 45, + maxHp: 45, + stats: { attack: 5, armor: 0 }, + }, + ...overrides, + }; +} + +describe('CombatEngineService', () => { + let engine: CombatEngineService; + + beforeEach(() => { + engine = new CombatEngineService(); + }); + + it('reduces monster HP by the calculated damage and emits a DAMAGE event', () => { + const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK }); + + // raw = 6 + 8 = 14; armor 0 -> 14 mitigated + expect(result.state.monster.currentHp).toBe(45 - 14); + expect(result.events[0]).toEqual({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: 14, + }); + }); + + it('lets the monster retaliate when it survives the player attack, and advances the round', () => { + const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK }); + + // raw = 5; armor 6 -> 5*60/66 = 4.545 -> rounds to 5 + expect(result.state.player.currentHp).toBe(100 - 5); + expect(result.events[1]).toEqual({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: 5, + }); + expect(result.state.status).toBe(CombatStatus.ACTIVE); + expect(result.state.round).toBe(2); + }); + + it('does not let the monster attack once it is reduced to 0 HP, and ends the combat as WON', () => { + const state = baseState({ + monster: { currentHp: 10, maxHp: 45, stats: { attack: 5, armor: 0 } }, + }); + + const result = engine.resolveAction(state, { action: CombatAction.ATTACK }); + + expect(result.state.monster.currentHp).toBe(0); + expect(result.state.status).toBe(CombatStatus.WON); + expect(result.state.round).toBe(1); + 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('ends the combat as LOST when the monster attack reduces the player to 0 HP', () => { + const state = baseState({ + player: { currentHp: 3, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 } }, + }); + + const result = engine.resolveAction(state, { action: CombatAction.ATTACK }); + + expect(result.state.player.currentHp).toBe(0); + expect(result.state.status).toBe(CombatStatus.LOST); + expect(result.events).toEqual([ + { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 }, + { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 5 }, + { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST }, + ]); + }); + + it('produces the exact same result for the same state and action (determinism)', () => { + const state = baseState(); + + const first = engine.resolveAction(state, { action: CombatAction.ATTACK }); + const second = engine.resolveAction(state, { action: CombatAction.ATTACK }); + + expect(first).toEqual(second); + }); + + it('throws UnsupportedCombatActionError for an action it does not implement', () => { + expect(() => + engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }), + ).toThrow(UnsupportedCombatActionError); + }); +}); diff --git a/apps/api/src/combat/combat-engine.service.ts b/apps/api/src/combat/combat-engine.service.ts new file mode 100644 index 0000000..a7e1725 --- /dev/null +++ b/apps/api/src/combat/combat-engine.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { CombatAction } from './combat-action.enum'; +import { calculateDamage } from './combat-damage'; +import { Combatant } from './combatant.enum'; +import { + CombatActionInput, + 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}`); + } +} + +@Injectable() +export class CombatEngineService { + resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult { + switch (input.action) { + case CombatAction.ATTACK: + return this.resolveAttack(state); + default: + throw new UnsupportedCombatActionError(input.action); + } + } + + private resolveAttack(state: CombatEngineState): CombatEngineResult { + 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); + events.push({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: playerDamage, + }); + + 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, + }; + } + + 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, + }); + + 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, + }; + } +} diff --git a/apps/api/src/combat/combat-engine.types.ts b/apps/api/src/combat/combat-engine.types.ts new file mode 100644 index 0000000..b108363 --- /dev/null +++ b/apps/api/src/combat/combat-engine.types.ts @@ -0,0 +1,39 @@ +import { Combatant } from './combatant.enum'; +import { CombatAction } from './combat-action.enum'; +import { CombatEventType } from './combat-event-type.enum'; +import { CombatStatus } from './combat-status.enum'; + +export interface CombatEngineCombatantStats { + attack: number; + weaponDamage?: number; + armor: number; +} + +export interface CombatEngineCombatant { + currentHp: number; + maxHp: number; + stats: CombatEngineCombatantStats; +} + +export interface CombatEngineState { + status: CombatStatus; + round: number; + player: CombatEngineCombatant; + monster: CombatEngineCombatant; +} + +export interface CombatActionInput { + action: CombatAction; +} + +export interface CombatEngineEvent { + source: Combatant; + target: Combatant; + type: CombatEventType; + amount?: number; +} + +export interface CombatEngineResult { + state: CombatEngineState; + events: CombatEngineEvent[]; +}