feat(api): let wounded monsters turn aggressive
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
Reference in New Issue
Block a user