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

@@ -10,15 +10,17 @@ import { HuntingService } from './hunting.service';
describe('HuntingController', () => {
let app: INestApplication<App>;
const startHunt = jest.fn();
const getActiveHunt = jest.fn();
beforeEach(async () => {
startHunt.mockReset();
getActiveHunt.mockReset();
const module = await Test.createTestingModule({
controllers: [HuntingController],
providers: [
{
provide: HuntingService,
useValue: { startHunt },
useValue: { startHunt, getActiveHunt },
},
],
}).compile();
@@ -47,4 +49,42 @@ describe('HuntingController', () => {
expect(startHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(huntResult);
});
it('serves the resumable hunt with its encounter statuses', async () => {
const huntResult = {
id: 'hunt-1',
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' },
encounters: [
{
id: 'encounter-1',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/monsters/ash-rat.png',
},
dangerRating: 'WEAK',
status: 'DEFEATED',
},
],
};
getActiveHunt.mockResolvedValue(huntResult);
const response = await request(app.getHttpServer())
.get('/api/hunts/active')
.expect(200);
expect(getActiveHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(huntResult);
});
it('serves an empty body when there is no resumable hunt', async () => {
getActiveHunt.mockResolvedValue(null);
const response = await request(app.getHttpServer())
.get('/api/hunts/active')
.expect(200);
expect(response.body).toEqual({});
});
});