Compare commits

...

22 Commits

Author SHA1 Message Date
Bastian Wagner
6c35c18018 bilder 2026-08-19 09:06:10 +02:00
Bastian Wagner
8e6e77b027 fix: avoid duplicate world map background loads 2026-08-19 09:02:14 +02:00
Bastian Wagner
1a30eb3491 fix: harden world travel presentation 2026-08-19 08:51:26 +02:00
Bastian Wagner
1e2bfe6e4b feat: render interactive ashen fields world 2026-08-19 08:25:42 +02:00
Bastian Wagner
fce03e220d fix: optimize shell accessibility and assets 2026-08-19 07:42:28 +02:00
Bastian Wagner
b9e5c66c8e feat: build dark fantasy application shell 2026-08-18 22:36:21 +02:00
Bastian Wagner
6304703d67 fix: clear recovered travel errors 2026-08-18 22:07:08 +02:00
Bastian Wagner
a738c61611 fix: harden world travel polling 2026-08-18 21:52:54 +02:00
Bastian Wagner
610c404759 feat: add signal-driven world state 2026-08-18 21:36:27 +02:00
Bastian Wagner
c641168c7d feat: expose current world location 2026-08-18 20:57:30 +02:00
Bastian Wagner
d06f05047b feat: add server authoritative travel flow 2026-08-18 20:30:09 +02:00
Bastian Wagner
e9b111daf8 feat: expose health and demo character APIs 2026-08-18 20:00:40 +02:00
Bastian Wagner
0615bb1841 fix: seed character at resolved south gate 2026-08-18 19:45:05 +02:00
Bastian Wagner
c35859a4de fix: preserve seeded location identities 2026-08-18 19:40:13 +02:00
Bastian Wagner
067134b684 feat: seed demo character and first locations 2026-08-18 19:30:31 +02:00
Bastian Wagner
a97152cded test: assert migration execution order 2026-08-18 19:17:57 +02:00
Bastian Wagner
21f701e904 fix: harden world travel migration 2026-08-18 19:03:21 +02:00
Bastian Wagner
492765d8ca feat: add world travel database schema 2026-08-18 18:27:21 +02:00
Bastian Wagner
1210ba2d15 fix: prefix API routes 2026-08-18 18:13:48 +02:00
Bastian Wagner
2903a0d670 chore: configure angular and postgres foundation 2026-08-18 18:02:40 +02:00
Bastian Wagner
4a689238e6 docs: plan first visible vertical slice 2026-08-18 17:18:17 +02:00
Bastian Wagner
a6ebaf0493 docs: specify first visible vertical slice 2026-08-18 17:12:23 +02:00
146 changed files with 13556 additions and 1559 deletions

View File

@@ -0,0 +1,50 @@
# 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

@@ -0,0 +1,105 @@
# 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

@@ -0,0 +1,83 @@
# 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

@@ -0,0 +1,151 @@
# 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

@@ -0,0 +1,74 @@
# 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

@@ -0,0 +1,51 @@
# 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.

View File

@@ -13,18 +13,26 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test": "jest --detectOpenHandles",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "typeorm-ts-node-commonjs",
"seed": "ts-node src/database/seeds/run-seed.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-express": "^11.2.1",
"@nestjs/typeorm": "^11.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.4.2",
"pg": "^8.23.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"typeorm": "^1.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",

View File

@@ -0,0 +1,12 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
export function configureApplication(app: INestApplication): void {
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
}

View File

@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@@ -1,10 +1,17 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CharactersModule } from './characters/characters.module';
import { DatabaseModule } from './database/database.module';
import { HealthModule } from './health/health.module';
import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
imports: [
DatabaseModule,
HealthModule,
CharactersModule,
TravelModule,
WorldModule,
],
})
export class AppModule {}

View File

@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { CharactersService } from './characters.service';
@Controller('characters')
export class CharactersController {
constructor(private readonly charactersService: CharactersService) {}
@Get('me')
getDemoCharacter() {
return this.charactersService.getDemoCharacter();
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { Character } from './entities/character.entity';
@Module({
imports: [TypeOrmModule.forFeature([Character])],
controllers: [CharactersController],
providers: [CharactersService],
})
export class CharactersModule {}

View File

@@ -0,0 +1,58 @@
import { NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
import { Character } from './entities/character.entity';
import { CharactersService } from './characters.service';
describe('CharactersService', () => {
it('returns the demo character with its current location summary', async () => {
const repository = {
findOne: jest.fn().mockResolvedValue({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
currentHp: 100,
baseHp: 100,
baseAttack: 6,
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
},
}),
} as unknown as Repository<Character>;
const service = new CharactersService(repository);
await expect(service.getDemoCharacter()).resolves.toEqual({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
currentHp: 100,
maxHp: 100,
attack: 6,
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
},
});
expect(repository.findOne).toHaveBeenCalledWith({
where: { id: DEMO_CHARACTER_ID },
relations: { currentLocation: true },
});
});
it('reports a missing demo seed as not found', async () => {
const repository = {
findOne: jest.fn().mockResolvedValue(null),
} as unknown as Repository<Character>;
const service = new CharactersService(repository);
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
NotFoundException,
);
});
});

View File

@@ -0,0 +1,39 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { Character } from './entities/character.entity';
@Injectable()
export class CharactersService {
constructor(
@InjectRepository(Character)
private readonly characters: Repository<Character>,
) {}
async getDemoCharacter() {
const character = await this.characters.findOne({
where: { id: DEMO_CHARACTER_ID },
relations: { currentLocation: true },
});
if (!character) {
throw new NotFoundException('Demo character has not been seeded');
}
return {
id: character.id,
name: character.name,
level: character.level,
experience: character.experience,
currentHp: character.currentHp,
maxHp: character.baseHp,
attack: character.baseAttack,
currentLocation: {
id: character.currentLocation.id,
key: character.currentLocation.key,
name: character.currentLocation.name,
},
};
}
}

View File

@@ -0,0 +1,49 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
@Entity({ name: 'characters' })
export class Character {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'level', type: 'integer' })
level!: number;
@Column({ name: 'experience', type: 'integer' })
experience!: number;
@Column({ name: 'base_hp', type: 'integer' })
baseHp!: number;
@Column({ name: 'base_attack', type: 'integer' })
baseAttack!: number;
@Column({ name: 'current_hp', type: 'integer' })
currentHp!: number;
@Column({ name: 'current_location_id', type: 'uuid' })
currentLocationId!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => LocationDefinition, (location) => location.characters, {
onDelete: 'RESTRICT',
})
@JoinColumn({ name: 'current_location_id' })
currentLocation!: LocationDefinition;
}

View File

@@ -0,0 +1,18 @@
import { createDatabaseConfig } from './database.config';
describe('createDatabaseConfig', () => {
it('uses postgres and permanently disables synchronization', () => {
expect(
createDatabaseConfig('postgresql://test:test@localhost/test'),
).toMatchObject({
type: 'postgres',
url: 'postgresql://test:test@localhost/test',
synchronize: false,
autoLoadEntities: true,
});
});
it('rejects an empty database URL', () => {
expect(() => createDatabaseConfig('')).toThrow('DATABASE_URL is required');
});
});

View File

@@ -0,0 +1,16 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export function createDatabaseConfig(
databaseUrl: string,
): TypeOrmModuleOptions {
if (!databaseUrl.trim()) {
throw new Error('DATABASE_URL is required');
}
return {
type: 'postgres',
url: databaseUrl,
autoLoadEntities: true,
synchronize: false,
};
}

View File

@@ -0,0 +1,16 @@
import 'dotenv/config';
import { DataSource } from 'typeorm';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl?.trim()) {
throw new Error('DATABASE_URL is required');
}
export const AppDataSource = new DataSource({
type: 'postgres',
url: databaseUrl,
entities: [__dirname + '/../**/*.entity.{ts,js}'],
migrations: [__dirname + '/migrations/*.{ts,js}'],
synchronize: false,
});

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { createDatabaseConfig } from '../config/database.config';
@Module({
imports: [
TypeOrmModule.forRootAsync({
useFactory: () => createDatabaseConfig(process.env.DATABASE_URL ?? ''),
}),
],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,111 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateVisibleVerticalSlice1787072400000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "location_definitions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"description" text NOT NULL,
"region_key" character varying(100) NOT NULL,
"min_recommended_level" integer NOT NULL,
"max_recommended_level" integer NOT NULL,
"danger_level" integer NOT NULL,
"is_safe" boolean NOT NULL,
"hunting_enabled" boolean NOT NULL,
"artwork_path" character varying(255) NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_location_definitions" PRIMARY KEY ("id")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_location_definitions_key" ON "location_definitions" ("key")',
);
await queryRunner.query(`CREATE TABLE "characters" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"name" character varying(150) NOT NULL,
"level" integer NOT NULL,
"experience" integer NOT NULL,
"base_hp" integer NOT NULL,
"base_attack" integer NOT NULL,
"current_hp" integer NOT NULL,
"current_location_id" uuid NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_characters" PRIMARY KEY ("id"),
CONSTRAINT "FK_characters_current_location" FOREIGN KEY ("current_location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_characters_current_location" ON "characters" ("current_location_id")',
);
await queryRunner.query(`CREATE TABLE "location_connections" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"from_location_id" uuid NOT NULL,
"to_location_id" uuid NOT NULL,
"travel_duration_seconds" integer NOT NULL,
"ambush_chance" numeric(5,4) NOT NULL,
"enabled" boolean NOT NULL,
CONSTRAINT "PK_location_connections" PRIMARY KEY ("id"),
CONSTRAINT "FK_location_connections_from_location" FOREIGN KEY ("from_location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_location_connections_to_location" FOREIGN KEY ("to_location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_location_connection_direction"
ON "location_connections" ("from_location_id", "to_location_id")`);
await queryRunner.query(
'CREATE INDEX "IDX_location_connections_from_location" ON "location_connections" ("from_location_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_location_connections_to_location" ON "location_connections" ("to_location_id")',
);
await queryRunner.query(
"CREATE TYPE \"travel_status_enum\" AS ENUM ('TRAVELLING', 'COMPLETED')",
);
await queryRunner.query(`CREATE TABLE "travels" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"origin_location_id" uuid NOT NULL,
"target_location_id" uuid NOT NULL,
"started_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"arrives_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"status" "travel_status_enum" NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_travels" PRIMARY KEY ("id"),
CONSTRAINT "FK_travels_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_travels_origin_location" FOREIGN KEY ("origin_location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_travels_target_location" FOREIGN KEY ("target_location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_travels_character" ON "travels" ("character_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_travels_origin_location" ON "travels" ("origin_location_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_travels_target_location" ON "travels" ("target_location_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_travel_per_character"
ON "travels" ("character_id")
WHERE "status" = 'TRAVELLING'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_active_travel_per_character"');
await queryRunner.query('DROP INDEX "IDX_travels_target_location"');
await queryRunner.query('DROP INDEX "IDX_travels_origin_location"');
await queryRunner.query('DROP INDEX "IDX_travels_character"');
await queryRunner.query('DROP TABLE "travels"');
await queryRunner.query(
'DROP INDEX "IDX_location_connections_to_location"',
);
await queryRunner.query(
'DROP INDEX "IDX_location_connections_from_location"',
);
await queryRunner.query('DROP INDEX "IDX_location_connection_direction"');
await queryRunner.query('DROP TABLE "location_connections"');
await queryRunner.query('DROP INDEX "IDX_characters_current_location"');
await queryRunner.query('DROP TABLE "characters"');
await queryRunner.query('DROP INDEX "IDX_location_definitions_key"');
await queryRunner.query('DROP TABLE "location_definitions"');
await queryRunner.query('DROP TYPE "travel_status_enum"');
}
}

View File

@@ -0,0 +1,231 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { CreateVisibleVerticalSlice1787072400000 } from './1787072400000-CreateVisibleVerticalSlice';
import { Travel } from '../../travel/entities/travel.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
describe('visible vertical slice schema', () => {
it('maps the location key and relationship foreign keys explicitly', () => {
const metadata = getMetadataArgsStorage();
const locationIndex = metadata.indices.find((index) => {
const metadataIndex = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
return (
index.target === LocationDefinition &&
index.columns?.includes('key') &&
(metadataIndex.options?.unique ?? metadataIndex.unique) === true
);
});
expect(locationIndex).toBeDefined();
const relations = metadata.relations.filter((relation) =>
[Character, LocationConnection, Travel].includes(
relation.target as typeof Character,
),
);
const joinColumns = metadata.joinColumns
.filter((joinColumn) =>
[Character, LocationConnection, Travel].includes(
joinColumn.target as typeof Character,
),
)
.map((joinColumn) => joinColumn.name);
expect(joinColumns).toEqual(
expect.arrayContaining([
'current_location_id',
'from_location_id',
'to_location_id',
'character_id',
'origin_location_id',
'target_location_id',
]),
);
expect(
relations.map((relation) => ({
onDelete: relation.options.onDelete,
propertyName: relation.propertyName,
target: relation.target,
})),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'currentLocation',
target: Character,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'fromLocation',
target: LocationConnection,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'toLocation',
target: LocationConnection,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'character',
target: Travel,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'originLocation',
target: Travel,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'targetLocation',
target: Travel,
}),
]),
);
});
it('emits reversible SQL in dependency order without extension ownership', async () => {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateVisibleVerticalSlice1787072400000();
await migration.up(queryRunner);
const upQueries = query.mock.calls.map(([sql]) => sql as string);
expect(upQueries).toHaveLength(14);
expect(
upQueries.map((sql) => {
if (sql.includes('CREATE TABLE "location_definitions"')) {
return 'create location definitions';
}
if (sql.includes('IDX_location_definitions_key')) {
return 'create location key index';
}
if (sql.includes('CREATE TABLE "characters"')) {
return 'create characters';
}
if (sql.includes('IDX_characters_current_location')) {
return 'create character location index';
}
if (sql.includes('CREATE TABLE "location_connections"')) {
return 'create location connections';
}
if (sql.includes('IDX_location_connection_direction')) {
return 'create connection direction index';
}
if (sql.includes('IDX_location_connections_from_location')) {
return 'create connection origin index';
}
if (sql.includes('IDX_location_connections_to_location')) {
return 'create connection target index';
}
if (sql.includes('CREATE TYPE "travel_status_enum"')) {
return 'create travel status enum';
}
if (sql.includes('CREATE TABLE "travels"')) {
return 'create travels';
}
if (sql.includes('IDX_travels_character')) {
return 'create travel character index';
}
if (sql.includes('IDX_travels_origin_location')) {
return 'create travel origin index';
}
if (sql.includes('IDX_travels_target_location')) {
return 'create travel target index';
}
if (sql.includes('IDX_active_travel_per_character')) {
return 'create active travel index';
}
return 'unexpected query';
}),
).toEqual([
'create location definitions',
'create location key index',
'create characters',
'create character location index',
'create location connections',
'create connection direction index',
'create connection origin index',
'create connection target index',
'create travel status enum',
'create travels',
'create travel character index',
'create travel origin index',
'create travel target index',
'create active travel index',
]);
expect(upQueries).toEqual(
expect.arrayContaining([
expect.stringContaining('CREATE TABLE "location_definitions"'),
expect.stringContaining('CREATE TABLE "characters"'),
expect.stringContaining('CREATE TABLE "location_connections"'),
expect.stringContaining('CREATE TABLE "travels"'),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_location_connection_direction"',
),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_active_travel_per_character"',
),
expect.stringContaining('WHERE "status" = \'TRAVELLING\''),
]),
);
expect(upQueries[0]).toContain('CREATE TABLE "location_definitions"');
expect(upQueries).not.toEqual(
expect.arrayContaining([expect.stringContaining('CREATE EXTENSION')]),
);
expect(upQueries).not.toEqual(
expect.arrayContaining([expect.stringContaining('uuid_generate_v4')]),
);
const tableQueries = upQueries.filter((sql) =>
sql.includes('CREATE TABLE'),
);
expect(tableQueries).toHaveLength(4);
expect(
tableQueries.every((sql) => sql.includes('DEFAULT gen_random_uuid()')),
).toBe(true);
const foreignKeySql = upQueries
.filter((sql) => sql.includes('ON DELETE RESTRICT'))
.join('\n');
expect(
upQueries.filter((sql) => sql.includes('ON DELETE RESTRICT')),
).toHaveLength(3);
expect(foreignKeySql).toContain('FK_characters_current_location');
expect(foreignKeySql).toContain('FK_location_connections_from_location');
expect(foreignKeySql).toContain('FK_location_connections_to_location');
expect(foreignKeySql).toContain('FK_travels_character');
expect(foreignKeySql).toContain('FK_travels_origin_location');
expect(foreignKeySql).toContain('FK_travels_target_location');
await migration.down(queryRunner);
const downQueries = query.mock.calls
.slice(upQueries.length)
.map(([sql]) => sql as string);
expect(downQueries).toEqual([
'DROP INDEX "IDX_active_travel_per_character"',
'DROP INDEX "IDX_travels_target_location"',
'DROP INDEX "IDX_travels_origin_location"',
'DROP INDEX "IDX_travels_character"',
'DROP TABLE "travels"',
'DROP INDEX "IDX_location_connections_to_location"',
'DROP INDEX "IDX_location_connections_from_location"',
'DROP INDEX "IDX_location_connection_direction"',
'DROP TABLE "location_connections"',
'DROP INDEX "IDX_characters_current_location"',
'DROP TABLE "characters"',
'DROP INDEX "IDX_location_definitions_key"',
'DROP TABLE "location_definitions"',
'DROP TYPE "travel_status_enum"',
]);
});
});

View File

@@ -0,0 +1,17 @@
import { AppDataSource } from '../data-source';
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
async function runSeed(): Promise<void> {
await AppDataSource.initialize();
try {
await seedVisibleVerticalSlice(AppDataSource);
} finally {
await AppDataSource.destroy();
}
}
void runSeed().catch((error: unknown) => {
console.error('Demo seed failed:', error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,2 @@
export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';

View File

@@ -0,0 +1,191 @@
import { DataSource } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
type Row = Record<string, unknown>;
const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
class InMemoryRepository {
readonly rows: Row[] = [];
readonly upsert = jest.fn(
async (
values: Row | Row[],
conflictPaths: string[] | { conflictPaths: string[] },
) => {
const conflictKeys = Array.isArray(conflictPaths)
? conflictPaths
: conflictPaths.conflictPaths;
for (const value of Array.isArray(values) ? values : [values]) {
const existing = this.rows.find((row) =>
conflictKeys.every((key) => row[key] === value[key]),
);
if (existing) {
Object.assign(existing, value);
} else {
this.rows.push({ ...value });
}
}
},
);
readonly findOneBy = jest.fn(async (criteria: Row) =>
this.rows.find((row) =>
Object.entries(criteria).every(([key, value]) => row[key] === value),
),
);
readonly insert = jest.fn(async (value: Row) => {
this.rows.push({ ...value });
});
readonly update = jest.fn(async (criteria: string | Row, value: Row) => {
const row = this.rows.find((candidate) =>
typeof criteria === 'string'
? candidate.id === criteria
: Object.entries(criteria).every(
([key, expected]) => candidate[key] === expected,
),
);
if (row) {
Object.assign(row, value);
}
});
}
function createDataSource(
locationRepository: InMemoryRepository,
connectionRepository: InMemoryRepository,
characterRepository: InMemoryRepository,
): DataSource {
return {
getRepository: jest.fn((entity: unknown) => {
if (entity === LocationDefinition) {
return locationRepository;
}
if (entity === LocationConnection) {
return connectionRepository;
}
if (entity === Character) {
return characterRepository;
}
throw new Error('Unexpected repository');
}),
} as unknown as DataSource;
}
describe('seedVisibleVerticalSlice', () => {
it('upserts the two locations and directed connections while inserting one stable demo character', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
);
await seedVisibleVerticalSlice(dataSource);
Object.assign(characterRepository.rows[0], {
currentLocationId: BURNED_ROAD_ID,
currentHp: 57,
experience: 39,
});
await seedVisibleVerticalSlice(dataSource);
expect(connectionRepository.upsert).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
fromLocationId: SOUTH_GATE_ID,
toLocationId: BURNED_ROAD_ID,
}),
expect.objectContaining({
fromLocationId: BURNED_ROAD_ID,
toLocationId: SOUTH_GATE_ID,
}),
]),
['fromLocationId', 'toLocationId'],
);
expect(characterRepository.insert).toHaveBeenCalledWith(
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
);
expect(locationRepository.rows).toHaveLength(2);
expect(locationRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'south-gate',
artworkPath: '/images/backgrounds/Suedtor.png',
}),
expect.objectContaining({
key: 'burned-road',
artworkPath: '/images/backgrounds/Aschestrasse.png',
}),
]),
);
expect(connectionRepository.rows).toHaveLength(2);
expect(characterRepository.rows).toHaveLength(1);
expect(characterRepository.rows[0]).toEqual(
expect.objectContaining({
currentLocationId: BURNED_ROAD_ID,
currentHp: 57,
experience: 39,
}),
);
});
it('preserves existing location IDs and uses them for the directed connections', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const persistedSouthGateId = '30000000-0000-4000-8000-000000000001';
locationRepository.rows.push({
id: persistedSouthGateId,
key: 'south-gate',
name: 'Veraltetes Südtor',
});
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
);
await seedVisibleVerticalSlice(dataSource);
expect(locationRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: persistedSouthGateId,
key: 'south-gate',
name: 'Südtor von Graufurt',
}),
expect.objectContaining({
id: BURNED_ROAD_ID,
key: 'burned-road',
}),
]),
);
expect(connectionRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
fromLocationId: persistedSouthGateId,
toLocationId: BURNED_ROAD_ID,
}),
expect.objectContaining({
fromLocationId: BURNED_ROAD_ID,
toLocationId: persistedSouthGateId,
}),
]),
);
expect(characterRepository.rows).toEqual([
expect.objectContaining({
id: DEMO_CHARACTER_ID,
currentLocationId: persistedSouthGateId,
}),
]);
});
});

View File

@@ -0,0 +1,104 @@
import { DataSource } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
import { Character } from '../../characters/entities/character.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { BURNED_ROAD_ID, SOUTH_GATE_ID } from './vertical-slice.constants';
export async function seedVisibleVerticalSlice(
dataSource: DataSource,
): Promise<void> {
const locationRepository = dataSource.getRepository(LocationDefinition);
const connectionRepository = dataSource.getRepository(LocationConnection);
const characterRepository = dataSource.getRepository(Character);
const locations = [
{
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'Südtor von Graufurt',
description:
'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
},
{
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Straße',
description:
'Die alte Handelsstraße führt durch verkohlte Felder. Zwischen Asche und zerbrochenen Wagen warten die ersten Gefahren.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 2,
dangerLevel: 1,
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
},
];
let southGateId = SOUTH_GATE_ID;
let burnedRoadId = BURNED_ROAD_ID;
for (const location of locations) {
const existing = await locationRepository.findOneBy({
key: location.key,
});
const { id, key, ...definition } = location;
const persistedId = existing?.id ?? id;
if (existing) {
await locationRepository.update(existing.id, definition);
} else {
await locationRepository.insert(location);
}
if (key === 'south-gate') {
southGateId = persistedId;
} else {
burnedRoadId = persistedId;
}
}
await connectionRepository.upsert(
[
{
fromLocationId: southGateId,
toLocationId: burnedRoadId,
travelDurationSeconds: 10,
ambushChance: '0.0500',
enabled: true,
},
{
fromLocationId: burnedRoadId,
toLocationId: southGateId,
travelDurationSeconds: 10,
ambushChance: '0.0500',
enabled: true,
},
],
['fromLocationId', 'toLocationId'],
);
const existing = await characterRepository.findOneBy({
id: DEMO_CHARACTER_ID,
});
if (!existing) {
await characterRepository.insert({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: southGateId,
});
}
}

View File

@@ -0,0 +1 @@
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';

View File

@@ -0,0 +1,7 @@
import { HealthController } from './health.controller';
describe('HealthController', () => {
it('returns an ok status for liveness checks', () => {
expect(new HealthController().getHealth()).toEqual({ status: 'ok' });
});
});

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
getHealth() {
return { status: 'ok' };
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -1,8 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { configureApplication } from './app.config';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
configureApplication(app);
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@@ -0,0 +1,9 @@
export const CLOCK = Symbol('CLOCK');
export interface Clock {
now(): Date;
}
export const systemClock: Clock = {
now: () => new Date(),
};

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class StartTravelDto {
@IsUUID()
targetLocationId!: string;
}

View File

@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { TravelStatus } from '../travel-status.enum';
@Entity({ name: 'travels' })
export class Travel {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'origin_location_id', type: 'uuid' })
originLocationId!: string;
@Column({ name: 'target_location_id', type: 'uuid' })
targetLocationId!: string;
@Column({ name: 'started_at', type: 'timestamptz' })
startedAt!: Date;
@Column({ name: 'arrives_at', type: 'timestamptz' })
arrivesAt!: Date;
@Column({
name: 'status',
type: 'enum',
enum: TravelStatus,
enumName: 'travel_status_enum',
})
status!: TravelStatus;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'origin_location_id' })
originLocation!: LocationDefinition;
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'target_location_id' })
targetLocation!: LocationDefinition;
}

View File

@@ -0,0 +1,4 @@
export enum TravelStatus {
TRAVELLING = 'TRAVELLING',
COMPLETED = 'COMPLETED',
}

View File

@@ -0,0 +1,48 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { TravelController } from './travel.controller';
import { TravelService } from './travel.service';
describe('TravelController request validation', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [TravelController],
providers: [
{
provide: TravelService,
useValue: {
startTravel: () => Promise.resolve({ status: 'TRAVELLING' }),
completeTravelIfDue: () => Promise.resolve({ status: 'IDLE' }),
},
},
],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('rejects server-owned travel timing and duration fields', async () => {
await request(app.getHttpServer())
.post('/api/travel')
.send({
targetLocationId: BURNED_ROAD_ID,
startedAt: '2026-08-18T10:00:00.000Z',
arrivesAt: '2026-08-18T10:00:10.000Z',
travelDurationSeconds: 10,
})
.expect(400);
});
});
const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';

View File

@@ -0,0 +1,22 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { StartTravelDto } from './dto/start-travel.dto';
import { TravelService } from './travel.service';
@Controller('travel')
export class TravelController {
constructor(private readonly travelService: TravelService) {}
@Post()
startTravel(@Body() request: StartTravelDto) {
return this.travelService.startTravel(
DEMO_CHARACTER_ID,
request.targetLocationId,
);
}
@Get('current')
getCurrentTravel() {
return this.travelService.completeTravelIfDue(DEMO_CHARACTER_ID);
}
}

View File

@@ -0,0 +1,49 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type TravelErrorCode =
| 'CHARACTER_NOT_FOUND'
| 'INVALID_TRAVEL_TARGET'
| 'TRAVEL_ALREADY_ACTIVE'
| 'TRAVEL_STATE_INVALID';
export class TravelDomainError extends HttpException {
constructor(
public readonly code: TravelErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function characterNotFound(): TravelDomainError {
return new TravelDomainError(
'CHARACTER_NOT_FOUND',
HttpStatus.NOT_FOUND,
'The character does not exist.',
);
}
export function invalidTravelTarget(): TravelDomainError {
return new TravelDomainError(
'INVALID_TRAVEL_TARGET',
HttpStatus.BAD_REQUEST,
'The selected location is not connected to the current location.',
);
}
export function travelAlreadyActive(): TravelDomainError {
return new TravelDomainError(
'TRAVEL_ALREADY_ACTIVE',
HttpStatus.CONFLICT,
'The character is already travelling.',
);
}
export function invalidTravelState(): TravelDomainError {
return new TravelDomainError(
'TRAVEL_STATE_INVALID',
HttpStatus.INTERNAL_SERVER_ERROR,
'The persisted travel references an unavailable location.',
);
}

View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { LocationConnection } from '../world/entities/location-connection.entity';
import { LocationDefinition } from '../world/entities/location-definition.entity';
import { CLOCK, systemClock } from './clock';
import { Travel } from './entities/travel.entity';
import { TravelController } from './travel.controller';
import { TravelService } from './travel.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Character,
LocationDefinition,
LocationConnection,
Travel,
]),
],
controllers: [TravelController],
providers: [TravelService, { provide: CLOCK, useValue: systemClock }],
exports: [TravelService],
})
export class TravelModule {}

View File

@@ -0,0 +1,400 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import {
BURNED_ROAD_ID,
SOUTH_GATE_ID,
} from '../database/seeds/vertical-slice.constants';
import { LocationConnection } from '../world/entities/location-connection.entity';
import { LocationDefinition } from '../world/entities/location-definition.entity';
import { Clock } from './clock';
import { Travel } from './entities/travel.entity';
import { TravelDomainError } from './travel.errors';
import { TravelService } from './travel.service';
import { TravelStatus } from './travel-status.enum';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const CONNECTION_ID = '30000000-0000-4000-8000-000000000001';
const TRAVEL_ID = '40000000-0000-4000-8000-000000000001';
const NOW = new Date('2026-08-18T10:00:00.000Z');
interface FakeState {
characters: Character[];
locations: LocationDefinition[];
connections: LocationConnection[];
travels: Travel[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
private readonly target: EntityTarget<T>,
private readonly inTransaction: boolean,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
lock?: { mode: string };
}): Promise<T | null> {
if (options.lock) {
if (!this.inTransaction) {
throw new Error('Pessimistic locks require a transaction');
}
this.dataSource.locks.push({
target: this.target,
mode: options.lock.mode,
});
}
return Promise.resolve(
this.rows().find((row) => this.matches(row, options.where)) ?? null,
);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(
this.rows().find((row) => this.matches(row, where)) ?? null,
);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (this.dataSource.failSaveTarget === this.target) {
throw new Error(`Failed to save ${this.targetName()}`);
}
if (!entity.id) {
entity.id = TRAVEL_ID;
}
const rows = this.rows();
const index = rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
rows.push(entity);
} else {
rows[index] = entity;
}
return Promise.resolve(entity);
}
private rows(): T[] {
if (this.target === Character) {
return this.state.characters as T[];
}
if (this.target === LocationDefinition) {
return this.state.locations as T[];
}
if (this.target === LocationConnection) {
return this.state.connections as T[];
}
if (this.target === Travel) {
return this.state.travels as T[];
}
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
}
private targetName(): string {
return typeof this.target === 'function'
? this.target.name
: 'EntitySchema';
}
}
class FakeEntityManager {
constructor(
private readonly state: FakeState,
private readonly dataSource: FakeDataSource,
) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, true, this.dataSource);
}
}
class FakeDataSource {
readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
failSaveTarget?: EntityTarget<unknown>;
constructor(public state: FakeState) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, false, this);
}
async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
const draft = structuredClone(this.state);
const result = await work(
new FakeEntityManager(draft, this) as unknown as EntityManager,
);
this.state = draft;
return result;
}
}
function location(id: string, key: string, name: string): LocationDefinition {
return {
id,
key,
name,
description: `${name} description`,
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 2,
dangerLevel: 1,
isSafe: id === SOUTH_GATE_ID,
huntingEnabled: id === BURNED_ROAD_ID,
artworkPath: `/assets/locations/${key}.webp`,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
characters: [],
outgoingConnections: [],
incomingConnections: [],
};
}
function createState(): FakeState {
const southGate = location(
SOUTH_GATE_ID,
'south-gate',
'S\u00fcdtor von Graufurt',
);
const burnedRoad = location(
BURNED_ROAD_ID,
'burned-road',
'Verbrannte Stra\u00dfe',
);
const character: Character = {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: SOUTH_GATE_ID,
currentLocation: southGate,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
};
const connection = {
id: CONNECTION_ID,
fromLocationId: SOUTH_GATE_ID,
toLocationId: BURNED_ROAD_ID,
travelDurationSeconds: 10,
ambushChance: '0.0500',
enabled: true,
fromLocation: southGate,
toLocation: burnedRoad,
} as LocationConnection;
return {
characters: [character],
locations: [southGate, burnedRoad],
connections: [connection],
travels: [],
};
}
function activeTravel(arrivesAt: Date): Travel {
return {
id: TRAVEL_ID,
characterId: CHARACTER_ID,
originLocationId: SOUTH_GATE_ID,
targetLocationId: BURNED_ROAD_ID,
startedAt: new Date('2026-08-18T09:59:50.000Z'),
arrivesAt,
status: TravelStatus.TRAVELLING,
createdAt: new Date('2026-08-18T09:59:50.000Z'),
} as Travel;
}
function createService(state = createState()) {
const dataSource = new FakeDataSource(state);
const clock: Clock = { now: () => new Date(NOW) };
const service = new TravelService(dataSource as unknown as DataSource, clock);
return { dataSource, service };
}
describe('TravelService', () => {
it('starts travel for an enabled directed connection', async () => {
const { dataSource, service } = createService();
await expect(
service.startTravel(CHARACTER_ID, BURNED_ROAD_ID),
).resolves.toEqual({
status: TravelStatus.TRAVELLING,
originLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
},
targetLocation: {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
},
startedAt: new Date('2026-08-18T10:00:00.000Z'),
arrivesAt: new Date('2026-08-18T10:00:10.000Z'),
});
expect(dataSource.state.travels).toHaveLength(1);
expect(dataSource.state.travels[0]).toMatchObject({
characterId: CHARACTER_ID,
originLocationId: SOUTH_GATE_ID,
targetLocationId: BURNED_ROAD_ID,
status: TravelStatus.TRAVELLING,
});
expect(dataSource.locks).toEqual([
{ target: Character, mode: 'pessimistic_write' },
{ target: Travel, mode: 'pessimistic_write' },
]);
});
it('rejects a target without an enabled connection', async () => {
const state = createState();
state.connections[0].enabled = false;
const { dataSource, service } = createService(state);
await expect(
service.startTravel(CHARACTER_ID, BURNED_ROAD_ID),
).rejects.toMatchObject<Partial<TravelDomainError>>({
code: 'INVALID_TRAVEL_TARGET',
});
expect(dataSource.state.travels).toEqual([]);
});
it('derives arrivesAt from the injected clock and connection duration', async () => {
const { dataSource, service } = createService();
const result = await service.startTravel(CHARACTER_ID, BURNED_ROAD_ID);
expect(result.startedAt).toEqual(new Date('2026-08-18T10:00:00.000Z'));
expect(result.arrivesAt).toEqual(new Date('2026-08-18T10:00:10.000Z'));
expect(dataSource.state.travels[0].arrivesAt).toEqual(
new Date('2026-08-18T10:00:10.000Z'),
);
});
it('rejects a second journey with a stable active-travel error', async () => {
const state = createState();
state.travels.push(activeTravel(new Date('2026-08-18T10:00:10.000Z')));
const { dataSource, service } = createService(state);
let error: unknown;
try {
await service.startTravel(CHARACTER_ID, BURNED_ROAD_ID);
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(TravelDomainError);
if (!(error instanceof TravelDomainError)) {
throw new Error('Expected TravelDomainError');
}
expect(error.getResponse()).toEqual({
statusCode: 409,
code: 'TRAVEL_ALREADY_ACTIVE',
message: 'The character is already travelling.',
});
expect(dataSource.state.travels).toHaveLength(1);
});
it('returns the current active travel without exposing persistence fields', async () => {
const state = createState();
state.travels.push(activeTravel(new Date('2026-08-18T10:00:10.000Z')));
const { service } = createService(state);
await expect(service.getCurrentTravel(CHARACTER_ID)).resolves.toEqual({
status: TravelStatus.TRAVELLING,
originLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
},
targetLocation: {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
},
startedAt: new Date('2026-08-18T09:59:50.000Z'),
arrivesAt: new Date('2026-08-18T10:00:10.000Z'),
});
});
it('does not complete or move the character before arrivesAt', async () => {
const state = createState();
state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.001Z')));
const { dataSource, service } = createService(state);
await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
status: TravelStatus.TRAVELLING,
originLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
},
targetLocation: {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
},
startedAt: new Date('2026-08-18T09:59:50.000Z'),
arrivesAt: new Date('2026-08-18T10:00:00.001Z'),
});
expect(dataSource.state.characters[0].currentLocationId).toBe(
SOUTH_GATE_ID,
);
expect(dataSource.state.travels[0].status).toBe(TravelStatus.TRAVELLING);
});
it('completes due travel and updates character location atomically', async () => {
const state = createState();
state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.000Z')));
const { dataSource, service } = createService(state);
await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
status: TravelStatus.COMPLETED,
targetLocation: {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
},
});
expect(dataSource.state.characters[0].currentLocationId).toBe(
BURNED_ROAD_ID,
);
expect(dataSource.state.travels[0].status).toBe(TravelStatus.COMPLETED);
await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
status: 'IDLE',
});
expect(dataSource.state.characters[0].currentLocationId).toBe(
BURNED_ROAD_ID,
);
});
it('rolls back both due-travel updates if either save fails', async () => {
const state = createState();
state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.000Z')));
const { dataSource, service } = createService(state);
dataSource.failSaveTarget = Character;
await expect(service.completeTravelIfDue(CHARACTER_ID)).rejects.toThrow(
'Failed to save Character',
);
expect(dataSource.state.characters[0].currentLocationId).toBe(
SOUTH_GATE_ID,
);
expect(dataSource.state.travels[0].status).toBe(TravelStatus.TRAVELLING);
});
});

View File

@@ -0,0 +1,224 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { LocationConnection } from '../world/entities/location-connection.entity';
import { LocationDefinition } from '../world/entities/location-definition.entity';
import { CLOCK } from './clock';
import type { Clock } from './clock';
import { Travel } from './entities/travel.entity';
import {
characterNotFound,
invalidTravelState,
invalidTravelTarget,
travelAlreadyActive,
} from './travel.errors';
import { TravelStatus } from './travel-status.enum';
export interface LocationSummary {
id: string;
key: string;
name: string;
}
export interface IdleTravelResponse {
status: 'IDLE';
}
export interface TravellingResponse {
status: TravelStatus.TRAVELLING;
originLocation: LocationSummary;
targetLocation: LocationSummary;
startedAt: Date;
arrivesAt: Date;
}
export interface CompletedTravelResponse {
status: TravelStatus.COMPLETED;
targetLocation: LocationSummary;
}
export type CurrentTravelResponse =
IdleTravelResponse | TravellingResponse | CompletedTravelResponse;
@Injectable()
export class TravelService {
constructor(
private readonly dataSource: DataSource,
@Inject(CLOCK) private readonly clock: Clock,
) {}
startTravel(
characterId: string,
targetLocationId: string,
): Promise<TravellingResponse> {
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const travels = manager.getRepository(Travel);
const locations = manager.getRepository(LocationDefinition);
const connections = manager.getRepository(LocationConnection);
const character = await this.lockCharacter(characters, characterId);
const activeTravel = await this.lockActiveTravel(travels, characterId);
if (activeTravel) {
throw travelAlreadyActive();
}
const targetLocation = await locations.findOneBy({
id: targetLocationId,
});
if (!targetLocation) {
throw invalidTravelTarget();
}
const connection = await connections.findOneBy({
fromLocationId: character.currentLocationId,
toLocationId: targetLocationId,
enabled: true,
});
if (!connection) {
throw invalidTravelTarget();
}
const originLocation = await locations.findOneBy({
id: character.currentLocationId,
});
if (!originLocation) {
throw invalidTravelState();
}
const startedAt = new Date(this.clock.now().getTime());
const arrivesAt = new Date(
startedAt.getTime() + connection.travelDurationSeconds * 1000,
);
const travel = travels.create({
characterId,
originLocationId: character.currentLocationId,
targetLocationId,
startedAt,
arrivesAt,
status: TravelStatus.TRAVELLING,
});
await travels.save(travel);
return this.toTravellingResponse(travel, originLocation, targetLocation);
});
}
async getCurrentTravel(characterId: string): Promise<CurrentTravelResponse> {
const travels = this.dataSource.getRepository(Travel);
const travel = await travels.findOneBy({
characterId,
status: TravelStatus.TRAVELLING,
});
if (!travel) {
return { status: 'IDLE' };
}
const locations = this.dataSource.getRepository(LocationDefinition);
const { originLocation, targetLocation } = await this.loadTravelLocations(
locations,
travel,
);
return this.toTravellingResponse(travel, originLocation, targetLocation);
}
completeTravelIfDue(characterId: string): Promise<CurrentTravelResponse> {
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const travels = manager.getRepository(Travel);
const locations = manager.getRepository(LocationDefinition);
const character = await this.lockCharacter(characters, characterId);
const travel = await this.lockActiveTravel(travels, characterId);
if (!travel) {
return { status: 'IDLE' };
}
const { originLocation, targetLocation } = await this.loadTravelLocations(
locations,
travel,
);
const now = this.clock.now();
if (travel.arrivesAt.getTime() > now.getTime()) {
return this.toTravellingResponse(
travel,
originLocation,
targetLocation,
);
}
travel.status = TravelStatus.COMPLETED;
character.currentLocationId = travel.targetLocationId;
await travels.save(travel);
await characters.save(character);
return {
status: TravelStatus.COMPLETED,
targetLocation: this.toLocationSummary(targetLocation),
};
});
}
private async lockCharacter(
characters: Repository<Character>,
characterId: string,
): Promise<Character> {
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
return character;
}
private lockActiveTravel(
travels: Repository<Travel>,
characterId: string,
): Promise<Travel | null> {
return travels.findOne({
where: { characterId, status: TravelStatus.TRAVELLING },
lock: { mode: 'pessimistic_write' },
});
}
private async loadTravelLocations(
locations: Repository<LocationDefinition>,
travel: Travel,
): Promise<{
originLocation: LocationDefinition;
targetLocation: LocationDefinition;
}> {
const [originLocation, targetLocation] = await Promise.all([
locations.findOneBy({ id: travel.originLocationId }),
locations.findOneBy({ id: travel.targetLocationId }),
]);
if (!originLocation || !targetLocation) {
throw invalidTravelState();
}
return { originLocation, targetLocation };
}
private toTravellingResponse(
travel: Travel,
originLocation: LocationDefinition,
targetLocation: LocationDefinition,
): TravellingResponse {
return {
status: TravelStatus.TRAVELLING,
originLocation: this.toLocationSummary(originLocation),
targetLocation: this.toLocationSummary(targetLocation),
startedAt: travel.startedAt,
arrivesAt: travel.arrivesAt,
};
}
private toLocationSummary(location: LocationDefinition): LocationSummary {
return {
id: location.id,
key: location.key,
name: location.name,
};
}
}

View File

@@ -0,0 +1,53 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { LocationDefinition } from './location-definition.entity';
@Entity({ name: 'location_connections' })
@Index(
'IDX_location_connection_direction',
['fromLocationId', 'toLocationId'],
{
unique: true,
},
)
export class LocationConnection {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'from_location_id', type: 'uuid' })
fromLocationId!: string;
@Column({ name: 'to_location_id', type: 'uuid' })
toLocationId!: string;
@Column({ name: 'travel_duration_seconds', type: 'integer' })
travelDurationSeconds!: number;
@Column({ name: 'ambush_chance', type: 'numeric', precision: 5, scale: 4 })
ambushChance!: string;
@Column({ name: 'enabled', type: 'boolean' })
enabled!: boolean;
@ManyToOne(
() => LocationDefinition,
(location) => location.outgoingConnections,
{ onDelete: 'RESTRICT' },
)
@JoinColumn({ name: 'from_location_id' })
fromLocation!: LocationDefinition;
@ManyToOne(
() => LocationDefinition,
(location) => location.incomingConnections,
{ onDelete: 'RESTRICT' },
)
@JoinColumn({ name: 'to_location_id' })
toLocation!: LocationDefinition;
}

View File

@@ -0,0 +1,63 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { LocationConnection } from './location-connection.entity';
@Entity({ name: 'location_definitions' })
@Index('IDX_location_definitions_key', ['key'], { unique: true })
export class LocationDefinition {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'description', type: 'text' })
description!: string;
@Column({ name: 'region_key', type: 'varchar', length: 100 })
regionKey!: string;
@Column({ name: 'min_recommended_level', type: 'integer' })
minRecommendedLevel!: number;
@Column({ name: 'max_recommended_level', type: 'integer' })
maxRecommendedLevel!: number;
@Column({ name: 'danger_level', type: 'integer' })
dangerLevel!: number;
@Column({ name: 'is_safe', type: 'boolean' })
isSafe!: boolean;
@Column({ name: 'hunting_enabled', type: 'boolean' })
huntingEnabled!: boolean;
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@OneToMany(() => Character, (character) => character.currentLocation)
characters!: Character[];
@OneToMany(() => LocationConnection, (connection) => connection.fromLocation)
outgoingConnections!: LocationConnection[];
@OneToMany(() => LocationConnection, (connection) => connection.toLocation)
incomingConnections!: LocationConnection[];
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { WorldService } from './world.service';
@Controller('world')
export class WorldController {
constructor(private readonly worldService: WorldService) {}
@Get('current-location')
getCurrentLocation() {
return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { TravelModule } from '../travel/travel.module';
import { LocationConnection } from './entities/location-connection.entity';
import { WorldController } from './world.controller';
import { WorldService } from './world.service';
@Module({
imports: [
TypeOrmModule.forFeature([Character, LocationConnection]),
TravelModule,
],
controllers: [WorldController],
providers: [WorldService],
})
export class WorldModule {}

View File

@@ -0,0 +1,167 @@
import { NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import {
BURNED_ROAD_ID,
SOUTH_GATE_ID,
} from '../database/seeds/vertical-slice.constants';
import { TravelService } from '../travel/travel.service';
import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity';
import { WorldService } from './world.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
function currentLocation(): LocationDefinition {
return {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
description:
'Das S\u00fcdtor ist der sichere Ausgangspunkt nach S\u00fcden.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/assets/locations/south-gate.webp',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
characters: [],
outgoingConnections: [],
incomingConnections: [],
};
}
function burnedRoad(): LocationDefinition {
return {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
description: 'Eine verbrannte Handelsroute durch die Aschenfelder.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 2,
dangerLevel: 1,
isSafe: false,
huntingEnabled: true,
artworkPath: '/assets/locations/burned-road.webp',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
characters: [],
outgoingConnections: [],
incomingConnections: [],
};
}
describe('WorldService', () => {
it('returns the authoritative current location and only enabled public connections', async () => {
const callOrder: string[] = [];
const location = currentLocation();
const completeTravelIfDue = jest.fn().mockImplementation(() => {
callOrder.push('completeTravelIfDue');
return Promise.resolve({ status: 'IDLE' });
});
const travelService = {
completeTravelIfDue,
} as unknown as TravelService;
const findCharacter = jest.fn().mockImplementation(() => {
callOrder.push('findCharacter');
return Promise.resolve({
id: CHARACTER_ID,
currentLocationId: SOUTH_GATE_ID,
currentLocation: location,
});
});
const characters = {
findOne: findCharacter,
} as unknown as Repository<Character>;
const findConnections = jest.fn().mockResolvedValue([
{
id: '30000000-0000-4000-8000-000000000001',
fromLocationId: SOUTH_GATE_ID,
toLocationId: BURNED_ROAD_ID,
travelDurationSeconds: 10,
ambushChance: '0.0500',
enabled: true,
fromLocation: location,
toLocation: burnedRoad(),
},
{
id: '30000000-0000-4000-8000-000000000002',
fromLocationId: SOUTH_GATE_ID,
toLocationId: '20000000-0000-4000-8000-000000000003',
travelDurationSeconds: 30,
ambushChance: '0.9000',
enabled: false,
fromLocation: location,
toLocation: burnedRoad(),
},
]);
const connections = {
find: findConnections,
} as unknown as Repository<LocationConnection>;
const service = new WorldService(travelService, characters, connections);
const result = await service.getCurrentLocation(CHARACTER_ID);
expect(callOrder).toEqual(['completeTravelIfDue', 'findCharacter']);
expect(result).toEqual({
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
description:
'Das S\u00fcdtor ist der sichere Ausgangspunkt nach S\u00fcden.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/assets/locations/south-gate.webp',
connections: [
{
targetLocation: {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
},
travelDurationSeconds: 10,
danger: 'LOW',
},
],
});
expect(findCharacter).toHaveBeenCalledWith({
where: { id: CHARACTER_ID },
relations: { currentLocation: true },
});
expect(findConnections).toHaveBeenCalledWith({
where: { fromLocationId: SOUTH_GATE_ID, enabled: true },
relations: { toLocation: true },
});
});
it('reports a missing character after travel completion', async () => {
const completeTravelIfDue = jest.fn().mockResolvedValue({
status: 'IDLE',
});
const travelService = {
completeTravelIfDue,
} as unknown as TravelService;
const findCharacter = jest.fn().mockResolvedValue(null);
const characters = {
findOne: findCharacter,
} as unknown as Repository<Character>;
const findConnections = jest.fn();
const connections = {
find: findConnections,
} as unknown as Repository<LocationConnection>;
const service = new WorldService(travelService, characters, connections);
await expect(
service.getCurrentLocation(CHARACTER_ID),
).rejects.toBeInstanceOf(NotFoundException);
expect(findConnections).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,93 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { TravelService } from '../travel/travel.service';
import { LocationConnection } from './entities/location-connection.entity';
export interface LocationSummary {
id: string;
key: string;
name: string;
}
export interface CurrentLocationConnection {
targetLocation: LocationSummary;
travelDurationSeconds: number;
danger: 'LOW' | 'HIGH';
}
export interface CurrentLocationResponse {
id: string;
key: string;
name: string;
description: string;
regionKey: string;
minRecommendedLevel: number;
maxRecommendedLevel: number;
dangerLevel: number;
isSafe: boolean;
huntingEnabled: boolean;
artworkPath: string;
connections: CurrentLocationConnection[];
}
@Injectable()
export class WorldService {
constructor(
private readonly travelService: TravelService,
@InjectRepository(Character)
private readonly characters: Repository<Character>,
@InjectRepository(LocationConnection)
private readonly connections: Repository<LocationConnection>,
) {}
async getCurrentLocation(
characterId: string,
): Promise<CurrentLocationResponse> {
await this.travelService.completeTravelIfDue(characterId);
const character = await this.characters.findOne({
where: { id: characterId },
relations: { currentLocation: true },
});
if (!character) {
throw new NotFoundException('Character not found.');
}
const connections = await this.connections.find({
where: { fromLocationId: character.currentLocationId, enabled: true },
relations: { toLocation: true },
});
const location = character.currentLocation;
return {
id: location.id,
key: location.key,
name: location.name,
description: location.description,
regionKey: location.regionKey,
minRecommendedLevel: location.minRecommendedLevel,
maxRecommendedLevel: location.maxRecommendedLevel,
dangerLevel: location.dangerLevel,
isSafe: location.isSafe,
huntingEnabled: location.huntingEnabled,
artworkPath: location.artworkPath,
connections: connections
.filter((connection) => connection.enabled)
.map((connection) => ({
targetLocation: {
id: connection.toLocation.id,
key: connection.toLocation.key,
name: connection.toLocation.name,
},
travelDurationSeconds: connection.travelDurationSeconds,
danger: this.toDangerRating(connection.ambushChance),
})),
};
}
private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' {
return Number(ambushChance) <= 0.05 ? 'LOW' : 'HIGH';
}
}

View File

@@ -1,29 +1,60 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
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';
describe('AppController (e2e)', () => {
@Module({})
class TestDatabaseModule {}
@Module({})
class TestCharactersModule {}
@Module({})
class TestTravelModule {}
@Module({})
class TestWorldModule {}
describe('API (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
})
.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();
});
it('/ (GET)', () => {
it('/api/health (GET)', () => {
return request(app.getHttpServer())
.get('/')
.get('/api/health')
.expect(200)
.expect('Hello World!');
.expect({ status: 'ok' });
});
it('/ (GET) is not an API route', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
afterEach(async () => {
await app.close();
await app?.close();
});
});

View File

@@ -2,7 +2,8 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
@@ -59,6 +60,9 @@
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "web:build:production"

View File

@@ -11,22 +11,22 @@
"private": true,
"packageManager": "npm@11.12.1",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@angular/common": "22.0.8",
"@angular/compiler": "22.0.8",
"@angular/core": "22.0.8",
"@angular/forms": "22.0.8",
"@angular/platform-browser": "22.0.8",
"@angular/router": "22.0.8",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.6",
"@angular/cli": "^21.2.6",
"@angular/compiler-cli": "^21.2.0",
"@angular/build": "22.0.9",
"@angular/cli": "22.0.9",
"@angular/compiler-cli": "22.0.8",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"typescript": "6.0.3",
"vitest": "^4.0.8"
}
}
}

7
apps/web/proxy.conf.json Normal file
View File

@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Some files were not shown because too many files have changed in this diff Show More