Files
ashen-realms/apps/api/src/combat/combat-engine.service.spec.ts
Bastian Wagner 081c9f83f9 docs
2026-08-22 16:41:47 +02:00

729 lines
21 KiB
TypeScript

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> = {},
): 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> = {},
): 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> = {},
): 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);
});
});
});