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]); + } } }