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.
This commit is contained in:
Bastian Wagner
2026-08-19 13:20:15 +02:00
parent ed03726931
commit 49eb110b25
2 changed files with 274 additions and 0 deletions

View File

@@ -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<typeof vi.fn>;
};
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');
});
});

View File

@@ -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<Record<string, string>> = {
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<HuntResult | null>(null);
private readonly selectedEncounterIdState = signal<string | null>(null);
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(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<void> {
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<void> {
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;
}
}