feat(api): let monsters raise a breakable defensive guard
This commit is contained in:
@@ -725,4 +725,146 @@ describe('CombatEngineService', () => {
|
|||||||
expect(state).toEqual(snapshot);
|
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 { StatusEffectType } from './status-effect.enum';
|
||||||
import type {
|
import type {
|
||||||
MonsterBleedAbility,
|
MonsterBleedAbility,
|
||||||
|
MonsterGuardAbility,
|
||||||
MonsterTelegraphAbility,
|
MonsterTelegraphAbility,
|
||||||
} from '../monsters/monster-abilities';
|
} from '../monsters/monster-abilities';
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export class CombatEngineService {
|
|||||||
|
|
||||||
const damage = calculateDamage(
|
const damage = calculateDamage(
|
||||||
player.stats,
|
player.stats,
|
||||||
monster.stats.armor,
|
this.effectiveArmor(monster),
|
||||||
multiplier,
|
multiplier,
|
||||||
);
|
);
|
||||||
monster.currentHp = Math.max(0, monster.currentHp - damage);
|
monster.currentHp = Math.max(0, monster.currentHp - damage);
|
||||||
@@ -86,7 +87,7 @@ export class CombatEngineService {
|
|||||||
|
|
||||||
const damage = calculateDamage(
|
const damage = calculateDamage(
|
||||||
player.stats,
|
player.stats,
|
||||||
monster.stats.armor,
|
this.effectiveArmor(monster),
|
||||||
SHIELD_BASH_MULTIPLIER,
|
SHIELD_BASH_MULTIPLIER,
|
||||||
);
|
);
|
||||||
monster.currentHp = Math.max(0, monster.currentHp - damage);
|
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);
|
return this.finishRound(state, player, monster, events, false, interrupted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +191,11 @@ export class CombatEngineService {
|
|||||||
});
|
});
|
||||||
const defeatedMonster = {
|
const defeatedMonster = {
|
||||||
...monster,
|
...monster,
|
||||||
stats: { ...monster.stats, pendingAction: undefined },
|
stats: {
|
||||||
|
...monster.stats,
|
||||||
|
pendingAction: undefined,
|
||||||
|
activeGuard: undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
state: {
|
state: {
|
||||||
@@ -183,6 +208,8 @@ export class CombatEngineService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.ageGuard(monster, events);
|
||||||
|
|
||||||
if (!interrupted) {
|
if (!interrupted) {
|
||||||
this.resolveMonsterTurn(state.round, monster, player, defended, events);
|
this.resolveMonsterTurn(state.round, monster, player, defended, events);
|
||||||
}
|
}
|
||||||
@@ -247,6 +274,21 @@ export class CombatEngineService {
|
|||||||
return;
|
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);
|
this.strikePlayer(monster, player, defendMultiplier, events);
|
||||||
|
|
||||||
// The bite lands first and then tears: Bleeding is applied on top of a
|
// 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
|
* A content-configured ability fires on every round divisible by its
|
||||||
* interval. A fixed cadence rather than a hidden roll keeps the fight
|
* interval. A fixed cadence rather than a hidden roll keeps the fight
|
||||||
* readable and the engine deterministic (AGENTS §10).
|
* readable and the engine deterministic (AGENTS §10).
|
||||||
*/
|
*/
|
||||||
private shouldTrigger(
|
private shouldTrigger(
|
||||||
ability: MonsterTelegraphAbility | MonsterBleedAbility | undefined,
|
ability:
|
||||||
|
| MonsterTelegraphAbility
|
||||||
|
| MonsterBleedAbility
|
||||||
|
| MonsterGuardAbility
|
||||||
|
| undefined,
|
||||||
round: number,
|
round: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
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
|
* 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.
|
* 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
|
// Monster-only: set when it telegraphs, cleared when the action resolves
|
||||||
// or is interrupted. Optional because the player's stats never carry it.
|
// or is interrupted. Optional because the player's stats never carry it.
|
||||||
pendingAction?: CombatIntent;
|
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.
|
// Monster-only: the content-authored mechanics this enemy fights with.
|
||||||
// Absent (or empty) means a plain attacker with no special behaviour.
|
// Absent (or empty) means a plain attacker with no special behaviour.
|
||||||
abilities?: MonsterAbilities;
|
abilities?: MonsterAbilities;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export enum CombatEventType {
|
|||||||
DEFEND = 'DEFEND',
|
DEFEND = 'DEFEND',
|
||||||
TELEGRAPH = 'TELEGRAPH',
|
TELEGRAPH = 'TELEGRAPH',
|
||||||
INTERRUPT = 'INTERRUPT',
|
INTERRUPT = 'INTERRUPT',
|
||||||
|
GUARD_RAISED = 'GUARD_RAISED',
|
||||||
|
GUARD_ENDED = 'GUARD_ENDED',
|
||||||
STATUS_APPLIED = 'STATUS_APPLIED',
|
STATUS_APPLIED = 'STATUS_APPLIED',
|
||||||
STATUS_DAMAGE = 'STATUS_DAMAGE',
|
STATUS_DAMAGE = 'STATUS_DAMAGE',
|
||||||
STATUS_EXPIRED = 'STATUS_EXPIRED',
|
STATUS_EXPIRED = 'STATUS_EXPIRED',
|
||||||
|
|||||||
@@ -26,9 +26,22 @@ export interface MonsterBleedAbility {
|
|||||||
durationRounds: number;
|
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 {
|
export interface MonsterAbilities {
|
||||||
telegraph?: MonsterTelegraphAbility;
|
telegraph?: MonsterTelegraphAbility;
|
||||||
bleed?: MonsterBleedAbility;
|
bleed?: MonsterBleedAbility;
|
||||||
|
guard?: MonsterGuardAbility;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NO_MONSTER_ABILITIES: MonsterAbilities = {};
|
export const NO_MONSTER_ABILITIES: MonsterAbilities = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user