From 610c404759a5e9c8bc63ef4bba2831d4dc91e0d8 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 18 Aug 2026 21:36:27 +0200 Subject: [PATCH] feat: add signal-driven world state --- .../task-7-report.md | 73 +++++++++ apps/web/src/app/app.config.ts | 6 +- apps/web/src/app/core/api/game-api.models.ts | 48 ++++++ .../src/app/core/api/game-api.service.spec.ts | 50 ++++++ apps/web/src/app/core/api/game-api.service.ts | 25 +++ .../app/features/world/world.store.spec.ts | 137 ++++++++++++++++ .../web/src/app/features/world/world.store.ts | 152 ++++++++++++++++++ 7 files changed, 487 insertions(+), 4 deletions(-) create mode 100644 .superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-7-report.md create mode 100644 apps/web/src/app/core/api/game-api.models.ts create mode 100644 apps/web/src/app/core/api/game-api.service.spec.ts create mode 100644 apps/web/src/app/core/api/game-api.service.ts create mode 100644 apps/web/src/app/features/world/world.store.spec.ts create mode 100644 apps/web/src/app/features/world/world.store.ts diff --git a/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-7-report.md b/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-7-report.md new file mode 100644 index 0000000..fb137b1 --- /dev/null +++ b/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-7-report.md @@ -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 +# All matched files use Prettier code style + +git diff --check -- +# 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. diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index cb1270e..744121a 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -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)], }; diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts new file mode 100644 index 0000000..c5ed58a --- /dev/null +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -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 }; diff --git a/apps/web/src/app/core/api/game-api.service.spec.ts b/apps/web/src/app/core/api/game-api.service.spec.ts new file mode 100644 index 0000000..d05bf54 --- /dev/null +++ b/apps/web/src/app/core/api/game-api.service.spec.ts @@ -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' }); + }); +}); diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts new file mode 100644 index 0000000..9356b7f --- /dev/null +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -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 { + return this.http.get('/api/characters/me'); + } + + getCurrentLocation(): Observable { + return this.http.get('/api/world/current-location'); + } + + startTravel(targetLocationId: string): Observable { + return this.http.post('/api/travel', { targetLocationId }); + } + + getCurrentTravel(): Observable { + return this.http.get('/api/travel/current'); + } +} diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts new file mode 100644 index 0000000..7e97d41 --- /dev/null +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -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; + getCurrentLocation: ReturnType; + getCurrentTravel: ReturnType; + startTravel: ReturnType; + }; + 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); + }); +}); diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts new file mode 100644 index 0000000..c319488 --- /dev/null +++ b/apps/web/src/app/features/world/world.store.ts @@ -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(null); + private readonly currentLocationState = signal(null); + private readonly selectedConnectionState = signal(null); + private readonly currentTravelState = signal(null); + private readonly remainingSecondsState = signal(null); + private readonly loadingState = signal(false); + private readonly errorState = signal(null); + private countdownTimer: ReturnType | 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 { + 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 { + 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 { + 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 { + 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 { + try { + const travel = await firstValueFrom(this.api.getCurrentTravel()); + await this.setCurrentTravel(travel); + } catch (error) { + this.errorState.set(this.toErrorMessage(error)); + } + } + + private async reloadAuthoritativeState(): Promise { + 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.'; + } +}