Files
ashen-realms/apps/web/src/app/features/world/local-location.store.ts
Bastian Wagner 5586e6c672 feat(web): translate error messages surfaced from API error codes to English
Translates the five client-side error-message maps (world, hunting, combat,
inventory, local-location stores) that re-translate already-English API
error codes back to display text, per design doc §4. Also fixes four
component specs that hard-coded the same German strings when asserting
rendered error text (combat-page, hunt-page, location-interaction-panel,
location-page).
2026-08-21 21:29:10 +02:00

98 lines
3.5 KiB
TypeScript

import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
LocationInteractionResult,
LocationPointOfInterest,
LocationPrimaryAction,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from './world.store';
const GENERIC_INTERACTION_ERROR = "This action isn't possible right now.";
// Mirrors the codes in `apps/api/src/world/world.errors.ts`.
const INTERACTION_ERROR_MESSAGES: Readonly<Record<string, string>> = {
LOCATION_INTERACTION_UNAVAILABLE: "There's nothing to discover here.",
CHARACTER_NOT_FOUND: 'Your character could not be found.',
};
/**
* State for the local location view.
*
* Location data comes from `WorldStore`, not from a second fetch path: that
* store already settles due travel before answering, so arriving at a place
* and looking around cannot disagree about where the character is. This store
* adds only what is local to the screen — the open interaction result.
*/
@Injectable({ providedIn: 'root' })
export class LocalLocationStore {
private readonly api = inject(GameApiService);
private readonly worldStore = inject(WorldStore);
private readonly interactionResultState = signal<LocationInteractionResult | null>(null);
private readonly interactionErrorState = signal<string | null>(null);
private readonly interactionPendingState = signal<string | null>(null);
readonly location = this.worldStore.currentLocation;
readonly loading = this.worldStore.loading;
readonly error = this.worldStore.error;
readonly interactionResult = this.interactionResultState.asReadonly();
readonly interactionError = this.interactionErrorState.asReadonly();
/** Key of the interaction currently in flight, so only that control busies. */
readonly interactionPending = this.interactionPendingState.asReadonly();
readonly interactionOpen = computed(
() => this.interactionResultState() !== null || this.interactionErrorState() !== null,
);
load(): Promise<void> {
return this.worldStore.load();
}
/**
* Runs a hotspot or action that reveals text. Navigation types (HUNT, MAP)
* never reach here — the page routes those itself.
*/
async runInteraction(interactionKey: string): Promise<void> {
if (this.interactionPendingState() !== null) {
return;
}
this.interactionPendingState.set(interactionKey);
this.interactionResultState.set(null);
this.interactionErrorState.set(null);
try {
this.interactionResultState.set(
await firstValueFrom(this.api.runLocationInteraction(interactionKey)),
);
} catch (error) {
this.interactionErrorState.set(this.toErrorMessage(error));
} finally {
this.interactionPendingState.set(null);
}
}
closeInteraction(): void {
this.interactionResultState.set(null);
this.interactionErrorState.set(null);
}
/** The hotspot an action mirrors, so an action bar entry can highlight it. */
interactionKeyOf(
action: LocationPrimaryAction | LocationPointOfInterest,
): string | undefined {
return 'poiKey' in action ? action.poiKey : action.key;
}
private toErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
return (code && INTERACTION_ERROR_MESSAGES[code]) || GENERIC_INTERACTION_ERROR;
}
return GENERIC_INTERACTION_ERROR;
}
}