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,
|
defended: boolean,
|
||||||
events: CombatEngineEvent[],
|
events: CombatEngineEvent[],
|
||||||
): void {
|
): void {
|
||||||
|
this.checkEnrage(monster, events);
|
||||||
|
|
||||||
const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1;
|
const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1;
|
||||||
const abilities = monster.stats.abilities ?? {};
|
const abilities = monster.stats.abilities ?? {};
|
||||||
|
|
||||||
@@ -305,10 +307,13 @@ export class CombatEngineService {
|
|||||||
multiplier: number,
|
multiplier: number,
|
||||||
events: CombatEngineEvent[],
|
events: CombatEngineEvent[],
|
||||||
): void {
|
): void {
|
||||||
|
const enrageMultiplier = monster.stats.enraged
|
||||||
|
? (monster.stats.abilities?.enrage?.damageMultiplier ?? 1)
|
||||||
|
: 1;
|
||||||
const damage = calculateDamage(
|
const damage = calculateDamage(
|
||||||
monster.stats,
|
monster.stats,
|
||||||
player.stats.armor,
|
player.stats.armor,
|
||||||
multiplier,
|
multiplier * enrageMultiplier,
|
||||||
);
|
);
|
||||||
player.currentHp = Math.max(0, player.currentHp - damage);
|
player.currentHp = Math.max(0, player.currentHp - damage);
|
||||||
events.push({
|
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. */
|
/** Armor the monster actually presents this round, guard included. */
|
||||||
private effectiveArmor(combatant: CombatEngineCombatant): number {
|
private effectiveArmor(combatant: CombatEngineCombatant): number {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface CombatEngineCombatantStats {
|
|||||||
// Effects currently ticking on this combatant. Only the player carries
|
// Effects currently ticking on this combatant. Only the player carries
|
||||||
// any today -- nothing in this slice bleeds a monster.
|
// any today -- nothing in this slice bleeds a monster.
|
||||||
statusEffects?: ActiveStatusEffect[];
|
statusEffects?: ActiveStatusEffect[];
|
||||||
|
// Monster-only: latched the first time its HP crosses the enrage threshold.
|
||||||
|
enraged?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatEngineCombatant {
|
export interface CombatEngineCombatant {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export enum CombatEventType {
|
|||||||
INTERRUPT = 'INTERRUPT',
|
INTERRUPT = 'INTERRUPT',
|
||||||
GUARD_RAISED = 'GUARD_RAISED',
|
GUARD_RAISED = 'GUARD_RAISED',
|
||||||
GUARD_ENDED = 'GUARD_ENDED',
|
GUARD_ENDED = 'GUARD_ENDED',
|
||||||
|
ENRAGED = 'ENRAGED',
|
||||||
STATUS_APPLIED = 'STATUS_APPLIED',
|
STATUS_APPLIED = 'STATUS_APPLIED',
|
||||||
STATUS_DAMAGE = 'STATUS_DAMAGE',
|
STATUS_DAMAGE = 'STATUS_DAMAGE',
|
||||||
STATUS_EXPIRED = 'STATUS_EXPIRED',
|
STATUS_EXPIRED = 'STATUS_EXPIRED',
|
||||||
|
|||||||
@@ -38,10 +38,21 @@ export interface MonsterGuardAbility {
|
|||||||
durationRounds: number;
|
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 {
|
export interface MonsterAbilities {
|
||||||
telegraph?: MonsterTelegraphAbility;
|
telegraph?: MonsterTelegraphAbility;
|
||||||
bleed?: MonsterBleedAbility;
|
bleed?: MonsterBleedAbility;
|
||||||
guard?: MonsterGuardAbility;
|
guard?: MonsterGuardAbility;
|
||||||
|
enrage?: MonsterEnrageAbility;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NO_MONSTER_ABILITIES: MonsterAbilities = {};
|
export const NO_MONSTER_ABILITIES: MonsterAbilities = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user