feat(api): let wounded monsters turn aggressive

This commit is contained in:
Bastian Wagner
2026-08-23 10:37:22 +02:00
parent 739467861a
commit 8a053123b9
5 changed files with 132 additions and 1 deletions

View File

@@ -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);
});
});
});

View File

@@ -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 (

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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 = {};