Compare commits
22 Commits
9e92731645
...
6c35c18018
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c35c18018 | ||
|
|
8e6e77b027 | ||
|
|
1a30eb3491 | ||
|
|
1e2bfe6e4b | ||
|
|
fce03e220d | ||
|
|
b9e5c66c8e | ||
|
|
6304703d67 | ||
|
|
a738c61611 | ||
|
|
610c404759 | ||
|
|
c641168c7d | ||
|
|
d06f05047b | ||
|
|
e9b111daf8 | ||
|
|
0615bb1841 | ||
|
|
c35859a4de | ||
|
|
067134b684 | ||
|
|
a97152cded | ||
|
|
21f701e904 | ||
|
|
492765d8ca | ||
|
|
1210ba2d15 | ||
|
|
2903a0d670 | ||
|
|
4a689238e6 | ||
|
|
a6ebaf0493 |
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -13,18 +13,26 @@
|
|||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest --detectOpenHandles",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/core": "^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",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
12
apps/api/src/app.config.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
import { CharactersModule } from './characters/characters.module';
|
||||||
import { AppService } from './app.service';
|
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({
|
@Module({
|
||||||
imports: [],
|
imports: [
|
||||||
controllers: [AppController],
|
DatabaseModule,
|
||||||
providers: [AppService],
|
HealthModule,
|
||||||
|
CharactersModule,
|
||||||
|
TravelModule,
|
||||||
|
WorldModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AppService {
|
|
||||||
getHello(): string {
|
|
||||||
return 'Hello World!';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
apps/api/src/characters/characters.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/characters/characters.module.ts
Normal 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 {}
|
||||||
58
apps/api/src/characters/characters.service.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/api/src/characters/characters.service.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/api/src/characters/entities/character.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
18
apps/api/src/config/database.config.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/api/src/config/database.config.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
16
apps/api/src/database/data-source.ts
Normal 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,
|
||||||
|
});
|
||||||
12
apps/api/src/database/database.module.ts
Normal 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 {}
|
||||||
@@ -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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
17
apps/api/src/database/seeds/run-seed.ts
Normal 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;
|
||||||
|
});
|
||||||
2
apps/api/src/database/seeds/vertical-slice.constants.ts
Normal 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';
|
||||||
191
apps/api/src/database/seeds/vertical-slice.seed.spec.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
104
apps/api/src/database/seeds/vertical-slice.seed.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/api/src/demo/demo-character.constants.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
7
apps/api/src/health/health.controller.spec.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
9
apps/api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
getHealth() {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { configureApplication } from './app.config';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
configureApplication(app);
|
||||||
|
app.enableShutdownHooks();
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
9
apps/api/src/travel/clock.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const CLOCK = Symbol('CLOCK');
|
||||||
|
|
||||||
|
export interface Clock {
|
||||||
|
now(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const systemClock: Clock = {
|
||||||
|
now: () => new Date(),
|
||||||
|
};
|
||||||
6
apps/api/src/travel/dto/start-travel.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class StartTravelDto {
|
||||||
|
@IsUUID()
|
||||||
|
targetLocationId!: string;
|
||||||
|
}
|
||||||
55
apps/api/src/travel/entities/travel.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
4
apps/api/src/travel/travel-status.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum TravelStatus {
|
||||||
|
TRAVELLING = 'TRAVELLING',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
}
|
||||||
48
apps/api/src/travel/travel.controller.spec.ts
Normal 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';
|
||||||
22
apps/api/src/travel/travel.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/api/src/travel/travel.errors.ts
Normal 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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/api/src/travel/travel.module.ts
Normal 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 {}
|
||||||
400
apps/api/src/travel/travel.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
224
apps/api/src/travel/travel.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/api/src/world/entities/location-connection.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
63
apps/api/src/world/entities/location-definition.entity.ts
Normal 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[];
|
||||||
|
}
|
||||||
13
apps/api/src/world/world.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/world/world.module.ts
Normal 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 {}
|
||||||
167
apps/api/src/world/world.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
93
apps/api/src/world/world.service.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,60 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication, Module } from '@nestjs/common';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
import { AppModule } from './../src/app.module';
|
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>;
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
}).compile();
|
})
|
||||||
|
.overrideModule(DatabaseModule)
|
||||||
|
.useModule(TestDatabaseModule)
|
||||||
|
.overrideModule(CharactersModule)
|
||||||
|
.useModule(TestCharactersModule)
|
||||||
|
.overrideModule(TravelModule)
|
||||||
|
.useModule(TestTravelModule)
|
||||||
|
.overrideModule(WorldModule)
|
||||||
|
.useModule(TestWorldModule)
|
||||||
|
.compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
|
configureApplication(app);
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/ (GET)', () => {
|
it('/api/health (GET)', () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get('/')
|
.get('/api/health')
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect('Hello World!');
|
.expect({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET) is not an API route', () => {
|
||||||
|
return request(app.getHttpServer()).get('/').expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await app.close();
|
await app?.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm"
|
"packageManager": "npm",
|
||||||
|
"analytics": false
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
@@ -59,6 +60,9 @@
|
|||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular/build:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "web:build:production"
|
"buildTarget": "web:build:production"
|
||||||
|
|||||||
@@ -11,22 +11,22 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.12.1",
|
"packageManager": "npm@11.12.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^21.2.0",
|
"@angular/common": "22.0.8",
|
||||||
"@angular/compiler": "^21.2.0",
|
"@angular/compiler": "22.0.8",
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "22.0.8",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "22.0.8",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "22.0.8",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "22.0.8",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.2.6",
|
"@angular/build": "22.0.9",
|
||||||
"@angular/cli": "^21.2.6",
|
"@angular/cli": "22.0.9",
|
||||||
"@angular/compiler-cli": "^21.2.0",
|
"@angular/compiler-cli": "22.0.8",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "6.0.3",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
7
apps/web/proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:3000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/web/public/images/Items.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/web/public/images/Sprites.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
apps/web/public/images/backgrounds/Aschengrube.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/backgrounds/Aschestrasse.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/backgrounds/Graufurt.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/web/public/images/backgrounds/Suedtor.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/backgrounds/Wachturm.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/backgrounds/map_ashen_realm.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/web/public/images/backgrounds/runtime/Aschestrasse-960.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
apps/web/public/images/backgrounds/runtime/Suedtor-960.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/web/public/images/enemies/AnführerDerBandeIcon.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/enemies/Aschenratte.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/web/public/images/enemies/AschenratteIcon.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/enemies/Hauptmann_der_Aschenbande.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/enemies/PluendererIcon.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/web/public/images/enemies/Pluendererhauptmann.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/enemies/PluendererhauptmannIcon.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/enemies/Strassenraeuber.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/images/enemies/VerbrannterPluendererIcon.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/images/enemies/VerkohlterPluenderer.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/images/enemies/VerwilderterStrassenhundIcon.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/enemies/Verwilderter_Strassenhund.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/hud/AttackIcon.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
apps/web/public/images/hud/BlockIcon.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
apps/web/public/images/hud/Character.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/hud/CharacterIcon.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/hud/FightIcon.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
apps/web/public/images/hud/HuntIcon.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/hud/Icons.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/hud/InventoryIcon.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/images/hud/MapsIcon.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/images/hud/PotionIcon.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
apps/web/public/images/hud/QuestsIcon.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/hud/ShieldIcon.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
apps/web/public/images/hud/difficulty_badges/strong.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/web/public/images/hud/difficulty_badges/suiting.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/images/hud/difficulty_badges/very_difficult.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
apps/web/public/images/hud/difficulty_badges/weak.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
apps/web/public/images/hud/runtime/CharacterIcon-128.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/web/public/images/hud/runtime/HuntIcon-128.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
apps/web/public/images/hud/runtime/InventoryIcon-128.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/web/public/images/hud/runtime/MapsIcon-128.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/public/images/hud/runtime/QuestsIcon-128.png
Normal file
|
After Width: | Height: | Size: 29 KiB |