From 739467861a12624eddf68191abfcbf19b4f75b30 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sun, 23 Aug 2026 10:27:01 +0200 Subject: [PATCH] feat(api): let monsters raise a breakable defensive guard --- .../src/combat/combat-engine.service.spec.ts | 142 ++++++++++++++++++ apps/api/src/combat/combat-engine.service.ts | 91 ++++++++++- apps/api/src/combat/combat-engine.types.ts | 4 + apps/api/src/combat/combat-event-type.enum.ts | 2 + apps/api/src/monsters/monster-abilities.ts | 13 ++ 5 files changed, 248 insertions(+), 4 deletions(-) diff --git a/apps/api/src/combat/combat-engine.service.spec.ts b/apps/api/src/combat/combat-engine.service.spec.ts index ab6d9a3..6fe6c50 100644 --- a/apps/api/src/combat/combat-engine.service.spec.ts +++ b/apps/api/src/combat/combat-engine.service.spec.ts @@ -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, + ); + }); + }); }); diff --git a/apps/api/src/combat/combat-engine.service.ts b/apps/api/src/combat/combat-engine.service.ts index 9cc7d2b..defe3a0 100644 --- a/apps/api/src/combat/combat-engine.service.ts +++ b/apps/api/src/combat/combat-engine.service.ts @@ -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. diff --git a/apps/api/src/combat/combat-engine.types.ts b/apps/api/src/combat/combat-engine.types.ts index fa089f8..421274a 100644 --- a/apps/api/src/combat/combat-engine.types.ts +++ b/apps/api/src/combat/combat-engine.types.ts @@ -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; diff --git a/apps/api/src/combat/combat-event-type.enum.ts b/apps/api/src/combat/combat-event-type.enum.ts index 938a542..1581e28 100644 --- a/apps/api/src/combat/combat-event-type.enum.ts +++ b/apps/api/src/combat/combat-event-type.enum.ts @@ -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', diff --git a/apps/api/src/monsters/monster-abilities.ts b/apps/api/src/monsters/monster-abilities.ts index fbf61a1..195236e 100644 --- a/apps/api/src/monsters/monster-abilities.ts +++ b/apps/api/src/monsters/monster-abilities.ts @@ -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 = {};