From 4a689238e6a1a715a4a9e0f9aa2d049f10d5b267 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 18 Aug 2026 17:18:17 +0200 Subject: [PATCH] docs: plan first visible vertical slice --- ...2026-08-18-first-visible-vertical-slice.md | 1020 +++++++++++++++++ 1 file changed, 1020 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-first-visible-vertical-slice.md diff --git a/docs/superpowers/plans/2026-08-18-first-visible-vertical-slice.md b/docs/superpowers/plans/2026-08-18-first-visible-vertical-slice.md new file mode 100644 index 0000000..95d184d --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-first-visible-vertical-slice.md @@ -0,0 +1,1020 @@ +# First Visible Vertical Slice Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the first visible Ashen Realms world screen with a PostgreSQL-backed demo character and a server-authoritative ten-second travel flow between Südtor von Graufurt and Verbrannte Straße. + +**Architecture:** Preserve the npm-workspace modular monolith: Angular renders a signal-driven game shell and NestJS exposes `/api` REST modules backed by TypeORM/PostgreSQL. Travel start and completion are transactional backend decisions; the browser displays a countdown derived from `arrivesAt` and refreshes only after the backend reports completion. + +**Tech Stack:** Angular 22, TypeScript, SCSS, Angular Signals, NestJS 11, TypeORM, PostgreSQL, REST, npm Workspaces, Jest, Angular unit-test builder/Vitest. + +**Spec:** `docs/superpowers/specs/2026-08-18-first-visible-vertical-slice-design.md` + +## Global Constraints + +- Keep `apps/web`, `apps/api`, `packages/shared`, and `packages/game-content`; do not add Nx or Turborepo. +- Upgrade the existing Angular 21.2 scaffold to Angular 22; keep NestJS 11. +- Every API route is under `/api`; Angular uses only relative `/api/...` URLs. +- TypeORM always uses `synchronize: false`; all schema changes are in the checked-in migration. +- PostgreSQL is the only runtime data source for character, locations, connections, and travel. +- The browser never supplies `startedAt`, `arrivesAt`, travel duration, origin, or completion status. +- Do not implement authentication, hunt, quests, merchants, inventory, equipment, combat, loot, realtime, chat, guilds, CMS, object storage, or microservices. +- Store `ambushChance` but do not evaluate it. +- Do not ship any screenshot from `docs/references` as an application asset. +- Preserve the user's existing uncommitted `package-lock.json` change and review lockfile diffs before staging. +- Before each implementation task, re-read all files in `docs/` and inspect all images in `docs/references/`, as requested by the project prompt. + +## File map + +### Backend + +- `apps/api/src/config/database.config.ts`: shared Nest/CLI TypeORM options and environment validation. +- `apps/api/src/database/data-source.ts`: TypeORM CLI data source. +- `apps/api/src/database/database.module.ts`: Nest TypeORM registration. +- `apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts`: complete V1 schema. +- `apps/api/src/database/seeds/vertical-slice.seed.ts`: exported idempotent seed function. +- `apps/api/src/database/seeds/run-seed.ts`: executable seed entry point. +- `apps/api/src/demo/demo-character.constants.ts`: backend-only stable demo UUID. +- `apps/api/src/characters/*`: Character entity, service, controller, DTO mapping, and module. +- `apps/api/src/world/*`: location entities, world service/controller/module, and response mapping. +- `apps/api/src/travel/*`: Travel entity/status, clock abstraction, service/controller/module, and DTOs. +- `apps/api/src/health/*`: health controller/module. + +### Frontend + +- `apps/web/src/app/core/api/game-api.service.ts`: typed relative HTTP calls. +- `apps/web/src/app/core/api/game-api.models.ts`: API response interfaces. +- `apps/web/src/app/features/world/world.store.ts`: signals, loading orchestration, countdown, and polling. +- `apps/web/src/app/features/world/world-page.component.*`: world composition and user decisions. +- `apps/web/src/app/features/world/location-node.component.*`: accessible current/reachable nodes. +- `apps/web/src/app/features/world/travel-panel.component.*`: selection/travel/countdown states. +- `apps/web/src/app/layout/*`: shell, topbar, side navigation, footer, and context panel. +- `apps/web/src/styles.scss`: tokens, reset, typography, and global material rules. +- `apps/web/public/assets/locations/south-gate.webp`: original generated scene. +- `apps/web/public/assets/characters/aric-portrait.webp`: restrained original generated portrait. + +--- + +### Task 1: Toolchain, scripts, and database configuration + +**Files:** +- Modify: `package.json` +- Modify: `apps/web/package.json` +- Modify: `apps/web/angular.json` +- Modify: `apps/api/package.json` +- Modify: `.env.example` +- Create: `apps/web/proxy.conf.json` +- Create: `apps/api/src/config/database.config.ts` +- Create: `apps/api/src/config/database.config.spec.ts` +- Create: `apps/api/src/database/database.module.ts` +- Create: `apps/api/src/database/data-source.ts` +- Modify: `apps/api/src/app.module.ts` + +**Interfaces:** +- Consumes: `process.env.DATABASE_URL` +- Produces: `createDatabaseConfig(databaseUrl: string): TypeOrmModuleOptions`, `AppDataSource`, root scripts `db:migrate`, `db:revert`, `db:seed`, `dev:api`, `dev:web` + +- [ ] **Step 1: Re-read project documentation and inspect references** + +Run: + +```powershell +Get-Content -Raw docs\*.md +Get-ChildItem docs\references -File +``` + +Open all three PNG references with the available image viewer. Confirm no implementation file has been touched before this check. + +- [ ] **Step 2: Write the failing database configuration test** + +```ts +import { createDatabaseConfig } from './database.config'; + +describe('createDatabaseConfig', () => { + it('uses postgres and permanently disables synchronization', () => { + expect(createDatabaseConfig('postgresql://test:test@localhost/test')).toMatchObject({ + type: 'postgres', + url: 'postgresql://test:test@localhost/test', + synchronize: false, + autoLoadEntities: true, + }); + }); + + it('rejects an empty database URL', () => { + expect(() => createDatabaseConfig('')).toThrow('DATABASE_URL is required'); + }); +}); +``` + +- [ ] **Step 3: Run the focused test and confirm RED** + +Run: `npm test --workspace=@ashen-realms/api -- database.config.spec.ts --runInBand` + +Expected: FAIL because `database.config.ts` does not exist. + +- [ ] **Step 4: Upgrade Angular and install backend persistence dependencies** + +Run: + +```powershell +npm install --workspace=@ashen-realms/web @angular/common@^22.0.0 @angular/compiler@^22.0.0 @angular/core@^22.0.0 @angular/forms@^22.0.0 @angular/platform-browser@^22.0.0 @angular/router@^22.0.0 @angular/build@^22.0.0 @angular/cli@^22.0.0 @angular/compiler-cli@^22.0.0 +npm install --workspace=@ashen-realms/api @nestjs/typeorm typeorm pg dotenv class-validator class-transformer +``` + +Review `package-lock.json`; retain pre-existing content and stage it only with the dependency changes that npm actually produced. + +- [ ] **Step 5: Implement strict database configuration** + +```ts +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; + +export function createDatabaseConfig(databaseUrl: string): TypeOrmModuleOptions { + if (!databaseUrl.trim()) { + throw new Error('DATABASE_URL is required'); + } + + return { + type: 'postgres', + url: databaseUrl, + autoLoadEntities: true, + synchronize: false, + }; +} +``` + +Register `TypeOrmModule.forRootAsync` in `DatabaseModule`, load `.env` in the CLI data source with `dotenv/config`, and use source paths in ts-node plus compiled paths in production: + +```ts +entities: [__dirname + '/../**/*.entity.{ts,js}'], +migrations: [__dirname + '/migrations/*.{ts,js}'], +synchronize: false, +``` + +- [ ] **Step 6: Add relative Angular proxy and stable workspace scripts** + +`apps/web/proxy.conf.json`: + +```json +{ + "/api": { + "target": "http://localhost:3000", + "secure": false, + "changeOrigin": true + } +} +``` + +Configure the Angular development serve target with `proxyConfig`. Add API TypeORM scripts using `typeorm-ts-node-commonjs` or the repository's working ts-node CLI form, then expose them at the root. + +- [ ] **Step 7: Run test and configuration builds** + +Run: + +```powershell +npm test --workspace=@ashen-realms/api -- database.config.spec.ts --runInBand +npm run build:web +npm run build:api +``` + +Expected: test PASS and both builds PASS. + +- [ ] **Step 8: Commit the coherent toolchain change** + +```powershell +git add -- package.json package-lock.json apps/web/package.json apps/web/angular.json apps/web/proxy.conf.json apps/api/package.json apps/api/src/config apps/api/src/database/database.module.ts apps/api/src/database/data-source.ts apps/api/src/app.module.ts .env.example +git commit -m "chore: configure angular and postgres foundation" +``` + +--- + +### Task 2: TypeORM entities and explicit migration + +**Files:** +- Create: `apps/api/src/world/entities/location-definition.entity.ts` +- Create: `apps/api/src/world/entities/location-connection.entity.ts` +- Create: `apps/api/src/characters/entities/character.entity.ts` +- Create: `apps/api/src/travel/entities/travel.entity.ts` +- Create: `apps/api/src/travel/travel-status.enum.ts` +- Create: `apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts` +- Create: `apps/api/src/database/migrations/visible-vertical-slice.migration.spec.ts` + +**Interfaces:** +- Consumes: TypeORM `Repository`, `MigrationInterface`, PostgreSQL UUID and `timestamptz` +- Produces: `Character`, `LocationDefinition`, `LocationConnection`, `Travel`, `TravelStatus` + +- [ ] **Step 1: Re-read docs and inspect references** + +Repeat the Task 1 documentation/reference check before editing. + +- [ ] **Step 2: Write failing entity metadata and migration tests** + +Use TypeORM metadata storage to assert the unique location key and relation join columns, and inspect the migration source to assert it creates all tables while never enabling synchronization: + +```ts +expect(locationMetadata.indices.some((index) => index.columns?.includes('key') && index.options.unique)).toBe(true); +expect(migrationText).toContain('CREATE TABLE "characters"'); +expect(migrationText).toContain('CREATE UNIQUE INDEX "IDX_active_travel_per_character"'); +expect(migrationText).not.toContain('synchronize'); +``` + +- [ ] **Step 3: Run focused tests and confirm RED** + +Run: `npm test --workspace=@ashen-realms/api -- visible-vertical-slice.migration.spec.ts --runInBand` + +Expected: FAIL because entities and migration do not exist. + +- [ ] **Step 4: Implement the four entities** + +Use explicit table/column names, UUID primary keys, `@CreateDateColumn({ type: 'timestamptz' })`, `@UpdateDateColumn`, and relations with scalar FK fields. The connection probability is: + +```ts +@Column({ name: 'ambush_chance', type: 'numeric', precision: 5, scale: 4 }) +ambushChance!: string; +``` + +The travel status is: + +```ts +export enum TravelStatus { + TRAVELLING = 'TRAVELLING', + COMPLETED = 'COMPLETED', +} +``` + +- [ ] **Step 5: Write the complete reversible migration** + +Create enum/table/index SQL in dependency order. Include: + +```sql +CREATE UNIQUE INDEX "IDX_location_connection_direction" +ON "location_connections" ("from_location_id", "to_location_id"); + +CREATE UNIQUE INDEX "IDX_active_travel_per_character" +ON "travels" ("character_id") +WHERE "status" = 'TRAVELLING'; +``` + +The `down` method drops indexes, tables in reverse FK order, and the travel enum. + +- [ ] **Step 6: Run tests and compile the migration** + +Run: + +```powershell +npm test --workspace=@ashen-realms/api -- visible-vertical-slice.migration.spec.ts --runInBand +npm run build:api +``` + +Expected: PASS. + +- [ ] **Step 7: Commit schema and migration** + +```powershell +git add -- apps/api/src/world/entities apps/api/src/characters/entities apps/api/src/travel/entities apps/api/src/travel/travel-status.enum.ts apps/api/src/database/migrations +git commit -m "feat: add world travel database schema" +``` + +--- + +### Task 3: Idempotent demo seed + +**Files:** +- Create: `apps/api/src/demo/demo-character.constants.ts` +- Create: `apps/api/src/database/seeds/vertical-slice.constants.ts` +- Create: `apps/api/src/database/seeds/vertical-slice.seed.ts` +- Create: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts` +- Create: `apps/api/src/database/seeds/run-seed.ts` +- Modify: `apps/api/package.json` +- Modify: `package.json` + +**Interfaces:** +- Consumes: `DataSource`, entity repositories +- Produces: `DEMO_CHARACTER_ID`, `seedVisibleVerticalSlice(dataSource: DataSource): Promise`, `npm run db:seed` + +- [ ] **Step 1: Re-read docs and inspect references** + +Repeat the required documentation/reference check. + +- [ ] **Step 2: Write a failing idempotency test with mocked repositories** + +Assert that locations are upserted by `key`, both directed connections are upserted by the unique pair, and the character insert uses the stable demo UUID. Call the seed twice and verify the fake repository still contains exactly two locations, two connections, and one character. + +- [ ] **Step 3: Confirm RED** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed.spec.ts --runInBand` + +Expected: FAIL because the seed function is missing. + +- [ ] **Step 4: Implement stable constants and content** + +Use fixed valid UUIDs: + +```ts +export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001'; +export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002'; +``` + +Seed German descriptions consistent with the world-content design. Set artwork paths to `/assets/locations/south-gate.webp` and `/assets/locations/burned-road.webp`; both initially resolve to the same panoramic scene so the path contract is ready for later replacement. + +- [ ] **Step 5: Preserve live demo state on re-seed** + +Upsert location definitions and connections. Insert the character only when absent: + +```ts +const existing = await characterRepository.findOneBy({ id: DEMO_CHARACTER_ID }); +if (!existing) { + await characterRepository.insert({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + baseHp: 100, + baseAttack: 6, + currentHp: 100, + currentLocationId: SOUTH_GATE_ID, + }); +} +``` + +- [ ] **Step 6: Add executable seed command and verify GREEN** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed.spec.ts --runInBand` + +Expected: PASS. + +- [ ] **Step 7: Commit seed** + +```powershell +git add -- package.json apps/api/package.json apps/api/src/demo apps/api/src/database/seeds +git commit -m "feat: seed demo character and first locations" +``` + +--- + +### Task 4: Health and demo-character API + +**Files:** +- Delete: `apps/api/src/app.controller.ts` +- Delete: `apps/api/src/app.controller.spec.ts` +- Delete: `apps/api/src/app.service.ts` +- Create: `apps/api/src/health/health.controller.ts` +- Create: `apps/api/src/health/health.controller.spec.ts` +- Create: `apps/api/src/health/health.module.ts` +- Create: `apps/api/src/characters/characters.service.ts` +- Create: `apps/api/src/characters/characters.service.spec.ts` +- Create: `apps/api/src/characters/characters.controller.ts` +- Create: `apps/api/src/characters/characters.module.ts` +- Modify: `apps/api/src/app.module.ts` +- Modify: `apps/api/src/main.ts` + +**Interfaces:** +- Consumes: `Repository`, `DEMO_CHARACTER_ID` +- Produces: `GET /api/health`, `GET /api/characters/me`, `CharactersService.getDemoCharacter()` + +- [ ] **Step 1: Re-read docs and inspect references** + +Repeat the required check. + +- [ ] **Step 2: Write failing health and character tests** + +```ts +expect(new HealthController().getHealth()).toEqual({ status: 'ok' }); + +expect(await service.getDemoCharacter()).toEqual({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + currentHp: 100, + maxHp: 100, + attack: 6, + currentLocation: { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'Südtor von Graufurt', + }, +}); +``` + +The fake repository returns a character with its `currentLocation` relation. + +- [ ] **Step 3: Confirm RED** + +Run: `npm test --workspace=@ashen-realms/api -- health.controller.spec.ts characters.service.spec.ts --runInBand` + +Expected: FAIL because controllers/services do not exist. + +- [ ] **Step 4: Implement focused modules and DTO mapping** + +Use `findOne({ where: { id: DEMO_CHARACTER_ID }, relations: { currentLocation: true } })`; throw `NotFoundException` if the seed has not run. Map `baseHp` to `maxHp` and `baseAttack` to `attack` in the service response. + +- [ ] **Step 5: Set global prefix** + +In `main.ts`: + +```ts +app.setGlobalPrefix('api'); +app.enableShutdownHooks(); +await app.listen(process.env.PORT ?? 3000); +``` + +- [ ] **Step 6: Verify GREEN and build** + +Run: + +```powershell +npm test --workspace=@ashen-realms/api -- health.controller.spec.ts characters.service.spec.ts --runInBand +npm run build:api +``` + +- [ ] **Step 7: Commit API foundation** + +```powershell +git add -- apps/api/src +git commit -m "feat: expose health and demo character APIs" +``` + +--- + +### Task 5: Server-authoritative TravelService and endpoints + +**Files:** +- Create: `apps/api/src/travel/clock.ts` +- Create: `apps/api/src/travel/dto/start-travel.dto.ts` +- Create: `apps/api/src/travel/travel.errors.ts` +- Create: `apps/api/src/travel/travel.service.ts` +- Create: `apps/api/src/travel/travel.service.spec.ts` +- Create: `apps/api/src/travel/travel.controller.ts` +- Create: `apps/api/src/travel/travel.module.ts` +- Modify: `apps/api/src/app.module.ts` + +**Interfaces:** +- Consumes: `DataSource`, `Clock.now(): Date`, `DEMO_CHARACTER_ID` +- Produces: `TravelService.startTravel(characterId: string, targetLocationId: string)`, `TravelService.getCurrentTravel(characterId: string)`, `TravelService.completeTravelIfDue(characterId: string)`, `POST /api/travel`, `GET /api/travel/current` + +- [ ] **Step 1: Re-read docs and inspect references** + +Repeat the required check. + +- [ ] **Step 2: Write the five required failing service tests** + +Create deterministic fixtures and an in-memory fake transaction manager. Cover: + +```ts +it('starts travel for an enabled directed connection'); +it('rejects a target without an enabled connection'); +it('derives arrivesAt from the injected clock and connection duration'); +it('does not complete or move the character before arrivesAt'); +it('completes due travel and updates character location atomically'); +``` + +For the clock test, inject `now = 2026-08-18T10:00:00.000Z`, duration `10`, and expect `2026-08-18T10:00:10.000Z` regardless of request content. + +- [ ] **Step 3: Confirm RED** + +Run: `npm test --workspace=@ashen-realms/api -- travel.service.spec.ts --runInBand` + +Expected: FAIL because `TravelService` is missing. + +- [ ] **Step 4: Implement clock and exact request validation** + +```ts +export const CLOCK = Symbol('CLOCK'); +export interface Clock { now(): Date; } +export const systemClock: Clock = { now: () => new Date() }; + +export class StartTravelDto { + @IsUUID() + targetLocationId!: string; +} +``` + +Enable a global `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })` so timestamps and duration fields are rejected. + +- [ ] **Step 5: Implement start and completion transactions** + +Within `dataSource.transaction`, lock character and active travel rows. Validate target existence, connection direction, `enabled`, and absence of active travel. Calculate: + +```ts +const startedAt = this.clock.now(); +const arrivesAt = new Date(startedAt.getTime() + connection.travelDurationSeconds * 1000); +``` + +Completion compares `travel.arrivesAt.getTime()` with `clock.now().getTime()`. Only when due, set status and character location and save both in the same transaction. + +- [ ] **Step 6: Map public responses** + +Return location summaries from loaded relations. Use `{ status: 'IDLE' }` when no travel exists, the full travelling shape while active, and `{ status: 'COMPLETED', targetLocation }` on the request that completes it. + +- [ ] **Step 7: Verify all five tests and controller compilation** + +Run: + +```powershell +npm test --workspace=@ashen-realms/api -- travel.service.spec.ts --runInBand +npm run build:api +``` + +Expected: five behavior tests PASS. + +- [ ] **Step 8: Commit travel domain** + +```powershell +git add -- apps/api/src/travel apps/api/src/main.ts apps/api/src/app.module.ts +git commit -m "feat: add server authoritative travel flow" +``` + +--- + +### Task 6: Current-location WorldService and endpoint + +**Files:** +- Create: `apps/api/src/world/world.service.ts` +- Create: `apps/api/src/world/world.service.spec.ts` +- Create: `apps/api/src/world/world.controller.ts` +- Create: `apps/api/src/world/world.module.ts` +- Modify: `apps/api/src/app.module.ts` + +**Interfaces:** +- Consumes: `TravelService.completeTravelIfDue(characterId)`, `Repository`, `Repository` +- Produces: `WorldService.getCurrentLocation(characterId)`, `GET /api/world/current-location` + +- [ ] **Step 1: Re-read docs and inspect references** + +Repeat the required check. + +- [ ] **Step 2: Write failing world mapping tests** + +Test that the service invokes travel completion before reading the character, returns the complete location DTO, filters disabled connections, and maps a 0.05 ambush chance to `LOW` without exposing the raw probability. + +```ts +expect(result.connections[0]).toEqual({ + targetLocation: { + id: BURNED_ROAD_ID, + key: 'burned-road', + name: 'Verbrannte Straße', + }, + travelDurationSeconds: 10, + danger: 'LOW', +}); +``` + +- [ ] **Step 3: Confirm RED** + +Run: `npm test --workspace=@ashen-realms/api -- world.service.spec.ts --runInBand` + +- [ ] **Step 4: Implement WorldService and module relationship** + +Export `TravelService` from `TravelModule`, import it in `WorldModule`, and query enabled outgoing connections with `targetLocation`. Avoid circular imports: TravelModule depends on entities, not WorldService. + +- [ ] **Step 5: Verify GREEN and all backend tests** + +Run: + +```powershell +npm test --workspace=@ashen-realms/api -- world.service.spec.ts --runInBand +npm test --workspace=@ashen-realms/api -- --runInBand +npm run build:api +``` + +- [ ] **Step 6: Commit world API** + +```powershell +git add -- apps/api/src/world apps/api/src/app.module.ts +git commit -m "feat: expose current world location" +``` + +--- + +### Task 7: Angular typed API and signal-driven WorldStore + +**Files:** +- Create: `apps/web/src/app/core/api/game-api.models.ts` +- Create: `apps/web/src/app/core/api/game-api.service.ts` +- Create: `apps/web/src/app/core/api/game-api.service.spec.ts` +- Create: `apps/web/src/app/features/world/world.store.ts` +- Create: `apps/web/src/app/features/world/world.store.spec.ts` +- Modify: `apps/web/src/app/app.config.ts` + +**Interfaces:** +- Consumes: `/api/characters/me`, `/api/world/current-location`, `/api/travel`, `/api/travel/current` +- Produces: `GameApiService`, `WorldStore.load()`, `WorldStore.selectConnection()`, `WorldStore.startTravel()`, read-only state signals + +- [ ] **Step 1: Re-read docs and inspect references** + +Repeat the required check. + +- [ ] **Step 2: Write failing relative-URL and request-body tests** + +With Angular HTTP testing utilities: + +```ts +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' }); +``` + +Also verify all GET URLs begin with `/api/` and contain no hostname. + +- [ ] **Step 3: Write failing store behavior tests** + +Use a fake API service and fake timer clock. Cover: + +- `load()` requests character, location, and current travel. +- `startTravel()` passes only the selected target ID. +- `remainingSeconds` is computed from `arrivesAt`. +- reaching zero calls `getCurrentTravel()` and does not assign a new location locally. +- only `COMPLETED` causes character/location reload. + +- [ ] **Step 4: Confirm RED** + +Run: `npm test --workspace=@ashen-realms/web -- --watch=false` + +Expected: FAIL because services/store do not exist. + +- [ ] **Step 5: Implement typed contracts and API service** + +Define discriminated travel types: + +```ts +export type CurrentTravel = + | { status: 'IDLE' } + | { status: 'TRAVELLING'; originLocation: LocationSummary; targetLocation: LocationSummary; startedAt: string; arrivesAt: string } + | { status: 'COMPLETED'; targetLocation: LocationSummary }; +``` + +Use `HttpClient` only with literal relative paths. + +- [ ] **Step 6: Implement WorldStore countdown without local completion** + +Use writable private signals and public `asReadonly()` signals. Calculate presentation time with: + +```ts +Math.max(0, Math.ceil((Date.parse(arrivesAt) - Date.now()) / 1000)); +``` + +When zero is reached, clear the interval and await the API response. Never set `currentLocation` to `targetLocation` inside the timer. + +- [ ] **Step 7: Verify GREEN** + +Run: + +```powershell +npm test --workspace=@ashen-realms/web -- --watch=false +npm run build:web +``` + +- [ ] **Step 8: Commit frontend data flow** + +```powershell +git add -- apps/web/src/app/core apps/web/src/app/features/world/world.store.ts apps/web/src/app/features/world/world.store.spec.ts apps/web/src/app/app.config.ts +git commit -m "feat: add world api state store" +``` + +--- + +### Task 8: Reusable Angular application shell + +**Files:** +- Replace: `apps/web/src/app/app.ts` +- Replace: `apps/web/src/app/app.html` +- Replace: `apps/web/src/app/app.scss` +- Replace: `apps/web/src/app/app.spec.ts` +- Modify: `apps/web/src/app/app.routes.ts` +- Create: `apps/web/src/app/layout/app-shell/app-shell.component.*` +- Create: `apps/web/src/app/layout/top-bar/top-bar.component.*` +- Create: `apps/web/src/app/layout/side-navigation/side-navigation.component.*` +- Create: `apps/web/src/app/layout/game-footer/game-footer.component.*` +- Create: `apps/web/src/app/layout/context-panel/context-panel.component.*` +- Modify: `apps/web/src/styles.scss` + +**Interfaces:** +- Consumes: `WorldStore.character`, Angular Router +- Produces: `AppShellComponent`, `TopBarComponent`, `SideNavigationComponent`, `GameFooterComponent`, `ContextPanelComponent`, `/ -> /world` + +- [ ] **Step 1: Re-read docs and inspect references** + +Repeat the required check. + +- [ ] **Step 2: Write the failing app-shell test** + +```ts +expect(fixture.nativeElement.querySelector('app-top-bar')).not.toBeNull(); +expect(fixture.nativeElement.querySelector('app-side-navigation')).not.toBeNull(); +expect(fixture.nativeElement.querySelector('main')).not.toBeNull(); +expect(fixture.nativeElement.querySelector('app-game-footer')).not.toBeNull(); +``` + +Also assert Karte is enabled/active and Jagd, Quests, Inventar, and Charakter are disabled. Assert Shop is absent. + +- [ ] **Step 3: Confirm RED** + +Run: `npm test --workspace=@ashen-realms/web -- --watch=false` + +- [ ] **Step 4: Define global design tokens** + +At `:root`, define at minimum: + +```scss +--ar-bg: #090b0d; +--ar-panel: #101316; +--ar-panel-muted: #17191b; +--ar-border: #554a39; +--ar-border-highlight: #9b7a42; +--ar-text: #ede7da; +--ar-text-muted: #aaa397; +--ar-gold: #c9a45f; +--ar-blue: #5ca9d8; +--ar-success: #78b96e; +--ar-warning: #d69445; +--ar-danger: #c74b42; +``` + +Add spacing, radius, shadow, and motion tokens plus a dark global reset. + +- [ ] **Step 5: Implement focused standalone shell components** + +Topbar receives character data from the store and renders no hardcoded stats. Use semantic `