feat(api): let monsters raise a breakable defensive guard

This commit is contained in:
Bastian Wagner
2026-08-23 10:27:01 +02:00
parent dd73f708c7
commit 739467861a
5 changed files with 248 additions and 4 deletions

View File

@@ -725,4 +725,146 @@ describe('CombatEngineService', () => {
expect(state).toEqual(snapshot);
});
});
const VETERAN_ABILITIES: MonsterAbilities = {
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 },
};
describe('guard', () => {
it('raises its guard instead of attacking on the interval round', () => {
const state = baseState({
round: 4,
monster: withAbilities(VETERAN_ABILITIES, {
currentHp: 120,
maxHp: 120,
stats: { attack: 14, armor: 10, abilities: VETERAN_ABILITIES },
}),
});
const result = new CombatEngineService().resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.state.player.currentHp).toBe(100);
expect(result.events.map((event) => event.type)).toContain(
CombatEventType.GUARD_RAISED,
);
expect(result.state.monster.stats.activeGuard).toEqual({
remainingRounds: 2,
armorBonus: 10,
});
});
it('adds the guard bonus to the armor the player has to cut through', () => {
const guarded = baseState({
round: 5,
monster: withAbilities(VETERAN_ABILITIES, {
currentHp: 120,
maxHp: 120,
stats: {
attack: 14,
armor: 10,
abilities: VETERAN_ABILITIES,
activeGuard: { remainingRounds: 2, armorBonus: 10 },
},
}),
});
const result = new CombatEngineService().resolveAction(guarded, {
action: CombatAction.ATTACK,
});
// 14 raw damage against armor 20 instead of 10: 14*60/80 = 10.5 -> 11.
const damage = result.events.find(
(event) => event.type === CombatEventType.DAMAGE,
);
expect(damage?.amount).toBe(11);
});
it('drops the guard when its rounds run out', () => {
const state = baseState({
round: 5,
monster: withAbilities(VETERAN_ABILITIES, {
currentHp: 120,
maxHp: 120,
stats: {
attack: 14,
armor: 10,
abilities: VETERAN_ABILITIES,
activeGuard: { remainingRounds: 1, armorBonus: 10 },
},
}),
});
const result = new CombatEngineService().resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.state.monster.stats.activeGuard).toBeUndefined();
expect(result.events.map((event) => event.type)).toContain(
CombatEventType.GUARD_ENDED,
);
});
it('lets Shield Bash break an active guard', () => {
const state = baseState({
round: 5,
monster: withAbilities(VETERAN_ABILITIES, {
currentHp: 120,
maxHp: 120,
stats: {
attack: 14,
armor: 10,
abilities: VETERAN_ABILITIES,
activeGuard: { remainingRounds: 2, armorBonus: 10 },
},
}),
});
const result = new CombatEngineService().resolveAction(state, {
action: CombatAction.SHIELD_BASH,
});
expect(result.state.monster.stats.activeGuard).toBeUndefined();
const types = result.events.map((event) => event.type);
expect(types).toContain(CombatEventType.INTERRUPT);
expect(types).toContain(CombatEventType.GUARD_ENDED);
});
it('lets a telegraph win when both are due in the same round', () => {
const collidingAbilities: MonsterAbilities = {
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
guard: { roundInterval: 2, armorBonus: 10, durationRounds: 2 },
};
const state = baseState({
round: 2,
monster: withAbilities(collidingAbilities, {
currentHp: 120,
maxHp: 120,
stats: { attack: 14, armor: 10, abilities: collidingAbilities },
}),
});
const result = new CombatEngineService().resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
expect(result.state.monster.stats.activeGuard).toBeUndefined();
});
it('leaves a monster without the ability exactly as it was', () => {
const state = baseState({ round: 4 });
const result = new CombatEngineService().resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.state.monster.stats.activeGuard).toBeUndefined();
expect(result.events.map((event) => event.type)).not.toContain(
CombatEventType.GUARD_RAISED,
);
});
});
});

View File

@@ -15,6 +15,7 @@ import { CombatStatus } from './combat-status.enum';
import { StatusEffectType } from './status-effect.enum';
import type {
MonsterBleedAbility,
MonsterGuardAbility,
MonsterTelegraphAbility,
} from '../monsters/monster-abilities';
@@ -65,7 +66,7 @@ export class CombatEngineService {
const damage = calculateDamage(
player.stats,
monster.stats.armor,
this.effectiveArmor(monster),
multiplier,
);
monster.currentHp = Math.max(0, monster.currentHp - damage);
@@ -86,7 +87,7 @@ export class CombatEngineService {
const damage = calculateDamage(
player.stats,
monster.stats.armor,
this.effectiveArmor(monster),
SHIELD_BASH_MULTIPLIER,
);
monster.currentHp = Math.max(0, monster.currentHp - damage);
@@ -108,6 +109,26 @@ export class CombatEngineService {
});
}
// A guard is a prepared stance like a wind-up, and the bash answers both.
// Only one INTERRUPT is emitted even when the bash breaks both at once --
// the player made one interruption, not two.
if (monster.stats.activeGuard) {
monster.stats.activeGuard = undefined;
if (!interrupted) {
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.INTERRUPT,
});
}
interrupted = true;
events.push({
source: Combatant.MONSTER,
target: Combatant.MONSTER,
type: CombatEventType.GUARD_ENDED,
});
}
return this.finishRound(state, player, monster, events, false, interrupted);
}
@@ -170,7 +191,11 @@ export class CombatEngineService {
});
const defeatedMonster = {
...monster,
stats: { ...monster.stats, pendingAction: undefined },
stats: {
...monster.stats,
pendingAction: undefined,
activeGuard: undefined,
},
};
return {
state: {
@@ -183,6 +208,8 @@ export class CombatEngineService {
};
}
this.ageGuard(monster, events);
if (!interrupted) {
this.resolveMonsterTurn(state.round, monster, player, defended, events);
}
@@ -247,6 +274,21 @@ export class CombatEngineService {
return;
}
if (this.shouldTrigger(abilities.guard, round)) {
const guard = abilities.guard as MonsterGuardAbility;
monster.stats.activeGuard = {
remainingRounds: guard.durationRounds,
armorBonus: guard.armorBonus,
};
events.push({
source: Combatant.MONSTER,
target: Combatant.MONSTER,
type: CombatEventType.GUARD_RAISED,
amount: guard.durationRounds,
});
return;
}
this.strikePlayer(monster, player, defendMultiplier, events);
// The bite lands first and then tears: Bleeding is applied on top of a
@@ -277,13 +319,24 @@ export class CombatEngineService {
});
}
/** Armor the monster actually presents this round, guard included. */
private effectiveArmor(combatant: CombatEngineCombatant): number {
return (
combatant.stats.armor + (combatant.stats.activeGuard?.armorBonus ?? 0)
);
}
/**
* A content-configured ability fires on every round divisible by its
* interval. A fixed cadence rather than a hidden roll keeps the fight
* readable and the engine deterministic (AGENTS §10).
*/
private shouldTrigger(
ability: MonsterTelegraphAbility | MonsterBleedAbility | undefined,
ability:
| MonsterTelegraphAbility
| MonsterBleedAbility
| MonsterGuardAbility
| undefined,
round: number,
): boolean {
return (
@@ -325,6 +378,36 @@ export class CombatEngineService {
});
}
/**
* Counts one round off an active guard, and drops it when it runs out.
*
* Called before the monster acts, so the guard it raises this round is not
* immediately aged: `durationRounds: 2` turns aside the player's next two
* attacks.
*/
private ageGuard(
monster: CombatEngineCombatant,
events: CombatEngineEvent[],
): void {
const guard = monster.stats.activeGuard;
if (!guard) {
return;
}
const remainingRounds = guard.remainingRounds - 1;
if (remainingRounds > 0) {
monster.stats.activeGuard = { ...guard, remainingRounds };
return;
}
monster.stats.activeGuard = undefined;
events.push({
source: Combatant.MONSTER,
target: Combatant.MONSTER,
type: CombatEventType.GUARD_ENDED,
});
}
/**
* Deals one round of every active effect, then ages it. Bleeding ignores
* armor: it is an open wound, not a blow that can be turned aside.

View File

@@ -34,6 +34,10 @@ export interface CombatEngineCombatantStats {
// Monster-only: set when it telegraphs, cleared when the action resolves
// or is interrupted. Optional because the player's stats never carry it.
pendingAction?: CombatIntent;
// Monster-only: set when it covers, cleared when the rounds run out or a
// Shield Bash breaks it. Its bonus is added to the monster's armor while
// it lasts (Playable Slice 0.10 §7).
activeGuard?: { remainingRounds: number; armorBonus: number };
// Monster-only: the content-authored mechanics this enemy fights with.
// Absent (or empty) means a plain attacker with no special behaviour.
abilities?: MonsterAbilities;

View File

@@ -4,6 +4,8 @@ export enum CombatEventType {
DEFEND = 'DEFEND',
TELEGRAPH = 'TELEGRAPH',
INTERRUPT = 'INTERRUPT',
GUARD_RAISED = 'GUARD_RAISED',
GUARD_ENDED = 'GUARD_ENDED',
STATUS_APPLIED = 'STATUS_APPLIED',
STATUS_DAMAGE = 'STATUS_DAMAGE',
STATUS_EXPIRED = 'STATUS_EXPIRED',

View File

@@ -26,9 +26,22 @@ export interface MonsterBleedAbility {
durationRounds: number;
}
export interface MonsterGuardAbility {
/**
* The monster forgoes its attack on every round divisible by this and
* covers instead, raising its armor for `durationRounds`. SHIELD_BASH
* breaks it, the same answer the telegraph already taught (Playable Slice
* 0.10 §5).
*/
roundInterval: number;
armorBonus: number;
durationRounds: number;
}
export interface MonsterAbilities {
telegraph?: MonsterTelegraphAbility;
bleed?: MonsterBleedAbility;
guard?: MonsterGuardAbility;
}
export const NO_MONSTER_ABILITIES: MonsterAbilities = {};