feat(combat): validate potions, persist telegraph state, and expose both on the combat DTO

Also updates the pre-existing exact-equality playerState assertion in
combat-equipment-integration.spec.ts, which broke from the new
potionsRemaining field but wasn't listed in the task brief's file scope.
This commit is contained in:
Bastian Wagner
2026-08-20 21:48:51 +02:00
parent 43d2085d2c
commit c7b9601eb4
3 changed files with 65 additions and 3 deletions

View File

@@ -369,7 +369,12 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', ()
// The finished combat's playerState snapshot (written once at startCombat) // The finished combat's playerState snapshot (written once at startCombat)
// must not be retroactively rewritten by equipping after the fight ends. // must not be retroactively rewritten by equipping after the fight ends.
expect(state.combats[0].playerState).toEqual({ attack: 6, weaponDamage: 8, armor: 0 }); expect(state.combats[0].playerState).toEqual({
attack: 6,
weaponDamage: 8,
armor: 0,
potionsRemaining: 2,
});
const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id); const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id);
expect(reloaded.status).toBe(result.status); expect(reloaded.status).toBe(result.status);

View File

@@ -325,6 +325,8 @@ describe('CombatService', () => {
name: 'Aric Duskwalker', name: 'Aric Duskwalker',
maxHp: 100, maxHp: 100,
currentHp: 100, currentHp: 100,
potionsRemaining: 2,
potionsMax: 2,
}); });
expect(combat.monster).toEqual({ expect(combat.monster).toEqual({
key: 'ash-rat', key: 'ash-rat',
@@ -333,6 +335,7 @@ describe('CombatService', () => {
maxHp: 45, maxHp: 45,
currentHp: 45, currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
}); });
expect(combat.events).toEqual([]); expect(combat.events).toEqual([]);
expect(dataSource.state.combats).toHaveLength(1); expect(dataSource.state.combats).toHaveLength(1);
@@ -653,6 +656,43 @@ describe('CombatService', () => {
expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]), expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
); );
}); });
it('resolves POTION, heals the player, and persists the reduced potion count', async () => {
const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
expect(result.player.potionsRemaining).toBe(1);
expect(result.player.currentHp).toBe(95);
const reloaded = await service.getCombat(CHARACTER_ID, combatId);
expect(reloaded.player.potionsRemaining).toBe(1);
});
it('rejects POTION once both potions have been used', async () => {
const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
await expectCombatDomainError(
service.performAction(CHARACTER_ID, combatId, CombatAction.POTION),
'COMBAT_NO_POTIONS_REMAINING',
);
});
it('persists the telegraphed Heavy Attack across a reload', async () => {
const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const telegraphed = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(telegraphed.monster.pendingIntent).toBe('HEAVY_ATTACK');
const reloaded = await service.getCombat(CHARACTER_ID, combatId);
expect(reloaded.monster.pendingIntent).toBe('HEAVY_ATTACK');
});
}); });
describe('getCombat', () => { describe('getCombat', () => {

View File

@@ -13,12 +13,13 @@ import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum'; import { TravelStatus } from '../travel/travel-status.enum';
import { CombatAction } from './combat-action.enum'; import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service'; import { CombatEngineService } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types'; import { CombatEngineState, CombatIntent } from './combat-engine.types';
import { import {
characterNotFound, characterNotFound,
characterTravelling, characterTravelling,
combatAlreadyActive, combatAlreadyActive,
combatAlreadyFinished, combatAlreadyFinished,
combatNoPotionsRemaining,
combatNotFound, combatNotFound,
combatStateInvalid, combatStateInvalid,
huntEncounterAlreadyConsumed, huntEncounterAlreadyConsumed,
@@ -27,12 +28,18 @@ import {
} from './combat.errors'; } from './combat.errors';
import { CombatStatus } from './combat-status.enum'; import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity'; import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity'; import { Combat, CombatPlayerState } from './entities/combat.entity';
// Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the
// persistent consumable inventory.
const STARTING_POTION_COUNT = 2;
export interface CombatPlayerDto { export interface CombatPlayerDto {
name: string; name: string;
maxHp: number; maxHp: number;
currentHp: number; currentHp: number;
potionsRemaining: number;
potionsMax: number;
} }
export interface CombatMonsterDto { export interface CombatMonsterDto {
@@ -42,6 +49,7 @@ export interface CombatMonsterDto {
maxHp: number; maxHp: number;
currentHp: number; currentHp: number;
artworkPath: string; artworkPath: string;
pendingIntent: CombatIntent | null;
} }
export interface CombatEventDto { export interface CombatEventDto {
@@ -142,6 +150,7 @@ export class CombatService {
attack: playerStats.attack, attack: playerStats.attack,
weaponDamage: playerStats.weaponDamage, weaponDamage: playerStats.weaponDamage,
armor: playerStats.armor, armor: playerStats.armor,
potionsRemaining: STARTING_POTION_COUNT,
}, },
monsterState: { attack: monster.attack, armor: monster.armor }, monsterState: { attack: monster.attack, armor: monster.armor },
completedAt: null, completedAt: null,
@@ -220,6 +229,9 @@ export class CombatService {
if (combat.status !== CombatStatus.ACTIVE) { if (combat.status !== CombatStatus.ACTIVE) {
throw combatAlreadyFinished(); throw combatAlreadyFinished();
} }
if (action === CombatAction.POTION && (combat.playerState.potionsRemaining ?? 0) <= 0) {
throw combatNoPotionsRemaining();
}
const actionRound = combat.round; const actionRound = combat.round;
const engineState = this.toEngineState(combat); const engineState = this.toEngineState(combat);
@@ -229,6 +241,8 @@ export class CombatService {
combat.status = result.state.status; combat.status = result.state.status;
combat.playerCurrentHp = result.state.player.currentHp; combat.playerCurrentHp = result.state.player.currentHp;
combat.monsterCurrentHp = result.state.monster.currentHp; combat.monsterCurrentHp = result.state.monster.currentHp;
combat.playerState = result.state.player.stats as CombatPlayerState;
combat.monsterState = result.state.monster.stats;
if (combat.status !== CombatStatus.ACTIVE) { if (combat.status !== CombatStatus.ACTIVE) {
combat.completedAt = new Date(); combat.completedAt = new Date();
await this.settleEncounter( await this.settleEncounter(
@@ -386,6 +400,8 @@ export class CombatService {
name: playerName, name: playerName,
maxHp: combat.playerMaxHp, maxHp: combat.playerMaxHp,
currentHp: combat.playerCurrentHp, currentHp: combat.playerCurrentHp,
potionsRemaining: combat.playerState.potionsRemaining,
potionsMax: STARTING_POTION_COUNT,
}, },
monster: { monster: {
key: monster.key, key: monster.key,
@@ -394,6 +410,7 @@ export class CombatService {
maxHp: combat.monsterMaxHp, maxHp: combat.monsterMaxHp,
currentHp: combat.monsterCurrentHp, currentHp: combat.monsterCurrentHp,
artworkPath: monster.artworkPath, artworkPath: monster.artworkPath,
pendingIntent: combat.monsterState.pendingAction ?? null,
}, },
events: events.map((event) => ({ events: events.map((event) => ({
round: event.round, round: event.round,