diff --git a/apps/web/src/app/features/combat/combat.store.spec.ts b/apps/web/src/app/features/combat/combat.store.spec.ts new file mode 100644 index 0000000..1e439a6 --- /dev/null +++ b/apps/web/src/app/features/combat/combat.store.spec.ts @@ -0,0 +1,161 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { from, of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import type { Combat } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { CombatStore } from './combat.store'; + +const startedCombat: Combat = { + id: 'combat-1', + status: 'ACTIVE', + round: 1, + player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 }, + monster: { + key: 'ash-rat', + name: 'Aschenratte', + level: 1, + maxHp: 45, + currentHp: 45, + artworkPath: '/images/monsters/ash-rat.png', + }, + events: [], +}; + +const afterAttack: Combat = { + ...startedCombat, + round: 2, + player: { ...startedCombat.player, currentHp: 95 }, + monster: { ...startedCombat.monster, currentHp: 31 }, + events: [ + { round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 }, + { round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, + ], +}; + +describe('CombatStore', () => { + let api: { + startCombat: ReturnType; + getCombat: ReturnType; + performCombatAction: ReturnType; + }; + let store: CombatStore; + + beforeEach(() => { + api = { + startCombat: vi.fn(() => of(startedCombat)), + getCombat: vi.fn(() => of(startedCombat)), + performCombatAction: vi.fn(() => of(afterAttack)), + }; + + TestBed.configureTestingModule({ + providers: [CombatStore, { provide: GameApiService, useValue: api }], + }); + store = TestBed.inject(CombatStore); + }); + + it('starts a combat and stores it', async () => { + await store.startCombat('encounter-1'); + + expect(api.startCombat).toHaveBeenCalledWith('encounter-1'); + expect(store.combat()).toEqual(startedCombat); + expect(store.error()).toBeNull(); + }); + + it('clears any previous combat and reports the mapped error when starting fails', async () => { + api.startCombat.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 409, + error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' }, + }), + ), + ); + + await store.startCombat('encounter-1'); + + expect(store.combat()).toBeNull(); + expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.'); + }); + + it('loads a combat by id', async () => { + await store.loadCombat('combat-1'); + + expect(api.getCombat).toHaveBeenCalledWith('combat-1'); + expect(store.combat()).toEqual(startedCombat); + }); + + it('reports the mapped error when loading an unknown combat', async () => { + api.getCombat.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 404, + error: { statusCode: 404, code: 'COMBAT_NOT_FOUND', message: 'Not found.' }, + }), + ), + ); + + await store.loadCombat('unknown'); + + expect(store.error()).toBe('Dieser Kampf wurde nicht gefunden.'); + }); + + it('sends only the ATTACK action and replaces combat with the server response', async () => { + await store.startCombat('encounter-1'); + + await store.attack(); + + expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK'); + expect(store.combat()).toEqual(afterAttack); + }); + + it('does nothing when attacking without a loaded combat', async () => { + await store.attack(); + + expect(api.performCombatAction).not.toHaveBeenCalled(); + }); + + it('ignores a second attack while the first is still pending', async () => { + await store.startCombat('encounter-1'); + let resolveAttack!: (value: Combat) => void; + api.performCombatAction.mockReturnValue( + from( + new Promise((resolve) => { + resolveAttack = resolve; + }), + ), + ); + + const first = store.attack(); + expect(store.actionPending()).toBe(true); + const second = store.attack(); + + resolveAttack(afterAttack); + await Promise.all([first, second]); + + expect(api.performCombatAction).toHaveBeenCalledOnce(); + }); + + it('clears actionPending after a failed attack and keeps the previous combat state', async () => { + await store.startCombat('encounter-1'); + api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler'))); + + await store.attack(); + + expect(store.actionPending()).toBe(false); + expect(store.combat()).toEqual(startedCombat); + expect(store.error()).toBe('Netzwerkfehler'); + }); + + it('clears the error message', async () => { + api.startCombat.mockReturnValue(throwError(() => new Error('x'))); + await store.startCombat('encounter-1'); + expect(store.error()).not.toBeNull(); + + store.clearError(); + + expect(store.error()).toBeNull(); + }); +}); diff --git a/apps/web/src/app/features/combat/combat.store.ts b/apps/web/src/app/features/combat/combat.store.ts new file mode 100644 index 0000000..34b4578 --- /dev/null +++ b/apps/web/src/app/features/combat/combat.store.ts @@ -0,0 +1,95 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { Combat } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; + +const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.'; + +// Mirrors the combat error codes returned by the combat endpoints. +// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`. +const COMBAT_ERROR_MESSAGES: Readonly> = { + HUNT_ENCOUNTER_NOT_FOUND: 'Diese Begegnung wurde nicht gefunden.', + HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.', + INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.', + CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.', + COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.', + COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.', + COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.', +}; + +@Injectable({ providedIn: 'root' }) +export class CombatStore { + private readonly combatState = signal(null); + private readonly loadingState = signal(false); + private readonly actionPendingState = signal(false); + private readonly errorState = signal(null); + + readonly combat = this.combatState.asReadonly(); + readonly loading = this.loadingState.asReadonly(); + readonly actionPending = this.actionPendingState.asReadonly(); + readonly error = this.errorState.asReadonly(); + + constructor(private readonly api: GameApiService) {} + + async startCombat(encounterId: string): Promise { + this.loadingState.set(true); + this.errorState.set(null); + + 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)); + } finally { + this.loadingState.set(false); + } + } + + async loadCombat(combatId: string): Promise { + this.loadingState.set(true); + this.errorState.set(null); + + try { + const combat = await firstValueFrom(this.api.getCombat(combatId)); + this.combatState.set(combat); + } catch (error) { + this.errorState.set(this.toErrorMessage(error)); + } finally { + this.loadingState.set(false); + } + } + + async attack(): Promise { + const combat = this.combatState(); + if (!combat || this.actionPendingState()) { + return; + } + + this.actionPendingState.set(true); + this.errorState.set(null); + + try { + const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK')); + this.combatState.set(updated); + } catch (error) { + this.errorState.set(this.toErrorMessage(error)); + } finally { + this.actionPendingState.set(false); + } + } + + clearError(): void { + this.errorState.set(null); + } + + private toErrorMessage(error: unknown): string { + if (error instanceof HttpErrorResponse) { + const code = (error.error as { code?: string } | null)?.code; + return (code && COMBAT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE; + } + + return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE; + } +}