From 49eb110b2550f42aebc580146211af916d0ec5cc Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 19 Aug 2026 13:20:15 +0200 Subject: [PATCH] feat: add HuntingStore for hunt/encounter state Mirrors WorldStore's signal-store architecture to own hunt state for Slice 0.2 (currentHunt, selectedEncounterId, loading, error) with a computed encounters accessor and the four hunt error codes mapped to German messages. --- .../features/hunting/hunting.store.spec.ts | 209 ++++++++++++++++++ .../src/app/features/hunting/hunting.store.ts | 65 ++++++ 2 files changed, 274 insertions(+) create mode 100644 apps/web/src/app/features/hunting/hunting.store.spec.ts create mode 100644 apps/web/src/app/features/hunting/hunting.store.ts diff --git a/apps/web/src/app/features/hunting/hunting.store.spec.ts b/apps/web/src/app/features/hunting/hunting.store.spec.ts new file mode 100644 index 0000000..455bbe8 --- /dev/null +++ b/apps/web/src/app/features/hunting/hunting.store.spec.ts @@ -0,0 +1,209 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import type { HuntResult } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { HuntingStore } from './hunting.store'; + +const huntResult: HuntResult = { + id: 'hunt-id', + location: { id: 'origin-id', key: 'south-gate', name: 'Südtor' }, + encounters: [ + { + id: 'encounter-1', + monster: { key: 'wolf', name: 'Wolf', level: 1, artworkPath: '/images/enemies/Wolf.png' }, + dangerRating: 'MATCH', + }, + { + id: 'encounter-2', + monster: { key: 'bear', name: 'Bär', level: 3, artworkPath: '/images/enemies/Bear.png' }, + dangerRating: 'STRONG', + }, + ], +}; + +const refreshedHuntResult: HuntResult = { + id: 'hunt-id-2', + location: { id: 'origin-id', key: 'south-gate', name: 'Südtor' }, + encounters: [ + { + id: 'encounter-3', + monster: { key: 'rat', name: 'Ratte', level: 1, artworkPath: '/images/enemies/Rat.png' }, + dangerRating: 'WEAK', + }, + ], +}; + +describe('HuntingStore', () => { + let api: { + startHunt: ReturnType; + }; + let store: HuntingStore; + + beforeEach(() => { + api = { + startHunt: vi.fn(() => of(huntResult)), + }; + + TestBed.configureTestingModule({ + providers: [HuntingStore, { provide: GameApiService, useValue: api }], + }); + store = TestBed.inject(HuntingStore); + }); + + it('starts a hunt and populates currentHunt and encounters', async () => { + await store.startHunt(); + + expect(api.startHunt).toHaveBeenCalledOnce(); + expect(store.currentHunt()).toEqual(huntResult); + expect(store.encounters()).toEqual(huntResult.encounters); + expect(store.loading()).toBe(false); + expect(store.error()).toBeNull(); + }); + + it('clears any previously selected encounter when starting a new hunt', async () => { + await store.startHunt(); + store.selectEncounter('encounter-1'); + expect(store.selectedEncounterId()).toBe('encounter-1'); + + await store.startHunt(); + + expect(store.selectedEncounterId()).toBeNull(); + }); + + it('exposes an empty encounters array before any hunt has started', () => { + expect(store.currentHunt()).toBeNull(); + expect(store.encounters()).toEqual([]); + }); + + it('sets loading while the request is in flight and clears it afterwards', async () => { + expect(store.loading()).toBe(false); + + const promise = store.startHunt(); + expect(store.loading()).toBe(true); + + await promise; + + expect(store.loading()).toBe(false); + }); + + it('clears loading even when the API call throws', async () => { + api.startHunt.mockReturnValue(throwError(() => new Error('Netzwerkfehler'))); + + await store.startHunt(); + + expect(store.loading()).toBe(false); + }); + + it('selects an encounter without making a network call', () => { + store.selectEncounter('encounter-2'); + + expect(store.selectedEncounterId()).toBe('encounter-2'); + expect(api.startHunt).not.toHaveBeenCalled(); + }); + + it('refreshHunt issues another API call and replaces currentHunt', async () => { + await store.startHunt(); + expect(store.currentHunt()).toEqual(huntResult); + + api.startHunt.mockReturnValue(of(refreshedHuntResult)); + await store.refreshHunt(); + + expect(api.startHunt).toHaveBeenCalledTimes(2); + expect(store.currentHunt()).toEqual(refreshedHuntResult); + expect(store.encounters()).toEqual(refreshedHuntResult.encounters); + }); + + it('maps CHARACTER_NOT_FOUND to its German message', async () => { + api.startHunt.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 404, + error: { statusCode: 404, code: 'CHARACTER_NOT_FOUND', message: 'Character not found.' }, + }), + ), + ); + + await store.startHunt(); + + expect(store.error()).toBe('Dein Charakter konnte nicht gefunden werden.'); + }); + + it('maps CHARACTER_TRAVELLING to its German message', async () => { + api.startHunt.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 409, + error: { statusCode: 409, code: 'CHARACTER_TRAVELLING', message: 'Character is travelling.' }, + }), + ), + ); + + await store.startHunt(); + + expect(store.error()).toBe('Du kannst nicht jagen, während du unterwegs bist.'); + }); + + it('maps HUNTING_NOT_AVAILABLE to its German message', async () => { + api.startHunt.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 400, + error: { statusCode: 400, code: 'HUNTING_NOT_AVAILABLE', message: 'Hunting not available.' }, + }), + ), + ); + + await store.startHunt(); + + expect(store.error()).toBe('An diesem Ort gibt es keine Jagdgebiete.'); + }); + + it('maps NO_HUNT_ENCOUNTERS_AVAILABLE to its German message', async () => { + api.startHunt.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 404, + error: { + statusCode: 404, + code: 'NO_HUNT_ENCOUNTERS_AVAILABLE', + message: 'No encounters available.', + }, + }), + ), + ); + + await store.startHunt(); + + expect(store.error()).toBe('Aktuell sind hier keine Gegner zu finden.'); + }); + + it('falls back to the generic message for an HttpErrorResponse with no known code', async () => { + api.startHunt.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 500, + error: { message: 'Internal server error' }, + }), + ), + ); + + await store.startHunt(); + + expect(store.error()).toBe('Weltzustand konnte nicht geladen werden.'); + }); + + it('uses the message of a genuine non-HTTP Error', async () => { + api.startHunt.mockReturnValue(throwError(() => new Error('Netzwerkfehler'))); + + await store.startHunt(); + + expect(store.error()).toBe('Netzwerkfehler'); + }); +}); diff --git a/apps/web/src/app/features/hunting/hunting.store.ts b/apps/web/src/app/features/hunting/hunting.store.ts new file mode 100644 index 0000000..a8606e3 --- /dev/null +++ b/apps/web/src/app/features/hunting/hunting.store.ts @@ -0,0 +1,65 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable, computed, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { HuntResult } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; + +const GENERIC_ERROR_MESSAGE = 'Weltzustand konnte nicht geladen werden.'; + +// Mirrors the hunt error codes returned by `POST /api/hunts`. +// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`. +const HUNT_ERROR_MESSAGES: Readonly> = { + CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.', + CHARACTER_TRAVELLING: 'Du kannst nicht jagen, während du unterwegs bist.', + HUNTING_NOT_AVAILABLE: 'An diesem Ort gibt es keine Jagdgebiete.', + NO_HUNT_ENCOUNTERS_AVAILABLE: 'Aktuell sind hier keine Gegner zu finden.', +}; + +@Injectable({ providedIn: 'root' }) +export class HuntingStore { + private readonly currentHuntState = signal(null); + private readonly selectedEncounterIdState = signal(null); + private readonly loadingState = signal(false); + private readonly errorState = signal(null); + + readonly currentHunt = this.currentHuntState.asReadonly(); + readonly selectedEncounterId = this.selectedEncounterIdState.asReadonly(); + readonly loading = this.loadingState.asReadonly(); + readonly error = this.errorState.asReadonly(); + + readonly encounters = computed(() => this.currentHuntState()?.encounters ?? []); + + constructor(private readonly api: GameApiService) {} + + async startHunt(): Promise { + this.loadingState.set(true); + this.errorState.set(null); + + try { + const hunt = await firstValueFrom(this.api.startHunt()); + this.currentHuntState.set(hunt); + this.selectedEncounterIdState.set(null); + } catch (error) { + this.errorState.set(this.toErrorMessage(error)); + } finally { + this.loadingState.set(false); + } + } + + async refreshHunt(): Promise { + await this.startHunt(); + } + + selectEncounter(encounterId: string): void { + this.selectedEncounterIdState.set(encounterId); + } + + private toErrorMessage(error: unknown): string { + if (error instanceof HttpErrorResponse) { + const code = (error.error as { code?: string } | null)?.code; + return (code && HUNT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE; + } + + return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE; + } +}