feat: add signal-driven world state
This commit is contained in:
@@ -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.
|
||||
@@ -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)],
|
||||
};
|
||||
|
||||
48
apps/web/src/app/core/api/game-api.models.ts
Normal file
48
apps/web/src/app/core/api/game-api.models.ts
Normal 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 };
|
||||
50
apps/web/src/app/core/api/game-api.service.spec.ts
Normal file
50
apps/web/src/app/core/api/game-api.service.spec.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
25
apps/web/src/app/core/api/game-api.service.ts
Normal file
25
apps/web/src/app/core/api/game-api.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
137
apps/web/src/app/features/world/world.store.spec.ts
Normal file
137
apps/web/src/app/features/world/world.store.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
152
apps/web/src/app/features/world/world.store.ts
Normal file
152
apps/web/src/app/features/world/world.store.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user