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