import { CombatAction } from './combat-action.enum'; import { CombatEngineService, UnsupportedCombatActionError, } from './combat-engine.service'; import { CombatEngineCombatant, CombatEngineState, } from './combat-engine.types'; import { CombatEventType } from './combat-event-type.enum'; import { CombatStatus } from './combat-status.enum'; import { Combatant } from './combatant.enum'; import { StatusEffectType } from './status-effect.enum'; import type { MonsterAbilities } from '../monsters/monster-abilities'; function baseState( overrides: Partial = {}, ): CombatEngineState { return { status: CombatStatus.ACTIVE, round: 1, player: { currentHp: 100, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 }, }, // The Ash Rat baseline: no telegraph, no status effect (spec §4). monster: monster(), ...overrides, }; } function monster( overrides: Partial = {}, ): CombatEngineCombatant { return { currentHp: 45, maxHp: 45, stats: { attack: 5, armor: 0 }, ...overrides, }; } /** * A monster whose content grants it an ability. Every special behaviour is * configuration now (spec §4, §8), so a test that wants a telegraph or a * bleed has to say so -- there is no rule every enemy shares any more. */ function withAbilities( abilities: MonsterAbilities, overrides: Partial = {}, ): CombatEngineCombatant { const base = monster(overrides); return { ...base, stats: { ...base.stats, abilities } }; } const ROAD_BANDIT_ABILITIES: MonsterAbilities = { telegraph: { roundInterval: 3, damageMultiplier: 1.6 }, }; const ROAD_HOUND_ABILITIES: MonsterAbilities = { bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 }, }; 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: '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: withAbilities(ROAD_BANDIT_ABILITIES, { 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, monster: withAbilities(ROAD_BANDIT_ABILITIES), }); 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: withAbilities(ROAD_BANDIT_ABILITIES, { currentHp: 10, 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.state.monster.stats.pendingAction).toBeUndefined(); 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); }); it('DEFEND mitigates a resolving Heavy Attack by half', () => { const state = baseState({ monster: withAbilities(ROAD_BANDIT_ABILITIES, { stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, }), }); const result = engine.resolveAction(state, { action: CombatAction.DEFEND }); // raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27; * defend 0.5 = 3.636 -> rounds to 4 expect(result.events).toEqual([ { source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND, }, { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 4, }, ]); expect(result.state.monster.stats.pendingAction).toBeUndefined(); expect(result.state.player.currentHp).toBe(100 - 4); }); describe('content-driven monster abilities (spec §4, §8)', () => { it('never telegraphs for a monster whose content grants it no telegraph', () => { // The Ash Rat is "pure baseline combat": round 3 must be an ordinary // exchange for it, even though it is the Road Bandit's wind-up round. const result = engine.resolveAction(baseState({ round: 3 }), { action: CombatAction.ATTACK, }); expect( result.events.some((event) => event.type === CombatEventType.TELEGRAPH), ).toBe(false); expect(result.state.monster.stats.pendingAction).toBeUndefined(); expect(result.state.player.currentHp).toBe(100 - 5); }); it('telegraphs on the cadence its own content sets, not a shared one', () => { // The Charred Raider winds up every second round rather than every // third, which is the whole of its "noticeably stronger" mechanic. const raider = withAbilities({ telegraph: { roundInterval: 2, damageMultiplier: 1.6 }, }); const round2 = engine.resolveAction( baseState({ round: 2, monster: raider }), { action: CombatAction.ATTACK }, ); const round3 = engine.resolveAction( baseState({ round: 3, monster: raider }), { action: CombatAction.ATTACK }, ); expect(round2.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK'); expect(round3.state.monster.stats.pendingAction).toBeUndefined(); }); it('uses the damage multiplier from the monster content when the Heavy Attack lands', () => { const state = baseState({ monster: withAbilities( { telegraph: { roundInterval: 3, damageMultiplier: 3 } }, { stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' } }, ), }); const result = engine.resolveAction(state, { action: CombatAction.ATTACK, }); // raw = 5; mitigated = 4.545...; * 3 = 13.6 -> rounds to 14 expect(result.events[1]).toMatchObject({ type: CombatEventType.DAMAGE, amount: 14, }); }); }); describe('Bleeding (spec §4)', () => { it('applies Bleeding on top of a normal bite, then ticks it the same round', () => { const state = baseState({ round: 3, monster: withAbilities(ROAD_HOUND_ABILITIES), }); const result = engine.resolveAction(state, { action: CombatAction.ATTACK, }); expect(result.events.slice(1)).toEqual([ { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 5, }, { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.STATUS_APPLIED, amount: 2, statusEffect: StatusEffectType.BLEED, }, { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.STATUS_DAMAGE, amount: 5, statusEffect: StatusEffectType.BLEED, }, ]); // The bite (5) and the first bleed tick (5) both land this round. expect(result.state.player.currentHp).toBe(100 - 10); expect(result.state.player.stats.statusEffects).toEqual([ { type: StatusEffectType.BLEED, remainingRounds: 1, damagePerRound: 5, }, ]); }); it('keeps ticking on later rounds and expires once its duration runs out', () => { const state = baseState({ round: 4, monster: withAbilities(ROAD_HOUND_ABILITIES), player: { currentHp: 90, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6, statusEffects: [ { type: StatusEffectType.BLEED, remainingRounds: 1, damagePerRound: 5, }, ], }, }, }); const result = engine.resolveAction(state, { action: CombatAction.ATTACK, }); expect(result.events.map((event) => event.type)).toEqual([ CombatEventType.DAMAGE, CombatEventType.DAMAGE, CombatEventType.STATUS_DAMAGE, CombatEventType.STATUS_EXPIRED, ]); // Monster bite (5) plus the last bleed tick (5). expect(result.state.player.currentHp).toBe(90 - 10); expect(result.state.player.stats.statusEffects).toEqual([]); }); it('ignores armor, because a bleed is an open wound and not a blow', () => { const armoured = baseState({ round: 4, player: { currentHp: 100, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 999, statusEffects: [ { type: StatusEffectType.BLEED, remainingRounds: 2, damagePerRound: 5, }, ], }, }, }); const result = engine.resolveAction(armoured, { action: CombatAction.ATTACK, }); expect( result.events.find( (event) => event.type === CombatEventType.STATUS_DAMAGE, ), ).toMatchObject({ amount: 5 }); }); it('still ticks in a round where SHIELD_BASH interrupted the monster', () => { // Bleeding sits on the player: interrupting its source stops the next // blow, not the wound already open. const state = baseState({ monster: withAbilities(ROAD_BANDIT_ABILITIES, { stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, }), player: { currentHp: 100, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6, statusEffects: [ { type: StatusEffectType.BLEED, remainingRounds: 2, damagePerRound: 5, }, ], }, }, }); const result = engine.resolveAction(state, { action: CombatAction.SHIELD_BASH, }); expect(result.events.map((event) => event.type)).toEqual([ CombatEventType.DAMAGE, CombatEventType.INTERRUPT, CombatEventType.STATUS_DAMAGE, ]); expect(result.state.player.currentHp).toBe(95); }); it('refreshes an existing Bleeding rather than stacking a second one', () => { const state = baseState({ round: 3, monster: withAbilities(ROAD_HOUND_ABILITIES), player: { currentHp: 100, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6, statusEffects: [ { type: StatusEffectType.BLEED, remainingRounds: 1, damagePerRound: 5, }, ], }, }, }); const result = engine.resolveAction(state, { action: CombatAction.ATTACK, }); expect(result.state.player.stats.statusEffects).toHaveLength(1); // Re-applied to 2 rounds, then aged by this round's own tick. expect(result.state.player.stats.statusEffects?.[0].remainingRounds).toBe( 1, ); }); it('ends the combat as LOST when a bleed tick empties the player', () => { const state = baseState({ round: 4, player: { currentHp: 4, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 999, statusEffects: [ { type: StatusEffectType.BLEED, remainingRounds: 2, damagePerRound: 5, }, ], }, }, }); const result = engine.resolveAction(state, { action: CombatAction.DEFEND, }); expect(result.state.player.currentHp).toBe(0); expect(result.state.status).toBe(CombatStatus.LOST); expect(result.events.at(-1)).toEqual({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST, }); }); it('does not tick a bleed once the killing blow has already won the fight', () => { const state = baseState({ monster: monster({ currentHp: 10 }), player: { currentHp: 3, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6, statusEffects: [ { type: StatusEffectType.BLEED, remainingRounds: 2, damagePerRound: 5, }, ], }, }, }); const result = engine.resolveAction(state, { action: CombatAction.ATTACK, }); expect(result.state.status).toBe(CombatStatus.WON); expect(result.state.player.currentHp).toBe(3); }); it('does not mutate the state it was given', () => { const state = baseState({ round: 3, monster: withAbilities(ROAD_HOUND_ABILITIES), }); const snapshot = structuredClone(state); engine.resolveAction(state, { action: CombatAction.ATTACK }); expect(state).toEqual(snapshot); }); }); });