feat(combat): add deterministic combat engine for ATTACK resolution
This commit is contained in:
107
apps/api/src/combat/combat-engine.service.spec.ts
Normal file
107
apps/api/src/combat/combat-engine.service.spec.ts
Normal file
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
83
apps/api/src/combat/combat-engine.service.ts
Normal file
83
apps/api/src/combat/combat-engine.service.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/api/src/combat/combat-engine.types.ts
Normal file
39
apps/api/src/combat/combat-engine.types.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user