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:
@@ -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);
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user