feat(combat): implement HEAVY_STRIKE, SHIELD_BASH, DEFEND, POTION, and monster telegraphing

This commit is contained in:
Bastian Wagner
2026-08-20 21:27:12 +02:00
parent fc1873e41f
commit b3dac8f61c
3 changed files with 302 additions and 37 deletions

View File

@@ -4,6 +4,7 @@ import { calculateDamage } from './combat-damage';
import { Combatant } from './combatant.enum';
import {
CombatActionInput,
CombatEngineCombatant,
CombatEngineEvent,
CombatEngineResult,
CombatEngineState,
@@ -17,62 +18,122 @@ export class UnsupportedCombatActionError extends Error {
}
}
// The monster telegraphs a Heavy Attack instead of striking every third
// round it acts, then resolves it the round after unless SHIELD_BASH
// interrupts it. A fixed cadence (not RNG) keeps combat deterministic
// (Playable Slice 0.6 spec §10/§11).
const TELEGRAPH_ROUND_INTERVAL = 3;
const HEAVY_ATTACK_MULTIPLIER = 1.6;
const SHIELD_BASH_MULTIPLIER = 0.7;
const DEFEND_MITIGATION_MULTIPLIER = 0.5;
const POTION_HEAL_FRACTION = 0.35;
@Injectable()
export class CombatEngineService {
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
switch (input.action) {
case CombatAction.ATTACK:
return this.resolveAttack(state);
return this.resolvePlayerStrike(state, 1);
case CombatAction.HEAVY_STRIKE:
return this.resolvePlayerStrike(state, HEAVY_ATTACK_MULTIPLIER);
case CombatAction.SHIELD_BASH:
return this.resolveShieldBash(state);
case CombatAction.DEFEND:
return this.resolveDefend(state);
case CombatAction.POTION:
return this.resolvePotion(state);
default:
throw new UnsupportedCombatActionError(input.action);
}
}
private resolveAttack(state: CombatEngineState): CombatEngineResult {
private resolvePlayerStrike(state: CombatEngineState, multiplier: number): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [];
const player = { ...state.player };
const monster = { ...state.monster };
const playerDamage = calculateDamage(player.stats, monster.stats.armor);
monster.currentHp = Math.max(0, monster.currentHp - playerDamage);
const damage = calculateDamage(player.stats, monster.stats.armor, multiplier);
monster.currentHp = Math.max(0, monster.currentHp - damage);
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: playerDamage,
amount: damage,
});
if (monster.currentHp <= 0) {
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.COMBAT_WON,
});
return {
state: { ...state, player, monster, status: CombatStatus.WON },
events,
};
return this.finishRound(state, player, monster, events, false);
}
private resolveShieldBash(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [];
const damage = calculateDamage(player.stats, monster.stats.armor, SHIELD_BASH_MULTIPLIER);
monster.currentHp = Math.max(0, monster.currentHp - damage);
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: damage,
});
let interrupted = false;
if (monster.stats.pendingAction) {
monster.stats.pendingAction = undefined;
interrupted = true;
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT });
}
const monsterDamage = calculateDamage(monster.stats, player.stats.armor);
player.currentHp = Math.max(0, player.currentHp - monsterDamage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: monsterDamage,
});
return this.finishRound(state, player, monster, events, false, interrupted);
}
if (player.currentHp <= 0) {
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.COMBAT_LOST,
});
return {
state: { ...state, player, monster, status: CombatStatus.LOST },
events,
};
private resolveDefend(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
];
return this.finishRound(state, player, monster, events, true);
}
private resolvePotion(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const rawHeal = Math.round(player.maxHp * POTION_HEAL_FRACTION);
const healed = Math.min(rawHeal, player.maxHp - player.currentHp);
player.currentHp += healed;
player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1;
const events: CombatEngineEvent[] = [
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.HEAL, amount: healed },
];
return this.finishRound(state, player, monster, events, false);
}
private finishRound(
state: CombatEngineState,
player: CombatEngineCombatant,
monster: CombatEngineCombatant,
events: CombatEngineEvent[],
defended: boolean,
interrupted = false,
): CombatEngineResult {
if (monster.currentHp <= 0) {
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON });
return { state: { ...state, player, monster, status: CombatStatus.WON }, events };
}
if (!interrupted) {
this.resolveMonsterTurn(state.round, monster, player, defended, events);
if (player.currentHp <= 0) {
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST });
return { state: { ...state, player, monster, status: CombatStatus.LOST }, events };
}
}
return {
@@ -80,4 +141,46 @@ export class CombatEngineService {
events,
};
}
private resolveMonsterTurn(
round: number,
monster: CombatEngineCombatant,
player: CombatEngineCombatant,
defended: boolean,
events: CombatEngineEvent[],
): void {
const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1;
if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
monster.stats.pendingAction = undefined;
const damage = calculateDamage(monster.stats, player.stats.armor, HEAVY_ATTACK_MULTIPLIER * defendMultiplier);
player.currentHp = Math.max(0, player.currentHp - damage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: damage,
});
return;
}
if (round % TELEGRAPH_ROUND_INTERVAL === 0) {
monster.stats.pendingAction = 'HEAVY_ATTACK';
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.TELEGRAPH });
return;
}
const damage = calculateDamage(monster.stats, player.stats.armor, defendMultiplier);
player.currentHp = Math.max(0, player.currentHp - damage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: damage,
});
}
private cloneCombatant(combatant: CombatEngineCombatant): CombatEngineCombatant {
return { ...combatant, stats: { ...combatant.stats } };
}
}