262 lines
11 KiB
Markdown
262 lines
11 KiB
Markdown
# Ashen Realms – First Visible Vertical Slice Design
|
||
|
||
**Date:** 2026-08-18
|
||
**Status:** Approved in chat; awaiting written-spec review
|
||
**Scope:** First visible, server-authoritative world and travel slice
|
||
|
||
## Purpose
|
||
|
||
This slice proves that Ashen Realms can present a premium dark-fantasy browser-RPG world while keeping character, location, connection, travel timing, and travel completion authoritative in the NestJS/PostgreSQL backend.
|
||
|
||
The completed slice lets a player open `/world`, see Aric Duskwalker at the Südtor von Graufurt, select the Verbrannte Straße, start a ten-second journey, watch a countdown derived from the server response, and arrive only after the backend completes the travel.
|
||
|
||
## Scope boundaries
|
||
|
||
Included:
|
||
|
||
- PostgreSQL and TypeORM foundation with explicit migration
|
||
- Reproducible seed for one demo character, two locations, and two directed connections
|
||
- Health, demo-character, current-location, travel-start, and current-travel REST endpoints
|
||
- Angular application shell and `/world` route
|
||
- Signal-based frontend state for the visible slice
|
||
- Original location artwork plus code-native fantasy UI
|
||
- Backend and frontend tests requested by the implementation prompt
|
||
- Local development documentation
|
||
|
||
Explicitly excluded:
|
||
|
||
- Authentication, users, JWTs, and account flows
|
||
- Hunting, combat, loot, inventory, equipment, quests, merchants, and currencies
|
||
- Realtime, WebSockets, chat, guilds, CMS, object storage, and microservices
|
||
- Ambush resolution; `ambushChance` is stored but not rolled
|
||
- Production containerization and NestJS static hosting, which are outside this slice's Definition of Done
|
||
|
||
## Technical approach
|
||
|
||
The existing npm-workspace structure remains unchanged:
|
||
|
||
```text
|
||
apps/web Angular SPA
|
||
apps/api NestJS modular monolith
|
||
packages/* reserved shared boundaries
|
||
docs/ product and implementation documentation
|
||
```
|
||
|
||
The existing Angular 21.2 scaffold is upgraded to Angular 22 to match the technical foundation document. NestJS remains version 11. No Nx, Turborepo, GraphQL, CQRS, event bus, or separate service is introduced.
|
||
|
||
The API uses a global `/api` prefix. Angular calls only relative `/api/...` URLs and the development server proxies them to `http://localhost:3000`.
|
||
|
||
## Persistence model
|
||
|
||
All primary keys are UUIDs. Entity and table names are explicit and stable. Timestamps use PostgreSQL `timestamptz`. `synchronize` is always `false`.
|
||
|
||
### Character
|
||
|
||
- `id`
|
||
- `name`
|
||
- `level`
|
||
- `experience`
|
||
- `baseHp`
|
||
- `baseAttack`
|
||
- `currentHp`
|
||
- `currentLocationId`
|
||
- `createdAt`
|
||
- `updatedAt`
|
||
|
||
`baseHp` is exposed as `maxHp` and `baseAttack` as `attack` until equipment is implemented.
|
||
|
||
### LocationDefinition
|
||
|
||
- `id`
|
||
- unique `key`
|
||
- `name`
|
||
- `description`
|
||
- `regionKey`
|
||
- `minRecommendedLevel`
|
||
- `maxRecommendedLevel`
|
||
- `dangerLevel`
|
||
- `isSafe`
|
||
- `huntingEnabled`
|
||
- `artworkPath`
|
||
- `createdAt`
|
||
- `updatedAt`
|
||
|
||
### LocationConnection
|
||
|
||
- `id`
|
||
- `fromLocationId`
|
||
- `toLocationId`
|
||
- `travelDurationSeconds`
|
||
- `ambushChance`
|
||
- `enabled`
|
||
|
||
The pair `(fromLocationId, toLocationId)` is unique.
|
||
|
||
### Travel
|
||
|
||
- `id`
|
||
- `characterId`
|
||
- `originLocationId`
|
||
- `targetLocationId`
|
||
- `startedAt`
|
||
- `arrivesAt`
|
||
- `status` (`TRAVELLING` or `COMPLETED`)
|
||
- `createdAt`
|
||
|
||
A PostgreSQL partial unique index permits only one `TRAVELLING` row per character. Service validation still returns a domain-friendly error before this constraint is reached.
|
||
|
||
## Migration and seed
|
||
|
||
One reviewed TypeORM migration creates the four tables, foreign keys, enum, indexes, and uniqueness constraints. No runtime schema synchronization is used.
|
||
|
||
The seed is idempotent and uses stable UUID constants for this deliberately fixed demo slice. It upserts:
|
||
|
||
- Aric Duskwalker, level 1, 100 HP, 6 base attack, at `south-gate`
|
||
- `south-gate`, safe, level 1, hunting disabled
|
||
- `burned-road`, unsafe, level 1–2, hunting enabled
|
||
- `south-gate -> burned-road`, ten seconds, 0.05 ambush chance
|
||
- `burned-road -> south-gate`, ten seconds, 0.05 ambush chance
|
||
|
||
Re-running the seed restores definition fields without duplicating rows. It does not reset the demo character's live location or delete travel history; a separate database reset remains an explicit developer action.
|
||
|
||
## Backend modules and API behavior
|
||
|
||
### Health
|
||
|
||
`GET /api/health` returns `{ "status": "ok" }` without requiring a database query.
|
||
|
||
### Characters
|
||
|
||
`GET /api/characters/me` resolves the stable demo-character ID in the backend and returns character data with the current location summary. No character ID is hardcoded in Angular.
|
||
|
||
### World
|
||
|
||
`GET /api/world/current-location` first asks the travel domain to complete any due journey. It then returns the authoritative current location and enabled outgoing connections.
|
||
|
||
The public connection DTO contains only target-location summary, duration, and a textual danger rating. For this slice, `ambushChance <= 0.05` maps to `LOW`. The raw probability remains internal.
|
||
|
||
If a journey is still active, the endpoint continues to report the origin as the character's current location; arrival is never inferred by the client.
|
||
|
||
### Travel
|
||
|
||
`POST /api/travel` accepts exactly:
|
||
|
||
```json
|
||
{
|
||
"targetLocationId": "uuid"
|
||
}
|
||
```
|
||
|
||
It validates the target, current character location, enabled directed connection, and absence of an active journey. It derives `startedAt` from the server clock and `arrivesAt` from the connection duration.
|
||
|
||
`GET /api/travel/current` runs completion logic in a database transaction. A due travel changes both the travel status and character location atomically. Before `arrivesAt`, neither value changes. Concurrent completion requests are serialized using row locking and remain idempotent.
|
||
|
||
When active, the endpoint returns `TRAVELLING`, origin, target, `startedAt`, and `arrivesAt`. When it completes a journey, it returns `COMPLETED` and the target. With no travel history it returns an explicit idle response rather than pretending a journey completed.
|
||
|
||
Domain errors use NestJS HTTP exceptions with stable error codes such as `INVALID_TRAVEL_TARGET` and `TRAVEL_ALREADY_ACTIVE`.
|
||
|
||
## Angular architecture
|
||
|
||
### Shell
|
||
|
||
The root renders a reusable `AppShellComponent` composed from:
|
||
|
||
- `TopBarComponent`
|
||
- `SideNavigationComponent`
|
||
- routed main content
|
||
- `ContextPanelComponent` owned by the world feature
|
||
- `GameFooterComponent`
|
||
|
||
`/` redirects to `/world`. Karte is active. Jagd, Quests, Inventar, and Charakter are visible but disabled. Shop is not shown.
|
||
|
||
### API and state
|
||
|
||
A typed API service owns all relative HTTP calls. A focused world store/facade owns these Angular signals:
|
||
|
||
- `character`
|
||
- `currentLocation`
|
||
- `selectedConnection`
|
||
- `currentTravel`
|
||
- `remainingSeconds`
|
||
- `loading`
|
||
- `error`
|
||
|
||
Components render state and forward player decisions. They do not validate connections, calculate travel completion, or mutate the character location.
|
||
|
||
On initial load, the store requests character, current location, and current travel. Starting travel posts only `targetLocationId`. The countdown is recalculated from the backend's ISO `arrivesAt` against the browser clock for presentation only. At zero, the store polls `GET /api/travel/current`; only a server `COMPLETED` response triggers reloading character and world data.
|
||
|
||
Timers are disposed when the store or page is destroyed. Errors appear in-shell and keep retry actions available; browser alerts are not used.
|
||
|
||
## Visual design
|
||
|
||
The visible surface follows the reference anatomy without copying the screenshots:
|
||
|
||
- compact full-width topbar with portrait, name, level, and HP
|
||
- narrow persistent left navigation
|
||
- dominant illustrated Aschenfelder scene in the center
|
||
- two large, readable location nodes connected by a visible travel path
|
||
- dense right-side location context panel
|
||
- bottom-centered travel panel inside the scene
|
||
- restrained persistent footer
|
||
|
||
An original raster artwork depicts the Südtor opening toward a burned road and distant ash fields. It contains no embedded UI text. Location nodes, connection paths, labels, controls, icons, and panels remain HTML/CSS/SVG so they stay interactive and accessible.
|
||
|
||
The visual system uses dark stone/metal surfaces, thin bronze borders, warm ash highlights, cool blue selection light, a restrained serif display face with readable system fallbacks, limited radii, and no glassmorphism, neon, white cards, or dashboard grids.
|
||
|
||
Central SCSS tokens define background, panel, muted panel, borders, text, gold, blue, success, warning, danger, spacing, radius, shadow, and motion values. Component styles consume these tokens rather than duplicating palette values.
|
||
|
||
Desktop is optimized for 1920×1080, 1440×900, and 1366×768. Tablet retains the core hierarchy with a narrower navigation and context column. Mobile optimization is deliberately deferred.
|
||
|
||
## Accessibility and interaction
|
||
|
||
- Location nodes are real buttons with selected, current, hover, focus, and disabled states.
|
||
- Color never communicates danger or status alone; text labels remain present.
|
||
- Focus indicators fit the bronze/blue fantasy system.
|
||
- Motion is subtle and disabled or reduced under `prefers-reduced-motion`.
|
||
- Loading and error states remain readable against the artwork.
|
||
|
||
## Testing strategy
|
||
|
||
Implementation follows test-driven development.
|
||
|
||
Backend tests cover:
|
||
|
||
- health response
|
||
- world DTO construction from location and connections
|
||
- valid connection starts travel
|
||
- invalid connection is rejected
|
||
- `arrivesAt` is derived from the injected server clock
|
||
- travel stays active before arrival
|
||
- due travel atomically completes and updates character location
|
||
|
||
Frontend tests cover:
|
||
|
||
- application shell composition
|
||
- world state loads character/location/travel through the API service
|
||
- travel start sends only `targetLocationId`
|
||
- countdown is derived from `arrivesAt`
|
||
- zero countdown polls the backend and does not locally mark arrival
|
||
|
||
Repository verification includes all workspace tests, API build, web build, migration compilation, seed compilation, and a browser walkthrough of selection, travel start, countdown, server completion, and return travel.
|
||
|
||
Visual verification compares the rendered implementation with `docs/references/world-travel-screen.png` for layout hierarchy, density, typography, palette, panel treatment, artwork prominence, node legibility, and interaction states. The reference image itself is never shipped as an application asset.
|
||
|
||
## Local operation
|
||
|
||
The README documents:
|
||
|
||
1. `npm install`
|
||
2. create PostgreSQL database `ashen_realms`
|
||
3. copy `.env.example` to `.env` and set `DATABASE_URL`
|
||
4. run migrations
|
||
5. run the seed
|
||
6. start API and web in separate terminals
|
||
|
||
Root scripts provide stable commands for migration, seed, API development, web development, tests, and both builds.
|
||
|
||
## Acceptance criteria
|
||
|
||
The slice is accepted when a clean database can be migrated and seeded, both applications start, `/api/health` returns OK, and the browser visibly supports the complete Südtor-to-Verbrannte-Straße travel flow with backend-owned arrival.
|
||
|
||
No excluded system is introduced, no frontend fake character becomes a data source, no absolute API URL is compiled into Angular, and no schema change depends on `synchronize`.
|