diff --git a/apps/api/src/combat/combat-engine.service.spec.ts b/apps/api/src/combat/combat-engine.service.spec.ts index 6fe6c50..6ad59c7 100644 --- a/apps/api/src/combat/combat-engine.service.spec.ts +++ b/apps/api/src/combat/combat-engine.service.spec.ts @@ -867,4 +867,89 @@ describe('CombatEngineService', () => { ); }); }); + + const HOUND_ABILITIES: MonsterAbilities = { + bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 }, + enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 }, + }; + + describe('enrage', () => { + it('turns aggressive once its HP crosses the threshold', () => { + // 80 max HP, 40 left; a 14-damage blow lands it on 26, below 35%. + const state = baseState({ + round: 1, + player: { + currentHp: 100, + maxHp: 100, + stats: { attack: 6, weaponDamage: 8, armor: 0 }, + }, + monster: withAbilities(HOUND_ABILITIES, { + currentHp: 40, + maxHp: 80, + stats: { attack: 12, armor: 0, abilities: HOUND_ABILITIES }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.enraged).toBe(true); + expect(result.events.map((event) => event.type)).toContain( + CombatEventType.ENRAGED, + ); + // The same round's reply already hits harder: 12 * 1.4 = 16.8 -> 17. + const monsterHit = result.events.find( + (event) => + event.type === CombatEventType.DAMAGE && + event.source === Combatant.MONSTER, + ); + expect(monsterHit?.amount).toBe(17); + }); + + it('stays quiet above the threshold', () => { + const state = baseState({ + round: 1, + monster: withAbilities(HOUND_ABILITIES, { + currentHp: 80, + maxHp: 80, + stats: { attack: 12, armor: 0, abilities: HOUND_ABILITIES }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.enraged).toBeUndefined(); + expect(result.events.map((event) => event.type)).not.toContain( + CombatEventType.ENRAGED, + ); + }); + + it('announces the change only once', () => { + const state = baseState({ + round: 2, + monster: withAbilities(HOUND_ABILITIES, { + currentHp: 20, + maxHp: 80, + stats: { + attack: 12, + armor: 0, + abilities: HOUND_ABILITIES, + enraged: true, + }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.DEFEND, + }); + + expect(result.events.map((event) => event.type)).not.toContain( + CombatEventType.ENRAGED, + ); + expect(result.state.monster.stats.enraged).toBe(true); + }); + }); }); diff --git a/apps/api/src/combat/combat-engine.service.ts b/apps/api/src/combat/combat-engine.service.ts index defe3a0..5050183 100644 --- a/apps/api/src/combat/combat-engine.service.ts +++ b/apps/api/src/combat/combat-engine.service.ts @@ -249,6 +249,8 @@ export class CombatEngineService { defended: boolean, events: CombatEngineEvent[], ): void { + this.checkEnrage(monster, events); + const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1; const abilities = monster.stats.abilities ?? {}; @@ -305,10 +307,13 @@ export class CombatEngineService { multiplier: number, events: CombatEngineEvent[], ): void { + const enrageMultiplier = monster.stats.enraged + ? (monster.stats.abilities?.enrage?.damageMultiplier ?? 1) + : 1; const damage = calculateDamage( monster.stats, player.stats.armor, - multiplier, + multiplier * enrageMultiplier, ); player.currentHp = Math.max(0, player.currentHp - damage); events.push({ @@ -319,6 +324,33 @@ export class CombatEngineService { }); } + /** + * Latches the enraged state the first time the monster's HP crosses its + * threshold. Checked before it acts, so the blow that wounded it is already + * answered in kind. + */ + private checkEnrage( + monster: CombatEngineCombatant, + events: CombatEngineEvent[], + ): void { + const enrage = monster.stats.abilities?.enrage; + if (!enrage || monster.stats.enraged) { + return; + } + + const threshold = (monster.maxHp * enrage.hpThresholdPercent) / 100; + if (monster.currentHp > threshold) { + return; + } + + monster.stats.enraged = true; + events.push({ + source: Combatant.MONSTER, + target: Combatant.MONSTER, + type: CombatEventType.ENRAGED, + }); + } + /** Armor the monster actually presents this round, guard included. */ private effectiveArmor(combatant: CombatEngineCombatant): number { return ( diff --git a/apps/api/src/combat/combat-engine.types.ts b/apps/api/src/combat/combat-engine.types.ts index 421274a..cce3bed 100644 --- a/apps/api/src/combat/combat-engine.types.ts +++ b/apps/api/src/combat/combat-engine.types.ts @@ -44,6 +44,8 @@ export interface CombatEngineCombatantStats { // Effects currently ticking on this combatant. Only the player carries // any today -- nothing in this slice bleeds a monster. statusEffects?: ActiveStatusEffect[]; + // Monster-only: latched the first time its HP crosses the enrage threshold. + enraged?: boolean; } export interface CombatEngineCombatant { diff --git a/apps/api/src/combat/combat-event-type.enum.ts b/apps/api/src/combat/combat-event-type.enum.ts index 1581e28..913195b 100644 --- a/apps/api/src/combat/combat-event-type.enum.ts +++ b/apps/api/src/combat/combat-event-type.enum.ts @@ -6,6 +6,7 @@ export enum CombatEventType { INTERRUPT = 'INTERRUPT', GUARD_RAISED = 'GUARD_RAISED', GUARD_ENDED = 'GUARD_ENDED', + ENRAGED = 'ENRAGED', 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 195236e..ac24108 100644 --- a/apps/api/src/monsters/monster-abilities.ts +++ b/apps/api/src/monsters/monster-abilities.ts @@ -38,10 +38,21 @@ export interface MonsterGuardAbility { durationRounds: number; } +export interface MonsterEnrageAbility { + /** + * Once its HP first falls to or below this share of maximum, the monster + * hits harder for the rest of the fight. One deterministic state change, + * never reversed and never rolled (Playable Slice 0.10 ยง5). + */ + hpThresholdPercent: number; + damageMultiplier: number; +} + export interface MonsterAbilities { telegraph?: MonsterTelegraphAbility; bleed?: MonsterBleedAbility; guard?: MonsterGuardAbility; + enrage?: MonsterEnrageAbility; } export const NO_MONSTER_ABILITIES: MonsterAbilities = {};