Compare commits

...

96 Commits

Author SHA1 Message Date
Bastian Wagner
d31d064d36 images 2026-08-20 13:23:55 +02:00
Bastian Wagner
d174d46fbd fix(web): carry the combat rewards field into the resume-combat fixture
The merge brought in `CombatDto.rewards`, which the resume spec's fixture
predates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:41:40 +02:00
Bastian Wagner
a87a13fad2 Merge branch 'master' into worktree-encounter-status 2026-08-20 10:34:52 +02:00
Bastian Wagner
3c59603efb feat(hunting): show cleared encounters and resume interrupted fights
The hunt screen kept whatever roll was last in memory, so a player coming
back from a fight saw every encounter as fresh. Encounters now carry their
own status, which the combat module advances as fights start and end.

- hunt_encounters.status replaces consumed_at, which only recorded that a
  fight had begun and could not distinguish a win from a loss
- a lost fight hands the encounter back as AVAILABLE, so it can be retried;
  the unique index tying one combat to one encounter goes with it
- GET /hunts/active serves the resumable hunt, which the hunt page adopts on
  entry rather than trusting its in-memory roll
- defeated encounters are crossed out and lose their hover and attack action
- a fresh page load rejoins a combat the server still holds open

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:34:46 +02:00
Bastian Wagner
624c47e5a6 Merge branch 'worktree-slice-0.4-first-loot' 2026-08-20 10:33:39 +02:00
Bastian Wagner
048cf80e78 test(slice-0.4): assert migration SQL and top-bar silver/XP rendering
Adds a real-SQL-inspection test for CreateLootAndRewards1788600000000
(mirroring the visible-vertical-slice pattern) so the one-reward-per-combat
unique index and other DB-level invariants can't be silently deleted
without failing a test, and asserts the TopBar renders character.silver
and character.experience values already present in the app.spec.ts fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:25:26 +02:00
Bastian Wagner
b92aecb71a renaming 2026-08-20 10:16:51 +02:00
Bastian Wagner
1a7a766f2b docs(slice-0.4): record First Loot verification results
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:04:26 +02:00
Bastian Wagner
b0e769d0da combat 2026-08-20 09:47:24 +02:00
Bastian Wagner
21b377f70c feat(web): show authoritative silver and XP in the top bar after victory
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:40:20 +02:00
Bastian Wagner
6711d51bcd feat(combat): replace the victory placeholder with the reward summary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:34:21 +02:00
Bastian Wagner
cf576133ff feat(web): add reward models and the reusable item card
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:24:58 +02:00
Bastian Wagner
6448605a72 feat(characters): expose persisted silver on the character endpoint
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:13:04 +02:00
Bastian Wagner
40e830a321 fix(combat): lock character before combat row and harden reward-transaction test
Fix a lock-order inversion Task 8 introduced: performAction locked the
combat row first and, inside grantVictoryRewards, the character row
second -- the opposite order to startCombat (character, then combat),
creating a deadlock cycle for two concurrent requests on the same
character. performAction now locks the character first via the
existing lockCharacter helper, matching startCombat; the later re-lock
inside grantVictoryRewards is a no-op within the same transaction.

Also strengthen the test that guards the transaction contract for
grantVictoryRewards: expect.anything() would have passed even if the
data source were handed over instead of the transaction manager, since
CombatRewardService has no runtime guard against that substitution.
The test now asserts on the captured argument's identity. Verified
this is load-bearing by temporarily passing the data source in place
of the manager and confirming the test fails.

Finally, make the rollback test's unchanged-XP/silver assertions real:
the fake grantVictoryRewards now writes through the transaction
manager before throwing, so the assertions prove the rollback
discarded those writes instead of passing vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:06:50 +02:00
Bastian Wagner
2a8883d479 feat(combat): resolve victory rewards in the combat completion transaction
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:46:35 +02:00
Bastian Wagner
d9585b166a fix(rewards): resolve item definitions up front and stabilize reward item order
Resolve every rolled ItemDefinition before mutating the character or
creating the CombatReward row, so a missing definition can no longer
half-grant (XP/silver saved, reward row created, then throw). Also
make the immediate grant response and a later loadRewards replay
agree on item order by sorting both on itemDefinitionId instead of
roll/insertion order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:36:54 +02:00
Bastian Wagner
af665dc677 feat(rewards): add CombatRewardService with idempotent victory rewards
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:13:37 +02:00
Bastian Wagner
07984110bf feat(loot): add LootService with deterministic independent rolls
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:05:59 +02:00
Bastian Wagner
c58a46b7d9 feat(loot): seed tier-1 items and the ash rat and road bandit loot tables
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:59:08 +02:00
Bastian Wagner
35559e3d2b feat(items): extract tier-1 item icons from the art sheet
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:47:42 +02:00
Bastian Wagner
67237f5ad8 effekte 2026-08-19 23:42:18 +02:00
Bastian Wagner
fe130c597e feat(loot): add loot and reward migration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:39:17 +02:00
Bastian Wagner
af9d422e8b feat(loot): add item, loot table, and combat reward entities
Declares every new TypeORM entity Slice 0.4 needs (ItemDefinition,
CharacterItem, LootTable, LootTableEntry, CombatReward, CombatRewardItem)
plus the ItemType/EquipmentSlot/ItemRarity enums, and adds the two columns
existing entities gain: Character.silver and MonsterDefinition.lootTableId.
No migration SQL or service logic yet - just schema declarations backed by
a metadata-driven schema spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:29:30 +02:00
Bastian Wagner
fd9852bfbf animation 2026-08-19 23:24:36 +02:00
Bastian Wagner
a5f7772a6d refactor(api): move RandomSource to shared and add rollInclusive
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:19:05 +02:00
Bastian Wagner
f008e53cc6 docs(slice-0.4): add the First Loot implementation plan
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:14:25 +02:00
Bastian Wagner
76e8ef4320 Merge branch 'worktree-slice-0.3-first-combat' 2026-08-19 22:47:34 +02:00
Bastian Wagner
6c4b7d29d0 feat(combat): play the round a beat at a time with attack and hit frames
The server resolves a whole round in one call, so both blows used to land
at the same instant. The page now keeps its own view of the combat and
plays the round back: the swing animates, the monster's HP and log line
land, then after a beat the monster strikes and the player recoils.

Both animations are six-frame sprite sheets driven by steps(6), which is
why the phase durations mirror the stylesheet.

The status row reflows on the stage's own width via a container query —
the side rails can squeeze it narrow while the viewport is still wide,
which previously overlapped the round marker with the player's name. The
component-style budget moves to 12kB to fit this screen's stylesheet.
2026-08-19 22:46:17 +02:00
Bastian Wagner
54f9d59ef4 feat(combat): use the cut-out enemy art and scale sprites per monster
Swap the enemy body art for the background-free versions and trim every
sprite to its opaque bounds so the fighters share one ground baseline
instead of floating in a padded box.

Sprite height is now a share of the battlefield rather than a fixed
clamp, set per monster key, so a low-slung rat and a standing bandit keep
believable proportions against the player at any stage size.
2026-08-19 22:46:05 +02:00
Bastian Wagner
f33b0c0e4a feat(combat): rejoin the running combat instead of dead-ending
Attacking while a combat is already active returned COMBAT_ALREADY_ACTIVE
and left the hunt page showing an error the player could not act on, with
no way back into the fight they were already in.

Add GET /api/combats/active so the client can resolve that combat, and
have the hunt page navigate into it when an attack is rejected for this
reason. CombatStore now also exposes the error code so callers can tell
this case apart from a genuinely failed attack.
2026-08-19 22:45:53 +02:00
Bastian Wagner
e42957d91e Merge branch 'worktree-slice-0.3-first-combat' 2026-08-19 22:40:32 +02:00
Bastian Wagner
c390a61594 sprites 2026-08-19 22:16:17 +02:00
Bastian Wagner
92b9f1cd35 feat(combat): rebuild the combat screen around the reference layout
Move both health bars to a status row at the top of the scene with a
circular portrait beside each, and place full-body sprites for the
player and the monster standing on the location background instead of
square portraits. The Angriff action now uses the ornate HUD frame art,
with the keybind in the frame's own tab.

Sprite and icon derivatives are keyed by monster key so both seeded
monsters resolve; the Aschenratte body art still carries its original
backdrop until a cut-out version replaces it at the same path.
2026-08-19 22:11:07 +02:00
Bastian Wagner
dcb248bd15 statusbar 2026-08-19 22:07:19 +02:00
Bastian Wagner
6affb9eddc Merge branch 'master' into worktree-slice-0.3-first-combat 2026-08-19 21:53:29 +02:00
Bastian Wagner
18f30a1ba1 test(combat): cover CombatService LOST persistence
CombatService only had engine-level coverage of the LOST transition.
Add service-level tests that force a loss (character.baseHp: 1) and
assert the persisted status, completedAt, further-action rejection,
and getCombat refresh behavior for a LOST combat.
2026-08-19 21:42:01 +02:00
Bastian Wagner
aa8c374db0 feat(combat): start real combats from the hunt page and navigate to /combat/:combatId 2026-08-19 17:03:35 +02:00
Bastian Wagner
1fd62cddde feat(combat): add CombatPageComponent and replace the combat/new placeholder route
Wires up the Slice 0.3 combat screen (player/monster HP bars, round
display, Angriff action, grouped German combat log, victory/defeat
panels) and replaces the Slice 0.2 combat/new placeholder route with
combat/:combatId loading CombatPageComponent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:53:59 +02:00
Bastian Wagner
4b1e7f034c feat(combat): add CombatStore
Signal-based store wrapping GameApiService.startCombat/getCombat/
performCombatAction with loading/error/actionPending state, following
the HuntingStore/WorldStore pattern. startCombat failures clear any
previously-loaded combat; loadCombat/attack failures preserve the
last-known-good combat. attack() guards against re-entrancy and
no-loaded-combat calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:46:36 +02:00
Bastian Wagner
8cf15afaee refactor(web): extract shared runtime monster-artwork lookup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:41:28 +02:00
Bastian Wagner
a6450817de feat(combat): add frontend Combat models and API methods
Adds Combat domain models (Combat, CombatPlayer, CombatMonster, CombatEvent)
and three GameApiService methods to interact with backend combat endpoints:
- startCombat(encounterId): POST /api/hunt-encounters/:id/attack
- getCombat(combatId): GET /api/combats/:id
- performCombatAction(combatId, action): POST /api/combats/:id/actions

Includes test coverage for all three methods.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:33:37 +02:00
Bastian Wagner
bb42195ef5 feat(combat): wire CombatModule into the application
Provide and export CharacterCombatStatsService from CharactersModule,
create CombatModule (registering the combat entities, controllers,
CombatService and CombatEngineService, and importing TravelModule and
CharactersModule), and register CombatModule in AppModule so the three
combat endpoints (attack, get combat, post action) are reachable from
the running app.
2026-08-19 16:19:06 +02:00
Bastian Wagner
4831dc20b0 feat(combat): add HTTP controllers for starting, reading, and acting on combats
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:09:08 +02:00
Bastian Wagner
85184e6e53 Cursor 2026-08-19 16:06:51 +02:00
Bastian Wagner
498349b5eb feat(combat): add CombatService orchestration and persistence
Wires the pure combat engine, character combat stats, and domain errors
into a transactional service that validates the HuntEncounter boundary,
snapshots stats into a new Combat row, and persists engine results.
2026-08-19 16:01:17 +02:00
Bastian Wagner
90f6a8cd78 feat(combat): add combat domain errors 2026-08-19 15:50:52 +02:00
Bastian Wagner
bd8a00227f feat(characters): add temporary combat-stats stand-in for equipment 2026-08-19 15:48:11 +02:00
Bastian Wagner
edae1af39a feat(combat): add deterministic combat engine for ATTACK resolution 2026-08-19 15:43:24 +02:00
Bastian Wagner
6cb4d02613 feat(combat): add deterministic damage formula 2026-08-19 15:39:17 +02:00
Bastian Wagner
47931ff717 feat(combat): add CreateCombatSystem migration 2026-08-19 15:34:46 +02:00
Bastian Wagner
68edc04ed6 feat(combat): add combat domain enums, entities, and encounter consumption field 2026-08-19 15:27:05 +02:00
Bastian Wagner
f24a5fea9c sprites 2026-08-19 14:43:25 +02:00
Bastian Wagner
deab7a31f1 docs: plan playable slice 0.2 first hunt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:39:27 +02:00
Bastian Wagner
137a18f4e7 style: fix prettier formatting drift in two API files
vertical-slice.seed.ts and danger-rating.spec.ts had line-wrapping
that no longer matched prettier's output, which a CI prettier --check
would flag. Formatting only, no logic change.
2026-08-19 14:33:41 +02:00
Bastian Wagner
833fa52d8d perf: serve optimized JPEG derivatives for monster artwork
encounter-card.component rendered the raw 2.39MB ash-rat.png and
2.00MB road-bandit.png directly, up to 3 cards per hunt (~7MB/load,
re-rendered on "Neu suchen"). Add 560px-wide JPEG runtime derivatives
(generated via PowerShell System.Drawing, HighQualityBicubic, quality
82) and switch to the <picture>/<source srcset> pattern already used
by context-panel for location backgrounds, keeping the original PNGs
as the <img> fallback. Also add loading="lazy" decoding="async".

ash-rat.png: 2,506,677 B -> ash-rat-560.jpg: 35,896 B
road-bandit.png: 2,097,049 B -> road-bandit-560.jpg: 50,797 B
2026-08-19 14:33:32 +02:00
Bastian Wagner
26909d7e2a fix: load WorldStore on direct /hunt navigation
HuntPageComponent never called WorldStore.load(), so opening /hunt
directly (bookmark/hard refresh) without first visiting /world left
currentLocation() at null forever, stranding the page and the context
panel on their empty states with no recovery. Add ngOnInit that calls
worldStore.load() only when no location is present yet, mirroring
WorldPageComponent's existing call and avoiding a duplicate request.
2026-08-19 14:33:15 +02:00
Bastian Wagner
4e684dc45e feat: add hunt page, combat placeholder, and Jagd navigation
Wires up Slice 0.2 end-to-end: HuntPageComponent renders the
hunting-unavailable/ready/loading/encounters-found states off
HuntingStore and WorldStore, Angreifen hands the HuntEncounter id to a
new inert CombatPlaceholderPageComponent via /combat/new, the Jagd nav
entry is enabled with router-driven active state (matching Karte's),
and the context panel now lists possible encounters for hunting-enabled
locations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:06:57 +02:00
Bastian Wagner
784bd3ce3a fix: replace invented hex color with existing --ar-bg token
Code review flagged .encounter-card__artwork-frame using a new
hardcoded #050607 background instead of reusing an existing design
token, violating the no-new-hex-colors requirement.
2026-08-19 13:39:35 +02:00
Bastian Wagner
15aa83d221 feat: add EncounterCard component for hunt encounter selection
Task 9 of Playable Slice 0.2 (First Hunt). Artwork-forward encounter
card reusing travel-panel's dark-fantasy panel chrome (tokens, border,
gradient, button style). Emits encounter.id (never monster.key) on
Angreifen click, preserving the frontend security boundary.
2026-08-19 13:33:33 +02:00
Bastian Wagner
59269a7608 feat: add DangerBadge shared component
Renders the 5 HuntEncounter danger tiers as German text plus a
per-tier BEM modifier class, mirroring travel-panel's text+class
danger pattern so color is never the sole signal. Colors map across
the existing 3 semantic tokens (success/warning/danger).
2026-08-19 13:26:58 +02:00
Bastian Wagner
49eb110b25 feat: add HuntingStore for hunt/encounter state
Mirrors WorldStore's signal-store architecture to own hunt state for
Slice 0.2 (currentHunt, selectedEncounterId, loading, error) with a
computed encounters accessor and the four hunt error codes mapped to
German messages.
2026-08-19 13:20:15 +02:00
Bastian Wagner
ed03726931 feat: add hunt API client and models for Playable Slice 0.2
Add to game-api.models.ts:
- DangerRating type for hunt encounter danger levels
- MonsterSummary interface with key, name, level, artworkPath
- HuntEncounter interface for individual hunt encounters
- HuntResult interface for hunt completion results, reusing LocationSummary

Extend CurrentLocationResponse with possibleMonsters: string[] field.

Add to game-api.service.ts:
- startHunt(): Observable<HuntResult> method posting to /api/hunts

Update test fixtures to include possibleMonsters field in mock locations.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 13:14:07 +02:00
Bastian Wagner
bb307da0ff feat: wire HuntingController/HuntingModule and enrich current-location with monster pool
Registers HuntingModule (POST /api/hunts) into the DI graph alongside its
new entities, and adds possibleMonsters (enabled LocationMonster pool,
weight-descending, empty when hunting is disabled) to
WorldService.getCurrentLocation. Also updates the pre-existing
DB-less app.e2e-spec.ts to override HuntingModule the same way the other
feature modules already are, since it now needs a real DataSource.
2026-08-19 12:53:09 +02:00
Bastian Wagner
568478dcd2 feat: add HuntingService core domain logic for first-hunt slice
Adds startHunt's server-authoritative flow: complete due travel, verify
the location allows hunting, weighted-random-roll exactly 3 encounters
from the location's enabled monster pool inside a locked transaction
that supersedes any prior active hunt, and compute a danger rating per
encounter from the rolled monster's own stats. Mirrors TravelService's
transaction/locking pattern and error conventions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 12:24:46 +02:00
Bastian Wagner
9423c28bd8 feat: seed Aschenratte and Straßenräuber for Verbrannte Straße
Adds the two starter monster definitions and their location-monster
mapping (weights 70/30) to the vertical-slice seed, following the
same findOneBy/update-or-insert pattern used for locations, plus an
upsert on (locationId, monsterId) for the mapping. Copies the source
artwork into the served images/monsters directory and extends the
seed spec harness to cover both idempotent inserts.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 12:08:26 +02:00
Bastian Wagner
e04827cd5a feat: add hunting system schema migration
Adds the migration and metadata spec for the hunting schema (monster
definitions, location monster spawn tables, hunts, and hunt encounters)
required by Playable Slice 0.2. Mirrors the raw-SQL style of the visible
vertical slice migration; explicitly asserts the CASCADE-vs-RESTRICT
deviation on HuntEncounter's relation to Hunt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 11:55:21 +02:00
Bastian Wagner
0c2f079a6e feat: add hunt/monster domain entities and pure danger-rating helper
Adds Task 1 of Playable Slice 0.2: pure entity/enum/helper definitions
for the Hunt/Encounter system (MonsterDefinition, LocationMonster,
Hunt, HuntEncounter, EncounterType, HuntStatus, RandomSource,
DangerRating). No DB migration, module wiring, or seed data yet.

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

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

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

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

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

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

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

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

17
.claude/launch.json Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "api",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:api"],
"port": 3000
},
{
"name": "web",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:web"],
"port": 4200
}
]
}

View File

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

176
README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
import { CharacterCombatStatsService } from './character-combat-stats.service';
import { Character } from './entities/character.entity';
describe('CharacterCombatStatsService', () => {
it('derives combat stats from the character, with a temporary fixed weapon/armor stand-in', () => {
const service = new CharacterCombatStatsService();
const character = { baseHp: 100, baseAttack: 6 } as Character;
expect(service.getStats(character)).toEqual({
maxHp: 100,
attack: 6,
weaponDamage: 8,
armor: 6,
});
});
});

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { Character } from './entities/character.entity';
export interface CharacterCombatStats {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
// TEMPORARY (Slice 0.3): there is no equipment system yet. These constants
// stand in for the starting weapon/armor until Slice 0.5 introduces real
// equipment. Replacing them there must not change this method's signature
// or the combat API it feeds (spec §10).
const TEMPORARY_WEAPON_DAMAGE = 8;
const TEMPORARY_ARMOR = 6;
@Injectable()
export class CharacterCombatStatsService {
getStats(character: Character): CharacterCombatStats {
return {
maxHp: character.baseHp,
attack: character.baseAttack,
weaponDamage: TEMPORARY_WEAPON_DAMAGE,
armor: TEMPORARY_ARMOR,
};
}
}

View File

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

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterCombatStatsService } from './character-combat-stats.service';
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, CharacterCombatStatsService],
exports: [CharacterCombatStatsService],
})
export class CharactersModule {}

View File

@@ -0,0 +1,85 @@
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,
silver: 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,
silver: 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('exposes the persisted silver so the HUD never has to guess', async () => {
const repository = {
findOne: jest.fn().mockResolvedValue({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 24,
silver: 18,
currentHp: 100,
baseHp: 100,
baseAttack: 6,
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'Südtor von Graufurt',
},
}),
} as unknown as Repository<Character>;
const service = new CharactersService(repository);
await expect(service.getDemoCharacter()).resolves.toEqual(
expect.objectContaining({ experience: 24, silver: 18 }),
);
});
it('reports a missing demo seed as not found', async () => {
const repository = {
findOne: jest.fn().mockResolvedValue(null),
} as unknown as Repository<Character>;
const service = new CharactersService(repository);
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
NotFoundException,
);
});
});

View File

@@ -0,0 +1,40 @@
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,
silver: character.silver,
currentHp: character.currentHp,
maxHp: character.baseHp,
attack: character.baseAttack,
currentLocation: {
id: character.currentLocation.id,
key: character.currentLocation.key,
name: character.currentLocation.name,
},
};
}
}

View File

@@ -0,0 +1,52 @@
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: 'silver', type: 'integer' })
silver!: number;
@Column({ name: 'base_hp', type: 'integer' })
baseHp!: number;
@Column({ name: 'base_attack', type: 'integer' })
baseAttack!: number;
@Column({ name: 'current_hp', type: 'integer' })
currentHp!: number;
@Column({ name: 'current_location_id', type: 'uuid' })
currentLocationId!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => LocationDefinition, (location) => location.characters, {
onDelete: 'RESTRICT',
})
@JoinColumn({ name: 'current_location_id' })
currentLocation!: LocationDefinition;
}

View File

@@ -0,0 +1,6 @@
// Only ATTACK is implemented in Slice 0.3. Future slices add HEAVY_STRIKE,
// SHIELD_BASH, DEFEND, POTION, FLEE as real members with their own
// CombatEngineService cases — do not add them here until their behavior ships.
export enum CombatAction {
ATTACK = 'ATTACK',
}

View File

@@ -0,0 +1,17 @@
import { calculateDamage } from './combat-damage';
describe('calculateDamage', () => {
it('applies the established armor mitigation formula', () => {
// raw = 12 + 15 = 27; 27 * 60 / (60 + 20) = 20.25 -> rounds to 20
expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20)).toBe(20);
});
it('never returns less than 1 damage, even against extreme armor', () => {
expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000)).toBe(1);
});
it('treats an attacker with no weaponDamage as having attack alone as its raw damage', () => {
// raw = 9; 9 * 60 / (60 + 5) = 8.307... -> rounds to 8
expect(calculateDamage({ attack: 9 }, 5)).toBe(8);
});
});

View File

@@ -0,0 +1,13 @@
export interface DamageAttacker {
attack: number;
weaponDamage?: number;
}
const ARMOR_MITIGATION_CONSTANT = 60;
export function calculateDamage(attacker: DamageAttacker, targetArmor: number): number {
const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0);
const mitigatedDamage =
(rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor);
return Math.max(1, Math.round(mitigatedDamage));
}

View File

@@ -0,0 +1,107 @@
import { CombatAction } from './combat-action.enum';
import { CombatEngineService, UnsupportedCombatActionError } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
import { Combatant } from './combatant.enum';
function baseState(overrides: Partial<CombatEngineState> = {}): CombatEngineState {
return {
status: CombatStatus.ACTIVE,
round: 1,
player: {
currentHp: 100,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6 },
},
monster: {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0 },
},
...overrides,
};
}
describe('CombatEngineService', () => {
let engine: CombatEngineService;
beforeEach(() => {
engine = new CombatEngineService();
});
it('reduces monster HP by the calculated damage and emits a DAMAGE event', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK });
// raw = 6 + 8 = 14; armor 0 -> 14 mitigated
expect(result.state.monster.currentHp).toBe(45 - 14);
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 14,
});
});
it('lets the monster retaliate when it survives the player attack, and advances the round', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK });
// raw = 5; armor 6 -> 5*60/66 = 4.545 -> rounds to 5
expect(result.state.player.currentHp).toBe(100 - 5);
expect(result.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 5,
});
expect(result.state.status).toBe(CombatStatus.ACTIVE);
expect(result.state.round).toBe(2);
});
it('does not let the monster attack once it is reduced to 0 HP, and ends the combat as WON', () => {
const state = baseState({
monster: { currentHp: 10, maxHp: 45, stats: { attack: 5, armor: 0 } },
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(result.state.monster.currentHp).toBe(0);
expect(result.state.status).toBe(CombatStatus.WON);
expect(result.state.round).toBe(1);
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON },
]);
});
it('ends the combat as LOST when the monster attack reduces the player to 0 HP', () => {
const state = baseState({
player: { currentHp: 3, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 } },
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(result.state.player.currentHp).toBe(0);
expect(result.state.status).toBe(CombatStatus.LOST);
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 5 },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST },
]);
});
it('produces the exact same result for the same state and action (determinism)', () => {
const state = baseState();
const first = engine.resolveAction(state, { action: CombatAction.ATTACK });
const second = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(first).toEqual(second);
});
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
expect(() =>
engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }),
).toThrow(UnsupportedCombatActionError);
});
});

View File

@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { CombatAction } from './combat-action.enum';
import { calculateDamage } from './combat-damage';
import { Combatant } from './combatant.enum';
import {
CombatActionInput,
CombatEngineEvent,
CombatEngineResult,
CombatEngineState,
} from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
export class UnsupportedCombatActionError extends Error {
constructor(action: string) {
super(`Unsupported combat action: ${action}`);
}
}
@Injectable()
export class CombatEngineService {
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
switch (input.action) {
case CombatAction.ATTACK:
return this.resolveAttack(state);
default:
throw new UnsupportedCombatActionError(input.action);
}
}
private resolveAttack(state: CombatEngineState): CombatEngineResult {
const events: CombatEngineEvent[] = [];
const player = { ...state.player };
const monster = { ...state.monster };
const playerDamage = calculateDamage(player.stats, monster.stats.armor);
monster.currentHp = Math.max(0, monster.currentHp - playerDamage);
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: playerDamage,
});
if (monster.currentHp <= 0) {
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.COMBAT_WON,
});
return {
state: { ...state, player, monster, status: CombatStatus.WON },
events,
};
}
const monsterDamage = calculateDamage(monster.stats, player.stats.armor);
player.currentHp = Math.max(0, player.currentHp - monsterDamage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: monsterDamage,
});
if (player.currentHp <= 0) {
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.COMBAT_LOST,
});
return {
state: { ...state, player, monster, status: CombatStatus.LOST },
events,
};
}
return {
state: { ...state, player, monster, status: CombatStatus.ACTIVE, round: state.round + 1 },
events,
};
}
}

View File

@@ -0,0 +1,39 @@
import { Combatant } from './combatant.enum';
import { CombatAction } from './combat-action.enum';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
export interface CombatEngineCombatantStats {
attack: number;
weaponDamage?: number;
armor: number;
}
export interface CombatEngineCombatant {
currentHp: number;
maxHp: number;
stats: CombatEngineCombatantStats;
}
export interface CombatEngineState {
status: CombatStatus;
round: number;
player: CombatEngineCombatant;
monster: CombatEngineCombatant;
}
export interface CombatActionInput {
action: CombatAction;
}
export interface CombatEngineEvent {
source: Combatant;
target: Combatant;
type: CombatEventType;
amount?: number;
}
export interface CombatEngineResult {
state: CombatEngineState;
events: CombatEngineEvent[];
}

View File

@@ -0,0 +1,5 @@
export enum CombatEventType {
DAMAGE = 'DAMAGE',
COMBAT_WON = 'COMBAT_WON',
COMBAT_LOST = 'COMBAT_LOST',
}

View File

@@ -0,0 +1,5 @@
export enum CombatStatus {
ACTIVE = 'ACTIVE',
WON = 'WON',
LOST = 'LOST',
}

View File

@@ -0,0 +1,97 @@
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 { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatController } from './combat.controller';
import { CombatService } from './combat.service';
describe('CombatController', () => {
let app: INestApplication<App>;
const getCombat = jest.fn();
const getActiveCombat = jest.fn();
const performAction = jest.fn();
beforeEach(async () => {
getCombat.mockReset();
getActiveCombat.mockReset();
performAction.mockReset();
const module = await Test.createTestingModule({
controllers: [CombatController],
providers: [
{ provide: CombatService, useValue: { getCombat, getActiveCombat, performAction } },
],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null };
getCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200);
expect(getCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1');
expect(response.body).toEqual(combat);
});
it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [], rewards: null };
getActiveCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(getCombat).not.toHaveBeenCalled();
expect(response.body).toEqual(combat);
});
it('returns an empty body from GET /api/combats/active when no combat is running', async () => {
getActiveCombat.mockResolvedValue(null);
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual({});
expect(getCombat).not.toHaveBeenCalled();
});
it('delegates POST /api/combats/:combatId/actions with only the action field', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [], rewards: null };
performAction.mockResolvedValue(combat);
const response = await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'ATTACK' })
.expect(201);
expect(performAction).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1', 'ATTACK');
expect(response.body).toEqual(combat);
});
it('rejects an unknown action value', async () => {
await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'HEAVY_STRIKE' })
.expect(400);
expect(performAction).not.toHaveBeenCalled();
});
it('rejects server-owned combat fields the client must never send', async () => {
await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'ATTACK', damage: 999, playerHp: 1, monsterHp: 1, round: 99 })
.expect(400);
expect(performAction).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,25 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatActionDto } from './dto/combat-action.dto';
import { CombatService } from './combat.service';
@Controller('combats')
export class CombatController {
constructor(private readonly combatService: CombatService) {}
// Declared before ':combatId' so the literal segment wins the route match.
@Get('active')
getActiveCombat() {
return this.combatService.getActiveCombat(DEMO_CHARACTER_ID);
}
@Get(':combatId')
getCombat(@Param('combatId') combatId: string) {
return this.combatService.getCombat(DEMO_CHARACTER_ID, combatId);
}
@Post(':combatId/actions')
performAction(@Param('combatId') combatId: string, @Body() dto: CombatActionDto) {
return this.combatService.performAction(DEMO_CHARACTER_ID, combatId, dto.action);
}
}

View File

@@ -0,0 +1,87 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type CombatErrorCode =
| 'HUNT_ENCOUNTER_NOT_FOUND'
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
| 'INVALID_HUNT_ENCOUNTER'
| 'CHARACTER_TRAVELLING'
| 'COMBAT_ALREADY_ACTIVE'
| 'COMBAT_NOT_FOUND'
| 'COMBAT_ALREADY_FINISHED'
| 'COMBAT_STATE_INVALID';
export class CombatDomainError extends HttpException {
constructor(
public readonly code: CombatErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function huntEncounterNotFound(): CombatDomainError {
return new CombatDomainError(
'HUNT_ENCOUNTER_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This encounter could not be found.',
);
}
export function huntEncounterAlreadyConsumed(): CombatDomainError {
return new CombatDomainError(
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
HttpStatus.CONFLICT,
'This encounter has already been used to start a combat.',
);
}
export function invalidHuntEncounter(): CombatDomainError {
return new CombatDomainError(
'INVALID_HUNT_ENCOUNTER',
HttpStatus.BAD_REQUEST,
'This encounter is not valid for the current character.',
);
}
export function characterTravelling(): CombatDomainError {
return new CombatDomainError(
'CHARACTER_TRAVELLING',
HttpStatus.CONFLICT,
'The character cannot fight while travelling.',
);
}
export function combatAlreadyActive(): CombatDomainError {
return new CombatDomainError(
'COMBAT_ALREADY_ACTIVE',
HttpStatus.CONFLICT,
'The character already has an active combat.',
);
}
export function combatNotFound(): CombatDomainError {
return new CombatDomainError(
'COMBAT_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This combat could not be found.',
);
}
export function combatAlreadyFinished(): CombatDomainError {
return new CombatDomainError(
'COMBAT_ALREADY_FINISHED',
HttpStatus.CONFLICT,
'This combat has already finished.',
);
}
export function combatStateInvalid(): CombatDomainError {
return new CombatDomainError(
'COMBAT_STATE_INVALID',
HttpStatus.INTERNAL_SERVER_ERROR,
'The persisted combat references unavailable data.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharactersModule } from '../characters/characters.module';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { RewardsModule } from '../rewards/rewards.module';
import { TravelModule } from '../travel/travel.module';
import { CombatEngineService } from './combat-engine.service';
import { CombatController } from './combat.controller';
import { CombatService } from './combat.service';
import { Combat } from './entities/combat.entity';
import { CombatEvent } from './entities/combat-event.entity';
import { HuntEncounterAttackController } from './hunt-encounter-attack.controller';
@Module({
imports: [
TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]),
TravelModule,
CharactersModule,
RewardsModule,
],
controllers: [CombatController, HuntEncounterAttackController],
providers: [CombatService, CombatEngineService],
})
export class CombatModule {}

View File

@@ -0,0 +1,964 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { CombatRewardService } from '../rewards/combat-reward.service';
import { TravelService } from '../travel/travel.service';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatDomainError } from './combat.errors';
import { CombatService } from './combat.service';
import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
const ENCOUNTER_ID = '30000000-0000-4000-8000-000000000001';
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
interface FakeState {
characters: Character[];
hunts: Hunt[];
huntEncounters: HuntEncounter[];
monsters: MonsterDefinition[];
combats: Combat[];
combatEvents: CombatEvent[];
}
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,
);
}
find(options: {
where: Partial<T>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> {
const matched = this.rows().filter((row) =>
this.matches(row, options.where),
);
const orderKey = options.order
? (Object.keys(options.order)[0] as keyof T)
: undefined;
if (orderKey) {
const direction = options.order![orderKey] === 'DESC' ? -1 : 1;
matched.sort((a, b) => {
if (a[orderKey] === b[orderKey]) return 0;
return a[orderKey] > b[orderKey] ? direction : -direction;
});
}
return Promise.resolve(matched);
}
count(options: { where: Partial<T> }): Promise<number> {
return Promise.resolve(
this.rows().filter((row) => this.matches(row, options.where)).length,
);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = this.dataSource.nextId(this.targetName());
}
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 === Hunt) return this.state.hunts as T[];
if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
if (this.target === MonsterDefinition) return this.state.monsters as T[];
if (this.target === Combat) return this.state.combats as T[];
if (this.target === CombatEvent) return this.state.combatEvents 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 }> = [];
private readonly idCounters = new Map<string, number>();
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;
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: 'location-1',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Character;
}
function monster(
overrides: Partial<MonsterDefinition> = {},
): MonsterDefinition {
return {
id: MONSTER_ID,
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
};
}
function hunt(overrides: Partial<Hunt> = {}): Hunt {
return {
id: HUNT_ID,
characterId: CHARACTER_ID,
locationId: 'location-1',
status: HuntStatus.ACTIVE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Hunt;
}
function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
return {
id: ENCOUNTER_ID,
huntId: HUNT_ID,
monsterDefinitionId: MONSTER_ID,
position: 0,
status: HuntEncounterStatus.AVAILABLE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as HuntEncounter;
}
function createState(overrides: Partial<FakeState> = {}): FakeState {
return {
characters: [character()],
hunts: [hunt()],
huntEncounters: [huntEncounter()],
monsters: [monster()],
combats: [],
combatEvents: [],
...overrides,
};
}
function fakeTravelService(
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
): TravelService {
return {
completeTravelIfDue: jest.fn().mockResolvedValue({ status }),
} as unknown as TravelService;
}
function fakeRewardService(
overrides: Partial<{
grantVictoryRewards: jest.Mock;
loadRewards: jest.Mock;
}> = {},
): CombatRewardService {
return {
grantVictoryRewards:
overrides.grantVictoryRewards ??
jest.fn().mockResolvedValue({ experience: 8, silver: 6, items: [] }),
loadRewards: overrides.loadRewards ?? jest.fn().mockResolvedValue(null),
} as unknown as CombatRewardService;
}
function createService(
options: { state?: FakeState; travelService?: TravelService } = {},
) {
const state = options.state ?? createState();
const dataSource = new FakeDataSource(state);
const travelService = options.travelService ?? fakeTravelService();
const combatEngine = new CombatEngineService();
const characterCombatStats = new CharacterCombatStatsService();
const service = new CombatService(
dataSource as unknown as DataSource,
travelService,
combatEngine,
characterCombatStats,
fakeRewardService(),
);
return { dataSource, service, travelService };
}
async function expectCombatDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(CombatDomainError);
if (!(error instanceof CombatDomainError)) {
throw new Error('Expected CombatDomainError');
}
expect(error.code).toBe(code);
}
describe('CombatService', () => {
describe('startCombat', () => {
it('starts an ACTIVE combat with snapshotted stats and full HP', async () => {
const { dataSource, service } = createService();
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(combat.status).toBe('ACTIVE');
expect(combat.round).toBe(1);
expect(combat.player).toEqual({
name: 'Aric Duskwalker',
maxHp: 100,
currentHp: 100,
});
expect(combat.monster).toEqual({
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
});
expect(combat.events).toEqual([]);
expect(dataSource.state.combats).toHaveLength(1);
expect(dataSource.state.combats[0]).toMatchObject({
characterId: CHARACTER_ID,
huntEncounterId: ENCOUNTER_ID,
monsterDefinitionId: MONSTER_ID,
status: CombatStatus.ACTIVE,
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
monsterState: { attack: 5, armor: 0 },
});
});
it('marks the encounter as IN_PROGRESS', async () => {
const { dataSource, service } = createService();
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.IN_PROGRESS,
);
});
it('rejects an unknown encounter id', async () => {
const { service } = createService();
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, 'unknown-id'),
'HUNT_ENCOUNTER_NOT_FOUND',
);
});
it('rejects an already-defeated encounter, and does not create a second combat', async () => {
const state = createState({
huntEncounters: [
huntEncounter({ status: HuntEncounterStatus.DEFEATED }),
],
});
const { dataSource, service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
);
expect(dataSource.state.combats).toHaveLength(0);
});
it('rejects an encounter whose fight is still IN_PROGRESS', async () => {
const state = createState({
huntEncounters: [
huntEncounter({ status: HuntEncounterStatus.IN_PROGRESS }),
],
});
const { dataSource, service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
);
expect(dataSource.state.combats).toHaveLength(0);
});
it('rejects an encounter belonging to a different character', async () => {
const state = createState({
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
});
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(OTHER_CHARACTER_ID, ENCOUNTER_ID),
'INVALID_HUNT_ENCOUNTER',
);
});
it('rejects an encounter whose hunt is no longer ACTIVE', async () => {
const state = createState({
hunts: [hunt({ status: HuntStatus.SUPERSEDED })],
});
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'INVALID_HUNT_ENCOUNTER',
);
});
it('rejects starting combat while the character is travelling', async () => {
const { service } = createService({
travelService: fakeTravelService('TRAVELLING'),
});
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'CHARACTER_TRAVELLING',
);
});
it('rejects starting a second combat while one is already ACTIVE', async () => {
const state = createState({
combats: [
{
id: 'combat-existing',
characterId: CHARACTER_ID,
huntEncounterId: 'other-encounter',
monsterDefinitionId: MONSTER_ID,
status: CombatStatus.ACTIVE,
round: 1,
playerMaxHp: 100,
playerCurrentHp: 100,
monsterMaxHp: 45,
monsterCurrentHp: 45,
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
monsterState: { attack: 5, armor: 0 },
createdAt: new Date(),
updatedAt: new Date(),
completedAt: null,
} as Combat,
],
});
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'COMBAT_ALREADY_ACTIVE',
);
});
it('locks the character and the active-combat lookup', async () => {
const { dataSource, service } = createService();
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(dataSource.locks).toEqual(
expect.arrayContaining([
{ target: Character, mode: 'pessimistic_write' },
{ target: Combat, mode: 'pessimistic_write' },
]),
);
});
});
describe('performAction', () => {
async function startedCombat(state = createState()) {
const context = createService({ state });
const combat = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
return { ...context, combatId: combat.id };
}
it('resolves ATTACK, persists HP/round changes, and returns them', async () => {
const { dataSource, service, combatId } = await startedCombat();
const result = await service.performAction(
CHARACTER_ID,
combatId,
CombatAction.ATTACK,
);
expect(result.status).toBe('ACTIVE');
expect(result.round).toBe(2);
expect(result.monster.currentHp).toBe(45 - 14);
expect(result.player.currentHp).toBe(100 - 5);
expect(dataSource.state.combats[0].round).toBe(2);
expect(dataSource.state.combats[0].monsterCurrentHp).toBe(31);
expect(dataSource.state.combats[0].playerCurrentHp).toBe(95);
});
it('persists ordered, sequential CombatEvents across multiple rounds', async () => {
const { dataSource, service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const events = dataSource.state.combatEvents
.filter((event) => event.combatId === combatId)
.sort((a, b) => a.sequence - b.sequence);
expect(events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]);
expect(events.map((event) => event.round)).toEqual([1, 1, 2, 2]);
expect(events[0]).toMatchObject({
source: 'PLAYER',
target: 'MONSTER',
type: 'DAMAGE',
amount: 14,
});
expect(events[1]).toMatchObject({
source: 'MONSTER',
target: 'PLAYER',
type: 'DAMAGE',
amount: 5,
});
});
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] });
const { dataSource, service, combatId } = await startedCombat(state);
const result = await service.performAction(
CHARACTER_ID,
combatId,
CombatAction.ATTACK,
);
expect(result.status).toBe('WON');
expect(dataSource.state.combats[0].completedAt).not.toBeNull();
await expectCombatDomainError(
service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
'COMBAT_ALREADY_FINISHED',
);
});
it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
const state = createState({
characters: [character({ baseHp: 1 })],
});
const { dataSource, service, combatId } = await startedCombat(state);
const result = await service.performAction(
CHARACTER_ID,
combatId,
CombatAction.ATTACK,
);
expect(result.status).toBe('LOST');
expect(dataSource.state.combats[0].status).toBe(CombatStatus.LOST);
expect(dataSource.state.combats[0].completedAt).not.toBeNull();
await expectCombatDomainError(
service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
'COMBAT_ALREADY_FINISHED',
);
});
it('marks the encounter DEFEATED when the fight is won', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.DEFEATED,
);
});
it('frees the encounter for another attempt when the fight is lost', async () => {
const state = createState({ characters: [character({ baseHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.AVAILABLE,
);
});
it('leaves the encounter IN_PROGRESS while the fight continues', async () => {
const { dataSource, service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.IN_PROGRESS,
);
});
it('lets a lost encounter be fought again as a fresh combat', async () => {
const state = createState({ characters: [character({ baseHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(retry.id).not.toBe(combatId);
expect(retry.status).toBe('ACTIVE');
expect(retry.round).toBe(1);
expect(retry.player.currentHp).toBe(retry.player.maxHp);
expect(dataSource.state.combats).toHaveLength(2);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.IN_PROGRESS,
);
});
it('rejects actions on an unknown combat id', async () => {
const { service } = createService();
await expectCombatDomainError(
service.performAction(
CHARACTER_ID,
'unknown-combat',
CombatAction.ATTACK,
),
'COMBAT_NOT_FOUND',
);
});
it('rejects actions from a character who does not own the combat', async () => {
const state = createState({
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
});
const { service, combatId } = await startedCombat(state);
await expectCombatDomainError(
service.performAction(
OTHER_CHARACTER_ID,
combatId,
CombatAction.ATTACK,
),
'COMBAT_NOT_FOUND',
);
});
it('locks the combat row for the duration of the action', async () => {
const { dataSource, service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.locks).toEqual(
expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
);
});
});
describe('getCombat', () => {
it('returns the persisted state and ordered events after a refresh', async () => {
const context = createService();
const started = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
await context.service.performAction(
CHARACTER_ID,
started.id,
CombatAction.ATTACK,
);
const reloaded = await context.service.getCombat(
CHARACTER_ID,
started.id,
);
expect(reloaded.round).toBe(2);
expect(reloaded.monster.currentHp).toBe(31);
expect(reloaded.events.map((event) => event.sequence)).toEqual([1, 2]);
});
it('rejects an unknown combat id', async () => {
const { service } = createService();
await expectCombatDomainError(
service.getCombat(CHARACTER_ID, 'unknown'),
'COMBAT_NOT_FOUND',
);
});
it('resolves the character ACTIVE combat so the hunt page can rejoin it', async () => {
const context = createService();
const started = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
await context.service.performAction(
CHARACTER_ID,
started.id,
CombatAction.ATTACK,
);
const active = await context.service.getActiveCombat(CHARACTER_ID);
expect(active?.id).toBe(started.id);
expect(active?.status).toBe('ACTIVE');
expect(active?.round).toBe(2);
});
it('resolves null when the character has no ACTIVE combat', async () => {
const { service } = createService();
await expect(service.getActiveCombat(CHARACTER_ID)).resolves.toBeNull();
});
it('resolves null once the only combat has finished', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] });
const context = createService({ state });
const started = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
await context.service.performAction(
CHARACTER_ID,
started.id,
CombatAction.ATTACK,
);
await expect(
context.service.getActiveCombat(CHARACTER_ID),
).resolves.toBeNull();
});
it('does not resolve another character ACTIVE combat', async () => {
const context = createService();
await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
await expect(
context.service.getActiveCombat(OTHER_CHARACTER_ID),
).resolves.toBeNull();
});
it('keeps returning LOST after the combat has ended', async () => {
const state = createState({
characters: [character({ baseHp: 1 })],
});
const context = createService({ state });
const started = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
await context.service.performAction(
CHARACTER_ID,
started.id,
CombatAction.ATTACK,
);
const reloaded = await context.service.getCombat(
CHARACTER_ID,
started.id,
);
expect(reloaded.status).toBe('LOST');
expect(reloaded.player.currentHp).toBe(0);
});
});
describe('rewards', () => {
it('grants rewards inside the same transaction when the round ends in victory', async () => {
const dataSource = new FakeDataSource(
createState({
combats: [
{
id: 'combat-1',
characterId: CHARACTER_ID,
huntEncounterId: ENCOUNTER_ID,
monsterDefinitionId: MONSTER_ID,
status: CombatStatus.ACTIVE,
round: 3,
playerMaxHp: 100,
playerCurrentHp: 80,
monsterMaxHp: 45,
monsterCurrentHp: 1,
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
monsterState: { attack: 5, armor: 0 },
completedAt: null,
} as Combat,
],
}),
);
const rewards = fakeRewardService({
grantVictoryRewards: jest.fn().mockResolvedValue({
experience: 8,
silver: 6,
items: [],
}),
});
const service = new CombatService(
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
new CharacterCombatStatsService(),
rewards,
);
const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK);
expect(result.status).toBe(CombatStatus.WON);
expect(result.rewards).toEqual({ experience: 8, silver: 6, items: [] });
expect(rewards.grantVictoryRewards).toHaveBeenCalledTimes(1);
// The reward service must receive the transaction manager, not the data
// source: `expect.anything()` would pass even if the code handed over
// `this.dataSource`, so assert on the captured argument's identity.
const passedManager = (rewards.grantVictoryRewards as jest.Mock).mock
.calls[0][0];
expect(passedManager).not.toBe(dataSource);
expect(rewards.grantVictoryRewards).toHaveBeenCalledWith(
passedManager,
expect.objectContaining({ id: 'combat-1', status: CombatStatus.WON }),
);
});
it('grants no rewards when the round ends in defeat', async () => {
const dataSource = new FakeDataSource(
createState({
combats: [
{
id: 'combat-1',
characterId: CHARACTER_ID,
huntEncounterId: ENCOUNTER_ID,
monsterDefinitionId: MONSTER_ID,
status: CombatStatus.ACTIVE,
round: 3,
playerMaxHp: 100,
playerCurrentHp: 1,
monsterMaxHp: 45,
monsterCurrentHp: 45,
playerState: { attack: 1, weaponDamage: 1, armor: 0 },
monsterState: { attack: 99, armor: 99 },
completedAt: null,
} as Combat,
],
}),
);
const rewards = fakeRewardService();
const service = new CombatService(
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
new CharacterCombatStatsService(),
rewards,
);
const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK);
expect(result.status).toBe(CombatStatus.LOST);
expect(result.rewards).toBeNull();
expect(rewards.grantVictoryRewards).not.toHaveBeenCalled();
});
it('replays the persisted reward when a finished combat is read again', async () => {
const persisted = {
experience: 16,
silver: 12,
items: [
{
characterItemId: 'character-item-1',
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
rarity: 'COMMON',
iconPath: '/images/items/bandit-blade.png',
},
quantity: 1,
},
],
};
const dataSource = new FakeDataSource(
createState({
combats: [
{
id: 'combat-1',
characterId: CHARACTER_ID,
huntEncounterId: ENCOUNTER_ID,
monsterDefinitionId: MONSTER_ID,
status: CombatStatus.WON,
round: 5,
playerMaxHp: 100,
playerCurrentHp: 62,
monsterMaxHp: 45,
monsterCurrentHp: 0,
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
monsterState: { attack: 5, armor: 0 },
completedAt: new Date('2026-08-19T09:00:00.000Z'),
} as Combat,
],
}),
);
const rewards = fakeRewardService({
loadRewards: jest.fn().mockResolvedValue(persisted),
grantVictoryRewards: jest.fn(),
});
const service = new CombatService(
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
new CharacterCombatStatsService(),
rewards,
);
const result = await service.getCombat(CHARACTER_ID, 'combat-1');
expect(result.rewards).toEqual(persisted);
// Reading must never grant: only the ACTIVE -> WON transition does.
expect(rewards.grantVictoryRewards).not.toHaveBeenCalled();
});
it('persists nothing at all when reward resolution fails mid-transaction', async () => {
const dataSource = new FakeDataSource(
createState({
combats: [
{
id: 'combat-1',
characterId: CHARACTER_ID,
huntEncounterId: ENCOUNTER_ID,
monsterDefinitionId: MONSTER_ID,
status: CombatStatus.ACTIVE,
round: 3,
playerMaxHp: 100,
playerCurrentHp: 80,
monsterMaxHp: 45,
monsterCurrentHp: 1,
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
monsterState: { attack: 5, armor: 0 },
completedAt: null,
} as Combat,
],
}),
);
const service = new CombatService(
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
new CharacterCombatStatsService(),
fakeRewardService({
// Genuinely write XP/silver through the transaction's manager
// before failing, so the assertions below prove the rollback
// discards those writes rather than passing vacuously because
// nothing was ever written.
grantVictoryRewards: jest.fn().mockImplementation(async (manager: EntityManager) => {
const characters = manager.getRepository(Character);
const combatCharacter = await characters.findOneBy({
id: CHARACTER_ID,
});
if (combatCharacter) {
combatCharacter.experience += 8;
combatCharacter.silver += 6;
await characters.save(combatCharacter);
}
throw new Error('reward persistence failed');
}),
}),
);
await expect(
service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK),
).rejects.toThrow('reward persistence failed');
// The whole round rolled back: the combat is still ACTIVE and unmodified,
// so no half-granted state can survive.
expect(dataSource.state.combats[0].status).toBe(CombatStatus.ACTIVE);
expect(dataSource.state.combats[0].monsterCurrentHp).toBe(1);
expect(dataSource.state.combatEvents).toHaveLength(0);
expect(dataSource.state.characters[0].experience).toBe(0);
expect(dataSource.state.characters[0].silver).toBe(0);
});
});
});

View File

@@ -0,0 +1,409 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { CombatRewardService } from '../rewards/combat-reward.service';
import type { CombatRewardDto } from '../rewards/combat-reward.service';
import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types';
import {
characterNotFound,
characterTravelling,
combatAlreadyActive,
combatAlreadyFinished,
combatNotFound,
combatStateInvalid,
huntEncounterAlreadyConsumed,
huntEncounterNotFound,
invalidHuntEncounter,
} from './combat.errors';
import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
export interface CombatPlayerDto {
name: string;
maxHp: number;
currentHp: number;
}
export interface CombatMonsterDto {
key: string;
name: string;
level: number;
maxHp: number;
currentHp: number;
artworkPath: string;
}
export interface CombatEventDto {
round: number;
sequence: number;
type: string;
source: string;
target: string;
amount?: number;
}
export interface CombatDto {
id: string;
status: CombatStatus;
round: number;
player: CombatPlayerDto;
monster: CombatMonsterDto;
events: CombatEventDto[];
rewards: CombatRewardDto | null;
}
@Injectable()
export class CombatService {
constructor(
private readonly dataSource: DataSource,
private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService,
private readonly characterCombatStats: CharacterCombatStatsService,
private readonly combatRewards: CombatRewardService,
) {}
async startCombat(
characterId: string,
encounterId: string,
): Promise<CombatDto> {
const travel = await this.travelService.completeTravelIfDue(characterId);
if (travel.status === TravelStatus.TRAVELLING) {
throw characterTravelling();
}
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const encounters = manager.getRepository(HuntEncounter);
const hunts = manager.getRepository(Hunt);
const monsters = manager.getRepository(MonsterDefinition);
const combats = manager.getRepository(Combat);
const character = await this.lockCharacter(characters, characterId);
const encounter = await encounters.findOne({
where: { id: encounterId },
lock: { mode: 'pessimistic_write' },
});
if (!encounter) {
throw huntEncounterNotFound();
}
if (encounter.status !== HuntEncounterStatus.AVAILABLE) {
throw huntEncounterAlreadyConsumed();
}
const hunt = await hunts.findOneBy({ id: encounter.huntId });
if (
!hunt ||
hunt.characterId !== characterId ||
hunt.status !== HuntStatus.ACTIVE
) {
throw invalidHuntEncounter();
}
const existingActiveCombat = await combats.findOne({
where: { characterId, status: CombatStatus.ACTIVE },
lock: { mode: 'pessimistic_write' },
});
if (existingActiveCombat) {
throw combatAlreadyActive();
}
const monster = await monsters.findOneBy({
id: encounter.monsterDefinitionId,
});
if (!monster) {
throw invalidHuntEncounter();
}
const playerStats = this.characterCombatStats.getStats(character);
const combat = combats.create({
characterId,
huntEncounterId: encounter.id,
monsterDefinitionId: monster.id,
status: CombatStatus.ACTIVE,
round: 1,
playerMaxHp: playerStats.maxHp,
playerCurrentHp: playerStats.maxHp,
monsterMaxHp: monster.maxHp,
monsterCurrentHp: monster.maxHp,
playerState: {
attack: playerStats.attack,
weaponDamage: playerStats.weaponDamage,
armor: playerStats.armor,
},
monsterState: { attack: monster.attack, armor: monster.armor },
completedAt: null,
});
await combats.save(combat);
encounter.status = HuntEncounterStatus.IN_PROGRESS;
await encounters.save(encounter);
return this.toCombatDto(combat, character.name, monster, [], null);
});
}
async getCombat(characterId: string, combatId: string): Promise<CombatDto> {
const combats = this.dataSource.getRepository(Combat);
const combat = await combats.findOne({
where: { id: combatId, characterId },
});
if (!combat) {
throw combatNotFound();
}
const [character, monster, events, rewards] = await Promise.all([
this.loadCharacter(combat.characterId),
this.loadMonster(combat.monsterDefinitionId),
this.loadEvents(combat.id),
this.combatRewards.loadRewards(combat.id),
]);
return this.toCombatDto(combat, character.name, monster, events, rewards);
}
async getActiveCombat(characterId: string): Promise<CombatDto | null> {
const combats = this.dataSource.getRepository(Combat);
const combat = await combats.findOne({
where: { characterId, status: CombatStatus.ACTIVE },
});
if (!combat) {
return null;
}
const [character, monster, events] = await Promise.all([
this.loadCharacter(combat.characterId),
this.loadMonster(combat.monsterDefinitionId),
this.loadEvents(combat.id),
]);
return this.toCombatDto(combat, character.name, monster, events, null);
}
async performAction(
characterId: string,
combatId: string,
action: CombatAction,
): Promise<CombatDto> {
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const combats = manager.getRepository(Combat);
const combatEvents = manager.getRepository(CombatEvent);
// Lock the character before the combat row here, matching the order
// startCombat already uses (character, then combat). grantVictoryRewards
// locks the character again later in this same transaction, which is a
// no-op re-lock — but locking it first here keeps both code paths
// consistent and avoids a lock-order inversion that could deadlock two
// concurrent requests against the same character. Do not reorder this.
await this.lockCharacter(characters, characterId);
const combat = await combats.findOne({
where: { id: combatId, characterId },
lock: { mode: 'pessimistic_write' },
});
if (!combat) {
throw combatNotFound();
}
if (combat.status !== CombatStatus.ACTIVE) {
throw combatAlreadyFinished();
}
const actionRound = combat.round;
const engineState = this.toEngineState(combat);
const result = this.combatEngine.resolveAction(engineState, { action });
combat.round = result.state.round;
combat.status = result.state.status;
combat.playerCurrentHp = result.state.player.currentHp;
combat.monsterCurrentHp = result.state.monster.currentHp;
if (combat.status !== CombatStatus.ACTIVE) {
combat.completedAt = new Date();
await this.settleEncounter(
manager.getRepository(HuntEncounter),
combat.huntEncounterId,
combat.status,
);
}
await combats.save(combat);
const startingSequence = await combatEvents.count({
where: { combatId: combat.id },
});
for (let index = 0; index < result.events.length; index += 1) {
const event = result.events[index];
const entity = combatEvents.create({
combatId: combat.id,
round: actionRound,
sequence: startingSequence + index + 1,
type: event.type,
source: event.source,
target: event.target,
amount: event.amount ?? null,
});
await combatEvents.save(entity);
}
// The engine decided the outcome; rewards are resolved here, outside it
// (spec §30). Running inside this transaction means a reward failure
// rolls the whole round back rather than leaving a half-granted victory.
const rewards =
combat.status === CombatStatus.WON
? await this.combatRewards.grantVictoryRewards(manager, combat)
: null;
const [character, monster, events] = await Promise.all([
this.loadCharacter(
combat.characterId,
manager.getRepository(Character),
),
this.loadMonster(
combat.monsterDefinitionId,
manager.getRepository(MonsterDefinition),
),
this.loadEvents(combat.id, combatEvents),
]);
return this.toCombatDto(combat, character.name, monster, events, rewards);
});
}
/**
* Records the fight's outcome on the encounter that spawned it. A win
* retires the encounter; a loss hands it back so the player can try again.
*/
private async settleEncounter(
encounters: Repository<HuntEncounter>,
encounterId: string,
outcome: CombatStatus,
): Promise<void> {
const encounter = await encounters.findOneBy({ id: encounterId });
if (!encounter) {
// combats.hunt_encounter_id is a RESTRICT FK; guaranteed to exist.
throw combatStateInvalid();
}
encounter.status =
outcome === CombatStatus.WON
? HuntEncounterStatus.DEFEATED
: HuntEncounterStatus.AVAILABLE;
await encounters.save(encounter);
}
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 async loadCharacter(
characterId: string,
repo?: Repository<Character>,
): Promise<Character> {
const characters = repo ?? this.dataSource.getRepository(Character);
const character = await characters.findOneBy({ id: characterId });
if (!character) {
// combats.character_id is a RESTRICT FK; a persisted combat's
// character is guaranteed to exist.
throw combatStateInvalid();
}
return character;
}
private async loadMonster(
monsterId: string,
repo?: Repository<MonsterDefinition>,
): Promise<MonsterDefinition> {
const monsters = repo ?? this.dataSource.getRepository(MonsterDefinition);
const monster = await monsters.findOneBy({ id: monsterId });
if (!monster) {
// combats.monster_definition_id is a RESTRICT FK; guaranteed to exist.
throw combatStateInvalid();
}
return monster;
}
private loadEvents(
combatId: string,
repo?: Repository<CombatEvent>,
): Promise<CombatEvent[]> {
const combatEvents = repo ?? this.dataSource.getRepository(CombatEvent);
return combatEvents.find({
where: { combatId },
order: { sequence: 'ASC' },
});
}
private toEngineState(combat: Combat): CombatEngineState {
return {
status: combat.status,
round: combat.round,
player: {
currentHp: combat.playerCurrentHp,
maxHp: combat.playerMaxHp,
stats: combat.playerState,
},
monster: {
currentHp: combat.monsterCurrentHp,
maxHp: combat.monsterMaxHp,
stats: combat.monsterState,
},
};
}
private toCombatDto(
combat: Combat,
playerName: string,
monster: MonsterDefinition,
events: CombatEvent[],
rewards: CombatRewardDto | null,
): CombatDto {
return {
id: combat.id,
status: combat.status,
round: combat.round,
player: {
name: playerName,
maxHp: combat.playerMaxHp,
currentHp: combat.playerCurrentHp,
},
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
maxHp: combat.monsterMaxHp,
currentHp: combat.monsterCurrentHp,
artworkPath: monster.artworkPath,
},
events: events.map((event) => ({
round: event.round,
sequence: event.sequence,
type: event.type,
source: event.source,
target: event.target,
amount: event.amount ?? undefined,
})),
rewards,
};
}
}

View File

@@ -0,0 +1,4 @@
export enum Combatant {
PLAYER = 'PLAYER',
MONSTER = 'MONSTER',
}

View File

@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { CombatAction } from '../combat-action.enum';
export class CombatActionDto {
@IsEnum(CombatAction)
action!: CombatAction;
}

View File

@@ -0,0 +1,62 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Combatant } from '../combatant.enum';
import { CombatEventType } from '../combat-event-type.enum';
import { Combat } from './combat.entity';
@Entity({ name: 'combat_events' })
@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], { unique: true })
export class CombatEvent {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'combat_id', type: 'uuid' })
combatId!: string;
@Column({ name: 'round', type: 'integer' })
round!: number;
@Column({ name: 'sequence', type: 'integer' })
sequence!: number;
@Column({
name: 'type',
type: 'enum',
enum: CombatEventType,
enumName: 'combat_event_type_enum',
})
type!: CombatEventType;
@Column({
name: 'source',
type: 'enum',
enum: Combatant,
enumName: 'combatant_enum',
})
source!: Combatant;
@Column({
name: 'target',
type: 'enum',
enum: Combatant,
enumName: 'combatant_enum',
})
target!: Combatant;
@Column({ name: 'amount', type: 'integer', nullable: true })
amount!: number | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => Combat, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'combat_id' })
combat!: Combat;
}

View File

@@ -0,0 +1,91 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { CombatStatus } from '../combat-status.enum';
export interface CombatCombatantState {
attack: number;
armor: number;
}
export interface CombatPlayerState extends CombatCombatantState {
weaponDamage: number;
}
@Entity({ name: 'combats' })
// Deliberately not unique: a lost fight frees the encounter to be retried,
// which creates a second combat row for the same encounter.
@Index('IDX_combats_hunt_encounter', ['huntEncounterId'])
export class Combat {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'hunt_encounter_id', type: 'uuid' })
huntEncounterId!: string;
@Column({ name: 'monster_definition_id', type: 'uuid' })
monsterDefinitionId!: string;
@Column({
name: 'status',
type: 'enum',
enum: CombatStatus,
enumName: 'combat_status_enum',
})
status!: CombatStatus;
@Column({ name: 'round', type: 'integer' })
round!: number;
@Column({ name: 'player_max_hp', type: 'integer' })
playerMaxHp!: number;
@Column({ name: 'player_current_hp', type: 'integer' })
playerCurrentHp!: number;
@Column({ name: 'monster_max_hp', type: 'integer' })
monsterMaxHp!: number;
@Column({ name: 'monster_current_hp', type: 'integer' })
monsterCurrentHp!: number;
@Column({ name: 'player_state', type: 'jsonb' })
playerState!: CombatPlayerState;
@Column({ name: 'monster_state', type: 'jsonb' })
monsterState!: CombatCombatantState;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt!: Date | null;
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => HuntEncounter, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'hunt_encounter_id' })
huntEncounter!: HuntEncounter;
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'monster_definition_id' })
monster!: MonsterDefinition;
}

View File

@@ -0,0 +1,41 @@
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 { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatService } from './combat.service';
import { HuntEncounterAttackController } from './hunt-encounter-attack.controller';
describe('HuntEncounterAttackController', () => {
let app: INestApplication<App>;
const startCombat = jest.fn();
beforeEach(async () => {
startCombat.mockReset();
const module = await Test.createTestingModule({
controllers: [HuntEncounterAttackController],
providers: [{ provide: CombatService, useValue: { startCombat } }],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates to combatService.startCombat with the demo character id and the encounter id', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null };
startCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer())
.post('/api/hunt-encounters/encounter-1/attack')
.expect(201);
expect(startCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'encounter-1');
expect(response.body).toEqual(combat);
});
});

View File

@@ -0,0 +1,13 @@
import { Controller, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatService } from './combat.service';
@Controller('hunt-encounters')
export class HuntEncounterAttackController {
constructor(private readonly combatService: CombatService) {}
@Post(':encounterId/attack')
attack(@Param('encounterId') encounterId: string) {
return this.combatService.startCombat(DEMO_CHARACTER_ID, encounterId);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateHuntingSystem1787500000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "monster_definitions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"level" integer NOT NULL,
"max_hp" integer NOT NULL,
"attack" integer NOT NULL,
"armor" integer NOT NULL,
"experience_reward" integer NOT NULL,
"silver_min" integer NOT NULL,
"silver_max" integer 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_monster_definitions" PRIMARY KEY ("id")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_monster_definitions_key" ON "monster_definitions" ("key")',
);
await queryRunner.query(
"CREATE TYPE \"location_monster_encounter_type_enum\" AS ENUM ('NORMAL', 'RARE', 'ELITE', 'BOSS')",
);
await queryRunner.query(`CREATE TABLE "location_monsters" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"location_id" uuid NOT NULL,
"monster_id" uuid NOT NULL,
"weight" integer NOT NULL,
"encounter_type" "location_monster_encounter_type_enum" NOT NULL DEFAULT 'NORMAL',
"enabled" boolean NOT NULL DEFAULT true,
CONSTRAINT "PK_location_monsters" PRIMARY KEY ("id"),
CONSTRAINT "FK_location_monsters_location" FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_location_monsters_monster" FOREIGN KEY ("monster_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_location_monsters_location" ON "location_monsters" ("location_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_location_monsters_monster" ON "location_monsters" ("monster_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_location_monsters_location_monster"
ON "location_monsters" ("location_id", "monster_id")`);
await queryRunner.query(
"CREATE TYPE \"hunt_status_enum\" AS ENUM ('ACTIVE', 'SUPERSEDED')",
);
await queryRunner.query(`CREATE TABLE "hunts" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"location_id" uuid NOT NULL,
"status" "hunt_status_enum" NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_hunts" PRIMARY KEY ("id"),
CONSTRAINT "FK_hunts_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_hunts_location" FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_hunts_character" ON "hunts" ("character_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_hunts_location" ON "hunts" ("location_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_hunt_per_character"
ON "hunts" ("character_id")
WHERE "status" = 'ACTIVE'`);
await queryRunner.query(`CREATE TABLE "hunt_encounters" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"hunt_id" uuid NOT NULL,
"monster_definition_id" uuid NOT NULL,
"position" integer NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_hunt_encounters" PRIMARY KEY ("id"),
CONSTRAINT "FK_hunt_encounters_hunt" FOREIGN KEY ("hunt_id") REFERENCES "hunts"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_hunt_encounters_monster_definition" FOREIGN KEY ("monster_definition_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_hunt_encounters_hunt" ON "hunt_encounters" ("hunt_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_hunt_encounters_monster_definition" ON "hunt_encounters" ("monster_definition_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'DROP INDEX "IDX_hunt_encounters_monster_definition"',
);
await queryRunner.query('DROP INDEX "IDX_hunt_encounters_hunt"');
await queryRunner.query('DROP TABLE "hunt_encounters"');
await queryRunner.query('DROP INDEX "IDX_active_hunt_per_character"');
await queryRunner.query('DROP INDEX "IDX_hunts_location"');
await queryRunner.query('DROP INDEX "IDX_hunts_character"');
await queryRunner.query('DROP TABLE "hunts"');
await queryRunner.query('DROP TYPE "hunt_status_enum"');
await queryRunner.query(
'DROP INDEX "IDX_location_monsters_location_monster"',
);
await queryRunner.query('DROP INDEX "IDX_location_monsters_monster"');
await queryRunner.query('DROP INDEX "IDX_location_monsters_location"');
await queryRunner.query('DROP TABLE "location_monsters"');
await queryRunner.query('DROP TYPE "location_monster_encounter_type_enum"');
await queryRunner.query('DROP INDEX "IDX_monster_definitions_key"');
await queryRunner.query('DROP TABLE "monster_definitions"');
}
}

View File

@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCombatSystem1788100000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "hunt_encounters" ADD COLUMN "consumed_at" TIMESTAMP WITH TIME ZONE',
);
await queryRunner.query(
"CREATE TYPE \"combat_status_enum\" AS ENUM ('ACTIVE', 'WON', 'LOST')",
);
await queryRunner.query(
"CREATE TYPE \"combat_event_type_enum\" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')",
);
await queryRunner.query(
"CREATE TYPE \"combatant_enum\" AS ENUM ('PLAYER', 'MONSTER')",
);
await queryRunner.query(`CREATE TABLE "combats" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"hunt_encounter_id" uuid NOT NULL,
"monster_definition_id" uuid NOT NULL,
"status" "combat_status_enum" NOT NULL,
"round" integer NOT NULL,
"player_max_hp" integer NOT NULL,
"player_current_hp" integer NOT NULL,
"monster_max_hp" integer NOT NULL,
"monster_current_hp" integer NOT NULL,
"player_state" jsonb NOT NULL,
"monster_state" jsonb NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"completed_at" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_combats" PRIMARY KEY ("id"),
CONSTRAINT "FK_combats_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_combats_hunt_encounter" FOREIGN KEY ("hunt_encounter_id") REFERENCES "hunt_encounters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_combats_monster_definition" FOREIGN KEY ("monster_definition_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_combats_character" ON "combats" ("character_id")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_combats_monster_definition" ON "combats" ("monster_definition_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_combat_per_character"
ON "combats" ("character_id")
WHERE "status" = 'ACTIVE'`);
await queryRunner.query(`CREATE TABLE "combat_events" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"combat_id" uuid NOT NULL,
"round" integer NOT NULL,
"sequence" integer NOT NULL,
"type" "combat_event_type_enum" NOT NULL,
"source" "combatant_enum" NOT NULL,
"target" "combatant_enum" NOT NULL,
"amount" integer,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_combat_events" PRIMARY KEY ("id"),
CONSTRAINT "FK_combat_events_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_combat_events_combat" ON "combat_events" ("combat_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_combat_events_combat_sequence"
ON "combat_events" ("combat_id", "sequence")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_combat_events_combat_sequence"');
await queryRunner.query('DROP INDEX "IDX_combat_events_combat"');
await queryRunner.query('DROP TABLE "combat_events"');
await queryRunner.query('DROP INDEX "IDX_active_combat_per_character"');
await queryRunner.query('DROP INDEX "IDX_combats_monster_definition"');
await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
await queryRunner.query('DROP INDEX "IDX_combats_character"');
await queryRunner.query('DROP TABLE "combats"');
await queryRunner.query('DROP TYPE "combatant_enum"');
await queryRunner.query('DROP TYPE "combat_event_type_enum"');
await queryRunner.query('DROP TYPE "combat_status_enum"');
await queryRunner.query(
'ALTER TABLE "hunt_encounters" DROP COLUMN "consumed_at"',
);
}
}

View File

@@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddHuntEncounterStatus1788200000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TYPE \"hunt_encounter_status_enum\" AS ENUM ('AVAILABLE', 'IN_PROGRESS', 'DEFEATED')",
);
await queryRunner.query(`ALTER TABLE "hunt_encounters"
ADD COLUMN "status" "hunt_encounter_status_enum" NOT NULL DEFAULT 'AVAILABLE'`);
// consumed_at only recorded that a fight had started, so the outcome has
// to be read off the combat it spawned. The unique index this migration
// drops guarantees at most one such combat per encounter.
await queryRunner.query(`UPDATE "hunt_encounters" AS "encounter"
SET "status" = CASE "combat"."status"
WHEN 'WON' THEN 'DEFEATED'::"hunt_encounter_status_enum"
WHEN 'ACTIVE' THEN 'IN_PROGRESS'::"hunt_encounter_status_enum"
ELSE 'AVAILABLE'::"hunt_encounter_status_enum"
END
FROM "combats" AS "combat"
WHERE "combat"."hunt_encounter_id" = "encounter"."id"`);
await queryRunner.query(
'ALTER TABLE "hunt_encounters" DROP COLUMN "consumed_at"',
);
// A retried encounter gets a second combat row, so the index that kept
// them one-to-one has to go; one ACTIVE combat per character is still
// enforced by IDX_active_combat_per_character.
await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
await queryRunner.query(
'CREATE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
);
await queryRunner.query(
'ALTER TABLE "hunt_encounters" ADD COLUMN "consumed_at" TIMESTAMP WITH TIME ZONE',
);
await queryRunner.query(`UPDATE "hunt_encounters"
SET "consumed_at" = now()
WHERE "status" <> 'AVAILABLE'`);
await queryRunner.query(
'ALTER TABLE "hunt_encounters" DROP COLUMN "status"',
);
await queryRunner.query('DROP TYPE "hunt_encounter_status_enum"');
}
}

View File

@@ -0,0 +1,174 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateLootAndRewards1788600000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Existing characters keep their progression; silver simply starts at 0.
await queryRunner.query(
'ALTER TABLE "characters" ADD COLUMN "silver" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
"CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')",
);
await queryRunner.query(
"CREATE TYPE \"equipment_slot_enum\" AS ENUM ('WEAPON', 'HEAD', 'CHEST', 'HANDS', 'LEGS', 'FEET', 'AMULET')",
);
await queryRunner.query(
"CREATE TYPE \"item_rarity_enum\" AS ENUM ('COMMON', 'RARE', 'EPIC')",
);
await queryRunner.query(`CREATE TABLE "item_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,
"type" "item_type_enum" NOT NULL,
"equipment_slot" "equipment_slot_enum",
"rarity" "item_rarity_enum" NOT NULL,
"tier" integer NOT NULL,
"required_level" integer NOT NULL,
"weapon_damage" integer NOT NULL DEFAULT 0,
"bonus_hp" integer NOT NULL DEFAULT 0,
"bonus_attack" integer NOT NULL DEFAULT 0,
"bonus_armor" integer NOT NULL DEFAULT 0,
"sell_price" integer NOT NULL DEFAULT 0,
"icon_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_item_definitions" PRIMARY KEY ("id")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_item_definitions_key" ON "item_definitions" ("key")',
);
await queryRunner.query(`CREATE TABLE "loot_tables" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_loot_tables" PRIMARY KEY ("id")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_loot_tables_key" ON "loot_tables" ("key")',
);
await queryRunner.query(`CREATE TABLE "loot_table_entries" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"loot_table_id" uuid NOT NULL,
"item_definition_id" uuid NOT NULL,
"position" integer NOT NULL,
"drop_chance" numeric(5,4) NOT NULL,
"min_quantity" integer NOT NULL DEFAULT 1,
"max_quantity" integer NOT NULL DEFAULT 1,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_loot_table_entries" PRIMARY KEY ("id"),
CONSTRAINT "CHK_loot_table_entries_drop_chance" CHECK ("drop_chance" >= 0 AND "drop_chance" <= 1),
CONSTRAINT "CHK_loot_table_entries_quantity" CHECK ("min_quantity" >= 1 AND "max_quantity" >= "min_quantity"),
CONSTRAINT "FK_loot_table_entries_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_loot_table_entries_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_position" ON "loot_table_entries" ("loot_table_id", "position")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_item" ON "loot_table_entries" ("loot_table_id", "item_definition_id")',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id" uuid',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD CONSTRAINT "FK_monster_definitions_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE RESTRICT ON UPDATE NO ACTION',
);
await queryRunner.query(
'CREATE INDEX "IDX_monster_definitions_loot_table" ON "monster_definitions" ("loot_table_id")',
);
await queryRunner.query(`CREATE TABLE "character_items" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"item_definition_id" uuid NOT NULL,
"quantity" integer NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_items" PRIMARY KEY ("id"),
CONSTRAINT "CHK_character_items_quantity" CHECK ("quantity" >= 1),
CONSTRAINT "FK_character_items_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_items_character_item" ON "character_items" ("character_id", "item_definition_id")',
);
await queryRunner.query(`CREATE TABLE "combat_rewards" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"combat_id" uuid NOT NULL,
"character_id" uuid NOT NULL,
"experience_granted" integer NOT NULL,
"silver_granted" integer NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_combat_rewards" PRIMARY KEY ("id"),
CONSTRAINT "FK_combat_rewards_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_combat_rewards_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
// The database half of the "one reward per combat" invariant (spec §7).
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_combat_rewards_combat" ON "combat_rewards" ("combat_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_combat_rewards_character" ON "combat_rewards" ("character_id")',
);
await queryRunner.query(`CREATE TABLE "combat_reward_items" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"combat_reward_id" uuid NOT NULL,
"character_item_id" uuid NOT NULL,
"item_definition_id" uuid NOT NULL,
"quantity" integer NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_combat_reward_items" PRIMARY KEY ("id"),
CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1),
CONSTRAINT "FK_combat_reward_items_reward" FOREIGN KEY ("combat_reward_id") REFERENCES "combat_rewards"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_combat_reward_items_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_combat_reward_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_combat_reward_items_reward" ON "combat_reward_items" ("combat_reward_id")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item" ON "combat_reward_items" ("combat_reward_id", "item_definition_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward_item"');
await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward"');
await queryRunner.query('DROP TABLE "combat_reward_items"');
await queryRunner.query('DROP INDEX "IDX_combat_rewards_character"');
await queryRunner.query('DROP INDEX "IDX_combat_rewards_combat"');
await queryRunner.query('DROP TABLE "combat_rewards"');
await queryRunner.query('DROP INDEX "IDX_character_items_character_item"');
await queryRunner.query('DROP TABLE "character_items"');
await queryRunner.query('DROP INDEX "IDX_monster_definitions_loot_table"');
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP CONSTRAINT "FK_monster_definitions_loot_table"',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "loot_table_id"',
);
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_item"');
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_position"');
await queryRunner.query('DROP TABLE "loot_table_entries"');
await queryRunner.query('DROP INDEX "IDX_loot_tables_key"');
await queryRunner.query('DROP TABLE "loot_tables"');
await queryRunner.query('DROP INDEX "IDX_item_definitions_key"');
await queryRunner.query('DROP TABLE "item_definitions"');
await queryRunner.query('DROP TYPE "item_rarity_enum"');
await queryRunner.query('DROP TYPE "equipment_slot_enum"');
await queryRunner.query('DROP TYPE "item_type_enum"');
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "silver"');
}
}

View File

@@ -0,0 +1,43 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Combat } from '../../combat/entities/combat.entity';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
describe('combat system schema', () => {
it('maps Combat and CombatEvent relations with the documented onDelete behavior', () => {
const metadata = getMetadataArgsStorage();
const relations = metadata.relations.filter(
(relation) => relation.target === Combat || relation.target === CombatEvent,
);
expect(
relations.map((relation) => ({
onDelete: relation.options.onDelete,
propertyName: relation.propertyName,
target: relation.target,
})),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: Combat }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'huntEncounter', target: Combat }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'monster', target: Combat }),
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatEvent }),
]),
);
});
it('enforces ordered, unique event sequencing per combat', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CombatEvent &&
candidate.columns?.includes('combatId') &&
candidate.columns?.includes('sequence'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean };
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Combat } from '../../combat/entities/combat.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../../hunting/hunt-encounter-status.enum';
describe('encounter status schema', () => {
it('stores the encounter status as a non-nullable enum on hunt_encounters', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === HuntEncounter &&
candidate.propertyName === 'status',
);
expect(column).toBeDefined();
expect(column?.options.type).toBe('enum');
expect(column?.options.enum).toBe(HuntEncounterStatus);
expect(column?.options.enumName).toBe('hunt_encounter_status_enum');
expect(column?.options.nullable).toBeFalsy();
});
it('drops consumedAt, whose gate the encounter status replaces', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === HuntEncounter &&
candidate.propertyName === 'consumedAt',
);
expect(column).toBeUndefined();
});
it('allows repeated combats per encounter so a lost fight can be retried', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === Combat &&
candidate.columns?.includes('huntEncounterId'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBeFalsy();
});
});

View File

@@ -0,0 +1,98 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
import { Hunt } from '../../hunting/entities/hunt.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
describe('hunting system schema', () => {
it('maps the monster definition key index and relationship foreign keys explicitly', () => {
const metadata = getMetadataArgsStorage();
const monsterKeyIndex = metadata.indices.find((index) => {
const metadataIndex = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
return (
index.target === MonsterDefinition &&
index.columns?.includes('key') &&
(metadataIndex.options?.unique ?? metadataIndex.unique) === true
);
});
expect(monsterKeyIndex).toBeDefined();
const relations = metadata.relations.filter((relation) =>
[LocationMonster, Hunt, HuntEncounter].includes(
relation.target as typeof LocationMonster,
),
);
const joinColumns = metadata.joinColumns
.filter((joinColumn) =>
[LocationMonster, Hunt, HuntEncounter].includes(
joinColumn.target as typeof LocationMonster,
),
)
.map((joinColumn) => joinColumn.name);
expect(joinColumns).toEqual(
expect.arrayContaining([
'location_id',
'monster_id',
'character_id',
'location_id',
'hunt_id',
'monster_definition_id',
]),
);
expect(
relations.map((relation) => ({
onDelete: relation.options.onDelete,
propertyName: relation.propertyName,
target: relation.target,
})),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'location',
target: LocationMonster,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'monster',
target: LocationMonster,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'character',
target: Hunt,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'location',
target: Hunt,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'monster',
target: HuntEncounter,
}),
]),
);
// Deliberate deviation from the codebase's usual RESTRICT: HuntEncounter
// rows are owned/composed by their parent Hunt and must be removed along
// with it, so this relation uses CASCADE. Asserted explicitly so a
// future refactor can't silently change it.
const huntEncounterToHunt = relations.find(
(relation) =>
relation.target === HuntEncounter && relation.propertyName === 'hunt',
);
expect(huntEncounterToHunt).toBeDefined();
expect(huntEncounterToHunt?.options.onDelete).toBe('CASCADE');
});
});

View File

@@ -0,0 +1,147 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { CreateLootAndRewards1788600000000 } from './1788600000000-CreateLootAndRewards';
import { Character } from '../../characters/entities/character.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTable } from '../../loot/entities/loot-table.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity';
function uniqueIndexFor(target: unknown, columns: string[]) {
const index = getMetadataArgsStorage().indices.find(
(candidate) =>
candidate.target === target &&
columns.every((column) => candidate.columns?.includes(column)),
);
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
return indexMetadata?.options?.unique ?? indexMetadata?.unique;
}
describe('loot and rewards schema', () => {
it('gives every combat at most one reward record', () => {
expect(uniqueIndexFor(CombatReward, ['combatId'])).toBe(true);
});
it('keeps one stack per character per item definition', () => {
expect(uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId'])).toBe(true);
});
it('keeps loot-table content keys and entry positions unique', () => {
expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true);
expect(uniqueIndexFor(LootTable, ['key'])).toBe(true);
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(true);
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId'])).toBe(true);
});
it('maps reward and loot relations with the documented onDelete behavior', () => {
const relations = getMetadataArgsStorage().relations.filter((relation) =>
[CombatReward, CombatRewardItem, CharacterItem, LootTableEntry, MonsterDefinition].includes(
relation.target as never,
),
);
expect(
relations.map((relation) => ({
onDelete: relation.options.onDelete,
propertyName: relation.propertyName,
target: relation.target,
})),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatReward }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: CombatReward }),
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combatReward', target: CombatRewardItem }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'characterItem', target: CombatRewardItem }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CombatRewardItem }),
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'character', target: CharacterItem }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CharacterItem }),
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'lootTable', target: LootTableEntry }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: LootTableEntry }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'lootTable', target: MonsterDefinition }),
]),
);
});
it('adds the character silver column and the nullable monster loot table link', () => {
const columns = getMetadataArgsStorage().columns;
const silver = columns.find(
(candidate) => candidate.target === Character && candidate.propertyName === 'silver',
);
expect(silver).toBeDefined();
expect(silver?.options.type).toBe('integer');
const lootTableId = columns.find(
(candidate) =>
candidate.target === MonsterDefinition && candidate.propertyName === 'lootTableId',
);
expect(lootTableId).toBeDefined();
expect(lootTableId?.options.nullable).toBe(true);
});
it('stores drop chance as a numeric column so probabilities stay data-driven', () => {
const dropChance = getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === LootTableEntry && candidate.propertyName === 'dropChance',
);
expect(dropChance?.options.type).toBe('numeric');
expect(dropChance?.options.precision).toBe(5);
expect(dropChance?.options.scale).toBe(4);
});
it('emits the real SQL that enforces the schema invariants, not just entity decorators', async () => {
// synchronize: false means entity decorators never touch the real database -
// only the raw SQL emitted by the migration itself does. Assert on that SQL
// directly so deleting a constraint here would fail this test.
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateLootAndRewards1788600000000();
await migration.up(queryRunner);
const upQueries = query.mock.calls.map(([sql]) => sql as string);
expect(upQueries).toEqual(
expect.arrayContaining([
// The database half of the "one reward per combat" invariant (spec §7, §37).
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_rewards_combat"'),
expect.stringContaining('CREATE UNIQUE INDEX "IDX_character_items_character_item"'),
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"'),
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "silver"'),
expect.stringContaining('ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"'),
]),
);
const checkConstraints = upQueries.filter((sql) => sql.includes('CHECK ('));
expect(checkConstraints.length).toBeGreaterThan(0);
expect(
checkConstraints.some(
(sql) =>
sql.includes('CHK_loot_table_entries_drop_chance') ||
sql.includes('CHK_character_items_quantity'),
),
).toBe(true);
await migration.down(queryRunner);
const downQueries = query.mock.calls
.slice(upQueries.length)
.map(([sql]) => sql as string);
// Proves down() is real and reverses the up() migration, not a no-op.
expect(downQueries).toEqual(
expect.arrayContaining([
expect.stringContaining('DROP TABLE "combat_rewards"'),
expect.stringContaining('DROP TABLE "character_items"'),
'ALTER TABLE "characters" DROP COLUMN "silver"',
]),
);
});
});

View File

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

View File

@@ -0,0 +1,216 @@
import { EquipmentSlot } from '../../items/equipment-slot.enum';
import { ItemRarity } from '../../items/item-rarity.enum';
import { ItemType } from '../../items/item-type.enum';
import {
ASH_RAT_LOOT_TABLE_ID,
ITEM_IDS,
ItemKey,
ROAD_BANDIT_LOOT_TABLE_ID,
} from './item.constants';
export interface SeedItemDefinition {
id: string;
key: ItemKey;
name: string;
description: string;
type: ItemType;
equipmentSlot: EquipmentSlot | null;
rarity: ItemRarity;
tier: number;
requiredLevel: number;
weaponDamage: number;
bonusHp: number;
bonusAttack: number;
bonusArmor: number;
sellPrice: number;
iconPath: string;
}
function item(
key: ItemKey,
name: string,
description: string,
type: ItemType,
equipmentSlot: EquipmentSlot | null,
rarity: ItemRarity,
stats: Partial<Pick<SeedItemDefinition, 'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor'>> = {},
): SeedItemDefinition {
return {
id: ITEM_IDS[key],
key,
name,
description,
type,
equipmentSlot,
rarity,
tier: 1,
requiredLevel: 1,
weaponDamage: stats.weaponDamage ?? 0,
bonusHp: stats.bonusHp ?? 0,
bonusAttack: stats.bonusAttack ?? 0,
bonusArmor: stats.bonusArmor ?? 0,
// Always 0: no merchants exist in Slice 0.4, and the balancing doc's
// Grenzmarken table lists purchase prices, not sell prices.
sellPrice: 0,
iconPath: `/images/items/${key}.png`,
};
}
// Stats from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19.
export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
item(
'worn-short-sword',
'Abgenutztes Kurzschwert',
'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
ItemType.WEAPON,
EquipmentSlot.WEAPON,
ItemRarity.COMMON,
{ weaponDamage: 8 },
),
item(
'bandit-blade',
'Räuberklinge',
'Eine grob gezahnte Klinge, geschmiedet für schnelle Überfälle.',
ItemType.WEAPON,
EquipmentSlot.WEAPON,
ItemRarity.COMMON,
{ weaponDamage: 11, bonusAttack: 1 },
),
item(
'ash-blade',
'Aschenklinge',
'In der Glut der Aschenfelder gehärtet; die Schneide glimmt noch.',
ItemType.WEAPON,
EquipmentSlot.WEAPON,
ItemRarity.RARE,
{ weaponDamage: 15, bonusAttack: 2 },
),
item(
'bandit-hood',
'Räuberhaube',
'Vernarbtes Leder, das Gesicht und Absicht des Trägers verbirgt.',
ItemType.ARMOR,
EquipmentSlot.HEAD,
ItemRarity.COMMON,
{ bonusArmor: 3, bonusHp: 5 },
),
item(
'reinforced-leather-jacket',
'Verstärkte Lederjacke',
'Mit Eisenplatten benähtes Leder, schwer und verlässlich.',
ItemType.ARMOR,
EquipmentSlot.CHEST,
ItemRarity.RARE,
{ bonusArmor: 7, bonusHp: 10 },
),
item(
'raider-gloves',
'Plündererhandschuhe',
'Beschlagene Handschuhe, abgegriffen von fremdem Gut.',
ItemType.ARMOR,
EquipmentSlot.HANDS,
ItemRarity.COMMON,
{ bonusArmor: 3, bonusAttack: 1 },
),
item(
'guardsman-legs',
'Wachmannsbeinkleid',
'Beinzeug der Grenzwacht, an den Knien geflickt.',
ItemType.ARMOR,
EquipmentSlot.LEGS,
ItemRarity.RARE,
{ bonusArmor: 5, bonusHp: 5 },
),
item(
'ash-boots',
'Aschenstiefel',
'Stiefel, die durch glimmende Felder getragen wurden und blieben.',
ItemType.ARMOR,
EquipmentSlot.FEET,
ItemRarity.RARE,
{ bonusArmor: 4, bonusHp: 5 },
),
item(
'borderwatch-sigil',
'Zeichen der Grenzwacht',
'Das Wappen eines Turms, den es nicht mehr gibt.',
ItemType.ARMOR,
EquipmentSlot.AMULET,
ItemRarity.RARE,
{ bonusAttack: 3, bonusHp: 10 },
),
item(
'burned-captain-pendant',
'Anhänger des verbrannten Hauptmanns',
'Ein Schädel aus Schlacke, in dem die Glut nie erlosch.',
ItemType.ARMOR,
EquipmentSlot.AMULET,
ItemRarity.EPIC,
{ bonusAttack: 3, bonusHp: 15, bonusArmor: 2 },
),
// Seeded as content only. Slice 0.4 implements no consumable use, and the
// Straßenräuber loot entry for it is deliberately deferred (spec §16).
item(
'small-healing-potion',
'Kleiner Heiltrank',
'Ein bitterer Sud, der Wunden für einen Atemzug vergessen lässt.',
ItemType.CONSUMABLE,
null,
ItemRarity.COMMON,
),
item(
'ash-pelt',
'Aschenfell',
'Versengtes Fell, zäh wie Leder und grau von Ascheflug.',
ItemType.MATERIAL,
null,
ItemRarity.COMMON,
),
];
export const LOOT_TABLES = [
{ id: ASH_RAT_LOOT_TABLE_ID, key: 'ash-rat-loot', name: 'Aschenratte Beute' },
{ id: ROAD_BANDIT_LOOT_TABLE_ID, key: 'road-bandit-loot', name: 'Straßenräuber Beute' },
];
export interface SeedLootTableEntry {
lootTableId: string;
itemDefinitionId: string;
position: number;
dropChance: string;
minQuantity: number;
maxQuantity: number;
enabled: boolean;
}
function entry(
lootTableId: string,
key: ItemKey,
position: number,
dropChance: string,
): SeedLootTableEntry {
return {
lootTableId,
itemDefinitionId: ITEM_IDS[key],
position,
dropChance,
minQuantity: 1,
maxQuantity: 1,
enabled: true,
};
}
/**
* Drop chances from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §2728.
* Every entry is an independent roll (spec §17), rolled in `position` order.
*
* DEFERRED: the Straßenräuber table also lists 10 % Kleiner Heiltrank. It is
* omitted here because Slice 0.4 implements no consumables (spec §16).
*/
export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [
entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '0.6000'),
entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'),
];

View File

@@ -0,0 +1,20 @@
// Stable content ids, in art-sheet order. Aschenfell (no sheet entry) is last.
export const ITEM_IDS = {
'worn-short-sword': '50000000-0000-4000-8000-000000000001',
'bandit-blade': '50000000-0000-4000-8000-000000000002',
'ash-blade': '50000000-0000-4000-8000-000000000003',
'bandit-hood': '50000000-0000-4000-8000-000000000004',
'reinforced-leather-jacket': '50000000-0000-4000-8000-000000000005',
'raider-gloves': '50000000-0000-4000-8000-000000000006',
'guardsman-legs': '50000000-0000-4000-8000-000000000007',
'ash-boots': '50000000-0000-4000-8000-000000000008',
'borderwatch-sigil': '50000000-0000-4000-8000-000000000009',
'burned-captain-pendant': '50000000-0000-4000-8000-00000000000a',
'small-healing-potion': '50000000-0000-4000-8000-00000000000b',
'ash-pelt': '50000000-0000-4000-8000-00000000000c',
} as const;
export type ItemKey = keyof typeof ITEM_IDS;
export const ASH_RAT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000001';
export const ROAD_BANDIT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000002';

View File

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

View File

@@ -0,0 +1,4 @@
export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';

View File

@@ -0,0 +1,339 @@
import { DataSource } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { LootTable } from '../../loot/entities/loot-table.entity';
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
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';
const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
const ROAD_BANDIT_MONSTER_ID = '30000000-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,
monsterRepository: InMemoryRepository,
locationMonsterRepository: InMemoryRepository,
itemRepository: InMemoryRepository = new InMemoryRepository(),
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
): DataSource {
return {
getRepository: jest.fn((entity: unknown) => {
if (entity === LocationDefinition) return locationRepository;
if (entity === LocationConnection) return connectionRepository;
if (entity === Character) return characterRepository;
if (entity === MonsterDefinition) return monsterRepository;
if (entity === LocationMonster) return locationMonsterRepository;
if (entity === ItemDefinition) return itemRepository;
if (entity === LootTable) return lootTableRepository;
if (entity === LootTableEntry) return lootEntryRepository;
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 monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
);
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,
}),
);
expect(monsterRepository.insert).toHaveBeenCalledTimes(2);
expect(monsterRepository.rows).toHaveLength(2);
expect(monsterRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
}),
expect.objectContaining({
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
}),
]),
);
expect(locationMonsterRepository.upsert).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: ASH_RAT_MONSTER_ID,
weight: 70,
}),
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: ROAD_BANDIT_MONSTER_ID,
weight: 30,
}),
]),
['locationId', 'monsterId'],
);
expect(locationMonsterRepository.rows).toHaveLength(2);
});
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 monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const persistedSouthGateId = '40000000-0000-4000-8000-000000000001';
locationRepository.rows.push({
id: persistedSouthGateId,
key: 'south-gate',
name: 'Veraltetes Südtor',
});
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
);
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,
}),
]);
});
it('seeds the tier-1 items and both loot tables idempotently and wires them to the monsters', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const itemRepository = new InMemoryRepository();
const lootTableRepository = new InMemoryRepository();
const lootEntryRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
itemRepository,
lootTableRepository,
lootEntryRepository,
);
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(itemRepository.rows).toHaveLength(12);
expect(itemRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'bandit-blade',
name: 'Räuberklinge',
type: 'WEAPON',
equipmentSlot: 'WEAPON',
rarity: 'COMMON',
weaponDamage: 11,
bonusAttack: 1,
sellPrice: 0,
iconPath: '/images/items/bandit-blade.png',
}),
expect.objectContaining({
key: 'ash-pelt',
name: 'Aschenfell',
type: 'MATERIAL',
equipmentSlot: null,
}),
]),
);
expect(lootTableRepository.rows).toHaveLength(2);
expect(lootEntryRepository.rows).toHaveLength(5);
expect(lootEntryRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
lootTableId: ASH_RAT_LOOT_TABLE_ID,
itemDefinitionId: ITEM_IDS['ash-pelt'],
position: 1,
dropChance: '0.6000',
}),
expect.objectContaining({
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
itemDefinitionId: ITEM_IDS['bandit-blade'],
position: 1,
dropChance: '0.1800',
}),
]),
);
// The Kleiner Heiltrank entry is deliberately deferred (spec §16).
expect(
lootEntryRepository.rows.some(
(row) => row.itemDefinitionId === ITEM_IDS['small-healing-potion'],
),
).toBe(false);
expect(monsterRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'ash-rat', lootTableId: ASH_RAT_LOOT_TABLE_ID }),
expect.objectContaining({ key: 'road-bandit', lootTableId: ROAD_BANDIT_LOOT_TABLE_ID }),
]),
);
});
});

View File

@@ -0,0 +1,208 @@
import { DataSource } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
import { Character } from '../../characters/entities/character.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { LootTable } from '../../loot/entities/loot-table.entity';
import { EncounterType } from '../../monsters/entities/encounter-type.enum';
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import {
ASH_RAT_LOOT_TABLE_ID,
ROAD_BANDIT_LOOT_TABLE_ID,
} from './item.constants';
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
import {
ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID,
ROAD_BANDIT_MONSTER_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 monsterRepository = dataSource.getRepository(MonsterDefinition);
const locationMonsterRepository = dataSource.getRepository(LocationMonster);
const itemRepository = dataSource.getRepository(ItemDefinition);
const lootTableRepository = dataSource.getRepository(LootTable);
const lootEntryRepository = dataSource.getRepository(LootTableEntry);
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'],
);
// Content is upserted by its stable key so re-running never duplicates rows
// and never touches player-owned character_items or combat_rewards.
await itemRepository.upsert(ITEM_DEFINITIONS, ['key']);
await lootTableRepository.upsert(LOOT_TABLES, ['key']);
await lootEntryRepository.upsert(LOOT_TABLE_ENTRIES, [
'lootTableId',
'itemDefinitionId',
]);
const monsters = [
{
id: ASH_RAT_MONSTER_ID,
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
lootTableId: ASH_RAT_LOOT_TABLE_ID,
},
{
id: ROAD_BANDIT_MONSTER_ID,
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
},
];
let ashRatId = ASH_RAT_MONSTER_ID;
let roadBanditId = ROAD_BANDIT_MONSTER_ID;
for (const monster of monsters) {
const existingMonster = await monsterRepository.findOneBy({
key: monster.key,
});
const { id, key, ...definition } = monster;
const persistedId = existingMonster?.id ?? id;
if (existingMonster) {
await monsterRepository.update(existingMonster.id, definition);
} else {
await monsterRepository.insert(monster);
}
if (key === 'ash-rat') {
ashRatId = persistedId;
} else {
roadBanditId = persistedId;
}
}
await locationMonsterRepository.upsert(
[
{
locationId: burnedRoadId,
monsterId: ashRatId,
weight: 70,
encounterType: EncounterType.NORMAL,
enabled: true,
},
{
locationId: burnedRoadId,
monsterId: roadBanditId,
weight: 30,
encounterType: EncounterType.NORMAL,
enabled: true,
},
],
['locationId', 'monsterId'],
);
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,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: southGateId,
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
import { calculateDangerRating, DangerRating } from './danger-rating';
// Demo character seed stats (Task 3): baseAttack: 6, baseHp: 100, no armor.
// power(character) = 6*4 + 0*2 + floor(100/5) = 24 + 0 + 20 = 44
const CHARACTER = { attack: 6, armor: 0, hp: 100 };
const CHARACTER_POWER = 44;
describe('calculateDangerRating', () => {
it('rates the seeded Aschenratte as MATCH', () => {
// Aschenratte: attack 5, armor 0, maxHp 45
// power(monster) = 5*4 + 0*2 + floor(45/5) = 20 + 0 + 9 = 29
// ratio = 29 / 44 = 0.6590909... -> not < 0.65, and < 1.0 -> MATCH
const monster = { attack: 5, armor: 0, hp: 45 };
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.MATCH);
});
it('rates the seeded Straßenräuber as STRONG', () => {
// Straßenräuber: attack 9, armor 5, maxHp 75
// power(monster) = 9*4 + 5*2 + floor(75/5) = 36 + 10 + 15 = 61
// ratio = 61 / 44 = 1.3863636... -> not < 1.0, and < 1.7 -> STRONG
const monster = { attack: 9, armor: 5, hp: 75 };
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.STRONG);
});
it('rates a monster just below the WEAK/MATCH boundary as WEAK', () => {
// Hand-picked monster: attack 4, armor 4, hp 20
// power(monster) = 4*4 + 4*2 + floor(20/5) = 16 + 8 + 4 = 28
// ratio = 28 / 44 = 0.6363636...
// WEAK/MATCH boundary is ratio < 0.65, i.e. power < 0.65 * 44 = 28.6.
// 28 is the largest integer power below that boundary -> WEAK.
// (One power higher, 29, is the Aschenratte case above, which is MATCH.)
const monster = { attack: 4, armor: 4, hp: 20 };
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.WEAK);
});
it('rates a monster just at the STRONG/VERY_DANGEROUS boundary as VERY_DANGEROUS', () => {
// Hand-picked monster: attack 15, armor 5, hp 25
// power(monster) = 15*4 + 5*2 + floor(25/5) = 60 + 10 + 5 = 75
// ratio = 75 / 44 = 1.7045454...
// STRONG/VERY_DANGEROUS boundary is ratio < 1.7, i.e. power < 1.7 * 44 = 74.8.
// 75 is the smallest integer power at/above that boundary -> VERY_DANGEROUS.
// (One power lower, 74, gives ratio 74/44 = 1.6818..., which is STRONG.)
const monster = { attack: 15, armor: 5, hp: 25 };
expect(calculateDangerRating(CHARACTER, monster)).toBe(
DangerRating.VERY_DANGEROUS,
);
});
it('rates a monster just at the VERY_DANGEROUS/DEADLY boundary as DEADLY', () => {
// Hand-picked monster: attack 20, armor 10, hp 10
// power(monster) = 20*4 + 10*2 + floor(10/5) = 80 + 20 + 2 = 102
// ratio = 102 / 44 = 2.3181818...
// VERY_DANGEROUS/DEADLY boundary is ratio < 2.3, i.e. power < 2.3 * 44 = 101.2.
// 102 is the smallest integer power at/above that boundary -> DEADLY.
// (One power lower, 101, gives ratio 101/44 = 2.2954..., which is VERY_DANGEROUS.)
const monster = { attack: 20, armor: 10, hp: 10 };
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.DEADLY);
});
});
// Sanity check that CHARACTER_POWER documented above matches the formula.
describe('CHARACTER_POWER sanity check', () => {
it('matches power(CHARACTER) as computed by the same formula', () => {
const power =
CHARACTER.attack * 4 + CHARACTER.armor * 2 + Math.floor(CHARACTER.hp / 5);
expect(power).toBe(CHARACTER_POWER);
});
});

View File

@@ -0,0 +1,31 @@
export enum DangerRating {
WEAK = 'WEAK',
MATCH = 'MATCH',
STRONG = 'STRONG',
VERY_DANGEROUS = 'VERY_DANGEROUS',
DEADLY = 'DEADLY',
}
export interface CombatantStats {
attack: number;
armor: number;
hp: number;
}
// power(entity) = attack*4 + armor*2 + floor(hp/5) — a deliberately small,
// provisional server-side stand-in for a future CombatPower system (none
// exists yet in this codebase). ratio = monsterPower / characterPower.
export function calculateDangerRating(
character: CombatantStats,
monster: CombatantStats,
): DangerRating {
const power = (stats: CombatantStats) =>
stats.attack * 4 + stats.armor * 2 + Math.floor(stats.hp / 5);
const ratio = power(monster) / power(character);
if (ratio < 0.65) return DangerRating.WEAK;
if (ratio < 1.0) return DangerRating.MATCH;
if (ratio < 1.7) return DangerRating.STRONG;
if (ratio < 2.3) return DangerRating.VERY_DANGEROUS;
return DangerRating.DEADLY;
}

View File

@@ -0,0 +1,51 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { HuntEncounterStatus } from '../hunt-encounter-status.enum';
import { Hunt } from './hunt.entity';
@Entity({ name: 'hunt_encounters' })
export class HuntEncounter {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'hunt_id', type: 'uuid' })
huntId!: string;
@Column({ name: 'monster_definition_id', type: 'uuid' })
monsterDefinitionId!: string;
@Column({ name: 'position', type: 'integer' })
position!: number;
// Owned by the combat module, which advances it as fights start and end.
// DEFEATED and IN_PROGRESS both bar a new fight; a lost fight resets the
// encounter to AVAILABLE so the player can try again.
@Column({
name: 'status',
type: 'enum',
enum: HuntEncounterStatus,
enumName: 'hunt_encounter_status_enum',
})
status!: HuntEncounterStatus;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
// CASCADE (unlike the other FKs in this file, which use RESTRICT): a
// HuntEncounter is owned/composed by its parent Hunt and has no
// independent lifecycle, so it should be removed along with its Hunt.
@ManyToOne(() => Hunt, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'hunt_id' })
hunt!: Hunt;
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'monster_definition_id' })
monster!: MonsterDefinition;
}

View File

@@ -0,0 +1,42 @@
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 { HuntStatus } from '../hunt-status.enum';
@Entity({ name: 'hunts' })
export class Hunt {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'location_id', type: 'uuid' })
locationId!: string;
@Column({
name: 'status',
type: 'enum',
enum: HuntStatus,
enumName: 'hunt_status_enum',
})
status!: HuntStatus;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'location_id' })
location!: LocationDefinition;
}

View File

@@ -0,0 +1,5 @@
export enum HuntEncounterStatus {
AVAILABLE = 'AVAILABLE',
IN_PROGRESS = 'IN_PROGRESS',
DEFEATED = 'DEFEATED',
}

View File

@@ -0,0 +1,4 @@
export enum HuntStatus {
ACTIVE = 'ACTIVE',
SUPERSEDED = 'SUPERSEDED',
}

View File

@@ -0,0 +1,90 @@
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 { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { HuntingController } from './hunting.controller';
import { HuntingService } from './hunting.service';
describe('HuntingController', () => {
let app: INestApplication<App>;
const startHunt = jest.fn();
const getActiveHunt = jest.fn();
beforeEach(async () => {
startHunt.mockReset();
getActiveHunt.mockReset();
const module = await Test.createTestingModule({
controllers: [HuntingController],
providers: [
{
provide: HuntingService,
useValue: { startHunt, getActiveHunt },
},
],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates to huntingService.startHunt with the demo character id and returns its result', async () => {
const huntResult = {
id: 'hunt-1',
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' },
encounters: [],
};
startHunt.mockResolvedValue(huntResult);
const response = await request(app.getHttpServer())
.post('/api/hunts')
.expect(201);
expect(startHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(huntResult);
});
it('serves the resumable hunt with its encounter statuses', async () => {
const huntResult = {
id: 'hunt-1',
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' },
encounters: [
{
id: 'encounter-1',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/monsters/ash-rat.png',
},
dangerRating: 'WEAK',
status: 'DEFEATED',
},
],
};
getActiveHunt.mockResolvedValue(huntResult);
const response = await request(app.getHttpServer())
.get('/api/hunts/active')
.expect(200);
expect(getActiveHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(huntResult);
});
it('serves an empty body when there is no resumable hunt', async () => {
getActiveHunt.mockResolvedValue(null);
const response = await request(app.getHttpServer())
.get('/api/hunts/active')
.expect(200);
expect(response.body).toEqual({});
});
});

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { HuntResultDto, HuntingService } from './hunting.service';
@Controller('hunts')
export class HuntingController {
constructor(private readonly huntingService: HuntingService) {}
@Post()
startHunt(): Promise<HuntResultDto> {
return this.huntingService.startHunt(DEMO_CHARACTER_ID);
}
@Get('active')
getActiveHunt(): Promise<HuntResultDto | null> {
return this.huntingService.getActiveHunt(DEMO_CHARACTER_ID);
}
}

View File

@@ -0,0 +1,37 @@
import { HttpException } from '@nestjs/common';
export class HuntingDomainError extends HttpException {
constructor(
public readonly code: string,
status: number,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function huntingNotAvailable(): HuntingDomainError {
return new HuntingDomainError(
'HUNTING_NOT_AVAILABLE',
400,
'Hunting is not available at the current location.',
);
}
export function characterTravelling(): HuntingDomainError {
return new HuntingDomainError(
'CHARACTER_TRAVELLING',
409,
'The character cannot hunt while travelling.',
);
}
export function noHuntEncountersAvailable(): HuntingDomainError {
return new HuntingDomainError(
'NO_HUNT_ENCOUNTERS_AVAILABLE',
409,
'No encounters are currently available at this location.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelModule } from '../travel/travel.module';
import { Hunt } from './entities/hunt.entity';
import { HuntEncounter } from './entities/hunt-encounter.entity';
import { HuntingController } from './hunting.controller';
import { HuntingService } from './hunting.service';
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
@Module({
imports: [
TypeOrmModule.forFeature([
MonsterDefinition,
LocationMonster,
Hunt,
HuntEncounter,
Character,
]),
TravelModule,
],
controllers: [HuntingController],
providers: [
HuntingService,
{ provide: RANDOM_SOURCE, useValue: systemRandomSource },
],
})
export class HuntingModule {}

View File

@@ -0,0 +1,726 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { EncounterType } from '../monsters/entities/encounter-type.enum';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum';
import { LocationDefinition } from '../world/entities/location-definition.entity';
import { DangerRating } from './danger-rating';
import { Hunt } from './entities/hunt.entity';
import { HuntEncounter } from './entities/hunt-encounter.entity';
import { HuntEncounterStatus } from './hunt-encounter-status.enum';
import { HuntStatus } from './hunt-status.enum';
import { HuntingDomainError } from './hunting.errors';
import { HuntingService } from './hunting.service';
import type { RandomSource } from '../shared/random-source';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002';
const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Aschenratte
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Strassenraeuber
const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001';
const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002';
interface FakeState {
characters: Character[];
locationMonsters: LocationMonster[];
hunts: Hunt[];
huntEncounters: HuntEncounter[];
}
// `find` in the fake ignores `relations`, so fixtures attach the joined
// monster the way TypeORM would have hydrated it.
function withMonster(
encounter: HuntEncounter,
monster: MonsterDefinition,
): HuntEncounter {
return { ...encounter, monster } as HuntEncounter;
}
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,
);
}
find(options: {
where: Partial<T>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> {
const matched = this.rows().filter((row) =>
this.matches(row, options.where),
);
const orderKey = options.order
? (Object.keys(options.order)[0] as keyof T)
: undefined;
if (orderKey) {
const direction = options.order![orderKey] === 'DESC' ? -1 : 1;
matched.sort((a, b) => {
if (a[orderKey] === b[orderKey]) return 0;
return a[orderKey] > b[orderKey] ? direction : -direction;
});
}
return Promise.resolve(matched);
}
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 = this.dataSource.nextId(this.targetName());
}
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);
}
update(where: Partial<T>, partial: Partial<T>): Promise<void> {
for (const row of this.rows()) {
if (this.matches(row, where)) {
Object.assign(row, partial);
}
}
return Promise.resolve();
}
private rows(): T[] {
if (this.target === Character) {
return this.state.characters as T[];
}
if (this.target === LocationMonster) {
return this.state.locationMonsters as T[];
}
if (this.target === Hunt) {
return this.state.hunts as T[];
}
if (this.target === HuntEncounter) {
return this.state.huntEncounters 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>;
private readonly idCounters = new Map<string, number>();
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;
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function huntingLocation(): LocationDefinition {
return {
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
description: 'A burned road.',
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: [],
};
}
function safeLocation(): LocationDefinition {
return {
id: SAFE_LOCATION_ID,
key: 'south-gate',
name: 'Suedtor von Graufurt',
description: 'A safe gate.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 2,
dangerLevel: 1,
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 character(currentLocation: LocationDefinition): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: currentLocation.id,
currentLocation,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
};
}
function monsterDefinition(
id: string,
key: string,
name: string,
overrides: Partial<MonsterDefinition> = {},
): MonsterDefinition {
return {
id,
key,
name,
level: 1,
maxHp: 20,
attack: 3,
armor: 0,
experienceReward: 10,
silverMin: 1,
silverMax: 3,
artworkPath: `/assets/monsters/${key}.webp`,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
};
}
function locationMonster(
id: string,
locationId: string,
monster: MonsterDefinition,
weight: number,
enabled = true,
): LocationMonster {
return {
id,
locationId,
monsterId: monster.id,
weight,
encounterType: EncounterType.NORMAL,
enabled,
monster,
} as LocationMonster;
}
function createState(): FakeState {
return {
characters: [character(safeLocation())],
locationMonsters: [],
hunts: [],
huntEncounters: [],
};
}
function fakeRandomSource(values: number[]): RandomSource {
const queue = [...values];
return {
next: () => {
const value = queue.shift();
if (value === undefined) {
throw new Error('fakeRandomSource exhausted its canned values');
}
return value;
},
};
}
function fakeTravelService(
overrides: Partial<TravelService> = {},
): TravelService {
return {
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
...overrides,
} as unknown as TravelService;
}
function createService(
options: {
state?: FakeState;
travelService?: TravelService;
randomSource?: RandomSource;
} = {},
) {
const state = options.state ?? createState();
const dataSource = new FakeDataSource(state);
const travelService = options.travelService ?? fakeTravelService();
const randomSource = options.randomSource ?? fakeRandomSource([]);
const service = new HuntingService(
dataSource as unknown as DataSource,
travelService,
randomSource,
);
return { dataSource, service, travelService, randomSource };
}
async function expectHuntingDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(HuntingDomainError);
if (!(error instanceof HuntingDomainError)) {
throw new Error('Expected HuntingDomainError');
}
expect(error.code).toBe(code);
}
describe('HuntingService', () => {
it('rejects hunting at a location where hunting is disabled', async () => {
const { service } = createService();
await expectHuntingDomainError(
service.startHunt(CHARACTER_ID),
'HUNTING_NOT_AVAILABLE',
);
});
it('starts a valid hunt with exactly three saved encounters', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Straßenräuber',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
];
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters).toHaveLength(3);
const encounterIds = new Set(result.encounters.map((e) => e.id));
expect(encounterIds.size).toBe(3);
expect(result.location).toEqual({
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
});
expect(dataSource.state.hunts).toHaveLength(1);
expect(dataSource.state.hunts[0]).toMatchObject({
characterId: CHARACTER_ID,
locationId: HUNTING_LOCATION_ID,
status: HuntStatus.ACTIVE,
});
expect(dataSource.state.huntEncounters).toHaveLength(3);
expect(dataSource.locks).toEqual([
{ target: Character, mode: 'pessimistic_write' },
]);
});
it('rejects hunting while the character is still travelling', async () => {
const travelService = fakeTravelService({
completeTravelIfDue: jest.fn().mockResolvedValue({
status: TravelStatus.TRAVELLING,
originLocation: { id: SAFE_LOCATION_ID, key: 'south-gate', name: 'x' },
targetLocation: {
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'y',
},
startedAt: new Date(),
arrivesAt: new Date(),
}) as unknown as TravelService['completeTravelIfDue'],
});
const { service } = createService({ travelService });
await expectHuntingDomainError(
service.startHunt(CHARACTER_ID),
'CHARACTER_TRAVELLING',
);
});
it('rejects hunting when the location has no enabled encounter pool', async () => {
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [];
const { service } = createService({ state });
await expectHuntingDomainError(
service.startHunt(CHARACTER_ID),
'NO_HUNT_ENCOUNTERS_AVAILABLE',
);
});
it('picks monsters deterministically from canned RandomSource rolls', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Straßenräuber',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
];
// 0.1*100=10 < 70 -> A ; 0.9*100=90 >= 70 -> B ; 0.1*100=10 < 70 -> A
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters.map((e) => e.monster.key)).toEqual([
'aschenratte',
'strassenraeuber',
'aschenratte',
]);
const persisted = [...dataSource.state.huntEncounters].sort(
(a, b) => a.position - b.position,
);
expect(persisted.map((e) => e.monsterDefinitionId)).toEqual([
MONSTER_A_ID,
MONSTER_B_ID,
MONSTER_A_ID,
]);
});
it('supersedes the previous active hunt when a new hunt is started', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
monsterA,
100,
),
];
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1, 0.1, 0.1, 0.1]),
});
await service.startHunt(CHARACTER_ID);
await service.startHunt(CHARACTER_ID);
expect(dataSource.state.hunts).toHaveLength(2);
const [firstHunt, secondHunt] = dataSource.state.hunts;
expect(firstHunt.status).toBe(HuntStatus.SUPERSEDED);
expect(secondHunt.status).toBe(HuntStatus.ACTIVE);
expect(firstHunt.id).not.toBe(secondHunt.id);
expect(dataSource.locks).toEqual([
{ target: Character, mode: 'pessimistic_write' },
{ target: Character, mode: 'pessimistic_write' },
]);
});
it('gives each encounter its own id matching the monster rolled for that slot', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Straßenräuber',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
];
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
});
await service.startHunt(CHARACTER_ID);
const encounters = [...dataSource.state.huntEncounters].sort(
(a, b) => a.position - b.position,
);
const ids = encounters.map((e) => e.id);
expect(new Set(ids).size).toBe(3);
expect(encounters[0].monsterDefinitionId).toBe(MONSTER_A_ID);
expect(encounters[1].monsterDefinitionId).toBe(MONSTER_B_ID);
expect(encounters[2].monsterDefinitionId).toBe(MONSTER_A_ID);
});
it('computes a danger rating per encounter from the real monster stats', async () => {
const weakMonster = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
{
attack: 1,
armor: 0,
maxHp: 5,
},
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.characters[0].baseAttack = 6;
state.characters[0].baseHp = 100;
state.locationMonsters = [
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
weakMonster,
100,
),
];
const { service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
for (const encounter of result.encounters) {
expect(encounter.dangerRating).toBe(DangerRating.WEAK);
}
});
it('marks every freshly rolled encounter as AVAILABLE', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
monsterA,
100,
),
];
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters.map((encounter) => encounter.status)).toEqual([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
]);
expect(
dataSource.state.huntEncounters.map((encounter) => encounter.status),
).toEqual([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
]);
});
describe('getActiveHunt', () => {
function activeHuntState(
statuses: HuntEncounterStatus[],
overrides: { huntStatus?: HuntStatus; huntLocationId?: string } = {},
) {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.hunts = [
{
id: 'hunt-1',
characterId: CHARACTER_ID,
locationId: overrides.huntLocationId ?? HUNTING_LOCATION_ID,
status: overrides.huntStatus ?? HuntStatus.ACTIVE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
} as Hunt,
];
state.huntEncounters = statuses.map((status, position) =>
withMonster(
{
id: `encounter-${position}`,
huntId: 'hunt-1',
monsterDefinitionId: MONSTER_A_ID,
position,
status,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
} as HuntEncounter,
monsterA,
),
);
return state;
}
it('returns null when the character has no active hunt', async () => {
const { service } = createService();
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
});
it('returns null when the only hunt has been superseded', async () => {
const state = activeHuntState([HuntEncounterStatus.AVAILABLE], {
huntStatus: HuntStatus.SUPERSEDED,
});
const { service } = createService({ state });
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
});
it('returns the active hunt with the persisted status of each encounter', async () => {
const state = activeHuntState([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.DEFEATED,
HuntEncounterStatus.IN_PROGRESS,
]);
const { service } = createService({ state });
const result = await service.getActiveHunt(CHARACTER_ID);
expect(result?.id).toBe('hunt-1');
expect(result?.location).toEqual({
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
});
expect(result?.encounters.map((encounter) => encounter.status)).toEqual([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.DEFEATED,
HuntEncounterStatus.IN_PROGRESS,
]);
expect(result?.encounters.map((encounter) => encounter.id)).toEqual([
'encounter-0',
'encounter-1',
'encounter-2',
]);
expect(result?.encounters[0].monster.key).toBe('aschenratte');
expect(result?.encounters[0].dangerRating).toBeDefined();
});
it('returns null once the character has left the hunt location', async () => {
const state = activeHuntState([HuntEncounterStatus.AVAILABLE], {
huntLocationId: SAFE_LOCATION_ID,
});
const { service } = createService({ state });
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
});
});
});

View File

@@ -0,0 +1,249 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import type { LocationSummary } from '../travel/travel.service';
import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum';
import { calculateDangerRating, DangerRating } from './danger-rating';
import { Hunt } from './entities/hunt.entity';
import { HuntEncounter } from './entities/hunt-encounter.entity';
import { HuntEncounterStatus } from './hunt-encounter-status.enum';
import { HuntStatus } from './hunt-status.enum';
import {
characterNotFound,
characterTravelling,
huntingNotAvailable,
noHuntEncountersAvailable,
} from './hunting.errors';
import { RANDOM_SOURCE } from '../shared/random-source';
import type { RandomSource } from '../shared/random-source';
export interface MonsterSummary {
key: string;
name: string;
level: number;
artworkPath: string;
}
export interface HuntEncounterDto {
id: string;
monster: MonsterSummary;
dangerRating: DangerRating;
status: HuntEncounterStatus;
}
export interface HuntResultDto {
id: string;
location: LocationSummary;
encounters: HuntEncounterDto[];
}
const ENCOUNTER_COUNT = 3;
@Injectable()
export class HuntingService {
constructor(
private readonly dataSource: DataSource,
private readonly travelService: TravelService,
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
) {}
async startHunt(characterId: string): Promise<HuntResultDto> {
const travel = await this.travelService.completeTravelIfDue(characterId);
if (travel.status === TravelStatus.TRAVELLING) {
throw characterTravelling();
}
const characters = this.dataSource.getRepository(Character);
const character = await characters.findOne({
where: { id: characterId },
relations: { currentLocation: true },
});
if (!character) {
// completeTravelIfDue already validated the character exists; this
// guard only protects against a pathological race and satisfies the
// type checker (currentLocation would otherwise be possibly undefined).
throw characterNotFound();
}
if (!character.currentLocation.huntingEnabled) {
throw huntingNotAvailable();
}
const locationMonsters = this.dataSource.getRepository(LocationMonster);
const pool = await locationMonsters.find({
where: { locationId: character.currentLocationId, enabled: true },
relations: { monster: true },
});
if (pool.length === 0) {
throw noHuntEncountersAvailable();
}
return this.dataSource.transaction(async (manager) => {
const txCharacters = manager.getRepository(Character);
const txHunts = manager.getRepository(Hunt);
const txEncounters = manager.getRepository(HuntEncounter);
const lockedCharacter = await this.lockCharacter(
txCharacters,
characterId,
);
await txHunts.update(
{ characterId, status: HuntStatus.ACTIVE },
{ status: HuntStatus.SUPERSEDED },
);
const hunt = txHunts.create({
characterId,
locationId: lockedCharacter.currentLocationId,
status: HuntStatus.ACTIVE,
});
await txHunts.save(hunt);
const pickedMonsters = this.rollEncounters(pool, ENCOUNTER_COUNT);
const encounterDtos: HuntEncounterDto[] = [];
for (let position = 0; position < pickedMonsters.length; position += 1) {
const monster = pickedMonsters[position];
const encounter = txEncounters.create({
huntId: hunt.id,
monsterDefinitionId: monster.id,
position,
status: HuntEncounterStatus.AVAILABLE,
});
await txEncounters.save(encounter);
encounterDtos.push(this.toEncounterDto(encounter, monster, character));
}
return {
id: hunt.id,
location: this.toLocationSummary(character.currentLocation),
encounters: encounterDtos,
};
});
}
/**
* The hunt the player can still act on, or null if there is none. A hunt
* is only resumable where it was rolled, so travelling away retires it and
* the player has to search the new area instead.
*/
async getActiveHunt(characterId: string): Promise<HuntResultDto | null> {
const characters = this.dataSource.getRepository(Character);
const character = await characters.findOne({
where: { id: characterId },
relations: { currentLocation: true },
});
if (!character) {
throw characterNotFound();
}
const hunts = this.dataSource.getRepository(Hunt);
const hunt = await hunts.findOne({
where: {
characterId,
status: HuntStatus.ACTIVE,
locationId: character.currentLocationId,
},
});
if (!hunt) {
return null;
}
const huntEncounters = this.dataSource.getRepository(HuntEncounter);
const encounters = await huntEncounters.find({
where: { huntId: hunt.id },
relations: { monster: true },
order: { position: 'ASC' },
});
return {
id: hunt.id,
location: this.toLocationSummary(character.currentLocation),
encounters: encounters.map((encounter) =>
this.toEncounterDto(encounter, encounter.monster, character),
),
};
}
private toEncounterDto(
encounter: HuntEncounter,
monster: MonsterDefinition,
character: Character,
): HuntEncounterDto {
return {
id: encounter.id,
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
artworkPath: monster.artworkPath,
},
dangerRating: calculateDangerRating(
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
{ attack: monster.attack, armor: monster.armor, hp: monster.maxHp },
),
status: encounter.status,
};
}
/**
* Rolls `count` independent weighted picks from `pool`. Each slot walks
* the pool in the order it was supplied, accumulating weight, and picks
* the first entry whose cumulative weight exceeds the roll
* (roll < cumulative). Pure and deterministic given a RandomSource, so
* it is trivially unit-testable with canned `next()` values.
*/
private rollEncounters(
pool: LocationMonster[],
count: number,
): MonsterDefinition[] {
const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0);
const picks: MonsterDefinition[] = [];
for (let i = 0; i < count; i += 1) {
const roll = this.randomSource.next() * totalWeight;
let cumulative = 0;
let picked: LocationMonster = pool[pool.length - 1];
for (const entry of pool) {
cumulative += entry.weight;
if (roll < cumulative) {
picked = entry;
break;
}
}
picks.push(picked.monster);
}
return picks;
}
private async lockCharacter(
characters: Repository<Character>,
characterId: string,
): Promise<Character> {
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
// The pre-transaction load above already confirmed the character
// exists; a miss here would only occur under a pathological
// concurrent deletion, which the schema's RESTRICT FKs prevent.
throw characterNotFound();
}
return character;
}
private toLocationSummary(
location: Character['currentLocation'],
): LocationSummary {
return {
id: location.id,
key: location.key,
name: location.name,
};
}
}

View File

@@ -0,0 +1,51 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { ItemDefinition } from './item-definition.entity';
/**
* One stack of one item definition owned by one character.
*
* Duplicate drops increment `quantity` (spec §28 allows duplicates and forbids
* duplicate protection). Slice 0.5 equips a `CharacterItem.id`, never an
* `ItemDefinition.id`.
*/
@Entity({ name: 'character_items' })
@Index('IDX_character_items_character_item', ['characterId', 'itemDefinitionId'], {
unique: true,
})
export class CharacterItem {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'item_definition_id', type: 'uuid' })
itemDefinitionId!: string;
@Column({ name: 'quantity', type: 'integer' })
quantity!: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'item_definition_id' })
itemDefinition!: ItemDefinition;
}

View File

@@ -0,0 +1,74 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { EquipmentSlot } from '../equipment-slot.enum';
import { ItemRarity } from '../item-rarity.enum';
import { ItemType } from '../item-type.enum';
@Entity({ name: 'item_definitions' })
@Index('IDX_item_definitions_key', ['key'], { unique: true })
export class ItemDefinition {
@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: 'type', type: 'enum', enum: ItemType, enumName: 'item_type_enum' })
type!: ItemType;
@Column({
name: 'equipment_slot',
type: 'enum',
enum: EquipmentSlot,
enumName: 'equipment_slot_enum',
nullable: true,
})
equipmentSlot!: EquipmentSlot | null;
@Column({ name: 'rarity', type: 'enum', enum: ItemRarity, enumName: 'item_rarity_enum' })
rarity!: ItemRarity;
@Column({ name: 'tier', type: 'integer' })
tier!: number;
@Column({ name: 'required_level', type: 'integer' })
requiredLevel!: number;
@Column({ name: 'weapon_damage', type: 'integer' })
weaponDamage!: number;
@Column({ name: 'bonus_hp', type: 'integer' })
bonusHp!: number;
@Column({ name: 'bonus_attack', type: 'integer' })
bonusAttack!: number;
@Column({ name: 'bonus_armor', type: 'integer' })
bonusArmor!: number;
// Always 0 in Slice 0.4: there are no merchants, and the balancing doc's
// Grenzmarken table lists purchase prices, not sell prices.
@Column({ name: 'sell_price', type: 'integer' })
sellPrice!: number;
@Column({ name: 'icon_path', type: 'varchar', length: 255 })
iconPath!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,10 @@
// Slice 0.4 only stores the slot as content data. Slice 0.5 makes it functional.
export enum EquipmentSlot {
WEAPON = 'WEAPON',
HEAD = 'HEAD',
CHEST = 'CHEST',
HANDS = 'HANDS',
LEGS = 'LEGS',
FEET = 'FEET',
AMULET = 'AMULET',
}

View File

@@ -0,0 +1,7 @@
// Mirrors docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19:
// Gewöhnlich / Selten / Episch. German labels live in the frontend.
export enum ItemRarity {
COMMON = 'COMMON',
RARE = 'RARE',
EPIC = 'EPIC',
}

View File

@@ -0,0 +1,6 @@
export enum ItemType {
WEAPON = 'WEAPON',
ARMOR = 'ARMOR',
MATERIAL = 'MATERIAL',
CONSUMABLE = 'CONSUMABLE',
}

View File

@@ -0,0 +1,63 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTable } from './loot-table.entity';
/**
* One independently rolled drop (spec §17).
*
* `position` fixes the roll order so injected randoms are predictable in tests;
* it is content ordering, not priority. A guaranteed drop is simply
* `dropChance = 1.0000` — no extra mechanism needed (spec §14).
*/
@Entity({ name: 'loot_table_entries' })
@Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], { unique: true })
@Index('IDX_loot_table_entries_table_item', ['lootTableId', 'itemDefinitionId'], { unique: true })
export class LootTableEntry {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'loot_table_id', type: 'uuid' })
lootTableId!: string;
@Column({ name: 'item_definition_id', type: 'uuid' })
itemDefinitionId!: string;
@Column({ name: 'position', type: 'integer' })
position!: number;
// PostgreSQL numeric arrives as a string, like LocationConnection.ambushChance.
@Column({ name: 'drop_chance', type: 'numeric', precision: 5, scale: 4 })
dropChance!: string;
@Column({ name: 'min_quantity', type: 'integer' })
minQuantity!: number;
@Column({ name: 'max_quantity', type: 'integer' })
maxQuantity!: number;
@Column({ name: 'enabled', type: 'boolean' })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => LootTable, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'loot_table_id' })
lootTable!: LootTable;
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'item_definition_id' })
itemDefinition!: ItemDefinition;
}

View File

@@ -0,0 +1,27 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'loot_tables' })
@Index('IDX_loot_tables_key', ['key'], { unique: true })
export class LootTable {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
import { LootTable } from './entities/loot-table.entity';
import { LootTableEntry } from './entities/loot-table-entry.entity';
import { LootService } from './loot.service';
@Module({
imports: [TypeOrmModule.forFeature([LootTable, LootTableEntry])],
providers: [LootService, { provide: RANDOM_SOURCE, useValue: systemRandomSource }],
exports: [LootService],
})
export class LootModule {}

View File

@@ -0,0 +1,128 @@
import { DataSource } from 'typeorm';
import type { RandomSource } from '../shared/random-source';
import { LootTableEntry } from './entities/loot-table-entry.entity';
import { LootService } from './loot.service';
const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001';
const ASH_PELT = '50000000-0000-4000-8000-00000000000c';
const WORN_SHORT_SWORD = '50000000-0000-4000-8000-000000000001';
function entry(overrides: Partial<LootTableEntry>): LootTableEntry {
return {
id: 'entry-1',
lootTableId: ASH_RAT_TABLE,
itemDefinitionId: ASH_PELT,
position: 1,
dropChance: '0.6000',
minQuantity: 1,
maxQuantity: 1,
enabled: true,
createdAt: new Date('2026-08-19T09:00:00.000Z'),
updatedAt: new Date('2026-08-19T09:00:00.000Z'),
...overrides,
} as LootTableEntry;
}
// Hands out the queued values in order, so a test states exactly which roll
// each value answers.
function queuedRandom(...values: number[]): RandomSource {
let index = 0;
return {
next: () => {
if (index >= values.length) {
throw new Error('LootService consumed more random values than the test queued');
}
return values[index++];
},
};
}
function dataSourceWith(entries: LootTableEntry[]): DataSource {
return {
getRepository: jest.fn(() => ({
find: jest.fn(async (options: { where: { lootTableId: string; enabled: boolean } }) =>
entries
.filter(
(candidate) =>
candidate.lootTableId === options.where.lootTableId &&
candidate.enabled === options.where.enabled,
)
.sort((a, b) => a.position - b.position),
),
})),
} as unknown as DataSource;
}
describe('LootService', () => {
const ashRatEntries = [
entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.6000' }),
entry({
id: 'entry-sword',
itemDefinitionId: WORN_SHORT_SWORD,
position: 2,
dropChance: '0.0800',
}),
];
it('drops an entry when the roll falls under its chance', async () => {
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.59, 0.07));
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [
{ itemDefinitionId: ASH_PELT, quantity: 1 },
{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 },
],
});
});
it('skips an entry when the roll lands on or above its chance', async () => {
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.6, 0.08));
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] });
});
it('rolls each entry independently, so one combat can drop only the second item', async () => {
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.9, 0.01));
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }],
});
});
it('rolls entries in position order so injected values stay predictable', async () => {
const outOfOrder = [
entry({ id: 'entry-sword', itemDefinitionId: WORN_SHORT_SWORD, position: 2, dropChance: '1.0000' }),
entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.0000' }),
];
const service = new LootService(dataSourceWith(outOfOrder), queuedRandom(0.5, 0.5));
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }],
});
});
it('ignores disabled entries', async () => {
const service = new LootService(
dataSourceWith([entry({ dropChance: '1.0000', enabled: false })]),
queuedRandom(),
);
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] });
});
it('consumes no quantity roll when min and max match, and one when they differ', async () => {
const stackable = [entry({ dropChance: '1.0000', minQuantity: 2, maxQuantity: 4 })];
// First value drops the entry, second picks the quantity (0.5 -> 3).
const service = new LootService(dataSourceWith(stackable), queuedRandom(0.1, 0.5));
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [{ itemDefinitionId: ASH_PELT, quantity: 3 }],
});
});
it('returns nothing for a monster without a loot table', async () => {
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom());
await expect(service.rollLoot(null)).resolves.toEqual({ items: [] });
});
});

View File

@@ -0,0 +1,66 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { RANDOM_SOURCE } from '../shared/random-source';
import type { RandomSource } from '../shared/random-source';
import { rollInclusive } from '../shared/roll-range';
import { LootTableEntry } from './entities/loot-table-entry.entity';
export interface LootRollItem {
itemDefinitionId: string;
quantity: number;
}
export interface LootRollResult {
items: LootRollItem[];
}
@Injectable()
export class LootService {
constructor(
private readonly dataSource: DataSource,
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
) {}
/**
* Rolls a loot table without persisting anything (spec §20).
*
* Every enabled entry is one independent roll in `position` order, so a
* single combat may yield nothing, one item, or several (spec §17). The
* quantity roll is skipped entirely when `minQuantity === maxQuantity`,
* which keeps the random sequence stable for the seeded content.
*/
async rollLoot(
lootTableId: string | null,
manager?: EntityManager,
): Promise<LootRollResult> {
if (!lootTableId) {
return { items: [] };
}
const entries = await (
manager?.getRepository(LootTableEntry) ??
this.dataSource.getRepository(LootTableEntry)
).find({
where: { lootTableId, enabled: true },
order: { position: 'ASC' },
});
const items: LootRollItem[] = [];
for (const entry of entries) {
if (this.randomSource.next() >= Number(entry.dropChance)) {
continue;
}
items.push({
itemDefinitionId: entry.itemDefinitionId,
quantity: rollInclusive(
this.randomSource,
entry.minQuantity,
entry.maxQuantity,
),
});
}
return { items };
}
}

View File

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

View File

@@ -0,0 +1,6 @@
export enum EncounterType {
NORMAL = 'NORMAL',
RARE = 'RARE',
ELITE = 'ELITE',
BOSS = 'BOSS',
}

View File

@@ -0,0 +1,45 @@
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { EncounterType } from './encounter-type.enum';
import { MonsterDefinition } from './monster-definition.entity';
@Entity({ name: 'location_monsters' })
export class LocationMonster {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'location_id', type: 'uuid' })
locationId!: string;
@Column({ name: 'monster_id', type: 'uuid' })
monsterId!: string;
@Column({ name: 'weight', type: 'integer' })
weight!: number;
@Column({
name: 'encounter_type',
type: 'enum',
enum: EncounterType,
enumName: 'location_monster_encounter_type_enum',
default: EncounterType.NORMAL,
})
encounterType!: EncounterType;
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'location_id' })
location!: LocationDefinition;
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'monster_id' })
monster!: MonsterDefinition;
}

View File

@@ -0,0 +1,61 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { LootTable } from '../../loot/entities/loot-table.entity';
@Entity({ name: 'monster_definitions' })
@Index('IDX_monster_definitions_key', ['key'], { unique: true })
export class MonsterDefinition {
@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: 'level', type: 'integer' })
level!: number;
@Column({ name: 'max_hp', type: 'integer' })
maxHp!: number;
@Column({ name: 'attack', type: 'integer' })
attack!: number;
@Column({ name: 'armor', type: 'integer' })
armor!: number;
@Column({ name: 'experience_reward', type: 'integer' })
experienceReward!: number;
@Column({ name: 'silver_min', type: 'integer' })
silverMin!: number;
@Column({ name: 'silver_max', type: 'integer' })
silverMax!: number;
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: string;
@Column({ name: 'loot_table_id', type: 'uuid', nullable: true })
lootTableId!: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => LootTable, { onDelete: 'RESTRICT', nullable: true })
@JoinColumn({ name: 'loot_table_id' })
lootTable!: LootTable | null;
}

View File

@@ -0,0 +1,445 @@
import { EntityManager, EntityTarget } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { LootService } from '../loot/loot.service';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import type { RandomSource } from '../shared/random-source';
import { CombatRewardService } from './combat-reward.service';
import { CombatReward } from './entities/combat-reward.entity';
import { CombatRewardItem } from './entities/combat-reward-item.entity';
import { RewardDomainError } from './rewards.errors';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const COMBAT_ID = '20000000-0000-4000-8000-000000000001';
const ASH_RAT_ID = '30000000-0000-4000-8000-000000000001';
const ROAD_BANDIT_ID = '30000000-0000-4000-8000-000000000002';
const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001';
const ROAD_BANDIT_TABLE = '60000000-0000-4000-8000-000000000002';
const BANDIT_BLADE = '50000000-0000-4000-8000-000000000002';
const BANDIT_HOOD = '50000000-0000-4000-8000-000000000001';
interface State {
characters: Character[];
monsters: MonsterDefinition[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
combatRewards: CombatReward[];
combatRewardItems: CombatRewardItem[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly rows: T[],
private readonly prefix: string,
) {}
findOne(options: { where: Partial<T> }): Promise<T | null> {
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);
}
find(options: {
where: Partial<T>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> {
const matched = this.rows.filter((row) => this.matches(row, options.where));
if (!options.order) {
return Promise.resolve(matched);
}
// Mirrors TypeORM's `order` clause so tests can prove ordering comes from
// the query, not from insertion order happening to line up.
const [key, direction] = Object.entries(options.order)[0] as [keyof T, 'ASC' | 'DESC'];
const sorted = [...matched].sort((a, b) => {
const left = a[key];
const right = b[key];
const comparison = left < right ? -1 : left > right ? 1 : 0;
return direction === 'DESC' ? -comparison : comparison;
});
return Promise.resolve(sorted);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = `${this.prefix}-${this.rows.length + 1}`;
}
const index = this.rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
this.rows.push(entity);
} else {
this.rows[index] = entity;
}
return Promise.resolve(entity);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
}
function fakeManager(state: State): EntityManager {
return {
getRepository: <T extends { id: string }>(target: EntityTarget<T>) => {
if (target === Character) return new FakeRepository(state.characters, 'character') as never;
if (target === MonsterDefinition) return new FakeRepository(state.monsters, 'monster') as never;
if (target === ItemDefinition)
return new FakeRepository(state.itemDefinitions, 'definition') as never;
if (target === CharacterItem)
return new FakeRepository(state.characterItems, 'character-item') as never;
if (target === CombatReward)
return new FakeRepository(state.combatRewards, 'reward') as never;
if (target === CombatRewardItem)
return new FakeRepository(state.combatRewardItems, 'reward-item') as never;
throw new Error('Unsupported repository');
},
} as unknown as EntityManager;
}
function combat(overrides: Partial<Combat> = {}): Combat {
return {
id: COMBAT_ID,
characterId: CHARACTER_ID,
monsterDefinitionId: ASH_RAT_ID,
status: CombatStatus.WON,
round: 4,
...overrides,
} as Combat;
}
function createState(overrides: Partial<State> = {}): State {
return {
characters: [{ id: CHARACTER_ID, experience: 12, silver: 3 } as Character],
monsters: [
{
id: ASH_RAT_ID,
key: 'ash-rat',
experienceReward: 8,
silverMin: 4,
silverMax: 7,
lootTableId: ASH_RAT_TABLE,
} as MonsterDefinition,
{
id: ROAD_BANDIT_ID,
key: 'road-bandit',
experienceReward: 16,
silverMin: 9,
silverMax: 15,
lootTableId: ROAD_BANDIT_TABLE,
} as MonsterDefinition,
],
itemDefinitions: [
{
id: BANDIT_BLADE,
key: 'bandit-blade',
name: 'Räuberklinge',
type: ItemType.WEAPON,
rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-blade.png',
} as ItemDefinition,
],
characterItems: [],
combatRewards: [],
combatRewardItems: [],
...overrides,
};
}
function fakeDataSource(state: State): EntityManager {
// `loadRewards`'s no-manager branch only calls `getRepository`, which
// `fakeManager` already implements identically for `DataSource`; reusing
// it (rather than duplicating the repository-resolution switch) keeps this
// fake backed by the exact same `state` rows as the transactional path.
return fakeManager(state);
}
function fakeLoot(...items: Array<{ itemDefinitionId: string; quantity: number }>): LootService {
return { rollLoot: jest.fn().mockResolvedValue({ items }) } as unknown as LootService;
}
function fixedRandom(value: number): RandomSource {
return { next: () => value };
}
function service(
state: State,
loot: LootService = fakeLoot(),
random: RandomSource = fixedRandom(0.5),
): CombatRewardService {
return new CombatRewardService({} as never, loot, random);
}
describe('CombatRewardService', () => {
describe('eligibility', () => {
it('rejects an ACTIVE combat', async () => {
const state = createState();
await expect(
service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.ACTIVE })),
).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' });
expect(state.combatRewards).toHaveLength(0);
expect(state.characters[0].experience).toBe(12);
expect(state.characters[0].silver).toBe(3);
});
it('rejects a LOST combat', async () => {
const state = createState();
await expect(
service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.LOST })),
).rejects.toBeInstanceOf(RewardDomainError);
expect(state.combatRewards).toHaveLength(0);
});
it('grants rewards for a WON combat', async () => {
const state = createState();
const reward = await service(state).grantVictoryRewards(fakeManager(state), combat());
expect(reward).toEqual({ experience: 8, silver: 6, items: [] });
expect(state.combatRewards).toHaveLength(1);
});
});
describe('Aschenratte', () => {
it('grants 8 XP and a silver roll inside 4-7, persisted on the character', async () => {
const state = createState();
const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards(
fakeManager(state),
combat(),
);
expect(reward.experience).toBe(8);
expect(reward.silver).toBe(4);
expect(state.characters[0].experience).toBe(20);
expect(state.characters[0].silver).toBe(7);
});
it('rolls the top of the silver range from the top of the random range', async () => {
const state = createState();
const reward = await service(state, fakeLoot(), fixedRandom(0.99)).grantVictoryRewards(
fakeManager(state),
combat(),
);
expect(reward.silver).toBe(7);
});
});
describe('Straßenräuber', () => {
const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID });
it('grants 16 XP and a silver roll inside 9-15', async () => {
const state = createState();
const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards(
fakeManager(state),
banditCombat,
);
expect(reward.experience).toBe(16);
expect(reward.silver).toBe(9);
});
it('persists a dropped Räuberklinge as a CharacterItem and references it in the reward', async () => {
const state = createState();
const reward = await service(
state,
fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }),
).grantVictoryRewards(fakeManager(state), banditCombat);
expect(state.characterItems).toEqual([
expect.objectContaining({
characterId: CHARACTER_ID,
itemDefinitionId: BANDIT_BLADE,
quantity: 1,
}),
]);
expect(reward.items).toEqual([
{
characterItemId: state.characterItems[0].id,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-blade.png',
},
quantity: 1,
},
]);
expect(state.combatRewardItems).toHaveLength(1);
});
it('reports no items when the Räuberklinge does not drop', async () => {
const state = createState();
const reward = await service(state, fakeLoot()).grantVictoryRewards(
fakeManager(state),
banditCombat,
);
expect(reward.items).toEqual([]);
expect(state.characterItems).toHaveLength(0);
expect(state.combatRewardItems).toHaveLength(0);
});
it('stacks a duplicate drop onto the existing CharacterItem without duplicate protection', async () => {
const state = createState({
characterItems: [
{
id: 'character-item-existing',
characterId: CHARACTER_ID,
itemDefinitionId: BANDIT_BLADE,
quantity: 1,
} as CharacterItem,
],
});
const reward = await service(
state,
fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }),
).grantVictoryRewards(fakeManager(state), banditCombat);
expect(state.characterItems).toHaveLength(1);
expect(state.characterItems[0].quantity).toBe(2);
// The reward reports what THIS combat granted, not the stack total.
expect(reward.items[0].quantity).toBe(1);
});
});
describe('idempotency', () => {
it('grants once and returns the same persisted reward on a repeat call', async () => {
const state = createState();
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
const subject = service(state, loot, fixedRandom(0));
const manager = fakeManager(state);
const first = await subject.grantVictoryRewards(manager, combat());
const second = await subject.grantVictoryRewards(manager, combat());
expect(second).toEqual(first);
expect(state.combatRewards).toHaveLength(1);
expect(state.combatRewardItems).toHaveLength(1);
expect(state.characterItems).toHaveLength(1);
expect(state.characterItems[0].quantity).toBe(1);
expect(state.characters[0].experience).toBe(20);
expect(state.characters[0].silver).toBe(7);
expect(loot.rollLoot).toHaveBeenCalledTimes(1);
});
});
describe('loadRewards', () => {
it('returns null for a combat that was never rewarded', async () => {
const state = createState();
await expect(
service(state).loadRewards(COMBAT_ID, fakeManager(state)),
).resolves.toBeNull();
});
it('replays the persisted reward without rerolling', async () => {
const state = createState();
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
const subject = service(state, loot, fixedRandom(0));
const manager = fakeManager(state);
const granted = await subject.grantVictoryRewards(manager, combat());
const replayed = await subject.loadRewards(COMBAT_ID, manager);
expect(replayed).toEqual(granted);
expect(loot.rollLoot).toHaveBeenCalledTimes(1);
});
it('reads a persisted reward through the injected DataSource when no manager is passed', async () => {
const state = createState();
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
const dataSource = fakeDataSource(state);
const subject = new CombatRewardService(dataSource as never, loot, fixedRandom(0));
const manager = fakeManager(state);
const granted = await subject.grantVictoryRewards(manager, combat());
// No manager argument: this is the non-transactional read the next
// task uses to render a reward screen.
const replayed = await subject.loadRewards(COMBAT_ID);
expect(replayed).toEqual(granted);
});
});
describe('failure handling', () => {
it('throws instead of half-granting when a rolled item definition is missing', async () => {
const state = createState();
await expect(
service(state, fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 })).grantVictoryRewards(
fakeManager(state),
combat(),
),
).rejects.toMatchObject({ code: 'REWARD_STATE_INVALID' });
expect(state.combatRewardItems).toHaveLength(0);
// Every rolled item definition is resolved before any mutation, so a
// missing one must leave no reward row and no character grant behind.
expect(state.combatRewards).toHaveLength(0);
expect(state.characters[0].experience).toBe(12);
expect(state.characters[0].silver).toBe(3);
});
});
describe('item order', () => {
it('orders items by itemDefinitionId in the immediate grant and the replayed reward, regardless of roll order', async () => {
const state = createState({
itemDefinitions: [
{
id: BANDIT_BLADE,
key: 'bandit-blade',
name: 'Räuberklinge',
type: ItemType.WEAPON,
rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-blade.png',
} as ItemDefinition,
{
id: BANDIT_HOOD,
key: 'bandit-hood',
name: 'Räuberkapuze',
type: ItemType.ARMOR,
rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-hood.png',
} as ItemDefinition,
],
});
const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID });
// Roll order is blade-then-hood, but BANDIT_HOOD's id sorts before
// BANDIT_BLADE's, so this only passes if both response paths sort by
// itemDefinitionId rather than returning rows in roll/insertion order.
const loot = fakeLoot(
{ itemDefinitionId: BANDIT_BLADE, quantity: 1 },
{ itemDefinitionId: BANDIT_HOOD, quantity: 1 },
);
const subject = service(state, loot, fixedRandom(0));
const manager = fakeManager(state);
const granted = await subject.grantVictoryRewards(manager, banditCombat);
const replayed = await subject.loadRewards(banditCombat.id, manager);
const expectedKeys = ['bandit-hood', 'bandit-blade'];
expect(granted.items.map((item) => item.item.key)).toEqual(expectedKeys);
expect(replayed?.items.map((item) => item.item.key)).toEqual(expectedKeys);
expect(replayed).toEqual(granted);
});
});
});

View File

@@ -0,0 +1,244 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { ItemRarity } from '../items/item-rarity.enum';
import { LootService } from '../loot/loot.service';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { RANDOM_SOURCE } from '../shared/random-source';
import type { RandomSource } from '../shared/random-source';
import { rollInclusive } from '../shared/roll-range';
import { CombatReward } from './entities/combat-reward.entity';
import { CombatRewardItem } from './entities/combat-reward-item.entity';
import { combatNotWon, rewardStateInvalid } from './rewards.errors';
export interface CombatRewardItemDto {
characterItemId: string;
item: {
key: string;
name: string;
rarity: ItemRarity;
iconPath: string;
};
quantity: number;
}
export interface CombatRewardDto {
experience: number;
silver: number;
items: CombatRewardItemDto[];
}
// Both DataSource and EntityManager expose this; naming it keeps the read path
// usable inside and outside a transaction without a union type.
type RepositoryScope = Pick<DataSource, 'getRepository'>;
@Injectable()
export class CombatRewardService {
constructor(
private readonly dataSource: DataSource,
private readonly lootService: LootService,
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
) {}
/**
* Grants a won combat's rewards exactly once (spec §7, §21).
*
* Runs inside the caller's transaction — `CombatService.performAction`
* already holds a pessimistic write lock on the combat row — so either
* everything below commits or nothing does.
*
* Roll order is fixed: silver first, then the loot table in `position`
* order. Tests depend on it.
*/
async grantVictoryRewards(
manager: EntityManager,
combat: Combat,
): Promise<CombatRewardDto> {
if (combat.status !== CombatStatus.WON) {
throw combatNotWon();
}
const rewards = manager.getRepository(CombatReward);
const existing = await rewards.findOne({ where: { combatId: combat.id } });
if (existing) {
// Already rewarded: replay rather than roll again.
return this.toDto(manager, existing);
}
const monster = await manager
.getRepository(MonsterDefinition)
.findOneBy({ id: combat.monsterDefinitionId });
if (!monster) {
throw rewardStateInvalid();
}
const experience = monster.experienceReward;
const silver = rollInclusive(
this.randomSource,
monster.silverMin,
monster.silverMax,
);
const roll = await this.lootService.rollLoot(monster.lootTableId, manager);
// Resolve every rolled item definition up front, before any mutation, so
// a missing definition throws `rewardStateInvalid()` before the
// character's XP/silver are touched or a `CombatReward` row is created.
// This keeps a failed grant from leaving partial writes behind.
const definitions = manager.getRepository(ItemDefinition);
const resolvedDefinitions = new Map<string, ItemDefinition>();
for (const rolled of roll.items) {
if (resolvedDefinitions.has(rolled.itemDefinitionId)) {
continue;
}
const definition = await definitions.findOneBy({
id: rolled.itemDefinitionId,
});
if (!definition) {
throw rewardStateInvalid();
}
resolvedDefinitions.set(rolled.itemDefinitionId, definition);
}
const characters = manager.getRepository(Character);
const character = await characters.findOne({
where: { id: combat.characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw rewardStateInvalid();
}
character.experience += experience;
character.silver += silver;
await characters.save(character);
const reward = await rewards.save(
rewards.create({
combatId: combat.id,
characterId: combat.characterId,
experienceGranted: experience,
silverGranted: silver,
}),
);
const characterItems = manager.getRepository(CharacterItem);
const rewardItems = manager.getRepository(CombatRewardItem);
const granted: Array<{ itemDefinitionId: string; dto: CombatRewardItemDto }> = [];
for (const rolled of roll.items) {
const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!;
const existingStack = await characterItems.findOne({
where: {
characterId: combat.characterId,
itemDefinitionId: rolled.itemDefinitionId,
},
lock: { mode: 'pessimistic_write' },
});
// Duplicates stack; Slice 0.4 adds no duplicate protection (spec §28).
const characterItem = existingStack
? Object.assign(existingStack, {
quantity: existingStack.quantity + rolled.quantity,
})
: characterItems.create({
characterId: combat.characterId,
itemDefinitionId: rolled.itemDefinitionId,
quantity: rolled.quantity,
});
await characterItems.save(characterItem);
await rewardItems.save(
rewardItems.create({
combatRewardId: reward.id,
characterItemId: characterItem.id,
itemDefinitionId: definition.id,
quantity: rolled.quantity,
}),
);
granted.push({
itemDefinitionId: rolled.itemDefinitionId,
dto: this.toItemDto(characterItem.id, definition, rolled.quantity),
});
}
// The immediate response and a later `loadRewards` replay must agree on
// item order; both sort on the same stable key (itemDefinitionId, which
// `toDto`'s query also orders by) rather than roll order.
granted.sort((a, b) => a.itemDefinitionId.localeCompare(b.itemDefinitionId));
const items = granted.map((entry) => entry.dto);
return { experience, silver, items };
}
/** Reads a persisted reward so a refresh replays it (spec §25, §48). */
async loadRewards(
combatId: string,
manager?: EntityManager,
): Promise<CombatRewardDto | null> {
const scope: RepositoryScope = manager ?? this.dataSource;
const reward = await scope
.getRepository(CombatReward)
.findOne({ where: { combatId } });
return reward ? this.toDto(scope, reward) : null;
}
private async toDto(
scope: RepositoryScope,
reward: CombatReward,
): Promise<CombatRewardDto> {
// Ordered by itemDefinitionId to agree with the sort `grantVictoryRewards`
// applies to its own response — the immediate grant and a later replay
// must list items identically.
const rewardItems = await scope
.getRepository(CombatRewardItem)
.find({
where: { combatRewardId: reward.id },
order: { itemDefinitionId: 'ASC' },
});
const definitions = scope.getRepository(ItemDefinition);
const items: CombatRewardItemDto[] = [];
for (const rewardItem of rewardItems) {
const definition = await definitions.findOneBy({
id: rewardItem.itemDefinitionId,
});
if (!definition) {
// combat_reward_items.item_definition_id is a RESTRICT FK.
throw rewardStateInvalid();
}
items.push(
this.toItemDto(rewardItem.characterItemId, definition, rewardItem.quantity),
);
}
return {
experience: reward.experienceGranted,
silver: reward.silverGranted,
items,
};
}
private toItemDto(
characterItemId: string,
definition: ItemDefinition,
quantity: number,
): CombatRewardItemDto {
// Drop chance, roll results, and loot-table ids never leave the server
// (spec §26).
return {
characterItemId,
item: {
key: definition.key,
name: definition.name,
rarity: definition.rarity,
iconPath: definition.iconPath,
},
quantity,
};
}
}

View File

@@ -0,0 +1,56 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { CombatReward } from './combat-reward.entity';
/**
* What this specific combat dropped.
*
* Needed because `CharacterItem.quantity` is a running stack total: after a
* duplicate drop it no longer says how much *this* victory granted, and a
* refreshed reward screen must replay the original result (spec §25, §48).
*/
@Entity({ name: 'combat_reward_items' })
@Index('IDX_combat_reward_items_reward', ['combatRewardId'])
@Index('IDX_combat_reward_items_reward_item', ['combatRewardId', 'itemDefinitionId'], {
unique: true,
})
export class CombatRewardItem {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'combat_reward_id', type: 'uuid' })
combatRewardId!: string;
@Column({ name: 'character_item_id', type: 'uuid' })
characterItemId!: string;
@Column({ name: 'item_definition_id', type: 'uuid' })
itemDefinitionId!: string;
@Column({ name: 'quantity', type: 'integer' })
quantity!: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => CombatReward, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'combat_reward_id' })
combatReward!: CombatReward;
@ManyToOne(() => CharacterItem, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'character_item_id' })
characterItem!: CharacterItem;
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'item_definition_id' })
itemDefinition!: ItemDefinition;
}

View File

@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { Combat } from '../../combat/entities/combat.entity';
/**
* Proof that one combat has already been rewarded (spec §8).
*
* The unique index on `combatId` is the database half of the idempotency
* invariant; `CombatRewardService` is the service half.
*/
@Entity({ name: 'combat_rewards' })
@Index('IDX_combat_rewards_combat', ['combatId'], { unique: true })
@Index('IDX_combat_rewards_character', ['characterId'])
export class CombatReward {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'combat_id', type: 'uuid' })
combatId!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'experience_granted', type: 'integer' })
experienceGranted!: number;
@Column({ name: 'silver_granted', type: 'integer' })
silverGranted!: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => Combat, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'combat_id' })
combat!: Combat;
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'character_id' })
character!: Character;
}

View File

@@ -0,0 +1,29 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type RewardErrorCode = 'COMBAT_NOT_WON' | 'REWARD_STATE_INVALID';
export class RewardDomainError extends HttpException {
constructor(
public readonly code: RewardErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function combatNotWon(): RewardDomainError {
return new RewardDomainError(
'COMBAT_NOT_WON',
HttpStatus.CONFLICT,
'Only a won combat can grant victory rewards.',
);
}
export function rewardStateInvalid(): RewardDomainError {
return new RewardDomainError(
'REWARD_STATE_INVALID',
HttpStatus.INTERNAL_SERVER_ERROR,
'The reward references unavailable data.',
);
}

View File

@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { LootModule } from '../loot/loot.module';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
import { CombatRewardService } from './combat-reward.service';
import { CombatReward } from './entities/combat-reward.entity';
import { CombatRewardItem } from './entities/combat-reward-item.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
Character,
CharacterItem,
ItemDefinition,
MonsterDefinition,
CombatReward,
CombatRewardItem,
]),
LootModule,
],
providers: [
CombatRewardService,
{ provide: RANDOM_SOURCE, useValue: systemRandomSource },
],
exports: [CombatRewardService],
})
export class RewardsModule {}

View File

@@ -0,0 +1,9 @@
export interface RandomSource {
next(): number; // uniform value in [0, 1)
}
export const RANDOM_SOURCE = Symbol('RANDOM_SOURCE');
export const systemRandomSource: RandomSource = {
next: () => Math.random(),
};

View File

@@ -0,0 +1,28 @@
import type { RandomSource } from './random-source';
import { rollInclusive } from './roll-range';
function fixed(...values: number[]): RandomSource {
let index = 0;
return { next: () => values[index++] };
}
describe('rollInclusive', () => {
it('maps the bottom of the random range to min and the top to max', () => {
expect(rollInclusive(fixed(0), 4, 7)).toBe(4);
expect(rollInclusive(fixed(0.999), 4, 7)).toBe(7);
});
it('spreads the random range evenly across every value in between', () => {
expect(rollInclusive(fixed(0.25), 4, 7)).toBe(5);
expect(rollInclusive(fixed(0.5), 4, 7)).toBe(6);
expect(rollInclusive(fixed(0.5), 9, 15)).toBe(12);
});
it('never exceeds max even if the source yields exactly 1', () => {
expect(rollInclusive(fixed(1), 9, 15)).toBe(15);
});
it('returns the single value when min equals max', () => {
expect(rollInclusive(fixed(0.7), 1, 1)).toBe(1);
});
});

View File

@@ -0,0 +1,19 @@
import type { RandomSource } from './random-source';
/**
* Rolls an inclusive integer in [min, max] from one value of `random`.
*
* `RandomSource.next()` is documented as [0, 1), but the clamp keeps a
* misbehaving or hand-stubbed source from ever exceeding `max`.
*/
export function rollInclusive(
random: RandomSource,
min: number,
max: number,
): number {
if (max <= min) {
return min;
}
return Math.min(max, min + Math.floor(random.next() * (max - min + 1)));
}

View File

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

View File

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

View File

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

View File

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

View File

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

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