This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View File

@@ -3,10 +3,15 @@ import {
CombatEngineService,
UnsupportedCombatActionError,
} from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types';
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> = {},
@@ -19,15 +24,44 @@ function baseState(
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6 },
},
monster: {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0 },
},
// 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;
@@ -175,11 +209,9 @@ describe('CombatEngineService', () => {
it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => {
const state = baseState({
monster: {
currentHp: 45,
maxHp: 45,
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
},
}),
});
const result = engine.resolveAction(state, {
@@ -277,7 +309,10 @@ describe('CombatEngineService', () => {
});
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
const round3State = baseState({ round: 3 });
const round3State = baseState({
round: 3,
monster: withAbilities(ROAD_BANDIT_ABILITIES),
});
const telegraphResult = engine.resolveAction(round3State, {
action: CombatAction.ATTACK,
@@ -311,11 +346,10 @@ describe('CombatEngineService', () => {
it('does not resolve a pending Heavy Attack when the monster is killed this round', () => {
const state = baseState({
monster: {
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
currentHp: 10,
maxHp: 45,
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
},
}),
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
@@ -353,11 +387,9 @@ describe('CombatEngineService', () => {
it('DEFEND mitigates a resolving Heavy Attack by half', () => {
const state = baseState({
monster: {
currentHp: 45,
maxHp: 45,
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
},
}),
});
const result = engine.resolveAction(state, { action: CombatAction.DEFEND });
@@ -379,4 +411,318 @@ describe('CombatEngineService', () => {
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);
});
});
});