feat: add signal-driven world state

This commit is contained in:
Bastian Wagner
2026-08-18 21:36:27 +02:00
parent c641168c7d
commit 610c404759
7 changed files with 487 additions and 4 deletions

View File

@@ -0,0 +1,73 @@
# Task 7 — Angular typed API and signal-driven WorldStore report
## Scope delivered
- Added typed public response models and `GameApiService` methods for character,
current location, travel start, and current travel.
- Configured Angular's application providers with `HttpClient`.
- Added `WorldStore` with private writable and public read-only signals for
character, world location, selection, travel, countdown, loading, and errors.
- The store derives presentation-only countdown seconds from server `arrivesAt`.
At zero it polls the API and never assigns the target location locally.
- Character and location are reloaded only after the API returns `COMPLETED`.
## Required preflight
- Re-read every document under `docs/`, including the approved design and
implementation plan, and inspected all three PNG reference images.
- Read the user-supplied visual asset guide. The untracked `apps/web/public/images/`
directory and `docs/references/Ashen_Realms_Visual_Asset_Style_Guide_V1.md`
remain unchanged and unstaged.
## TDD evidence
### RED
```powershell
npm test --workspace=@ashen-realms/web -- --watch=false
```
Initial result: failed as expected because `game-api.service`,
`game-api.models`, and `world.store` did not exist. The compiler reported only
their unresolved imports from the newly added tests.
### GREEN
```powershell
npm test --workspace=@ashen-realms/web -- --watch=false --include='src/app/core/api/game-api.service.spec.ts' --include='src/app/features/world/world.store.spec.ts'
```
Result: 2 test files passed, 7 tests passed.
The store tests cover initial loading, target-only travel start, `arrivesAt`
countdown calculation, polling at zero without local arrival, and reload only
after `COMPLETED`.
## Verification evidence
```powershell
npm test --workspace=@ashen-realms/web -- --watch=false
# 3 test files passed, 9 tests passed
npm run build:web
# Angular production build completed successfully
npm exec --workspace=@ashen-realms/web -- prettier --check <Task-7 files>
# All matched files use Prettier code style
git diff --check -- <Task-7 files>
# exit 0
```
The web workspace declares neither a `lint` script nor an ESLint dependency, so
there is no repository-configured lint command to run for this task.
## Deliberate limits and concerns
- The store retains a `COMPLETED` travel response after its authoritative
character/location refresh. The following API poll returns `IDLE`; the later
world UI can decide when to clear the completion presentation.
- The mandated code-review workflow normally requires a reviewer subagent, but
this task explicitly prohibited subagents. The implementation was instead
reviewed directly against the task brief and verified through the focused and
complete web test/build checks above.

View File

@@ -1,11 +1,9 @@
import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes)
]
providers: [provideBrowserGlobalErrorListeners(), provideHttpClient(), provideRouter(routes)],
};

View File

@@ -0,0 +1,48 @@
export interface LocationSummary {
id: string;
key: string;
name: string;
}
export interface CharacterResponse {
id: string;
name: string;
level: number;
experience: number;
currentHp: number;
maxHp: number;
attack: number;
currentLocation: LocationSummary;
}
export interface CurrentLocationConnection {
targetLocation: LocationSummary;
travelDurationSeconds: number;
danger: 'LOW' | 'HIGH';
}
export interface CurrentLocationResponse {
id: string;
key: string;
name: string;
description: string;
regionKey: string;
minRecommendedLevel: number;
maxRecommendedLevel: number;
dangerLevel: number;
isSafe: boolean;
huntingEnabled: boolean;
artworkPath: string;
connections: CurrentLocationConnection[];
}
export type CurrentTravel =
| { status: 'IDLE' }
| {
status: 'TRAVELLING';
originLocation: LocationSummary;
targetLocation: LocationSummary;
startedAt: string;
arrivesAt: string;
}
| { status: 'COMPLETED'; targetLocation: LocationSummary };

View File

@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { GameApiService } from './game-api.service';
describe('GameApiService', () => {
let service: GameApiService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [GameApiService, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(GameApiService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => {
http.verify();
});
it('uses relative API URLs for all read requests', () => {
service.getCharacter().subscribe();
service.getCurrentLocation().subscribe();
service.getCurrentTravel().subscribe();
const requests = http.match((request) => request.method === 'GET');
expect(requests.map((request) => request.request.url)).toEqual([
'/api/characters/me',
'/api/world/current-location',
'/api/travel/current',
]);
expect(requests.every((request) => !request.request.url.includes('://'))).toBe(true);
for (const request of requests) {
request.flush({});
}
});
it('posts only the target location ID when starting travel', () => {
service.startTravel('target-uuid').subscribe();
const request = http.expectOne('/api/travel');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({ targetLocationId: 'target-uuid' });
request.flush({ status: 'IDLE' });
});
});

View File

@@ -0,0 +1,25 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { CharacterResponse, CurrentLocationResponse, CurrentTravel } from './game-api.models';
@Injectable({ providedIn: 'root' })
export class GameApiService {
constructor(private readonly http: HttpClient) {}
getCharacter(): Observable<CharacterResponse> {
return this.http.get<CharacterResponse>('/api/characters/me');
}
getCurrentLocation(): Observable<CurrentLocationResponse> {
return this.http.get<CurrentLocationResponse>('/api/world/current-location');
}
startTravel(targetLocationId: string): Observable<CurrentTravel> {
return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
}
getCurrentTravel(): Observable<CurrentTravel> {
return this.http.get<CurrentTravel>('/api/travel/current');
}
}

View File

@@ -0,0 +1,137 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { vi } from 'vitest';
import type {
CharacterResponse,
CurrentLocationResponse,
CurrentTravel,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from './world.store';
const character: CharacterResponse = {
id: 'character-id',
name: 'Aric Duskwalker',
level: 1,
experience: 0,
currentHp: 100,
maxHp: 100,
attack: 6,
currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
};
const currentLocation: CurrentLocationResponse = {
id: 'origin-id',
key: 'south-gate',
name: 'Südtor',
description: 'Der Ausgang zur Wildnis.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 1,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [
{
targetLocation: {
id: 'target-id',
key: 'burned-road',
name: 'Verbrannte Straße',
},
travelDurationSeconds: 10,
danger: 'LOW',
},
],
};
const travelling: CurrentTravel = {
status: 'TRAVELLING',
originLocation: character.currentLocation,
targetLocation: currentLocation.connections[0].targetLocation,
startedAt: '2026-08-18T10:00:00.000Z',
arrivesAt: '2026-08-18T10:00:10.000Z',
};
describe('WorldStore', () => {
let api: {
getCharacter: ReturnType<typeof vi.fn>;
getCurrentLocation: ReturnType<typeof vi.fn>;
getCurrentTravel: ReturnType<typeof vi.fn>;
startTravel: ReturnType<typeof vi.fn>;
};
let store: WorldStore;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z'));
api = {
getCharacter: vi.fn(() => of(character)),
getCurrentLocation: vi.fn(() => of(currentLocation)),
getCurrentTravel: vi.fn(() => of({ status: 'IDLE' } satisfies CurrentTravel)),
startTravel: vi.fn(() => of(travelling)),
};
TestBed.configureTestingModule({
providers: [WorldStore, { provide: GameApiService, useValue: api }],
});
store = TestBed.inject(WorldStore);
});
afterEach(() => {
vi.useRealTimers();
});
it('loads the character, current location, and current travel', async () => {
await store.load();
expect(api.getCharacter).toHaveBeenCalledOnce();
expect(api.getCurrentLocation).toHaveBeenCalledOnce();
expect(api.getCurrentTravel).toHaveBeenCalledOnce();
expect(store.character()).toEqual(character);
expect(store.currentLocation()).toEqual(currentLocation);
});
it('starts travel with only the selected target ID', async () => {
await store.load();
store.selectConnection(currentLocation.connections[0]);
await store.startTravel();
expect(api.startTravel).toHaveBeenCalledWith('target-id');
});
it('derives the remaining seconds from the server arrivesAt timestamp', async () => {
api.getCurrentTravel.mockReturnValue(of(travelling));
await store.load();
expect(store.remainingSeconds()).toBe(10);
});
it('polls the server at zero without assigning the target as the current location', async () => {
api.getCurrentTravel
.mockReturnValueOnce(of(travelling))
.mockReturnValueOnce(of({ status: 'IDLE' } satisfies CurrentTravel));
await store.load();
const initialLocation = store.currentLocation();
await vi.advanceTimersByTimeAsync(10_000);
expect(api.getCurrentTravel).toHaveBeenCalledTimes(2);
expect(store.currentLocation()).toBe(initialLocation);
});
it('reloads character and location only after the server reports completion', async () => {
api.getCurrentTravel
.mockReturnValueOnce(of(travelling))
.mockReturnValueOnce(of({ status: 'COMPLETED', targetLocation: travelling.targetLocation }));
await store.load();
await vi.advanceTimersByTimeAsync(10_000);
expect(api.getCharacter).toHaveBeenCalledTimes(2);
expect(api.getCurrentLocation).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,152 @@
import { Injectable, OnDestroy, signal } from '@angular/core';
import { firstValueFrom, forkJoin } from 'rxjs';
import {
CharacterResponse,
CurrentLocationConnection,
CurrentLocationResponse,
CurrentTravel,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
@Injectable({ providedIn: 'root' })
export class WorldStore implements OnDestroy {
private readonly characterState = signal<CharacterResponse | null>(null);
private readonly currentLocationState = signal<CurrentLocationResponse | null>(null);
private readonly selectedConnectionState = signal<CurrentLocationConnection | null>(null);
private readonly currentTravelState = signal<CurrentTravel | null>(null);
private readonly remainingSecondsState = signal<number | null>(null);
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null);
private countdownTimer: ReturnType<typeof setInterval> | undefined;
readonly character = this.characterState.asReadonly();
readonly currentLocation = this.currentLocationState.asReadonly();
readonly selectedConnection = this.selectedConnectionState.asReadonly();
readonly currentTravel = this.currentTravelState.asReadonly();
readonly remainingSeconds = this.remainingSecondsState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly error = this.errorState.asReadonly();
constructor(private readonly api: GameApiService) {}
async load(): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
try {
const travel = await this.loadSnapshot();
await this.setCurrentTravel(travel);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
selectConnection(connection: CurrentLocationConnection | null): void {
this.selectedConnectionState.set(connection);
}
async startTravel(): Promise<void> {
const connection = this.selectedConnectionState();
if (!connection) {
return;
}
this.loadingState.set(true);
this.errorState.set(null);
try {
const travel = await firstValueFrom(this.api.startTravel(connection.targetLocation.id));
await this.setCurrentTravel(travel);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
ngOnDestroy(): void {
this.stopCountdown();
}
private async loadSnapshot(): Promise<CurrentTravel> {
const { character, location, travel } = await firstValueFrom(
forkJoin({
character: this.api.getCharacter(),
location: this.api.getCurrentLocation(),
travel: this.api.getCurrentTravel(),
}),
);
this.characterState.set(character);
this.currentLocationState.set(location);
this.selectedConnectionState.set(null);
return travel;
}
private async setCurrentTravel(travel: CurrentTravel): Promise<void> {
this.currentTravelState.set(travel);
this.stopCountdown();
if (travel.status === 'TRAVELLING') {
this.startCountdown(travel.arrivesAt);
return;
}
this.remainingSecondsState.set(null);
if (travel.status === 'COMPLETED') {
await this.reloadAuthoritativeState();
}
}
private startCountdown(arrivesAt: string): void {
const updateRemainingSeconds = () => {
const remainingSeconds = Math.max(0, Math.ceil((Date.parse(arrivesAt) - Date.now()) / 1000));
this.remainingSecondsState.set(remainingSeconds);
if (remainingSeconds === 0) {
this.stopCountdown();
void this.refreshTravelAfterCountdown();
}
};
updateRemainingSeconds();
if (this.countdownTimer === undefined) {
this.countdownTimer = setInterval(updateRemainingSeconds, 1_000);
}
}
private async refreshTravelAfterCountdown(): Promise<void> {
try {
const travel = await firstValueFrom(this.api.getCurrentTravel());
await this.setCurrentTravel(travel);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
}
}
private async reloadAuthoritativeState(): Promise<void> {
const { character, location } = await firstValueFrom(
forkJoin({
character: this.api.getCharacter(),
location: this.api.getCurrentLocation(),
}),
);
this.characterState.set(character);
this.currentLocationState.set(location);
this.selectedConnectionState.set(null);
}
private stopCountdown(): void {
if (this.countdownTimer !== undefined) {
clearInterval(this.countdownTimer);
this.countdownTimer = undefined;
}
}
private toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unable to load world state.';
}
}