Compare commits

...

7 Commits

Author SHA1 Message Date
Bastian Wagner
5b662aad4c docs 2026-08-19 11:14:17 +02:00
Bastian Wagner
d09476ba41 test: cover the successful travel start happy path in the e2e smoke suite
The DB-gated e2e suite covered health, seeded reads, and the arrivesAt
whitelist rejection, but never asserted that a valid POST /api/travel
actually transitions to TRAVELLING, that a concurrent second start is
rejected with 409/TRAVEL_ALREADY_ACTIVE, or that a travel completes and
moves the character. Added one test covering all three plus GET
/api/world/current-location reflecting the move, and restores the demo
character to its original location afterward so the suite stays safely
re-runnable.
2026-08-19 10:50:30 +02:00
Bastian Wagner
2e7b280270 fix: map backend travel error codes to German messages and resync on failed start
HttpErrorResponse implements the Error interface structurally but does not
extend the Error class, so `error instanceof Error` was always false for
HTTP failures and every travel error (400/409/500/network) fell through to
the same generic fallback string, hiding the backend's specific
TravelDomainError code/message (e.g. TRAVEL_ALREADY_ACTIVE) from the user.

toErrorMessage now checks `error instanceof HttpErrorResponse` first and
maps known travel error codes to specific German messages, falling back to
the generic message for unknown codes and to `error.message` for genuine
non-HTTP errors.

startTravel()'s catch block also now resyncs current travel state from the
server (reusing the existing pollCurrentTravel/refreshCurrentTravel path)
so a failed start caused by a race with another tab/request doesn't leave
the store's local state stale.
2026-08-19 10:50:20 +02:00
Bastian Wagner
1bb4feb6fb docs: correct fidelity ledger's claim about the map background artwork
The ledger claimed the map scene renders per-location Suedtor/Aschestrasse
artwork, but world-page.component.scss hardcodes a single static world-map
background image; per-location artwork is only used in the context-panel
thumbnail. Corrected the description to match the shipped behavior.
2026-08-19 10:50:10 +02:00
Bastian Wagner
e0e6db3ae8 chore: move unreferenced source art out of the served web assets
apps/web/public/images was shipping ~90 MB of source art (enemies, npc,
combat status icons, HUD icon originals/difficulty badges, unused
background paintings) that no component, template, or stylesheet
actually references, since Angular copies public/ verbatim into every
browser build. Moved everything not referenced under apps/web/src to a
new art/ directory at the repo root, preserving the original subfolder
layout; only the 11 files actually loaded by the app (runtime HUD/
background derivatives and their PNG fallbacks) remain under
apps/web/public/images. Documented the split in README.md.
2026-08-19 10:50:00 +02:00
Bastian Wagner
ec6c34e341 test: verify first visible vertical slice
Drives the full world/travel flow in Chromium via Playwright against the
live API and seeded Postgres, capturing the three required desktop
viewports and comparing them against the accepted design reference.

Fixes two evidenced defects found during verification:
- world-page scene sizing overflowed the viewport at all three required
  desktop sizes, pushing the footer (and, at 1440x900/1366x768, the travel
  button) below the fold; scene height now accounts for surrounding chrome
  so nothing is clipped.
- the generic HTTP-failure fallback message was hardcoded in English while
  the rest of the UI is German; now uses a matching German string.

Adds Playwright as a web devDependency for this and future browser
verification passes (no product-code dependency).
2026-08-19 10:13:40 +02:00
Bastian Wagner
2f53e5ba80 docs: add visible slice local workflow
Document the migration/seed/API/web workflow in a root README, add an
API e2e smoke test that always checks /api/health and additionally
exercises the real DatabaseModule/seeded data plus the arrivesAt
validation rejection when DATABASE_URL is available, and add a
`test:e2e` root script.

Also fix `.env` loading so the documented root-level `.env` is actually
found: `main.ts` never loaded dotenv at all, and `data-source.ts`
loaded it relative to `process.cwd()`, which is `apps/api` (not the
repo root) whenever npm runs a `--workspace` script. Both now resolve
the repo-root `.env` explicitly. Also narrowed the CLI migrations glob
to numeric-prefixed files so it no longer tries to load the colocated
`*.migration.spec.ts` as a migration.

Verified end-to-end against a real PostgreSQL instance: migrate, seed
twice (idempotent), start the API, and exercise every documented route
including a rejected POST /api/travel body containing arrivesAt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 09:41:16 +02:00
66 changed files with 8096 additions and 519 deletions

View File

@@ -1,6 +1,10 @@
# Copy this file to `.env` (repo root) and adjust as needed. See README.md
# for the full local setup workflow (install, migrate, seed, run api/web).
NODE_ENV=development
PORT=3000
# Requires a reachable PostgreSQL server with a database named `ashen_realms`
# owned by this user/password (or your own local equivalent connection string).
DATABASE_URL=postgresql://ashen:ashen@localhost:5432/ashen_realms
JWT_ACCESS_SECRET=change-me

View File

@@ -1,50 +0,0 @@
# Task 4: Health and demo-character API report
## RED
- Re-read every Markdown document below `docs/` and inspected `combat-screen.png`, `hunting-screen.png`, and `world-travel-screen.png` before implementation.
- Added focused health and character-service tests, then ran:
```powershell
npm test --workspace=@ashen-realms/api -- health.controller.spec.ts characters.service.spec.ts --runInBand
```
- Observed the expected RED result: both suites failed because `HealthController` and `CharactersService` did not exist.
## GREEN
- Replaced the scaffold root controller/service with `HealthModule` and `CharactersModule`.
- `GET /api/health` returns `{ status: 'ok' }` without a database query.
- `GET /api/characters/me` obtains the fixed demo ID server-side, loads `currentLocation`, maps `baseHp` to `maxHp` and `baseAttack` to `attack`, and raises `NotFoundException` when the seed is absent.
- Preserved the existing tested `configureApplication()` global-prefix seam and enabled Nest shutdown hooks in `main.ts` without repeating prefix configuration.
- Updated the E2E assertion from the retired hello-world root route to `/api/health`; it replaces both database-dependent modules so the health test remains database independent.
## Verification
| Command | Result |
| --- | --- |
| `npm test --workspace=@ashen-realms/api -- health.controller.spec.ts characters.service.spec.ts --runInBand` | PASS: 2 suites, 3 tests |
| `npm run test:e2e --workspace=@ashen-realms/api -- --runInBand` | PASS: 1 suite, 2 tests |
| `npm test --workspace=@ashen-realms/api -- --runInBand` | PASS: 5 suites, 9 tests |
| `npm run build:api` | PASS |
| `apps/api/node_modules/.bin/prettier.cmd --check <task files>` | PASS |
| `git diff --check` | PASS |
`npx prettier` did not resolve the workspace-local executable in this environment; the checked-in workspace binary at `apps/api/node_modules/.bin/prettier.cmd` was used for the formatting check.
## Files
- Added `apps/api/src/health/*` and character controller/service/module plus unit tests.
- Updated `apps/api/src/app.module.ts`, `apps/api/src/main.ts`, and `apps/api/test/app.e2e-spec.ts`.
- Removed `apps/api/src/app.controller.ts`, `app.controller.spec.ts`, and `app.service.ts`.
## Self-review and concerns
- Confirmed the repository query loads only the required current-location relation and response does not expose persistence-only base-stat names.
- Confirmed the missing seed path is tested as a 404-producing Nest exception.
- Confirmed `/` remains a 404 while `/api/health` is available through the existing global prefix.
- No task-specific concerns remain. Pre-existing untracked `apps/web/public/images/` and `docs/references/Ashen_Realms_Visual_Asset_Style_Guide_V1.md` are intentionally excluded.
## Commit
- Pending: `feat: expose health and demo character APIs`

View File

@@ -1,105 +0,0 @@
# Task 5 Report: Server-authoritative travel
## Outcome
Implemented the NestJS travel domain and public endpoints:
- `POST /api/travel`
- `GET /api/travel/current`
- injected deterministic/system clock abstraction
- exact `StartTravelDto` input validation
- stable idle, travelling, completed, and domain-error response shapes
- `TravelModule` registration in the application
Travel start and completion use `DataSource.transaction`. Both operations acquire a pessimistic write lock on the character row followed by the active travel row. Relations are deliberately not joined into either locking query, which keeps the queries compatible with TypeORM/PostgreSQL `FOR UPDATE` behavior. Due completion changes the travel status and character location through repositories owned by the same transaction; a failed second save rolls back both mutations.
No ambush evaluation or other later-slice logic was added. Existing untracked frontend images and the visual asset guide were preserved and excluded from the commit.
## RED evidence
Command:
```powershell
npm test --workspace=@ashen-realms/api -- travel.service.spec.ts travel.controller.spec.ts --runInBand
```
Initial result: exit code 1. Both suites failed to resolve the missing production modules:
```text
Cannot find module './travel.service' from 'travel/travel.service.spec.ts'
Cannot find module './travel.controller' from 'travel/travel.controller.spec.ts'
Test Suites: 2 failed, 2 total
Tests: 0 total
```
The active-travel regression test was also mutation-checked. Temporarily removing the `TRAVEL_ALREADY_ACTIVE` guard produced the expected focused failure (`Expected constructor: TravelDomainError; Received constructor: Object`); restoring the guard returned the test to green.
## GREEN evidence
Focused travel tests:
```powershell
npm test --workspace=@ashen-realms/api -- travel.service.spec.ts travel.controller.spec.ts --runInBand
```
```text
Test Suites: 2 passed, 2 total
Tests: 9 passed, 9 total
Time: 10.973 s
```
The service suite covers all five required behaviors plus active-travel rejection, public current-travel mapping, idempotent repeat completion, pessimistic-lock observation, and rollback when the second completion save fails. The controller suite drives a real Nest HTTP pipeline and verifies that server-owned timestamp/duration fields receive HTTP 400.
Full API suite:
```powershell
npm test --workspace=@ashen-realms/api -- --runInBand
```
```text
Test Suites: 7 passed, 7 total
Tests: 18 passed, 18 total
Time: 15.87 s
```
API E2E:
```powershell
npm run test:e2e --workspace=@ashen-realms/api -- --runInBand
```
```text
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Time: 9.406 s
```
API build:
```powershell
npm run build:api
```
Result: exit code 0 (`nest build`).
Formatting and lint:
```powershell
apps/api/node_modules/.bin/prettier.cmd --check <Task 5 files>
node_modules/.bin/eslint.cmd <Task 5 files>
```
Both commands exited 0. Prettier reported `All matched files use Prettier code style!`; ESLint reported no findings.
## Integration notes and self-review
- The first build exposed TypeScript `TS1272` for a decorated `Clock` parameter under `isolatedModules`; importing `Clock` as a type fixed the root cause, after which tests and build were rerun.
- Registering the database-backed `TravelModule` exposed that the existing health E2E test replaced `DatabaseModule` and `CharactersModule` but not travel. The harness now replaces `TravelModule` as well, preserving the database-free health test.
- Lock order is identical in start and completion, reducing deadlock risk.
- The character row serializes concurrent starts even when no active travel row exists yet; the partial unique database index remains the final invariant.
- The due comparison treats `arrivesAt === now` as due and never moves the character earlier.
- Public responses contain only location summaries and travel timestamps/status, never entities, duration input, ambush probability, or persistence metadata.
## Remaining concern
No live-PostgreSQL travel integration test was added because the repository's current E2E harness intentionally runs without a database; clean migration/seed/API database smoke coverage belongs to Task 10. The transaction code uses supported real `EntityManager.getRepository`, `Repository.findOne` lock options, `findOneBy`, `create`, and `save` behavior, and the stateful fake verifies transaction commit/rollback semantics rather than mock call counts.

View File

@@ -1,83 +0,0 @@
# Task 6 — Current-location World API report
## Scope delivered
- Added `WorldService.getCurrentLocation(characterId)`.
- Added `GET /api/world/current-location` using the stable demo character.
- Added `WorldModule`, including TypeORM repositories for `Character` and
`LocationConnection`, and imported it through `AppModule`.
- `WorldService` completes due travel before loading the character, returns the
complete current-location DTO, returns only enabled outgoing connections, and
exposes a target summary, duration, and textual danger rating only. The raw
`ambushChance` is not part of the public DTO; `0.0500` maps to `LOW`.
- Kept the existing database-free health E2E harness valid by overriding the
newly imported `WorldModule`, just as it already overrides the database,
character, and travel modules.
## Required preflight
- Read the complete Task-6 brief and the binding project documentation,
including the visual asset style guide.
- Inspected `world-travel-screen.png`, `combat-screen.png`, and
`hunting-screen.png` in `docs/references/`.
- Preserved the user-owned untracked `apps/web/public/images/` directory and
`docs/references/Ashen_Realms_Visual_Asset_Style_Guide_V1.md` unchanged.
## TDD evidence
### RED
Command:
```powershell
npm test --workspace=@ashen-realms/api -- world.service.spec.ts --runInBand
```
Initial result: failed because `./world.service` did not exist. After the
minimal initial implementation, the test exposed that a disabled connection
could still be returned by an unexpected repository result. The service now
defensively filters disabled connections in addition to querying with
`enabled: true`.
### GREEN
```powershell
npm test --workspace=@ashen-realms/api -- world.service.spec.ts --runInBand
```
Result: 1 suite passed, 2 tests passed.
## Verification evidence
```powershell
npm test --workspace=@ashen-realms/api -- --runInBand
# 8 suites passed, 20 tests passed
npm run test:e2e --workspace=@ashen-realms/api -- --runInBand
# 1 suite passed, 2 tests passed
npm run build:api
# Nest build completed successfully
# From apps/api:
.\node_modules\.bin\prettier.cmd --check <Task-6 TypeScript files>
npx eslint <Task-6 TypeScript files>
# Prettier: all matched files use Prettier code style; ESLint exit 0
git diff --check -- apps/api/src/app.module.ts apps/api/src/world apps/api/test/app.e2e-spec.ts
# exit 0
```
The initial E2E run was reproducibly red because the new `WorldModule` caused
TypeORM repository construction after the test had intentionally replaced the
database module. Adding its corresponding empty module override restored the
existing database-free health test without changing product behavior.
## Deliberate limits and concerns
- The approved first slice only needs the seeded 5% route, so the public
connection danger mapping currently distinguishes `LOW` (at or below 5%) and
`HIGH` (above 5%). Future routes may require finer danger tiers when their
product thresholds are specified.
- This task adds no database-backed route E2E assertion; the dedicated
migration/seed route smoke test is scheduled for Task 10.

View File

@@ -1,151 +0,0 @@
# 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
```
## Review fix round 2
The transient-poll regression now asserts both phases: the failed poll exposes
`Temporary failure`, and the successful authoritative `IDLE` retry clears it.
The new assertion was RED against the prior implementation because the error
remained set after the retry. `refreshCurrentTravel()` now clears the polling
error only after a successful API response and before applying that response;
the countdown contract and all other error paths are unchanged.
Fresh verification:
```powershell
npm test --workspace=@ashen-realms/web -- --watch=false --include='src/app/features/world/world.store.spec.ts'
# 1 test file passed, 9 tests passed
npm test --workspace=@ashen-realms/web -- --watch=false
# 3 test files passed, 13 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.
## Review fix round 1
### Findings addressed
- A journey whose `arrivesAt` was already in the past made an immediate poll
but also installed a countdown interval. This could overlap an outstanding
request.
- A late response after store destruction could still apply state and start
follow-on work.
The store now makes a single immediate poll at local zero with no countdown
interval. It uses one in-flight poll at a time and schedules a deliberate
one-second retry when the server still returns `TRAVELLING` or a poll fails.
All late async continuations check the destroy flag before changing state or
scheduling timers. The browser still never declares arrival or changes the
location itself.
### TDD evidence
```powershell
npm test --workspace=@ashen-realms/web -- --watch=false --include='src/app/features/world/world.store.spec.ts'
```
RED result: 2 of 9 tests failed against the previous implementation. The
already-expired test observed three calls where a pending request must allow
only two, and the destroy test observed an unwanted second character/location
reload after a late `COMPLETED` response.
GREEN result: 1 test file passed, 9 tests passed. The expanded cases establish
that `IDLE` and continuing `TRAVELLING` do not reload authoritative character
or location data, expired/skewed client time remains single-flight/throttled,
a transient poll error retries and recovers, and destruction ignores a late
response.
### Fresh verification
```powershell
npm test --workspace=@ashen-realms/web -- --watch=false
# 3 test files passed, 13 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
```

View File

@@ -1,74 +0,0 @@
# Task 8 report: reusable Angular application shell
## Scope delivered
- Replaced the Angular scaffold with a route-hosting root component and a reusable dark-fantasy shell.
- Added focused standalone components for the app shell, store-driven top bar, side navigation, footer, and context-panel frame.
- Added requested design tokens, spacing, motion, elevation, global reset, responsive shell grid, and reduced-motion protection.
- Configured `/` and fallback routing to `/world`; the route lazy-loads a deliberately scene-free `WorldPageComponent` placeholder so Task 9 can add the world composition without changing the route contract.
- Made Karte the only enabled active navigation entry. Jagd, Quests, Inventar, and Charakter are disabled buttons with accessible explanatory labels. Shop is not rendered.
- Top-bar character identity, level, and hit points are rendered only from `WorldStore.character`; it has no hardcoded character statistics.
## Preflight and asset evidence
- Re-read the Task 8 brief, visible-slice implementation plan, progress ledger, prior task reports, and the full user-provided visual asset style guide.
- Visually inspected `combat-screen.png`, `hunting-screen.png`, and `world-travel-screen.png` before editing.
- Visually inspected the relevant user HUD assets before use. The shell uses only:
- `apps/web/public/images/hud/CharacterIcon.png`
- `apps/web/public/images/hud/MapsIcon.png`
- `apps/web/public/images/hud/HuntIcon.png`
- `apps/web/public/images/hud/QuestsIcon.png`
- `apps/web/public/images/hud/InventoryIcon.png`
## TDD evidence
### RED
`npm test --workspace=@ashen-realms/web -- --watch=false` failed as intended before production work: the new shell specification expected `app-top-bar`, but the scaffold only contained a router outlet. The existing two test files remained green (11 passing tests).
### GREEN
`npm test --workspace=@ashen-realms/web -- --watch=false` passed with 3 test files and 12 tests. The added shell test asserts top bar, side navigation, semantic main content, footer, enabled active Karte, disabled future destinations, and Shop absence.
## Final verification
- `npm test --workspace=@ashen-realms/web -- --watch=false`: 3 files and 12 tests passing.
- `npm run build:web`: production Angular build passed and emits a lazy `world-page-component` chunk.
- `npm exec --workspace=@ashen-realms/web -- prettier --check ...`: all Task 8 files formatted.
- `git diff --check`: no whitespace errors.
## Visual QA limitation
The Browser plugin is not available in this session. The repository has no installed Playwright package, and a non-mutating `npm exec --workspace=@ashen-realms/web -- playwright --version` check could not resolve a local runner and timed out. No browser dependency was added outside task scope. Rendered browser verification remains for Task 11, which owns final browser workflow and fidelity captures.
## Intentional limits
- The world route placeholder contains no scene, nodes, API loading, or travel panel; Task 9 owns those surfaces.
- The context panel is only a reusable framed shell until Task 9 supplies world data.
## Review fix round 1
### Accessibility and responsive behavior
- The active Karte button now has the explicit accessible name `Karte`, so its label remains available when the compact viewport hides visible navigation text.
- At widths below 900px, the context panel now reflows into a full-width row below main content instead of being hidden. At widths below 620px it remains in DOM order after main content.
### Runtime HUD derivatives
The five original 1254 x 1254 user assets remain unmodified. The shell now loads deterministic 128 x 128 PNG derivatives from `images/hud/runtime/`; each was resized locally with high-quality bicubic interpolation and no creative image change.
| Asset | Original bytes | Runtime derivative bytes |
| --- | ---: | ---: |
| CharacterIcon | 2,283,892 | 26,670 |
| MapsIcon | 2,144,772 | 27,774 |
| HuntIcon | 2,313,949 | 29,486 |
| QuestsIcon | 2,235,955 | 29,554 |
| InventoryIcon | 2,060,169 | 26,142 |
The combined persistent-icon payload is reduced from 11,038,737 bytes to 139,626 bytes (about 98.7%). The resized Maps icon was visually inspected after generation.
### Regression evidence
RED: the added explicit accessible-name assertion failed before the fix because the active Karte control had no `aria-label`.
GREEN: `npm test --workspace=@ashen-realms/web -- --watch=false` passed with 3 test files and 14 tests. The added coverage verifies the explicit Karte name, non-null WorldStore character rendering, and root/wildcard `/world` route redirects.

View File

@@ -1,51 +0,0 @@
# Task 9 Report: World UI
## Reference inventory
- `world-travel-screen.png`: persistent top bar and left navigation, a dominant central map, labelled current/reachable nodes with a dotted path, dense right context, and a bottom-centred travel decision panel.
- `hunting-screen.png`: confirms the shared dark iron/bronze material treatment, restrained gold highlights, readable serif display hierarchy, and dense information without white cards or modern pills.
- `combat-screen.png`: confirms the same persistent shell, dark desaturated palette, thin ornate borders, and blue only as a controlled active-state accent.
The reference screenshots were inspected but are not shipped or referenced by the application.
## Artwork and layout decision
- No ImageGen was used: the controller ruling makes the delivered user artwork final.
- `/images/backgrounds/map_ashen_realm.png` is the dominant WorldPage scene. Its wide path, city, watchtower, and ash-field composition provides a clearer interactive map than either location image.
- The server-delivered location paths are `/images/backgrounds/Suedtor.png` and `/images/backgrounds/Aschestrasse.png`; the ContextPanel renders the authoritative current-location image, so it changes only after server-confirmed arrival.
- This deliberately supersedes the older brief wording that asked for generated assets and API artwork as the main scene background. It preserves the binding controller ruling while retaining the server-provided artwork as context.
## Delivered behavior
- `WorldPageComponent` loads `WorldStore` on init, renders a code-native SVG connection and two code-native location buttons, shows loading/error/retry states, and never advances location locally.
- `LocationNodeComponent` exposes current, selected, reachable, disabled, hover, and focus states with textual labels.
- `TravelPanelComponent` presents no-selection guidance, selected target/travel-time/danger with `Reise beginnen`, and an arrivesAt-derived travelling countdown with disabled action.
- `ContextPanelComponent` renders the current/selected location, description, recommended level, safe status, hunting status, and server artwork.
- Seed paths now match the delivered assets and are covered by the API seed test.
## Review-fix round
- Map-node geography is now anchored to the stable location keys (`south-gate` and `burned-road`). Current, reachable, and selected state only changes presentation; a regression test swaps the authoritative current location without moving either node.
- On a server `COMPLETED` response, the selected connection is cleared and the store stays loading until the authoritative character/location/travel reload completes. All travel actions are disabled in that interval; no local arrival or location update is performed.
- The moving countdown is a `<time role="timer">` without a broad live region. The selected and travelling mode headings remain labelled for assistive technology.
- The decision panel now occupies normal document flow below the map scene, reserving a non-overlapping zone at desktop and narrow breakpoints.
- The original delivered PNGs remain untouched. A deterministic local System.Drawing JPEG conversion (quality 86, high-quality bicubic) provides runtime derivatives: `map_ashen_realm-1440.jpg` (1440x810, 233,778 B), `Suedtor-960.jpg` (960x540, 94,286 B), and `Aschestrasse-960.jpg` (960x540, 93,277 B). The WorldPage and ContextPanel prefer these derivatives while retaining each server artwork path as the `<img>` fallback.
- The load error is exposed as an alert and its retry control is covered by a store-load regression test.
- The map no longer declares the runtime JPEG and original PNG as comma-separated background layers. It uses the original PNG as the classic CSS fallback and, where `image-set` with JPEG is supported, replaces it with one runtime JPEG candidate. The WorldPage regression test verifies there is no inline multi-layer binding or `mapBackground` source left to reintroduce the double load.
## Verification
- RED confirmed: four new WorldPage contracts failed against the empty placeholder; seed artwork-path assertion failed against the former WebP paths.
- Focused WorldPage test: 4/4 pass.
- Full web tests: 18/18 pass.
- Affected API seed tests: 2/2 pass.
- `npm run build:web` and `npm run build:api` pass.
- Review focused World/Store/Context tests: 19 pass (4 files; 4 pre-existing skips).
- Review full web suite: 23/23 pass.
- Affected API seed test: 2/2 pass.
- Both review builds (`npm run build:web`, `npm run build:api`) pass.
- Review-fix round 2: focused WorldPage test 7/7 pass; full web suite and web build re-run after the fallback change.
## Remaining concern
Rendered browser fidelity QA is intentionally deferred to Task 11, as instructed. The Browser plugin is unavailable and no Playwright fallback was started for this task.

176
README.md Normal file
View File

@@ -0,0 +1,176 @@
# Ashen Realms
A dark-fantasy browser RPG built as an npm-workspace modular monolith:
- `apps/web` — Angular 22 single-page application
- `apps/api` — NestJS 11 + TypeORM + PostgreSQL API
- `packages/*` — reserved shared boundaries (currently unused)
This README documents the first visible vertical slice: a server-authoritative
world/travel loop between two locations (Südtor von Graufurt and Verbrannte
Straße) for one demo character, with no login required.
## Prerequisites
- Node.js and npm (workspace-aware npm, matching the versions used by CI/your
toolchain).
- A reachable PostgreSQL server (PostgreSQL 13 or newer — the schema uses the
built-in `gen_random_uuid()`, which needs no extensions) with a database
named `ashen_realms`. Create it once, e.g.:
```powershell
psql -h localhost -U postgres -c "CREATE DATABASE ashen_realms;"
psql -h localhost -U postgres -c "CREATE USER ashen WITH PASSWORD 'ashen';"
psql -h localhost -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE ashen_realms TO ashen;"
```
Adjust user/password/host to match your own local PostgreSQL instance; just
keep `DATABASE_URL` (below) pointed at it.
## Local setup
Run from the repository root:
```powershell
npm install
Copy-Item .env.example .env
npm run db:migrate
npm run db:seed
npm run dev:api
npm run dev:web
```
Run `dev:api` and `dev:web` in separate terminals — both watch and keep
running. Once both are up:
- API: `http://localhost:3000/api/...` (see routes below)
- Web: `http://localhost:4200`, which proxies `/api/*` requests to
`http://localhost:3000` in development (see `apps/web/proxy.conf.json`)
Open `http://localhost:4200/world` to see Aric Duskwalker at the Südtor von
Graufurt and travel to the Verbrannte Straße and back.
### Configuration (`.env`)
`.env.example` (repo root) documents every variable. Copy it to `.env` at the
repo root — `.env` is git-ignored and must never be committed. The important
one for local setup is:
```
DATABASE_URL=postgresql://ashen:ashen@localhost:5432/ashen_realms
```
This is the default local PostgreSQL connection string: database name
`ashen_realms`, default port `5432`. Edit it if your local PostgreSQL uses a
different user, password, host, or port. All API workspace scripts
(`db:migrate`, `db:seed`, `dev:api`, and `apps/api`'s own `test:e2e`) resolve
this `.env` from the repository root regardless of which workspace directory
npm runs the underlying command in.
`synchronize` is always `false`; schema changes only happen through the
checked-in migration at
`apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts`.
The seed (`apps/api/src/database/seeds/vertical-slice.seed.ts`) is idempotent:
running `npm run db:seed` multiple times upserts the same two locations, two
connections, and one demo character without creating duplicates or resetting
the character's current (live) location.
### Ports
| Service | Port | Notes |
|---|---|---|
| API (NestJS) | `3000` | All routes are under `/api` (e.g. `/api/health`). |
| Web (Angular dev server) | `4200` | Proxies `/api/*` to the API in development. |
## Available routes (first slice)
```
GET /api/health
GET /api/characters/me
GET /api/world/current-location
GET /api/travel/current
POST /api/travel { "targetLocationId": "<uuid>" }
```
`POST /api/travel` accepts exactly `targetLocationId`; the global validation
pipe rejects any other property (e.g. a client-supplied `arrivesAt`) with
`400 Bad Request`, since `arrivesAt` is always server-derived.
## Assets
`apps/web/public/images` holds only the derivative image files the Angular
build actually serves (resized `runtime/*-128.png` HUD icons, `runtime/*-960.jpg`
and `runtime/*-1440.jpg` background variants, and the small set of PNG
fallbacks referenced directly by source/styles). Everything under `apps/web/public`
is copied verbatim into the browser build, so keep that folder limited to
files a component, template, or stylesheet actually references.
Original/unresized source art (enemy, NPC, and combat-status artwork, HUD icon
originals, difficulty badges, etc.) that isn't loaded by the app lives in
`art/` at the repository root instead, mirroring the same subfolder layout
(e.g. `art/enemies`, `art/npc`, `art/hud`). It is not part of any build output.
## Scripts
Run these from the repository root unless noted otherwise.
| Script | Description |
|---|---|
| `npm run dev:web` | Start the Angular dev server (port 4200). |
| `npm run dev:api` | Start the NestJS API in watch mode (port 3000). |
| `npm run build:web` | Production build of the Angular app. |
| `npm run build:api` | Production build of the NestJS app. |
| `npm run build` | Both builds. |
| `npm test` | Unit tests for every workspace. |
| `npm run test:e2e` | API end-to-end/smoke tests (see below). |
| `npm run db:migrate` | Run pending TypeORM migrations against `DATABASE_URL`. |
| `npm run db:revert` | Revert the last migration. |
| `npm run db:seed` | Run the idempotent demo-content seed. |
## Testing
Unit tests never require a database:
```powershell
npm test --workspace=@ashen-realms/api -- --runInBand
```
The API end-to-end smoke test (`apps/api/test/visible-slice.e2e-spec.ts`)
always asserts `GET /api/health`. When `DATABASE_URL` is set and reachable,
it additionally boots the real `AppModule` (real database, entities, and
controllers) and asserts the seeded character/world responses and the
`arrivesAt` validation rejection. Those database-backed assertions are
skipped — not failed — when no database is configured, so `npm run test:e2e`
is safe to run without any local PostgreSQL setup too:
```powershell
npm run test:e2e --workspace=@ashen-realms/api -- --runInBand
```
Both builds:
```powershell
npm run build:api
npm run build:web
```
## Known limitations of this first vertical slice
This slice deliberately excludes (per
`docs/superpowers/specs/2026-08-18-first-visible-vertical-slice-design.md`):
- Authentication, user accounts, JWTs, or any login/registration flow — there
is exactly one hardcoded demo character (Aric Duskwalker).
- Hunting, combat, loot, inventory, equipment, quests, merchants, and
currencies.
- Realtime features: WebSockets, chat, guilds, CMS, object storage, or a
multi-service/microservice architecture.
- Ambush resolution: `ambushChance` is stored on each connection and surfaced
only as a coarse `LOW`/`HIGH` danger rating; it is never rolled.
- Production containerization or NestJS static hosting.
- Client-side clock skew can shift how the travel countdown *displays*, but
arrival is always confirmed by the server (`GET /api/travel/current` /
`GET /api/world/current-location`), never inferred locally.
Only two locations and one directed pair of connections exist
(`south-gate` ⇄ `burned-road`), each with a fixed 10-second travel duration.

View File

@@ -1,6 +1,14 @@
import 'dotenv/config';
import { config } from 'dotenv';
import { resolve } from 'path';
import { DataSource } from 'typeorm';
// npm workspace scripts (`db:migrate`, `db:seed`) run with this file's
// workspace (`apps/api`) as the current working directory, not the repo
// root where the documented `.env` lives. Resolve it explicitly so the
// documented `npm run db:migrate` / `npm run db:seed` flow works regardless
// of the caller's working directory.
config({ path: resolve(__dirname, '../../../../.env') });
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl?.trim()) {
@@ -11,6 +19,6 @@ export const AppDataSource = new DataSource({
type: 'postgres',
url: databaseUrl,
entities: [__dirname + '/../**/*.entity.{ts,js}'],
migrations: [__dirname + '/migrations/*.{ts,js}'],
migrations: [__dirname + '/migrations/[0-9]*.{ts,js}'],
synchronize: false,
});

View File

@@ -1,7 +1,15 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { configureApplication } from './app.config';
// `dev:api` runs via the `apps/api` npm workspace, so process.cwd() is
// `apps/api`, not the repo root where the documented `.env` lives. Resolve
// it explicitly so `npm run dev:api` picks up `DATABASE_URL` after
// `Copy-Item .env.example .env` as documented in the README.
config({ path: resolve(__dirname, '..', '..', '..', '.env') });
async function bootstrap() {
const app = await NestFactory.create(AppModule);
configureApplication(app);

View File

@@ -5,5 +5,6 @@
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
},
"testTimeout": 15000
}

View File

@@ -0,0 +1,255 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, Module } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
import { CharactersModule } from './../src/characters/characters.module';
import { DatabaseModule } from './../src/database/database.module';
import { configureApplication } from './../src/app.config';
import { TravelModule } from './../src/travel/travel.module';
import { WorldModule } from './../src/world/world.module';
import { DEMO_CHARACTER_ID } from './../src/demo/demo-character.constants';
import { BURNED_ROAD_ID } from './../src/database/seeds/vertical-slice.constants';
// `npm run test:e2e` runs from the `apps/api` workspace, so process.cwd()
// is `apps/api`, not the repo root where the documented `.env` lives.
// Load it the same way `main.ts`/`data-source.ts` do, without overriding a
// `DATABASE_URL` a developer or CI already exported.
config({ path: resolve(__dirname, '../../../.env') });
@Module({})
class TestDatabaseModule {}
@Module({})
class TestCharactersModule {}
@Module({})
class TestTravelModule {}
@Module({})
class TestWorldModule {}
describe('Visible vertical slice smoke (e2e)', () => {
describe('without a developer database', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideModule(DatabaseModule)
.useModule(TestDatabaseModule)
.overrideModule(CharactersModule)
.useModule(TestCharactersModule)
.overrideModule(TravelModule)
.useModule(TestTravelModule)
.overrideModule(WorldModule)
.useModule(TestWorldModule)
.compile();
app = moduleFixture.createNestApplication();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app?.close();
});
it('GET /api/health returns ok', () => {
return request(app.getHttpServer())
.get('/api/health')
.expect(200)
.expect({ status: 'ok' });
});
});
// These assertions exercise the real DatabaseModule, TypeORM entities and
// the seeded demo character/world data, so they only run when a reachable
// PostgreSQL database is configured (see README.md for local setup).
const describeWithDatabase = process.env.DATABASE_URL
? describe
: describe.skip;
describeWithDatabase('against a migrated and seeded database', () => {
let app: INestApplication<App>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApplication(app);
await app.init();
});
afterAll(async () => {
await app?.close();
});
it('GET /api/health returns ok', () => {
return request(app.getHttpServer())
.get('/api/health')
.expect(200)
.expect({ status: 'ok' });
});
it('GET /api/characters/me returns the seeded demo character', async () => {
const response = await request(app.getHttpServer())
.get('/api/characters/me')
.expect(200);
const body = response.body as {
id: string;
name: string;
currentLocation: { id: string; key: string; name: string };
};
expect(body).toMatchObject({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
});
expect(typeof body.currentLocation.id).toBe('string');
expect(typeof body.currentLocation.key).toBe('string');
expect(typeof body.currentLocation.name).toBe('string');
});
it('GET /api/world/current-location returns the character location with its connections', async () => {
const response = await request(app.getHttpServer())
.get('/api/world/current-location')
.expect(200);
const body = response.body as {
id: string;
key: string;
name: string;
connections: unknown[];
};
expect(typeof body.id).toBe('string');
expect(typeof body.key).toBe('string');
expect(typeof body.name).toBe('string');
expect(Array.isArray(body.connections)).toBe(true);
expect(body.connections.length).toBeGreaterThan(0);
});
it('GET /api/travel/current returns a known travel status', async () => {
const response = await request(app.getHttpServer())
.get('/api/travel/current')
.expect(200);
const body = response.body as { status: string };
expect(['IDLE', 'TRAVELLING', 'COMPLETED']).toContain(body.status);
});
it('POST /api/travel rejects a body carrying a non-whitelisted arrivesAt field', () => {
return request(app.getHttpServer())
.post('/api/travel')
.send({
targetLocationId: BURNED_ROAD_ID,
arrivesAt: new Date().toISOString(),
})
.expect(400);
});
it(
'POST /api/travel starts a travel, rejects a concurrent start, and completes into the moved character (full happy path)',
async () => {
// `GET current-location` lazily completes any overdue travel left
// behind by a previous run before we read the starting point, so
// this test is safe to re-run without a fresh seed.
const origin = await request(app.getHttpServer())
.get('/api/world/current-location')
.expect(200);
const originLocationId: string = origin.body.id;
const outbound = origin.body.connections[0];
const targetLocationId: string = outbound.targetLocation.id;
const travelDurationSeconds: number = outbound.travelDurationSeconds;
const started = await request(app.getHttpServer())
.post('/api/travel')
.send({ targetLocationId })
.expect(201);
expect(started.body).toMatchObject({
status: 'TRAVELLING',
targetLocation: { id: targetLocationId },
});
expect(typeof started.body.arrivesAt).toBe('string');
expect(Number.isNaN(Date.parse(started.body.arrivesAt))).toBe(false);
const concurrentStart = await request(app.getHttpServer())
.post('/api/travel')
.send({ targetLocationId })
.expect(409);
expect(concurrentStart.body).toMatchObject({
code: 'TRAVEL_ALREADY_ACTIVE',
});
const arrivedTravel = await pollUntilTravelCompletes(
app,
travelDurationSeconds,
);
expect(arrivedTravel).toMatchObject({
status: 'COMPLETED',
targetLocation: { id: targetLocationId },
});
const arrivedLocation = await request(app.getHttpServer())
.get('/api/world/current-location')
.expect(200);
expect(arrivedLocation.body.id).toBe(targetLocationId);
// Restore the demo character to its original location so the suite
// (and this test) stays safely re-runnable.
const returnConnection = arrivedLocation.body.connections.find(
(connection: { targetLocation: { id: string } }) =>
connection.targetLocation.id === originLocationId,
);
expect(returnConnection).toBeDefined();
await request(app.getHttpServer())
.post('/api/travel')
.send({ targetLocationId: originLocationId })
.expect(201);
const returnedTravel = await pollUntilTravelCompletes(
app,
returnConnection.travelDurationSeconds,
);
expect(returnedTravel).toMatchObject({
status: 'COMPLETED',
targetLocation: { id: originLocationId },
});
const restoredLocation = await request(app.getHttpServer())
.get('/api/world/current-location')
.expect(200);
expect(restoredLocation.body.id).toBe(originLocationId);
},
30_000,
);
async function pollUntilTravelCompletes(
application: INestApplication<App>,
travelDurationSeconds: number,
): Promise<{ status: string; targetLocation?: { id: string } }> {
const deadline = Date.now() + travelDurationSeconds * 1000 + 5_000;
let body: { status: string; targetLocation?: { id: string } };
do {
const response = await request(application.getHttpServer())
.get('/api/travel/current')
.expect(200);
body = response.body;
if (body.status === 'COMPLETED') {
return body;
}
await new Promise((resolve) => setTimeout(resolve, 500));
} while (Date.now() < deadline);
return body;
}
});
});

View File

@@ -25,6 +25,7 @@
"@angular/cli": "22.0.9",
"@angular/compiler-cli": "22.0.8",
"jsdom": "^28.0.0",
"playwright": "^1.62.1",
"prettier": "^3.8.1",
"typescript": "6.0.3",
"vitest": "^4.0.8"

View File

@@ -11,7 +11,7 @@
.world-page__scene {
position: relative;
isolation: isolate;
min-block-size: clamp(30rem, 67vh, 44rem);
min-block-size: clamp(20rem, calc(100dvh - 25rem), 44rem);
overflow: hidden;
border: 1px solid var(--ar-border);
background-color: #151718;

View File

@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of, Subject, throwError } from 'rxjs';
import { vi } from 'vitest';
@@ -237,4 +238,96 @@ describe('WorldStore', () => {
expect(store.loading()).toBe(false);
expect(store.currentTravel()).toEqual({ status: 'IDLE' });
});
it('maps a known HttpErrorResponse travel error code to a specific German message', async () => {
await store.load();
store.selectConnection(currentLocation.connections[0]);
api.startTravel.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: {
statusCode: 409,
code: 'TRAVEL_ALREADY_ACTIVE',
message: 'The character is already travelling.',
},
}),
),
);
// Hold the post-failure resync pending so the mapped message is
// observable before it gets cleared by a successful resync.
const pendingResync = new Subject<CurrentTravel>();
api.getCurrentTravel.mockReturnValue(pendingResync);
await store.startTravel();
expect(store.error()).toBe('Du befindest dich bereits auf Reisen.');
pendingResync.next({ status: 'IDLE' });
pendingResync.complete();
});
it('falls back to the generic message for an HttpErrorResponse with no known code', async () => {
await store.load();
store.selectConnection(currentLocation.connections[0]);
api.startTravel.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 500,
error: { message: 'Internal server error' },
}),
),
);
const pendingResync = new Subject<CurrentTravel>();
api.getCurrentTravel.mockReturnValue(pendingResync);
await store.startTravel();
expect(store.error()).toBe('Weltzustand konnte nicht geladen werden.');
pendingResync.next({ status: 'IDLE' });
pendingResync.complete();
});
it('uses the message of a genuine non-HTTP Error', async () => {
await store.load();
store.selectConnection(currentLocation.connections[0]);
api.startTravel.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
const pendingResync = new Subject<CurrentTravel>();
api.getCurrentTravel.mockReturnValue(pendingResync);
await store.startTravel();
expect(store.error()).toBe('Netzwerkfehler');
pendingResync.next({ status: 'IDLE' });
pendingResync.complete();
});
it('resyncs current travel state from the server after a failed travel start', async () => {
await store.load();
store.selectConnection(currentLocation.connections[0]);
api.startTravel.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: {
statusCode: 409,
code: 'TRAVEL_ALREADY_ACTIVE',
message: 'The character is already travelling.',
},
}),
),
);
api.getCurrentTravel.mockReturnValue(of(travelling));
await store.startTravel();
await vi.advanceTimersByTimeAsync(0);
expect(api.getCurrentTravel).toHaveBeenCalledTimes(2);
expect(store.currentTravel()).toEqual(travelling);
});
});

View File

@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, OnDestroy, signal } from '@angular/core';
import { firstValueFrom, forkJoin } from 'rxjs';
import {
@@ -8,6 +9,17 @@ import {
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Weltzustand konnte nicht geladen werden.';
// Mirrors the `TravelErrorCode` union in `apps/api/src/travel/travel.errors.ts`.
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
const TRAVEL_ERROR_MESSAGES: Readonly<Record<string, string>> = {
TRAVEL_ALREADY_ACTIVE: 'Du befindest dich bereits auf Reisen.',
INVALID_TRAVEL_TARGET: 'Dieses Ziel ist von hier aus nicht erreichbar.',
CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.',
TRAVEL_STATE_INVALID: 'Der Reisezustand ist ungültig. Bitte lade die Seite neu.',
};
@Injectable({ providedIn: 'root' })
export class WorldStore implements OnDestroy {
private readonly characterState = signal<CharacterResponse | null>(null);
@@ -88,6 +100,7 @@ export class WorldStore implements OnDestroy {
} catch (error) {
if (!this.destroyed) {
this.errorState.set(this.toErrorMessage(error));
this.pollCurrentTravel();
}
} finally {
if (!this.destroyed) {
@@ -241,6 +254,11 @@ export class WorldStore implements OnDestroy {
}
private toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unable to load world state.';
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
return (code && TRAVEL_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
}
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
}
}

View File

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,187 @@
# First Visible Vertical Slice — Fidelity Ledger
Reference: `docs/references/world-travel-screen.png` (concept mock, Graufurt hub with
full RPG chrome: currencies, mail/friends/settings icons, "Shop" nav item, region
detail panel with encounters/rewards, "Gebiet erkunden" action).
Renders (this task, post-fix): `docs/verification/first-visible-slice-1920x1080.png`,
`docs/verification/first-visible-slice-1440x900.png`,
`docs/verification/first-visible-slice-1366x768.png`. All three captured with
Playwright/Chromium at `http://localhost:4200/world`, live API + seeded Postgres,
with Verbrannte Straße selected as the travel target (matches the reference's
state: travel panel populated).
Scope reminder: the reference is a full-game concept mock. This slice implements
only the demo character, two locations, and travel — currencies, mail/social,
settings, Shop, encounters/rewards, and "Gebiet erkunden" exploration are
out of scope by design (see Task 110 rulings) and are recorded below as
deliberate deviations, not defects.
## Comparison points
### 1. Shell proportions and persistent regions
- Reference: top bar (portrait, name/level, HP bar, currencies, icon row) +
left icon nav (Karte/Jagd/Quests/Inventar/Charakter/Shop) + center map +
right context panel + footer (chat/system line, world/online/time).
- Render: top bar (portrait, name/level, HP bar, brand wordmark) + left nav
(Karte enabled; Jagd/Quests/Inventar/Charakter present but disabled,
`aria-label="… ist noch nicht verfügbar"`) + center map + right
"Gebietsinfo" panel + footer (status dot + text, ornament, region name).
- Result: structural grid matches (top bar / nav+main+context / footer).
Currency row, mail/friends/settings/logout icons, and "Shop" nav item are
absent — deliberate, no such systems exist yet. No fix needed.
### 2. Artwork dominance and crop
- Reference: large painterly Graufurt gate artwork fills the map region,
`background-size: cover`-style dominant visual, quiet areas reserved for
path/nodes/panel.
- Render: `.world-page__scene` uses a single static, project-generated world
map background (`/images/backgrounds/map_ashen_realm.png`, upgraded to
`/images/backgrounds/runtime/map_ashen_realm-1440.jpg` via `image-set()`
where supported) for the whole map region, `background-size: cover`, same
compositional role as the reference's gate artwork. Per-location artwork
(`Suedtor.png` / `Aschestrasse.png`) is generated per the style guide too,
but is only used as the small thumbnail in the right "Gebietsinfo" panel
(`context-panel.component.html`), not as the map background.
- Result: matches intent for dominance/crop/compositional role. The map
region itself does not switch per-location artwork the way the reference's
single-gate mock implies — a static world map plus a per-location panel
thumbnail is an established, deliberate choice from earlier tasks, not a
regression introduced here — left as a recorded deviation, not fixed.
### 3. Typography hierarchy and control typography
- Reference: large serif location title overlaid top-left of the map, green
safety subtitle, short descriptive paragraph in the same overlay; serif
headers in the side panel.
- Render: serif (`Georgia`) location title top-left of the map
(`.world-page__location-title`); description/safety/level facts live in
the right "Gebietsinfo" panel instead of a second overlay block on the
map. Control typography (buttons, dl labels) uses the app's sans body
font with small-caps-style eyebrows ("REISEN", "GEBIETSINFO").
- Result: title hierarchy matches (serif, large, top-left). The map-overlay
vs. side-panel placement of description text is an established
information-architecture choice from earlier tasks (Task 8/9), not a
regression introduced here — left as a recorded deviation, not fixed,
since re-architecting panel content is outside this task's evidenced-defect
scope.
### 4. Dark palette, bronze borders, and blue/current state
- Reference: anthracite/black panels, bronze/gold hairline borders, blue ring
on the current-location node, gold ring + gold path on selected/target.
- Render: `--ar-bg`/`--ar-panel` dark palette, `--ar-border`/
`--ar-border-highlight` bronze/gold borders on top bar, panels, and travel
box; `.location-node--current` renders a blue marker ring (confirmed in all
three captures, "Aktueller Ort" label green), selected/target node renders
gold ring + gold dashed path (confirmed, "Ausgewähltes Ziel" label gold).
- Result: matches the style guide's colour semantics (blue = current/active
navigation, gold = selection/important action). No fix needed.
### 5. Node/path legibility
- Reference: node names + short state label beneath each node, dashed gold
connecting path.
- Render: same pattern — `location-node__name` + `location-node__state`
("Aktueller Ort" / "Ausgewähltes Ziel" / "Erreichbar"), dashed gold SVG
path between the two nodes. Legible with sufficient text-shadow contrast
against the background artwork at all three captured sizes.
- Result: matches. No fix needed.
### 6. Travel panel placement and action prominence
- Reference: travel panel overlays the bottom edge of the map itself, as a
floating box, with a full-width primary blue button.
- Render: `.world-page__travel-panel` is a separate block directly beneath
the map (not overlaid on top of it), centered, `min(29rem, 100%)` wide,
with a full-width primary button (`Reise beginnen` / `Zur Verbrannte
Straße reisen?`) using the gold-bordered, blue-tinted button style.
- Result: placement differs (below vs. overlaid on the map) — an established
layout choice from earlier tasks, not introduced or regressed by this
task. Action prominence (full-width primary button, clear label, disabled
state while travelling) matches reference intent. Not treated as a
material mismatch requiring a redesign in this verification-only task.
### 7. Context-panel information density
- Reference: region title, recommended level, "Mögliche Begegnungen" icon
row, description, "Mögliche Belohnungen" icon row, "Gebiet erkunden"
button.
- Render: "GEBIETSINFO" eyebrow, location name, selected-target note,
artwork thumbnail, description paragraph, facts list (recommended level /
safety / hunting availability).
- Result: reduced density is intentional — encounters, rewards, and area
exploration are later-gameplay systems explicitly out of scope for this
slice (per the plan's deliberate limitations). Not a defect.
### 8. Responsive behaviour at all three sizes
- Evidence (pre-fix): measured via a Playwright DOM probe —
`document.documentElement.scrollHeight` vs. `window.innerHeight`:
- 1920×1080: docHeight 1098 vs. viewport 1080 (18px overflow; footer's
bottom 18px clipped below the fold).
- 1440×900: docHeight 997 vs. viewport 900 (97px overflow; travel button
and footer entirely below the fold).
- 1366×768: docHeight 909 vs. viewport 768 (141px overflow; travel button
and footer entirely below the fold, ~half the travel panel cut off).
- Root cause: `.world-page__scene { min-block-size: clamp(30rem, 67vh,
44rem); }` sized the map purely off viewport height without accounting
for the fixed chrome around it (top bar + paddings + travel panel +
footer ≈ 395px), so the three required desktop sizes all overflowed the
viewport and pushed the footer (and, at the two smaller sizes, the
travel button) out of view without scrolling.
- **Fixed** in `apps/web/src/app/features/world/world-page.component.scss`:
changed to `min-block-size: clamp(20rem, calc(100dvh - 25rem), 44rem)`,
which sizes the map to the remaining space after the known chrome height
(with a small safety margin), while keeping the existing 2044rem floor
and ceiling.
- Evidence (post-fix), same probe: 1920×1080 → 1080 vs. 1080 (0 overflow);
1440×900 → 900 vs. 900 (0 overflow); 1366×768 → 768 vs. 768 (0
overflow). Re-captured screenshots (see paths above) show the top bar,
nav, map with both nodes, full travel panel including the primary
button, context panel, and footer all fully visible with no clipping at
all three sizes.
- Result: **material mismatch found and fixed.**
## Above-the-fold copy diff
Every visible string across all three renders was checked against the
brief's allowed-copy categories (API content, requested navigation labels,
travel labels, restrained system/footer text):
| Text | Source | Category |
|---|---|---|
| "Aric Duskwalker", "Stufe 1", "100 / 100" | API (`/api/characters/me`) | API content |
| "Südtor von Graufurt", "Verbrannte Straße", description paragraph, "11", "Sicherer Ort", "Keine Jagd" | API (`/api/world/current-location`) | API content |
| "Verbrannte Straße", "00:00:10", "Niedrig" | API (`/api/world/current-location` connections) | API content |
| "Karte", "Jagd", "Quests", "Inventar", "Charakter" | side nav | requested navigation labels |
| "REISEN", "Zur … reisen?", "Ziel", "Reisezeit", "Gefahr", "Reise beginnen", "Ankunft in", "REISE LÄUFT", "Reise läuft", "Wähle einen erreichbaren Ort auf der Karte." | travel panel | travel labels |
| "GEBIETSINFO", "Ausgewähltes Ziel: …", "Empfohlene Stufe", "Sicherheit", "Jagd", "Aktueller Ort", "Ausgewähltes Ziel", "Erreichbar" | context panel / node labels | restrained system text |
| "Verbindung bereit", "Aschenfelder", "Ashen Realms" | footer / top bar | restrained system/footer text |
| "Weltzustand wird geladen…", "Weltkarte wird vorbereitet.", "Erneut versuchen" | loading/empty states | restrained system text |
| "Unable to load world state." | error fallback (`world.store.ts`) | **violation — English string in an otherwise fully German UI** |
**Fixed:** `apps/web/src/app/features/world/world.store.ts`,
`toErrorMessage()` fallback changed from `'Unable to load world state.'` to
`'Weltzustand konnte nicht geladen werden.'`, matching the German tone/case
used everywhere else in the app (e.g. `"Weltzustand wird geladen…"`).
Verified live: killed the API process, reloaded `/world`, confirmed the
in-shell error box now reads "Weltzustand konnte nicht geladen werden." with
no `window.alert()` dialog fired (Playwright `page.on('dialog', …)` listener
recorded zero dialogs across every step of the verification pass).
No invented, promotional, or placeholder copy was found anywhere in the
above-the-fold content.
## Summary
One functional/visual defect found and fixed (responsive overflow/clipping
at all three required desktop sizes — item 8). One copy defect found and
fixed (English fallback error string — copy diff table above). All other
comparison points either matched the reference's visual language directly,
or reflect deliberate, already-ruled-on scope reductions from earlier tasks
(no currencies/social/settings/Shop, reduced context-panel density, no
map-overlay description block, travel panel placed below rather than over
the map) that this verification-only task did not redesign.

48
package-lock.json generated
View File

@@ -758,6 +758,7 @@
"@angular/cli": "22.0.9",
"@angular/compiler-cli": "22.0.8",
"jsdom": "^28.0.0",
"playwright": "^1.62.1",
"prettier": "^3.8.1",
"typescript": "6.0.3",
"vitest": "^4.0.8"
@@ -18472,6 +18473,53 @@
"node": ">=8"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",

View File

@@ -13,6 +13,7 @@
"build:api": "npm run build --workspace=@ashen-realms/api",
"build": "npm run build:web && npm run build:api",
"test": "npm run test --workspaces --if-present",
"test:e2e": "npm run test:e2e --workspace=@ashen-realms/api --",
"db:migrate": "npm run typeorm --workspace=@ashen-realms/api -- migration:run -d src/database/data-source.ts",
"db:revert": "npm run typeorm --workspace=@ashen-realms/api -- migration:revert -d src/database/data-source.ts",
"db:seed": "npm run seed --workspace=@ashen-realms/api"