feat(hunting): show cleared encounters and resume interrupted fights
The hunt screen kept whatever roll was last in memory, so a player coming back from a fight saw every encounter as fresh. Encounters now carry their own status, which the combat module advances as fights start and end. - hunt_encounters.status replaces consumed_at, which only recorded that a fight had begun and could not distinguish a win from a loss - a lost fight hands the encounter back as AVAILABLE, so it can be retried; the unique index tying one combat to one encounter goes with it - GET /hunts/active serves the resumable hunt, which the hunt page adopts on entry rather than trusting its in-memory roll - defeated encounters are crossed out and lose their hover and attack action - a fresh page load rejoins a combat the server still holds open Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { CharacterCombatStatsService } from '../characters/character-combat-stat
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
|
||||
import { HuntStatus } from '../hunting/hunt-status.enum';
|
||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||
import { TravelService } from '../travel/travel.service';
|
||||
@@ -221,7 +222,7 @@ function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
|
||||
huntId: HUNT_ID,
|
||||
monsterDefinitionId: MONSTER_ID,
|
||||
position: 0,
|
||||
consumedAt: null,
|
||||
status: HuntEncounterStatus.AVAILABLE,
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
} as HuntEncounter;
|
||||
@@ -315,12 +316,14 @@ describe('CombatService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the encounter as consumed', async () => {
|
||||
it('marks the encounter as IN_PROGRESS', async () => {
|
||||
const { dataSource, service } = createService();
|
||||
|
||||
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(dataSource.state.huntEncounters[0].consumedAt).not.toBeNull();
|
||||
expect(dataSource.state.huntEncounters[0].status).toBe(
|
||||
HuntEncounterStatus.IN_PROGRESS,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown encounter id', async () => {
|
||||
@@ -332,10 +335,25 @@ describe('CombatService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an already-consumed encounter, and does not create a second combat', async () => {
|
||||
it('rejects an already-defeated encounter, and does not create a second combat', async () => {
|
||||
const state = createState({
|
||||
huntEncounters: [
|
||||
huntEncounter({ consumedAt: new Date('2026-08-18T09:05:00.000Z') }),
|
||||
huntEncounter({ status: HuntEncounterStatus.DEFEATED }),
|
||||
],
|
||||
});
|
||||
const { dataSource, service } = createService({ state });
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
|
||||
);
|
||||
expect(dataSource.state.combats).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an encounter whose fight is still IN_PROGRESS', async () => {
|
||||
const state = createState({
|
||||
huntEncounters: [
|
||||
huntEncounter({ status: HuntEncounterStatus.IN_PROGRESS }),
|
||||
],
|
||||
});
|
||||
const { dataSource, service } = createService({ state });
|
||||
@@ -518,6 +536,55 @@ describe('CombatService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the encounter DEFEATED when the fight is won', async () => {
|
||||
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
expect(dataSource.state.huntEncounters[0].status).toBe(
|
||||
HuntEncounterStatus.DEFEATED,
|
||||
);
|
||||
});
|
||||
|
||||
it('frees the encounter for another attempt when the fight is lost', async () => {
|
||||
const state = createState({ characters: [character({ baseHp: 1 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
expect(dataSource.state.huntEncounters[0].status).toBe(
|
||||
HuntEncounterStatus.AVAILABLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the encounter IN_PROGRESS while the fight continues', async () => {
|
||||
const { dataSource, service, combatId } = await startedCombat();
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
expect(dataSource.state.huntEncounters[0].status).toBe(
|
||||
HuntEncounterStatus.IN_PROGRESS,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a lost encounter be fought again as a fresh combat', async () => {
|
||||
const state = createState({ characters: [character({ baseHp: 1 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(retry.id).not.toBe(combatId);
|
||||
expect(retry.status).toBe('ACTIVE');
|
||||
expect(retry.round).toBe(1);
|
||||
expect(retry.player.currentHp).toBe(retry.player.maxHp);
|
||||
expect(dataSource.state.combats).toHaveLength(2);
|
||||
expect(dataSource.state.huntEncounters[0].status).toBe(
|
||||
HuntEncounterStatus.IN_PROGRESS,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects actions on an unknown combat id', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user