feat(combat): implement HEAVY_STRIKE, SHIELD_BASH, DEFEND, POTION, and monster telegraphing
This commit is contained in:
@@ -101,7 +101,169 @@ describe('CombatEngineService', () => {
|
|||||||
|
|
||||||
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
|
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }),
|
engine.resolveAction(baseState(), { action: 'FLEE' as CombatAction }),
|
||||||
).toThrow(UnsupportedCombatActionError);
|
).toThrow(UnsupportedCombatActionError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('HEAVY_STRIKE deals 160% damage to the monster', () => {
|
||||||
|
const result = engine.resolveAction(baseState(), { action: CombatAction.HEAVY_STRIKE });
|
||||||
|
|
||||||
|
// raw = 14; 14 * 1.6 = 22.4 -> rounds to 22
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.MONSTER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 22,
|
||||||
|
});
|
||||||
|
expect(result.state.monster.currentHp).toBe(45 - 22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => {
|
||||||
|
const result = engine.resolveAction(baseState(), { action: CombatAction.SHIELD_BASH });
|
||||||
|
|
||||||
|
// raw = 14; 14 * 0.7 = 9.8 -> rounds to 10
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.MONSTER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 10,
|
||||||
|
});
|
||||||
|
expect(result.events.some((event) => event.type === CombatEventType.INTERRUPT)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => {
|
||||||
|
const state = baseState({
|
||||||
|
monster: {
|
||||||
|
currentHp: 45,
|
||||||
|
maxHp: 45,
|
||||||
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.SHIELD_BASH });
|
||||||
|
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 10 },
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT },
|
||||||
|
]);
|
||||||
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
||||||
|
expect(result.state.player.currentHp).toBe(100);
|
||||||
|
expect(result.state.round).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFEND deals no damage and halves the monster normal attack this round', () => {
|
||||||
|
const result = engine.resolveAction(baseState(), { action: CombatAction.DEFEND });
|
||||||
|
|
||||||
|
expect(result.state.monster.currentHp).toBe(45);
|
||||||
|
// raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
|
||||||
|
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 2 },
|
||||||
|
]);
|
||||||
|
expect(result.state.player.currentHp).toBe(98);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POTION heals 35% of max HP, decrements potionsRemaining, and the monster still acts', () => {
|
||||||
|
const state = baseState({
|
||||||
|
player: {
|
||||||
|
currentHp: 60,
|
||||||
|
maxHp: 100,
|
||||||
|
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 2 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.POTION });
|
||||||
|
|
||||||
|
// 35% of 100 = 35
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.HEAL,
|
||||||
|
amount: 35,
|
||||||
|
});
|
||||||
|
expect(result.events[1]).toEqual({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 5,
|
||||||
|
});
|
||||||
|
// 60 + 35 healed - 5 monster hit = 90
|
||||||
|
expect(result.state.player.currentHp).toBe(90);
|
||||||
|
expect(result.state.player.stats.potionsRemaining).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps POTION healing at the maximum HP', () => {
|
||||||
|
const state = baseState({
|
||||||
|
player: {
|
||||||
|
currentHp: 90,
|
||||||
|
maxHp: 100,
|
||||||
|
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.POTION });
|
||||||
|
|
||||||
|
// 35% of 100 = 35, but only 10 HP is missing
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.HEAL,
|
||||||
|
amount: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
|
||||||
|
const round3State = baseState({ round: 3 });
|
||||||
|
|
||||||
|
const telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK });
|
||||||
|
|
||||||
|
expect(telegraphResult.state.player.currentHp).toBe(100);
|
||||||
|
expect(telegraphResult.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
|
||||||
|
expect(telegraphResult.events[1]).toEqual({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.TELEGRAPH,
|
||||||
|
});
|
||||||
|
expect(telegraphResult.state.round).toBe(4);
|
||||||
|
|
||||||
|
const resolveResult = engine.resolveAction(telegraphResult.state, { action: CombatAction.ATTACK });
|
||||||
|
|
||||||
|
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7
|
||||||
|
expect(resolveResult.events[1]).toEqual({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 7,
|
||||||
|
});
|
||||||
|
expect(resolveResult.state.monster.stats.pendingAction).toBeUndefined();
|
||||||
|
expect(resolveResult.state.player.currentHp).toBe(100 - 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resolve a pending Heavy Attack when the monster is killed this round', () => {
|
||||||
|
const state = baseState({
|
||||||
|
monster: {
|
||||||
|
currentHp: 10,
|
||||||
|
maxHp: 45,
|
||||||
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
||||||
|
|
||||||
|
expect(result.state.status).toBe(CombatStatus.WON);
|
||||||
|
expect(result.state.player.currentHp).toBe(100);
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic for HEAVY_STRIKE as well', () => {
|
||||||
|
const state = baseState();
|
||||||
|
|
||||||
|
const first = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
|
||||||
|
const second = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { calculateDamage } from './combat-damage';
|
|||||||
import { Combatant } from './combatant.enum';
|
import { Combatant } from './combatant.enum';
|
||||||
import {
|
import {
|
||||||
CombatActionInput,
|
CombatActionInput,
|
||||||
|
CombatEngineCombatant,
|
||||||
CombatEngineEvent,
|
CombatEngineEvent,
|
||||||
CombatEngineResult,
|
CombatEngineResult,
|
||||||
CombatEngineState,
|
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()
|
@Injectable()
|
||||||
export class CombatEngineService {
|
export class CombatEngineService {
|
||||||
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
|
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
|
||||||
switch (input.action) {
|
switch (input.action) {
|
||||||
case CombatAction.ATTACK:
|
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:
|
default:
|
||||||
throw new UnsupportedCombatActionError(input.action);
|
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 events: CombatEngineEvent[] = [];
|
||||||
const player = { ...state.player };
|
|
||||||
const monster = { ...state.monster };
|
|
||||||
|
|
||||||
const playerDamage = calculateDamage(player.stats, monster.stats.armor);
|
const damage = calculateDamage(player.stats, monster.stats.armor, multiplier);
|
||||||
monster.currentHp = Math.max(0, monster.currentHp - playerDamage);
|
monster.currentHp = Math.max(0, monster.currentHp - damage);
|
||||||
events.push({
|
events.push({
|
||||||
source: Combatant.PLAYER,
|
source: Combatant.PLAYER,
|
||||||
target: Combatant.MONSTER,
|
target: Combatant.MONSTER,
|
||||||
type: CombatEventType.DAMAGE,
|
type: CombatEventType.DAMAGE,
|
||||||
amount: playerDamage,
|
amount: damage,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (monster.currentHp <= 0) {
|
return this.finishRound(state, player, monster, events, false);
|
||||||
events.push({
|
}
|
||||||
source: Combatant.PLAYER,
|
|
||||||
target: Combatant.MONSTER,
|
private resolveShieldBash(state: CombatEngineState): CombatEngineResult {
|
||||||
type: CombatEventType.COMBAT_WON,
|
const player = this.cloneCombatant(state.player);
|
||||||
});
|
const monster = this.cloneCombatant(state.monster);
|
||||||
return {
|
const events: CombatEngineEvent[] = [];
|
||||||
state: { ...state, player, monster, status: CombatStatus.WON },
|
|
||||||
events,
|
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);
|
return this.finishRound(state, player, monster, events, false, interrupted);
|
||||||
player.currentHp = Math.max(0, player.currentHp - monsterDamage);
|
}
|
||||||
events.push({
|
|
||||||
source: Combatant.MONSTER,
|
|
||||||
target: Combatant.PLAYER,
|
|
||||||
type: CombatEventType.DAMAGE,
|
|
||||||
amount: monsterDamage,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (player.currentHp <= 0) {
|
private resolveDefend(state: CombatEngineState): CombatEngineResult {
|
||||||
events.push({
|
const player = this.cloneCombatant(state.player);
|
||||||
source: Combatant.MONSTER,
|
const monster = this.cloneCombatant(state.monster);
|
||||||
target: Combatant.PLAYER,
|
const events: CombatEngineEvent[] = [
|
||||||
type: CombatEventType.COMBAT_LOST,
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
|
||||||
});
|
];
|
||||||
return {
|
|
||||||
state: { ...state, player, monster, status: CombatStatus.LOST },
|
return this.finishRound(state, player, monster, events, true);
|
||||||
events,
|
}
|
||||||
};
|
|
||||||
|
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 {
|
return {
|
||||||
@@ -80,4 +141,46 @@ export class CombatEngineService {
|
|||||||
events,
|
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 } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -829,7 +829,7 @@ describe('CombatService', () => {
|
|||||||
huntEncounterId: ENCOUNTER_ID,
|
huntEncounterId: ENCOUNTER_ID,
|
||||||
monsterDefinitionId: MONSTER_ID,
|
monsterDefinitionId: MONSTER_ID,
|
||||||
status: CombatStatus.ACTIVE,
|
status: CombatStatus.ACTIVE,
|
||||||
round: 3,
|
round: 2,
|
||||||
playerMaxHp: 100,
|
playerMaxHp: 100,
|
||||||
playerCurrentHp: 1,
|
playerCurrentHp: 1,
|
||||||
monsterMaxHp: 45,
|
monsterMaxHp: 45,
|
||||||
|
|||||||
Reference in New Issue
Block a user