From c7b9601eb4654b80278dc7ab18247219450598f2 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 21:48:51 +0200 Subject: [PATCH] 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. --- .../combat-equipment-integration.spec.ts | 7 +++- apps/api/src/combat/combat.service.spec.ts | 40 +++++++++++++++++++ apps/api/src/combat/combat.service.ts | 21 +++++++++- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/apps/api/src/combat/combat-equipment-integration.spec.ts b/apps/api/src/combat/combat-equipment-integration.spec.ts index d74d9db..7e6710d 100644 --- a/apps/api/src/combat/combat-equipment-integration.spec.ts +++ b/apps/api/src/combat/combat-equipment-integration.spec.ts @@ -369,7 +369,12 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () // The finished combat's playerState snapshot (written once at startCombat) // 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); expect(reloaded.status).toBe(result.status); diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 9acf50a..74b254a 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -325,6 +325,8 @@ describe('CombatService', () => { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, + potionsRemaining: 2, + potionsMax: 2, }); expect(combat.monster).toEqual({ key: 'ash-rat', @@ -333,6 +335,7 @@ describe('CombatService', () => { maxHp: 45, currentHp: 45, artworkPath: '/images/monsters/ash-rat.png', + pendingIntent: null, }); expect(combat.events).toEqual([]); expect(dataSource.state.combats).toHaveLength(1); @@ -653,6 +656,43 @@ describe('CombatService', () => { 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', () => { diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index 425f804..224e714 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -13,12 +13,13 @@ import { TravelService } from '../travel/travel.service'; import { TravelStatus } from '../travel/travel-status.enum'; import { CombatAction } from './combat-action.enum'; import { CombatEngineService } from './combat-engine.service'; -import { CombatEngineState } from './combat-engine.types'; +import { CombatEngineState, CombatIntent } from './combat-engine.types'; import { characterNotFound, characterTravelling, combatAlreadyActive, combatAlreadyFinished, + combatNoPotionsRemaining, combatNotFound, combatStateInvalid, huntEncounterAlreadyConsumed, @@ -27,12 +28,18 @@ import { } from './combat.errors'; import { CombatStatus } from './combat-status.enum'; 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 { name: string; maxHp: number; currentHp: number; + potionsRemaining: number; + potionsMax: number; } export interface CombatMonsterDto { @@ -42,6 +49,7 @@ export interface CombatMonsterDto { maxHp: number; currentHp: number; artworkPath: string; + pendingIntent: CombatIntent | null; } export interface CombatEventDto { @@ -142,6 +150,7 @@ export class CombatService { attack: playerStats.attack, weaponDamage: playerStats.weaponDamage, armor: playerStats.armor, + potionsRemaining: STARTING_POTION_COUNT, }, monsterState: { attack: monster.attack, armor: monster.armor }, completedAt: null, @@ -220,6 +229,9 @@ export class CombatService { if (combat.status !== CombatStatus.ACTIVE) { throw combatAlreadyFinished(); } + if (action === CombatAction.POTION && (combat.playerState.potionsRemaining ?? 0) <= 0) { + throw combatNoPotionsRemaining(); + } const actionRound = combat.round; const engineState = this.toEngineState(combat); @@ -229,6 +241,8 @@ export class CombatService { combat.status = result.state.status; combat.playerCurrentHp = result.state.player.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) { combat.completedAt = new Date(); await this.settleEncounter( @@ -386,6 +400,8 @@ export class CombatService { name: playerName, maxHp: combat.playerMaxHp, currentHp: combat.playerCurrentHp, + potionsRemaining: combat.playerState.potionsRemaining, + potionsMax: STARTING_POTION_COUNT, }, monster: { key: monster.key, @@ -394,6 +410,7 @@ export class CombatService { maxHp: combat.monsterMaxHp, currentHp: combat.monsterCurrentHp, artworkPath: monster.artworkPath, + pendingIntent: combat.monsterState.pendingAction ?? null, }, events: events.map((event) => ({ round: event.round,