docs
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user