Files
ashen-realms/apps/web/src/app/features/combat/combat.store.ts
Bastian Wagner f33b0c0e4a feat(combat): rejoin the running combat instead of dead-ending
Attacking while a combat is already active returned COMBAT_ALREADY_ACTIVE
and left the hunt page showing an error the player could not act on, with
no way back into the fight they were already in.

Add GET /api/combats/active so the client can resolve that combat, and
have the hunt page navigate into it when an attack is rejected for this
reason. CombatStore now also exposes the error code so callers can tell
this case apart from a genuinely failed attack.
2026-08-19 22:45:53 +02:00

132 lines
4.2 KiB
TypeScript

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);
private readonly errorCodeState = signal<string | null>(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<void> {
this.loadingState.set(true);
this.clearError();
try {
const combat = await firstValueFrom(this.api.startCombat(encounterId));
this.combatState.set(combat);
} catch (error) {
this.combatState.set(null);
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<Combat | null> {
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);
}
}
async loadCombat(combatId: string): Promise<void> {
this.loadingState.set(true);
this.clearError();
try {
const combat = await firstValueFrom(this.api.getCombat(combatId));
this.combatState.set(combat);
} catch (error) {
this.setError(error);
} finally {
this.loadingState.set(false);
}
}
async attack(): Promise<void> {
const combat = this.combatState();
if (!combat || this.actionPendingState()) {
return;
}
this.actionPendingState.set(true);
this.clearError();
try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
this.combatState.set(updated);
} catch (error) {
this.setError(error);
} finally {
this.actionPendingState.set(false);
}
}
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 {
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;
}
}