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:
Bastian Wagner
2026-08-20 10:34:30 +02:00
parent 67237f5ad8
commit 3c59603efb
26 changed files with 869 additions and 66 deletions

View File

@@ -46,6 +46,7 @@ const threeEncounterHunt: HuntResult = {
id: 'encounter-1',
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
status: 'AVAILABLE',
},
{
id: 'encounter-2',
@@ -56,11 +57,13 @@ const threeEncounterHunt: HuntResult = {
artworkPath: '/images/enemies/RoadBandit.png',
},
dangerRating: 'MATCH',
status: 'AVAILABLE',
},
{
id: 'encounter-3',
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
status: 'AVAILABLE',
},
],
};
@@ -93,6 +96,7 @@ describe('HuntPageComponent', () => {
encounters: () => HuntResult['encounters'];
startHunt: ReturnType<typeof vi.fn>;
refreshHunt: ReturnType<typeof vi.fn>;
loadActiveHunt: ReturnType<typeof vi.fn>;
selectEncounter: ReturnType<typeof vi.fn>;
};
let combatStore: {
@@ -115,6 +119,7 @@ describe('HuntPageComponent', () => {
encounters: () => currentHunt()?.encounters ?? [],
startHunt: vi.fn(() => Promise.resolve()),
refreshHunt: vi.fn(() => Promise.resolve()),
loadActiveHunt: vi.fn(() => Promise.resolve()),
selectEncounter: vi.fn(),
};
combatStore = {
@@ -301,6 +306,75 @@ describe('HuntPageComponent', () => {
expect(huntingStore.startHunt).not.toHaveBeenCalled();
});
it('adopts the resumable hunt on page entry so cleared encounters stay marked', async () => {
await setup(burnedRoad);
expect(huntingStore.loadActiveHunt).toHaveBeenCalledOnce();
expect(huntingStore.startHunt).not.toHaveBeenCalled();
});
it('marks a defeated encounter and takes away its attack action', async () => {
const clearedHunt: HuntResult = {
...threeEncounterHunt,
encounters: [
{ ...threeEncounterHunt.encounters[0], status: 'DEFEATED' },
threeEncounterHunt.encounters[1],
threeEncounterHunt.encounters[2],
],
};
const fixture = await setup(burnedRoad, clearedHunt);
const element = fixture.nativeElement as HTMLElement;
const cards = element.querySelectorAll('app-encounter-card');
expect(cards[0].querySelector('.encounter-card__defeated-mark')).not.toBeNull();
expect(cards[1].querySelector('.encounter-card__defeated-mark')).toBeNull();
const attackButtons = Array.from(
element.querySelectorAll<HTMLButtonElement>('.encounter-card__attack'),
);
expect(attackButtons[0].disabled).toBe(true);
expect(attackButtons[1].disabled).toBe(false);
});
it('keeps an in-progress encounter unmarked but locked', async () => {
const fightingHunt: HuntResult = {
...threeEncounterHunt,
encounters: [
{ ...threeEncounterHunt.encounters[0], status: 'IN_PROGRESS' },
threeEncounterHunt.encounters[1],
threeEncounterHunt.encounters[2],
],
};
const fixture = await setup(burnedRoad, fightingHunt);
const element = fixture.nativeElement as HTMLElement;
const cards = element.querySelectorAll('app-encounter-card');
expect(cards[0].querySelector('.encounter-card__defeated-mark')).toBeNull();
const attackButtons = Array.from(
element.querySelectorAll<HTMLButtonElement>('.encounter-card__attack'),
);
expect(attackButtons[0].disabled).toBe(true);
});
it('does not start a combat from a defeated encounter card', async () => {
const clearedHunt: HuntResult = {
...threeEncounterHunt,
encounters: [
{ ...threeEncounterHunt.encounters[0], status: 'DEFEATED' },
threeEncounterHunt.encounters[1],
threeEncounterHunt.encounters[2],
],
};
const fixture = await setup(burnedRoad, clearedHunt);
const element = fixture.nativeElement as HTMLElement;
element.querySelectorAll<HTMLButtonElement>('.encounter-card__attack')[0].click();
await Promise.resolve();
expect(combatStore.startCombat).not.toHaveBeenCalled();
});
it('loads the world state on init when no location has been loaded yet (direct navigation/hard refresh)', async () => {
await setup(null);