feat(combat): add CombatStore
Signal-based store wrapping GameApiService.startCombat/getCombat/ performCombatAction with loading/error/actionPending state, following the HuntingStore/WorldStore pattern. startCombat failures clear any previously-loaded combat; loadCombat/attack failures preserve the last-known-good combat. attack() guards against re-entrancy and no-loaded-combat calls. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
161
apps/web/src/app/features/combat/combat.store.spec.ts
Normal file
161
apps/web/src/app/features/combat/combat.store.spec.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||||
|
getCombat: ReturnType<typeof vi.fn>;
|
||||||
|
performCombatAction: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
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<Combat>((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();
|
||||||
|
});
|
||||||
|
});
|
||||||
95
apps/web/src/app/features/combat/combat.store.ts
Normal file
95
apps/web/src/app/features/combat/combat.store.ts
Normal file
@@ -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<Record<string, string>> = {
|
||||||
|
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<Combat | null>(null);
|
||||||
|
private readonly loadingState = signal(false);
|
||||||
|
private readonly actionPendingState = signal(false);
|
||||||
|
private readonly errorState = signal<string | null>(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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user