Compare commits
7 Commits
6c35c18018
...
5b662aad4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b662aad4c | ||
|
|
d09476ba41 | ||
|
|
2e7b280270 | ||
|
|
1bb4feb6fb | ||
|
|
e0e6db3ae8 | ||
|
|
ec6c34e341 | ||
|
|
2f53e5ba80 |
@@ -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
|
NODE_ENV=development
|
||||||
PORT=3000
|
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
|
DATABASE_URL=postgresql://ashen:ashen@localhost:5432/ashen_realms
|
||||||
|
|
||||||
JWT_ACCESS_SECRET=change-me
|
JWT_ACCESS_SECRET=change-me
|
||||||
|
|||||||
@@ -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`
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import 'dotenv/config';
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { DataSource } from 'typeorm';
|
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;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
if (!databaseUrl?.trim()) {
|
if (!databaseUrl?.trim()) {
|
||||||
@@ -11,6 +19,6 @@ export const AppDataSource = new DataSource({
|
|||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
url: databaseUrl,
|
url: databaseUrl,
|
||||||
entities: [__dirname + '/../**/*.entity.{ts,js}'],
|
entities: [__dirname + '/../**/*.entity.{ts,js}'],
|
||||||
migrations: [__dirname + '/migrations/*.{ts,js}'],
|
migrations: [__dirname + '/migrations/[0-9]*.{ts,js}'],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { configureApplication } from './app.config';
|
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() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
configureApplication(app);
|
configureApplication(app);
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
}
|
},
|
||||||
|
"testTimeout": 15000
|
||||||
}
|
}
|
||||||
|
|||||||
255
apps/api/test/visible-slice.e2e-spec.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@angular/cli": "22.0.9",
|
"@angular/cli": "22.0.9",
|
||||||
"@angular/compiler-cli": "22.0.8",
|
"@angular/compiler-cli": "22.0.8",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
.world-page__scene {
|
.world-page__scene {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
min-block-size: clamp(30rem, 67vh, 44rem);
|
min-block-size: clamp(20rem, calc(100dvh - 25rem), 44rem);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--ar-border);
|
border: 1px solid var(--ar-border);
|
||||||
background-color: #151718;
|
background-color: #151718;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { of, Subject, throwError } from 'rxjs';
|
import { of, Subject, throwError } from 'rxjs';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
@@ -237,4 +238,96 @@ describe('WorldStore', () => {
|
|||||||
expect(store.loading()).toBe(false);
|
expect(store.loading()).toBe(false);
|
||||||
expect(store.currentTravel()).toEqual({ status: 'IDLE' });
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { Injectable, OnDestroy, signal } from '@angular/core';
|
import { Injectable, OnDestroy, signal } from '@angular/core';
|
||||||
import { firstValueFrom, forkJoin } from 'rxjs';
|
import { firstValueFrom, forkJoin } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +9,17 @@ import {
|
|||||||
} from '../../core/api/game-api.models';
|
} from '../../core/api/game-api.models';
|
||||||
import { GameApiService } from '../../core/api/game-api.service';
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class WorldStore implements OnDestroy {
|
export class WorldStore implements OnDestroy {
|
||||||
private readonly characterState = signal<CharacterResponse | null>(null);
|
private readonly characterState = signal<CharacterResponse | null>(null);
|
||||||
@@ -88,6 +100,7 @@ export class WorldStore implements OnDestroy {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.destroyed) {
|
if (!this.destroyed) {
|
||||||
this.errorState.set(this.toErrorMessage(error));
|
this.errorState.set(this.toErrorMessage(error));
|
||||||
|
this.pollCurrentTravel();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!this.destroyed) {
|
if (!this.destroyed) {
|
||||||
@@ -241,6 +254,11 @@ export class WorldStore implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toErrorMessage(error: unknown): string {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
BIN
docs/verification/first-visible-slice-1366x768.png
Normal file
|
After Width: | Height: | Size: 648 KiB |
BIN
docs/verification/first-visible-slice-1440x900.png
Normal file
|
After Width: | Height: | Size: 837 KiB |
BIN
docs/verification/first-visible-slice-1920x1080.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
187
docs/verification/first-visible-slice-fidelity.md
Normal 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 1–10 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 20–44rem 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, "1–1", "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
@@ -758,6 +758,7 @@
|
|||||||
"@angular/cli": "22.0.9",
|
"@angular/cli": "22.0.9",
|
||||||
"@angular/compiler-cli": "22.0.8",
|
"@angular/compiler-cli": "22.0.8",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
@@ -18472,6 +18473,53 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"build:api": "npm run build --workspace=@ashen-realms/api",
|
"build:api": "npm run build --workspace=@ashen-realms/api",
|
||||||
"build": "npm run build:web && npm run build:api",
|
"build": "npm run build:web && npm run build:api",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"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: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: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"
|
"db:seed": "npm run seed --workspace=@ashen-realms/api"
|
||||||
|
|||||||