From f33b0c0e4a5115abffc2873d09416bab2e1c93ae Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 19 Aug 2026 22:45:53 +0200 Subject: [PATCH] feat(combat): rejoin the running combat instead of dead-ending Attacking while a combat is already active returned COMBAT_ALREADY_ACTIVE and left the hunt page showing an error the player could not act on, with no way back into the fight they were already in. Add GET /api/combats/active so the client can resolve that combat, and have the hunt page navigate into it when an attack is rejected for this reason. CombatStore now also exposes the error code so callers can tell this case apart from a genuinely failed attack. --- apps/api/src/combat/combat.controller.spec.ts | 27 +++++++++- apps/api/src/combat/combat.controller.ts | 6 +++ apps/api/src/combat/combat.service.spec.ts | 52 +++++++++++++++++++ apps/api/src/combat/combat.service.ts | 18 +++++++ .../src/app/core/api/game-api.service.spec.ts | 8 +++ apps/web/src/app/core/api/game-api.service.ts | 4 ++ .../app/features/combat/combat.store.spec.ts | 33 ++++++++++++ .../src/app/features/combat/combat.store.ts | 48 ++++++++++++++--- .../hunt-page/hunt-page.component.spec.ts | 45 ++++++++++++++++ .../hunting/hunt-page/hunt-page.component.ts | 10 ++++ 10 files changed, 244 insertions(+), 7 deletions(-) diff --git a/apps/api/src/combat/combat.controller.spec.ts b/apps/api/src/combat/combat.controller.spec.ts index a2e5be3..578a32e 100644 --- a/apps/api/src/combat/combat.controller.spec.ts +++ b/apps/api/src/combat/combat.controller.spec.ts @@ -10,14 +10,18 @@ import { CombatService } from './combat.service'; describe('CombatController', () => { let app: INestApplication; const getCombat = jest.fn(); + const getActiveCombat = jest.fn(); const performAction = jest.fn(); beforeEach(async () => { getCombat.mockReset(); + getActiveCombat.mockReset(); performAction.mockReset(); const module = await Test.createTestingModule({ controllers: [CombatController], - providers: [{ provide: CombatService, useValue: { getCombat, performAction } }], + providers: [ + { provide: CombatService, useValue: { getCombat, getActiveCombat, performAction } }, + ], }).compile(); app = module.createNestApplication(); @@ -39,6 +43,27 @@ describe('CombatController', () => { expect(response.body).toEqual(combat); }); + it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => { + const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [] }; + getActiveCombat.mockResolvedValue(combat); + + const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200); + + expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID); + expect(getCombat).not.toHaveBeenCalled(); + expect(response.body).toEqual(combat); + }); + + it('returns an empty body from GET /api/combats/active when no combat is running', async () => { + getActiveCombat.mockResolvedValue(null); + + const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200); + + expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID); + expect(response.body).toEqual({}); + expect(getCombat).not.toHaveBeenCalled(); + }); + it('delegates POST /api/combats/:combatId/actions with only the action field', async () => { const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [] }; performAction.mockResolvedValue(combat); diff --git a/apps/api/src/combat/combat.controller.ts b/apps/api/src/combat/combat.controller.ts index 4d68e56..2b2a6e8 100644 --- a/apps/api/src/combat/combat.controller.ts +++ b/apps/api/src/combat/combat.controller.ts @@ -7,6 +7,12 @@ import { CombatService } from './combat.service'; export class CombatController { constructor(private readonly combatService: CombatService) {} + // Declared before ':combatId' so the literal segment wins the route match. + @Get('active') + getActiveCombat() { + return this.combatService.getActiveCombat(DEMO_CHARACTER_ID); + } + @Get(':combatId') getCombat(@Param('combatId') combatId: string) { return this.combatService.getCombat(DEMO_CHARACTER_ID, combatId); diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index e0ee9ac..3fc0898 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -587,6 +587,58 @@ describe('CombatService', () => { ); }); + it('resolves the character ACTIVE combat so the hunt page can rejoin it', async () => { + const context = createService(); + const started = await context.service.startCombat( + CHARACTER_ID, + ENCOUNTER_ID, + ); + await context.service.performAction( + CHARACTER_ID, + started.id, + CombatAction.ATTACK, + ); + + const active = await context.service.getActiveCombat(CHARACTER_ID); + + expect(active?.id).toBe(started.id); + expect(active?.status).toBe('ACTIVE'); + expect(active?.round).toBe(2); + }); + + it('resolves null when the character has no ACTIVE combat', async () => { + const { service } = createService(); + + await expect(service.getActiveCombat(CHARACTER_ID)).resolves.toBeNull(); + }); + + it('resolves null once the only combat has finished', async () => { + const state = createState({ monsters: [monster({ maxHp: 10 })] }); + const context = createService({ state }); + const started = await context.service.startCombat( + CHARACTER_ID, + ENCOUNTER_ID, + ); + await context.service.performAction( + CHARACTER_ID, + started.id, + CombatAction.ATTACK, + ); + + await expect( + context.service.getActiveCombat(CHARACTER_ID), + ).resolves.toBeNull(); + }); + + it('does not resolve another character ACTIVE combat', async () => { + const context = createService(); + await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID); + + await expect( + context.service.getActiveCombat(OTHER_CHARACTER_ID), + ).resolves.toBeNull(); + }); + it('keeps returning LOST after the combat has ended', async () => { const state = createState({ characters: [character({ baseHp: 1 })], diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index e85434d..60eb4e0 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -168,6 +168,24 @@ export class CombatService { return this.toCombatDto(combat, character.name, monster, events); } + async getActiveCombat(characterId: string): Promise { + const combats = this.dataSource.getRepository(Combat); + const combat = await combats.findOne({ + where: { characterId, status: CombatStatus.ACTIVE }, + }); + if (!combat) { + return null; + } + + const [character, monster, events] = await Promise.all([ + this.loadCharacter(combat.characterId), + this.loadMonster(combat.monsterDefinitionId), + this.loadEvents(combat.id), + ]); + + return this.toCombatDto(combat, character.name, monster, events); + } + async performAction( characterId: string, combatId: string, diff --git a/apps/web/src/app/core/api/game-api.service.spec.ts b/apps/web/src/app/core/api/game-api.service.spec.ts index 932aea3..bc3a992 100644 --- a/apps/web/src/app/core/api/game-api.service.spec.ts +++ b/apps/web/src/app/core/api/game-api.service.spec.ts @@ -65,6 +65,14 @@ describe('GameApiService', () => { request.flush({}); }); + it('gets the running combat from the literal active route', () => { + service.getActiveCombat().subscribe(); + + const request = http.expectOne('/api/combats/active'); + expect(request.request.method).toBe('GET'); + request.flush({}); + }); + it('posts only the action enum when performing a combat action', () => { service.performCombatAction('combat-uuid', 'ATTACK').subscribe(); diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts index f3167f1..ae438f7 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -42,6 +42,10 @@ export class GameApiService { return this.http.get(`/api/combats/${combatId}`); } + getActiveCombat(): Observable { + return this.http.get('/api/combats/active'); + } + performCombatAction(combatId: string, action: CombatAction): Observable { return this.http.post(`/api/combats/${combatId}/actions`, { action }); } diff --git a/apps/web/src/app/features/combat/combat.store.spec.ts b/apps/web/src/app/features/combat/combat.store.spec.ts index 1e439a6..0add925 100644 --- a/apps/web/src/app/features/combat/combat.store.spec.ts +++ b/apps/web/src/app/features/combat/combat.store.spec.ts @@ -37,6 +37,7 @@ describe('CombatStore', () => { let api: { startCombat: ReturnType; getCombat: ReturnType; + getActiveCombat: ReturnType; performCombatAction: ReturnType; }; let store: CombatStore; @@ -45,6 +46,7 @@ describe('CombatStore', () => { api = { startCombat: vi.fn(() => of(startedCombat)), getCombat: vi.fn(() => of(startedCombat)), + getActiveCombat: vi.fn(() => of(startedCombat)), performCombatAction: vi.fn(() => of(afterAttack)), }; @@ -77,6 +79,37 @@ describe('CombatStore', () => { expect(store.combat()).toBeNull(); expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.'); + expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE'); + }); + + it('loads the running combat and clears the error that sent us looking for it', async () => { + api.startCombat.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 409, + error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' }, + }), + ), + ); + await store.startCombat('encounter-1'); + + const active = await store.loadActiveCombat(); + + expect(api.getActiveCombat).toHaveBeenCalledOnce(); + expect(active).toEqual(startedCombat); + expect(store.combat()).toEqual(startedCombat); + expect(store.error()).toBeNull(); + expect(store.errorCode()).toBeNull(); + }); + + it('resolves null and keeps the combat empty when no fight is running', async () => { + api.getActiveCombat.mockReturnValue(of(null)); + + const active = await store.loadActiveCombat(); + + expect(active).toBeNull(); + expect(store.combat()).toBeNull(); }); it('loads a combat by id', async () => { diff --git a/apps/web/src/app/features/combat/combat.store.ts b/apps/web/src/app/features/combat/combat.store.ts index 34b4578..3ca086a 100644 --- a/apps/web/src/app/features/combat/combat.store.ts +++ b/apps/web/src/app/features/combat/combat.store.ts @@ -24,24 +24,46 @@ export class CombatStore { private readonly loadingState = signal(false); private readonly actionPendingState = signal(false); private readonly errorState = signal(null); + private readonly errorCodeState = signal(null); readonly combat = this.combatState.asReadonly(); readonly loading = this.loadingState.asReadonly(); readonly actionPending = this.actionPendingState.asReadonly(); readonly error = this.errorState.asReadonly(); + readonly errorCode = this.errorCodeState.asReadonly(); constructor(private readonly api: GameApiService) {} async startCombat(encounterId: string): Promise { this.loadingState.set(true); - this.errorState.set(null); + this.clearError(); try { const combat = await firstValueFrom(this.api.startCombat(encounterId)); this.combatState.set(combat); } catch (error) { this.combatState.set(null); - this.errorState.set(this.toErrorMessage(error)); + this.setError(error); + } finally { + this.loadingState.set(false); + } + } + + // Resolves the combat the character is already in, so an attack rejected with + // COMBAT_ALREADY_ACTIVE can rejoin that fight instead of dead-ending. + async loadActiveCombat(): Promise { + this.loadingState.set(true); + + try { + const combat = await firstValueFrom(this.api.getActiveCombat()); + if (combat) { + this.combatState.set(combat); + this.clearError(); + } + return combat; + } catch (error) { + this.setError(error); + return null; } finally { this.loadingState.set(false); } @@ -49,13 +71,13 @@ export class CombatStore { async loadCombat(combatId: string): Promise { this.loadingState.set(true); - this.errorState.set(null); + this.clearError(); try { const combat = await firstValueFrom(this.api.getCombat(combatId)); this.combatState.set(combat); } catch (error) { - this.errorState.set(this.toErrorMessage(error)); + this.setError(error); } finally { this.loadingState.set(false); } @@ -68,13 +90,13 @@ export class CombatStore { } this.actionPendingState.set(true); - this.errorState.set(null); + this.clearError(); try { const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK')); this.combatState.set(updated); } catch (error) { - this.errorState.set(this.toErrorMessage(error)); + this.setError(error); } finally { this.actionPendingState.set(false); } @@ -82,6 +104,20 @@ export class CombatStore { clearError(): void { this.errorState.set(null); + this.errorCodeState.set(null); + } + + private setError(error: unknown): void { + this.errorCodeState.set(this.toErrorCode(error)); + this.errorState.set(this.toErrorMessage(error)); + } + + private toErrorCode(error: unknown): string | null { + if (error instanceof HttpErrorResponse) { + return (error.error as { code?: string } | null)?.code ?? null; + } + + return null; } private toErrorMessage(error: unknown): string { diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index 430ff18..f39f1a0 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -98,7 +98,9 @@ describe('HuntPageComponent', () => { let combatStore: { combat: ReturnType>; error: ReturnType>; + errorCode: ReturnType>; startCombat: ReturnType; + loadActiveCombat: ReturnType; clearError: ReturnType; }; let router: Router; @@ -118,7 +120,9 @@ describe('HuntPageComponent', () => { combatStore = { combat: signal(null), error: signal(null), + errorCode: signal(null), startCombat: vi.fn(() => Promise.resolve()), + loadActiveCombat: vi.fn(() => Promise.resolve(null)), clearError: vi.fn(), }; @@ -226,6 +230,47 @@ describe('HuntPageComponent', () => { expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]); }); + it('rejoins the running combat when the attack is rejected with COMBAT_ALREADY_ACTIVE', async () => { + const fixture = await setup(burnedRoad, threeEncounterHunt); + combatStore.startCombat.mockImplementation(async () => { + combatStore.errorCode.set('COMBAT_ALREADY_ACTIVE'); + combatStore.error.set('Du befindest dich bereits in einem Kampf.'); + }); + combatStore.loadActiveCombat.mockResolvedValue({ ...startedCombat, id: 'combat-running' }); + const element = fixture.nativeElement as HTMLElement; + + const attackButtons = Array.from(element.querySelectorAll('button')).filter( + (button) => button.textContent?.trim() === 'Angreifen', + ); + attackButtons[0].click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(combatStore.loadActiveCombat).toHaveBeenCalledOnce(); + expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-running']); + }); + + it('does not look for a running combat when the attack fails for another reason', async () => { + const fixture = await setup(burnedRoad, threeEncounterHunt); + combatStore.startCombat.mockImplementation(async () => { + combatStore.errorCode.set('HUNT_ENCOUNTER_ALREADY_CONSUMED'); + combatStore.error.set('Diese Begegnung wurde bereits genutzt.'); + }); + const element = fixture.nativeElement as HTMLElement; + + const attackButtons = Array.from(element.querySelectorAll('button')).filter( + (button) => button.textContent?.trim() === 'Angreifen', + ); + attackButtons[0].click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(combatStore.loadActiveCombat).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]); + }); + it('shows a combat-start error and dismisses it', async () => { const fixture = await setup(burnedRoad, threeEncounterHunt); combatStore.error.set('Du befindest dich bereits in einem Kampf.'); diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts index 476f7cc..74eadeb 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts @@ -48,6 +48,16 @@ export class HuntPageComponent implements OnInit { const combat = this.combatStore.combat(); if (combat) { void this.router.navigate(['/combat', combat.id]); + return; + } + + // A fight already running is not a dead end: rejoin it rather than + // leaving the player stuck behind an error they cannot act on. + if (this.combatStore.errorCode() === 'COMBAT_ALREADY_ACTIVE') { + const active = await this.combatStore.loadActiveCombat(); + if (active) { + void this.router.navigate(['/combat', active.id]); + } } }