Compare commits

..

202 Commits

Author SHA1 Message Date
Bastian Wagner
7b738a072d art 2026-08-22 23:03:48 +02:00
Bastian Wagner
69a26460ae notice board 2026-08-22 22:46:58 +02:00
Bastian Wagner
fb7a96f032 layout interactions 2026-08-22 22:42:55 +02:00
Bastian Wagner
93916457cb icons 2026-08-22 22:34:51 +02:00
Bastian Wagner
2dadc5c4a7 art 2026-08-22 21:56:28 +02:00
Bastian Wagner
139c5517f2 Merge branch 'slice/0.8.5-reputation-gated-merchant-offers' 2026-08-22 21:27:30 +02:00
Bastian Wagner
481c6be5a8 docs: translate the 0.8.5 research notes to English
AGENTS.md §33 and this branch's own constraint are English-only, and the notes
were the one German document left. Content and structure are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:07:27 +02:00
Bastian Wagner
1a7018d790 test(seed): prove a re-seed leaves five offers at the tuned numbers
The five offer tests this branch added all read the SHOP_OFFERS constant; none
ran the seed, so the one guarantee the stable-id design exists to provide --
re-seeding does not duplicate content -- was covered by nothing. This runs
`seedVisibleVerticalSlice` twice against an in-memory shop-offer repository and
asserts the five stable ids survive.

Confirmed catchable: reverting the conflict target to
['shopId', 'itemDefinitionId'] leaves 4 rows under the fake, because both bag
offers carry `itemDefinitionId: null` and collapse into one. On Postgres it
would fail outright.

The prices (12/30/40/35/60) and thresholds (reputation 25 and 40, renown 3) are
pinned in the same test: AGENTS.md §39 forbids silent rebalancing, and the
structural tests never looked at a number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:07:26 +02:00
Bastian Wagner
a454cf955e test(shop): make three nominal spec §10 cases exercise what they claim
Case 7 ("price is still required even when the reputation condition is met")
and case 3 ("sufficient regional reputation allows purchase") both built
fixtures with `conditions: []`. With no requirement present, none can be met,
so neither test touched the gate it was named after. Both now carry a
satisfied REGION_REPUTATION condition.

Case 4 ("insufficient World Renown blocks purchase") had no test at all. It
matters because a renown block must surface as SHOP_OFFER_LOCKED rather than
MERCHANT_REPUTATION_TOO_LOW -- renown is not the merchant's regard, and
telling the player to go and earn reputation would point at the wrong bar.
Verified by widening the reputation-blame check to include WORLD_RENOWN, which
fails the new test alone.

Also pins a bag offer's description to empty, so the duplicate capacity line
cannot come back through the API side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:07:25 +02:00
Bastian Wagner
f0edf89ad3 fix(shop): make purchase resolve offers the same way the view does
`purchase` listed a shop's offers with no ordering while `getShopView` orders
by `sortOrder`, so the two paths answered "which offer does this key mean" by
different rules, one of them at the database's discretion. Harmless today
because item and bag keys are disjoint, but not a difference worth keeping.

The faction lookup behind requirement labels also read every faction while the
condition engine only matches enabled ones, so a gate on a disabled faction
would have shown that faction's name next to a requirement the engine treats
as unmeetable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:07:05 +02:00
Bastian Wagner
ab8bbeae5d fix(shop): render a bag offer's capacity line once, not twice
`resolveTarget` gave a bag target the same string for `description` and
`effectSummary`, and the shop row renders both, so the slice's two flagship
offers showed "Capacity: 5 Raider Trophies" on consecutive lines. Spec §5's
worked example shows it once.

A bag definition carries no flavour text of its own, so the description is now
empty and the row omits the span entirely rather than emitting an empty one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:06:46 +02:00
Bastian Wagner
81e44b2c15 docs: record 0.8.5 acceptance and the 0.9 referral mechanism
Ticks Slice 0.8.5's acceptance criteria against the implemented
behavior, verified against the seed and service code rather than
assumed, and notes that the Bandit Blade's World Renown 3 gate is
deliberately unreachable until Slice 0.11 adds the milestones to
reach it.

Points Slice 0.9 at the concrete bypass mechanism that now exists
(BORIN_OFFER_IDS.hideBag's bypassConditions flag) instead of the
placeholder reference to "the quest/referral exception".

Also commits the slice's plan and research-notes documents, which
were untracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:43:53 +02:00
Bastian Wagner
7b03310458 style: apply eslint --fix reformatting to files this slice touched
Lint was intentionally deferred through tasks 1-7 to keep each task's
diff scoped. Running it now only reformats line-wrapping in the four
shop/seed files this slice already modified; the ~86 pre-existing
problems in npcs, travel, the e2e spec and elsewhere are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:41:47 +02:00
Bastian Wagner
374926e494 feat(web): announce offers a trade just unlocked 2026-08-22 20:30:36 +02:00
Bastian Wagner
681af334bf fix(web): make ShopOfferView.effectSummary/requirements required
The server always sends both fields; an optional frontend type only
dodged a transient gap in merchant.store.spec.ts's shopView() fixture
and risked a requirement line silently failing to render if a future
fixture omitted the fields. Add the two fields to that single fixture
instead (an open offer, so null/[] are the honest values) and drop the
now-unnecessary `?? []` tolerance in the template.
2026-08-22 20:20:26 +02:00
Bastian Wagner
00784094ab feat(web): show offer requirements, current progress and effects 2026-08-22 20:15:51 +02:00
Bastian Wagner
36adc32659 feat(content): gate the trophy pouch, hide bag and bandit blade 2026-08-22 20:04:48 +02:00
Bastian Wagner
80be829309 test(shops): make the gate's guards fail when they are removed 2026-08-22 18:52:23 +02:00
Bastian Wagner
7a5e800a18 feat(shops): sell loot bags, honour bypass conditions, explain locks 2026-08-22 18:34:24 +02:00
Bastian Wagner
7e1b79315e test(shops): cover non-finite and missing-key guards in describeRequirement 2026-08-22 18:18:44 +02:00
Bastian Wagner
149a9521be feat(shops): describe offer requirements and effects in English 2026-08-22 18:14:12 +02:00
Bastian Wagner
9bd28b5e2c feat(conditions): report the current value behind each condition 2026-08-22 18:07:57 +02:00
Bastian Wagner
c295bae63a test(shops): add entity-schema cross-check for sellable loot bags
The migration spec only asserted SQL substrings against a mocked
QueryRunner and had no getMetadataArgsStorage() check that ShopOffer's
column options and partial unique indexes actually match the new
schema. Since ts-jest does not type-check in this package
(isolatedModules: true), this is the only automated guard against
entity/migration drift -- matches the house convention in
npc-system.migration.spec.ts and loot-bags.migration.spec.ts.
2026-08-22 17:59:37 +02:00
Bastian Wagner
3b28450fb1 feat(shops): let an offer sell a loot bag and carry bypass conditions
Adds a second, mutually exclusive target column (loot_bag_definition_id)
and a bypass_conditions column to shop_offers, so a later slice's quest
referral can open one offer that reputation alone would not. Keeps
shop.service.ts compiling against the now-nullable itemDefinition with
temporary non-null assertions; Task 4 replaces them with a real branch
on offer kind.
2026-08-22 17:51:47 +02:00
Bastian Wagner
8e6ec7b43c fix(web): stop hunting encounter cards from collapsing to 0px height
The host switched to display:grid + justify-items:center to center the
new flavor-text line under each card. Grid items size to their own
content by default, and .encounter-card has no in-flow content (every
child is absolutely positioned), so its intrinsic width collapsed to 0
-- taking the aspect-ratio-derived height down with it.

Revert the host to a plain block layout and center the flavor line with
its own margin instead of grid alignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyWTLMpKYUGMz84CKrtT8
2026-08-22 17:03:11 +02:00
Bastian Wagner
081c9f83f9 docs 2026-08-22 16:41:47 +02:00
Bastian Wagner
dfa62fd152 fix: translate the last five German fixture strings found by the final review's re-review
Fixes 5 residual findings from the re-review of Playable Slice 0.6.6's
whole-branch review: two twice-hit spec files still carried invented
or seeded German fixture values (reputation-display faction name,
inventory-detail-panel item description and item name), a world store
fixture still used a German location description, and a dev comment
in reputation-content.ts referenced a faction name that no longer
exists in the code. All are literal string substitutions using
already-translated canon English text; no keys, ids, or logic changed.
2026-08-22 09:17:23 +02:00
Bastian Wagner
8418a93329 fix: correct final whole-branch review findings for 0.6.6 English content
- TRAVELLING spelling: fix TRAVELING -> TRAVELLING in travel-panel.component.html
  to match UK spelling used everywhere else (world/hunting/combat stores + API
  error messages)
- Grenzwacht -> Border Watch: fix stale German faction display name in
  reputation-display, reputation.service/controller, turn-in.service specs,
  and the vertical-slice seed spec test title
- Suedtor von Graufurt -> Graufurt South Gate: fix stale ASCII-transliterated
  German location name in hunting.service.spec.ts
- Aschenfelder(n) -> Ashen Fields: fix stale German location name/description
  in top-bar.component.spec.ts and context-panel.component.spec.ts (key
  identifiers left untouched)
- Fix 7 test titles still describing translated error messages as "German"
  across world/hunting/inventory/combat store specs
- README: update demo location names from German to their current English
  names (Graufurt South Gate, Burned Road)
- item-rarity.enum.ts: fix now-false comment claiming rarity labels are
  German; the frontend RARITY_LABELS map is English

Pure literal-string/comment substitutions; no keys, ids, or logic changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
2026-08-22 09:03:13 +02:00
Bastian Wagner
6b32f14bb5 fix(test): translate the e2e smoke test's monster name assertions to English
visible-slice.e2e-spec.ts still asserted the German monster names
'Aschenratte'/'Straßenräuber' that Task 1 already translated in the
seed data. Update the assertion to the actual English seeded values
'Ash Rat'/'Road Bandit'. This file lives outside the *.spec.ts glob
(it's matched by jest-e2e.json's *.e2e-spec.ts pattern instead), so it
was missed by prior sweeps scoped to the unit-test glob.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
2026-08-21 23:36:18 +02:00
Bastian Wagner
fdd83f2dee fix(test): translate the remaining Worn Shortsword and Padded Helm fixture names
Task 10 review sweep found two German strings the closing sweep missed:
'Abgenutztes Kurzschwert' (8 occurrences across 5 spec files, the real
seeded worn-short-sword item) and 'Gepolsterter Helm' (1 occurrence, a
synthetic test-only bonus-hp-helm fixture). Translated both to their
English equivalents per the item-content.ts glossary and slice style.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
2026-08-21 23:18:12 +02:00
Bastian Wagner
1322285b0f fix: close out remaining German player-facing text across web and API tests
Repo-wide grep sweep (apps/web/src, apps/api/src) for leftover German
content strings missed by Tasks 1-9, mostly in spec fixtures/assertions
that mirror already-translated seed content (monster/item names, POI
titles and action labels, location names/descriptions) plus a few real
source-file gaps:

- inventory-detail-panel.component.ts: STAT_LABELS (Waffenschaden,
  Angriff, Leben, Rüstung) were never translated by Task 6; now match
  the identical English labels already used in inventory-page.component.html.
- app-shell.component.html: aria-label="Spielinhalt" -> "Game content"
  (this file was outside every prior task's file list).
- location-interaction-panel.component.spec.ts: dead NPC-quote fixture
  translated to match the real wounded-scout POI text.

Code comments referencing German source-spec section titles or
not-yet-seeded faction names, and inline calculation-documentation
comments, are left as-is per the source spec's scope (dev-facing
comments may stay German).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
2026-08-21 22:59:34 +02:00
Bastian Wagner
ae163f000a test: reconcile the shared location fixture with Task 1's English seed content
current-location.fixture.ts was the one shared test fixture deliberately
left German through the slice. Updates location name/description,
monster names, and POI/reward-preview labels to match local-location.content.ts
and vertical-slice.seed.ts verbatim (no keys touched).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
2026-08-21 22:59:14 +02:00
Bastian Wagner
846bb7428c feat(web): translate topbar, navigation, context panel, footer, and reputation display to English
Translates the remaining German static copy in the layout/shared components
per the design doc glossary: top-bar (World Renown, Silver, loading text),
side-navigation (nav labels and aria-labels), context-panel (area info,
safety/hunt facts, encounters), game-footer (connection status, Ashen Fields),
and reputation-display (aria-label, highest-rank text). Also fixes stale
German rank-label fixtures in reputation-display.component.spec.ts flagged
by Task 2's reviewer, and updates app.spec.ts assertions that exercised the
now-translated side-navigation/top-bar output via the app shell.
2026-08-21 22:23:20 +02:00
Bastian Wagner
14d26d06a4 feat(web): translate hunt screen and encounter card copy to English
Translates every static German string in hunt-page.component.html and
encounter-card.component.html/ts: headings, buttons, aria-labels,
loading/error text, the monster level label, and the encounter
action-label logic (Defeated/In Combat/Attack).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
2026-08-21 22:11:50 +02:00
Bastian Wagner
1c0a13475c feat(web): translate location, travel, and world-map screen copy to English 2026-08-21 22:02:26 +02:00
Bastian Wagner
ca3e072807 feat(web): translate inventory and item-detail screen copy to English 2026-08-21 21:52:53 +02:00
Bastian Wagner
833074d9fe fix(web): translate the combat page's monster level badge to English
Closes a task-5 review finding: the fighter__level badge on the combat
page still read "Stufe {{ level }}"; the design doc gap that excluded
it has since been fixed. Changed to "Level {{ level }}" to match
encounter-card.component.html's pending equivalent (out of scope here).
2026-08-21 21:45:53 +02:00
Bastian Wagner
2dd0f32f88 docs: fill a glossary gap the survey missed -- combat page monster level
combat-page.component.html's "Stufe {{ combat.monster.level }}" fighter
badge was found by the original content survey but never made it into
the design doc's Combat glossary section, so Task 5's brief never
carried it. Task 5's implementer correctly left the string untouched
rather than inventing a translation without direction -- exactly the
right call.

Adds the missing row now: "Level {{ combat.monster.level }}", the same
treatment as the identical monster-level display already specified for
encounter-card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:41:24 +02:00
Bastian Wagner
0923fc0a23 feat(web): translate combat log, action buttons, and victory/defeat screen to English
Translates formatEvent() and monsterIntentLabel() combat-log templates,
default participant-name fallbacks (Du/Der Gegner -> You/The enemy), and
every static string in the combat page template: round header, action
buttons, outcome headings, rewards panel, post-combat buttons, log panel,
and loading/error/aria-label text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
2026-08-21 21:40:04 +02:00
Bastian Wagner
5586e6c672 feat(web): translate error messages surfaced from API error codes to English
Translates the five client-side error-message maps (world, hunting, combat,
inventory, local-location stores) that re-translate already-English API
error codes back to display text, per design doc §4. Also fixes four
component specs that hard-coded the same German strings when asserting
rendered error text (combat-page, hunt-page, location-interaction-panel,
location-page).
2026-08-21 21:29:10 +02:00
Bastian Wagner
5222bfb2ab feat(web): translate slot, rarity, danger, and location-type labels to English
Translates the four static Angular label maps (SLOT_LABELS, RARITY_LABELS,
DANGER_LABELS, LOCATION_TYPE_LABELS) and the inventory detail panel's
hardcoded item-type fallback per the English Game Content Foundation
design doc glossary. Updates every spec assertion across the web app that
checks rendered text sourced from these maps, including specs owned by
later tasks (combat-page, encounter-card, inventory-page) where they
render one of these labels.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 20:16:56 +02:00
Bastian Wagner
e48409fbf1 feat(reputation): translate rank labels to English
Translates the six REPUTATION_RANKS labels in reputation-rank.ts
(Geachtet/Vertraut/Anerkannt/Bekannt/Geduldet/Fremder -> Esteemed/
Trusted/Recognized/Known/Tolerated/Stranger), per the glossary in
docs/superpowers/specs/2026-08-21-english-game-content-foundation-design.md
section 4. Keys and thresholds are unchanged.

Also updates reputation.service.spec.ts and reputation.controller.spec.ts,
which asserted the German rankLabel value returned by
resolveReputationRank() via the service/controller layer.
2026-08-21 20:08:59 +02:00
Bastian Wagner
b8ee56ca1a feat(content): translate seeded items, locations, and reputation faction to English
Converts all German player-facing content in the API's seed files to
English per the English Game Content Foundation glossary: item
name/description, monster name, location name/description, all
local-location POI/action content, and the border-guard reputation
faction. Technical keys are unchanged.
2026-08-21 20:00:04 +02:00
Bastian Wagner
74fc89dfe5 docs: implementation plan for Slice 0.6.6 English Game Content Foundation
10 tasks, batched by layer (per subagent-driven-development's batching
guidance -- this is same-shape content/copy work across ~40 files, not
one task per file): API seed content, API reputation rank labels, web
static label maps, web error-message maps, then five feature-area
template sweeps (combat, inventory, world/travel, hunting,
layout/shared), closing with the shared test fixture and a repo-wide
grep sweep as the Definition-of-Done gate.

Unlike Slice 0.6.5, this plan has no designed red-build window: every
task changes string VALUES only, never a field/interface a sibling
task still reads, so both the API build and the Angular bundle compile
stay green throughout.

All test-filter commands in every task were run and verified against
this worktree before being written into the plan, rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:50:51 +02:00
Bastian Wagner
9214bf99d2 docs: design doc for Slice 0.6.6 English Game Content Foundation
Full-repo survey of every player-facing German string (seed content,
API-computed reputation rank labels, five client-side error-message
translation maps, six static Angular label maps, all combat-log
generation, ~30 component templates), plus rulings on everything the
source spec leaves open: monster keys stay unchanged (only name
changes), no migration is needed (content lives in seed-file values,
not schema), reputation rank labels move on the API side, combat log
templates are freely composed rather than translated 1:1, and the
Renown/Reputation German-only "Ansehen"/"Ruf" split carries over as
"World Renown"/"Reputation" without the German-specific collision
problem that motivated it.

A complete glossary gives every implementer the exact English value to
use verbatim -- no translation judgment calls left to task execution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:43:41 +02:00
Bastian Wagner
cedd87f648 Merge branch 'master' into worktree-playable-slice-0.6.5-renown-reputation
Reconciles Slice 0.6.5 (Renown & Reputation Foundation) against master's
persistent-HP-and-regeneration slice, which landed independently and
touches several of the same files (Character entity, CombatService,
EquipmentService, the inventory detail panel).

Conflict resolutions:
- CharacterStatsService/EquipmentService constructor wiring: kept
  master's CharacterVitalsService injection, which this branch's
  version of the same files didn't have yet.
- CombatService.performAction: kept master's HP-guard logic
  (characterTooWounded, vitals pause-on-enter) alongside this branch's
  multi-line calculate() call style.
- Inventory detail panel (.html/.ts/.scss/.spec.ts): master had
  redesigned the panel (wrapping section, rarity styling, flavour
  text, a shared inventory.labels.ts) on top of the OLD level-gated
  component, since this branch's removal of the level gate (R4, Task
  8/14) hadn't reached master yet. Kept master's visual redesign in
  full, but with the level-gate concept removed throughout: no
  requiredLevel stat block, no meetsLevelRequirement() branch in the
  equip button, no now-dead .detail__value--unmet SCSS rule. Kept both
  branches' independent tests (non-equippable-item, flavour-text).
- inventory-page.component.ts: dropped master's dead characterLevel
  computed (nothing in the template read it, and the level concept is
  gone); kept its independent bagCells/bagUsed/bagCapacity grid
  feature, which has nothing to do with renown or level.

Post-merge fixture repairs (three files failed the Angular bundle
compile because they predate master's hpRegenPerSecond/hpRegenSince
fields or master's item description field, neither conflict-marked
since git considered them non-overlapping edits):
- app.spec.ts: a 'renders loaded character values' test added on
  master after this branch forked still used the abolished level/
  experience fields on its decoy fixture -- retargeted to renown.
- inventory-detail-panel.component.spec.ts: the ashPelt fixture added
  by this branch's final-review follow-up predates master's required
  description field.
- top-bar.component.spec.ts: this branch's fixture predates master's
  required hpRegenPerSecond/hpRegenSince fields.

No database migration touches the same column: master's
1792000000000-AddHpRegeneration only adds characters.hp_regen_since,
independent of this slice's 1791000000000-CreateRenownAndReputation.
Timestamp ordering between the two was already correct with no rename
needed.

Verified: API 288/288 (267 from this slice + 21 from master), API
build zero errors, web 237/237 (230 from this slice + 7 from master).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:23:10 +02:00
Bastian Wagner
23ed527eea fix: address the final whole-branch review's findings
The branch review approved the slice with no blocking findings. These
are the substantive non-blocking ones.

N1, the one no per-task review could see: every seeded monster rolls
silverMin/silverMax = 0 (R7), so the victory screen showed "Silber +0"
after every fight in the shipped game. Three tasks were each correct in
isolation -- the mechanism stays, the values are zero, the field still
exists -- and the composite was wrong. Now conditional, with a test.
This does not contradict R16: R16 deleted the XP block because the
field ceased to exist, leaving nothing to hide. Silver still exists and
can be non-zero, so a conditional is the right tool.

N2: world.store.ts still said a combat "granted XP and silver". Same
false-fact-in-a-comment defect fixed in b5bcd50, one file over.

N4: resolveReputationRank threw a TypeError on negative input, since
findIndex returns -1 and REPUTATION_RANKS[-1] is undefined. Unreachable
today, but grantReputation is public and accepts any number.

N5/N6: grantReputation resolved factions without the enabled filter the
read path applies, so a disabled faction could accumulate invisible
reputation -- "disabled" was not actually a kill switch. The dense read
also had no ORDER BY, so the list could reorder between requests.

N8: design 13 requires silver stay 0 for every seeded monster; only two
of four were pinned. Re-adding silver to the others would have shipped
silently.

N9: spec 36's "a normal kill grants no Renown" had no test. It was
structurally guaranteed but unasserted -- now locked down against a
later slice wiring renown into combat.

N10: an impossible renown: 0 fixture, and RENOWN_MIN exported but never
used to clamp the floor.

API 268/268, web 230/230, API build zero errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:50:30 +02:00
Bastian Wagner
0c9f00d9a8 fix(web): finish the reward-preview sweep the seed change started
When I dropped Silber and Erfahrung from the burned road's seeded
reward preview (e5752c7, because R7 zeroed every monster's silver on
that road), I updated the server content but not the web fixture that
mirrors it. burnedRoadFixture() still advertised Silber, and the
sidebar spec still asserted it rendered -- so the web tests were
proving the UI shows a category the API no longer sends.

Also clears dead level/experience fixture fields and an experience key
in a reward-service mock from combat-equipment-integration.spec.ts.
They were inert (nothing read them) but they date from after the
API-side sweep and describe fields the entity and DTO no longer have.

API 267/267, web 229/229.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:28:52 +02:00
Bastian Wagner
688c4cf70f test(inventory): cover the non-equippable branch of the detail panel
The panel's three-way actions block had no test for its middle branch:
an item with equipmentSlot null should show "Nicht ausrüstbar" and no
equip button. The gap predates this slice -- Task 14's review found it
while confirming the removed level gate had not cost coverage.

Deferred until now on purpose: the web bundle could not compile for the
whole slice, so committing this earlier would have meant shipping a test
I had never executed.

Load-bearing: dropping the !isEquippable() guard would fall through to
the else branch and render the button, failing the assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:16:53 +02:00
Bastian Wagner
18f4be586b fix(web): locations recommend Ansehen, not the abolished Stufe
Two location panels still labelled their recommended range "Empfohlene
Stufe". The player no longer has a Stufe -- this slice replaced the
1-7 level scale with Ansehen 1-15, so the UI was recommending a scale
the character cannot be measured on.

Source slice doc section 3 defines this concept as Recommended Renown
per region (Aschenfelder 1-5). The seeded values already sit inside
that range (south-gate 1-1, burned-road 1-2), so only the label was
wrong -- no data change needed.

The min_recommended_level / max_recommended_level column and property
names are left as-is: internal naming, no user-visible effect, and
renaming them would reach across the API, the entity, the DTO and the
seed for no behavioural gain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:15:42 +02:00
Bastian Wagner
b350c3f9b1 fix(web): sweep every remaining fixture off level/experience/requiredLevel
Task 17 (final task) of Renown & Reputation Foundation. Fixes the last 3
compile errors blocking the web suite (app.spec.ts, world.store.spec.ts)
by aligning CharacterResponse fixtures to the real renown-only shape, and
removes the two remaining dead 'experience' reward-preview leftovers
(current-location fixture entry, location-icon glyph) now that the API
no longer seeds them. Full web suite: 24 files / 228 tests, all green,
for the first time this entire slice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:13:37 +02:00
Bastian Wagner
b5bcd50dcd docs(web): correct the victory-refresh comment about what combat grants
The comment claimed the server had granted "silver and renown" on a
combat win. It does not: grantVictoryRewards adds silver and item drops,
and nothing in apps/api/src/combat or apps/api/src/rewards touches
renown at all. Renown comes from milestones only.

Introduced when I committed Task 15's staged work after its agent was
cut off mid-step; caught by the task review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:06:38 +02:00
Bastian Wagner
4cb759b628 feat(reputation): add the reusable Regional Reputation display component 2026-08-21 18:06:37 +02:00
Bastian Wagner
221819880c arts und agents 2026-08-21 17:32:05 +02:00
Bastian Wagner
ab6227881e feat(web): bind the HUD health bar to the locally-ticking HP value
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:12:55 +02:00
Bastian Wagner
848f9d9195 feat(web): count displayed HP up locally between server syncs 2026-08-21 16:06:54 +02:00
Bastian Wagner
c27b6c5026 feat(web): model HP regen fields and map CHARACTER_TOO_WOUNDED
- Add hpRegenPerSecond and hpRegenSince fields to CharacterResponse
- Update inventory-page.component.spec.ts fixture with new fields
- Add CHARACTER_TOO_WOUNDED error mapping to combat.store
- Add test for CHARACTER_TOO_WOUNDED error mapping
- Update app.spec.ts and world.store.spec.ts fixtures for compilation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:59:32 +02:00
Bastian Wagner
caf63b34a4 chore(api): anchor the seeded demo character's HP regeneration
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:53:20 +02:00
Bastian Wagner
cfe1866956 test(api): update combat/equipment integration harness for HP vitals wiring 2026-08-21 15:49:35 +02:00
Bastian Wagner
52178ba79d test(api): make equip() re-anchor test exercise the overflow scenario
The re-anchor test in equipment.service.spec.ts anchored hpRegenSince
at null, which meant currentHp already equaled the old maxHp and
nothing in equip() could change it whether settle() ran correctly, was
a no-op, or read the wrong (post-change) maxHp. Anchor currentHp at 90
with hpRegenSince 600s before the harness's fixed clock so settle()
must actually cap accumulated regen at the OLD maxHp for the assertion
to hold, catching both failure modes the original fixture missed.
2026-08-21 15:46:28 +02:00
Bastian Wagner
dd204db4b3 feat(api): re-anchor HP regeneration before an equipment-driven max-HP change 2026-08-21 15:40:27 +02:00
Bastian Wagner
9d1c1a7706 feat(web): drop the XP block from the combat victory screen
Design ruling R16: the victory screen removes the data-reward-experience
element and its "Erfahrung" line outright rather than hiding them behind
a conditional. CombatReward is now { silver, items }.

The surviving spec assertion checks that [data-reward-experience] is
absent, so it would genuinely fail if the block came back -- rather than
merely observing that some text changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:38:40 +02:00
Bastian Wagner
b070bf2b0d feat(api): mirror HP onto the character each round and resume regen at combat end 2026-08-21 15:08:04 +02:00
Bastian Wagner
c0401d5c60 feat(inventory): remove the requiredLevel equip gate from the detail panel
The API can no longer emit ITEM_LEVEL_REQUIREMENT_NOT_MET (Task 8 removed
the level gate server-side) and InventoryItem.item no longer carries
requiredLevel (Task 12). Drop the characterLevel input, the
meetsLevelRequirement computed, the associated template branch and copy,
and the dead German error-message mapping. Update fixtures across the
inventory specs to the real InventoryItem/CharacterResponse shapes.
2026-08-21 15:06:00 +02:00
Bastian Wagner
df4c0c5527 fix(web): use the German "Ansehen" for Renown in the Topbar
The Topbar rendered the English word "Renown", replacing the German
"Stufe" it showed before. That contradicted the slice's own global
constraint that all user-facing copy is German, and it was the single
English string in a component visible on every screen -- next to
"Silber", "Lebenspunkte" and "Charakterdaten werden geladen".

This was a spec defect, not an implementation one: the design doc and
the task brief both prescribed "Renown" literally. The document's
working language leaked into a copy-locked line.

"Ruf" is not available as the German term -- this project already uses
it for the separate per-faction Reputation system, and the two are
deliberately kept distinct. "Ansehen" (standing/prestige) is unclaimed
and matches the spec's own definition of Renown as the character's
overall significance in the world.

Records the decision as R17 in the design doc so later Renown UI does
not reintroduce the English term, and renames the now-inaccurate
top-bar__level class to top-bar__renown (nothing else referenced it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:59:01 +02:00
Bastian Wagner
ac1329be46 feat(api): carry HP into combat and gate starting a fight on it 2026-08-21 14:56:05 +02:00
Bastian Wagner
cea1d2ecf5 feat(web): show Renown instead of Level in the Topbar, remove the XP display 2026-08-21 14:51:04 +02:00
Bastian Wagner
432483e958 feat(api): add CHARACTER_TOO_WOUNDED combat error 2026-08-21 14:48:05 +02:00
Bastian Wagner
909f793eb6 feat(api): expose effective HP and regen anchor from GET /characters/me
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 14:45:31 +02:00
Bastian Wagner
02037b2917 feat(api): CharacterStatsService reports effective (regenerated) HP
- Update CharacterStatsService constructor to accept CharacterVitalsService
- Compute currentHp via CharacterVitalsService.effectiveHp() instead of raw pass-through
- Add hpRegenPerSecond and hpRegenSince fields to EffectiveCharacterStats return type
- Update spec with new test cases for regeneration calculation and field pass-through
- Equipment and combat tests now fail as expected (separate tasks will fix constructor calls)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 14:41:17 +02:00
Bastian Wagner
c32d1804d3 feat(renown): add Renown/Reputation/TurnIn web models and API client methods 2026-08-21 14:41:11 +02:00
Bastian Wagner
47494abf50 feat(api): provide CharacterVitalsService from CharactersModule 2026-08-21 14:36:22 +02:00
Bastian Wagner
71f58bc1b6 feat(api): add CharacterVitalsService for anchored HP regeneration
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 14:32:18 +02:00
Bastian Wagner
7609c49899 feat(characters): expose renown on GET /api/characters/me instead of level/experience 2026-08-21 14:31:03 +02:00
Bastian Wagner
e5752c7ee9 fix(content): stop promising rewards the burned road can no longer drop
The location view's "Mögliche Belohnungen" preview listed Silber and
Erfahrung. This slice made both false:

- Experience no longer exists as a concept (spec 1).
- Every monster seeded on this road now rolls silverMin/silverMax = 0
  (design R7), so a kill yields no silver. Silver reaches the player
  through turn-ins instead.

The list's own comment states the rule it was breaking: the view may not
promise a drop the roll does not guarantee (spec 8). Equipment and
material stay -- the loot tables still back both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:30:53 +02:00
Bastian Wagner
ea26e4b844 feat(api): add HP regeneration rate constant 2026-08-21 14:29:35 +02:00
Bastian Wagner
40ff950390 test(combat): stop asserting the deleted experience reward contract
combat.service.spec.ts mocked CombatRewardService, so it never noticed
that design R8 removed CombatRewardDto.experience. Its mocks returned
{ experience, silver, items } and one assertion required experience: 8
back out -- encoding a contract the production DTO no longer has. A mock
lying about the real shape is worse than no test: it would keep passing
if the real DTO drifted further.

The rollback test deliberately wrote two independent fields so its
assertions could not pass vacuously ("rolled back" vs "never written").
Swapped the abolished experience for renown rather than dropping to a
single field, preserving that intent.

Monster level is untouched -- it is a monster stat this slice keeps, not
the abolished character level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:27:20 +02:00
Bastian Wagner
19e08f0b16 feat(api): add hp_regen_since column for persistent HP regeneration
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 14:26:44 +02:00
Bastian Wagner
b55518b451 refactor(api): move Clock abstraction from travel to shared 2026-08-21 14:21:39 +02:00
Bastian Wagner
34a7bb9dbd fix(api): sweep every remaining test fixture off level/experience/requiredLevel/experienceReward 2026-08-21 14:19:12 +02:00
Bastian Wagner
2579b23a5c Add implementation plan for persistent HP and out-of-combat regeneration
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 14:12:38 +02:00
Bastian Wagner
8835671657 fix(monsters): finish deleting experienceReward, column included
Design ruling R7 abolishes XP as a concept and says the column goes with
it, but no task in the plan actually dropped it -- the plan only dropped
characters.experience and combat_rewards.experience_granted. Task 9
removed experienceReward from the seed literals, leaving
monster_definitions.experience_reward as a NOT NULL column with no
default that nothing supplies. The first monster insert against a real
database would have failed on a constraint violation.

No suite here could have caught it: none of them connect to Postgres.

Drops the column in the slice migration (which has never been run, so
amending it in place is correct rather than stacking a second one),
removes the entity field, and clears the three test fixtures that still
set it. silver_min/silver_max deliberately stay -- spec 15 keeps a
direct currency drop available as a lore-valid exception, and XP has no
such carve-out.

Also retargets the seed idempotency test off renown: 1, which is the
seed's own default and so could not distinguish "preserved" from
"reset to default".

NOTE ON SCOPE: this commit also absorbs a Prettier reformatting pass
that was already sitting uncommitted in the working tree, which is why
it touches ~59 files. That churn is purely cosmetic line-rewrapping --
verified by inspection, and the suite is green at 267/267 with the build
at exactly the 3 expected errors owned by Tasks 10 and 11. The repo is
not Prettier-clean at baseline (119 files still flagged), so this was a
partial run by an earlier step, not a deliberate repo-wide format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:10:19 +02:00
Bastian Wagner
a75a670509 Add design doc for persistent character HP and out-of-combat regeneration
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 13:56:43 +02:00
Bastian Wagner
dce26e00ad feat(content): migrate ItemType, seed Räuberabzeichen/Grenzwacht/turn-ins, zero direct monster rewards 2026-08-21 13:49:49 +02:00
Bastian Wagner
dd90e05446 optik 2026-08-21 13:39:52 +02:00
Bastian Wagner
413fafd574 feat(equipment): remove the requiredLevel equipment gate - owned items are always equippable 2026-08-21 13:34:10 +02:00
Bastian Wagner
83eef5d9e5 docs(rewards): drop the stale XP reference from the no-partial-writes comment
The comment sat two lines above the deleted experience computation and
still named XP as something the guard protects. Only silver survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:28:53 +02:00
Bastian Wagner
c9d9144b5f feat(rewards): remove classical XP from the combat reward pipeline 2026-08-21 13:23:46 +02:00
Bastian Wagner
6fb61792e7 test(turn-in): cover the unknown-character rejection path
TurnInService locks and validates the character row before touching
inventory, silver, or reputation, but no test exercised that branch.
Both sibling services in this slice (renown, reputation) have the
equivalent test; this closes the gap.

`characterNotFound` is shared from travel.errors and so is not a
TurnInDomainError -- assert on the wire contract (code + status) the
way reputation.service.spec.ts does for the same shared error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:19:17 +02:00
Bastian Wagner
4e3af9fe39 feat(turn-in): add TurnInService with atomic item/silver/reputation exchange and POST /api/turn-ins 2026-08-21 13:07:46 +02:00
Bastian Wagner
987242541d inventory 2026-08-21 12:52:44 +02:00
Bastian Wagner
321738496d fix(reputation): validate the character exists before granting reputation 2026-08-21 09:30:04 +02:00
Bastian Wagner
79d4e04172 feat(reputation): add ReputationService, rank resolver integration, and GET /api/reputation 2026-08-21 09:22:36 +02:00
Bastian Wagner
4c9397336f feat(renown): add RenownService with milestone completion and base-stat recomputation 2026-08-21 09:14:36 +02:00
Bastian Wagner
152499acfc Pair every API suite verification with a build
apps/api runs ts-jest with isolatedModules: true, which skips cross-file
type checking. Task 3 proved the gap concretely: the suite reported
233/234 green while npm run build reported 21 real errors across 6
files. Every full-suite step now runs test && build, with a Global
Constraints note explaining why the build half is not redundant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:07:37 +02:00
Bastian Wagner
4fa1923525 feat(renown): add Reputation/Renown/TurnIn entities and update Character/ItemDefinition/CombatReward 2026-08-21 09:03:37 +02:00
Bastian Wagner
3662029ec9 test(renown): pin the migration's backfill and constraint statement ordering
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:56:48 +02:00
Bastian Wagner
1a50e817f1 feat(renown): migrate Character/ItemDefinition schema and add Reputation/Renown/TurnIn tables
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UT2tLMQ2HfbWGkZyotocKm
2026-08-21 08:48:54 +02:00
Bastian Wagner
88b98aa605 feat(renown): add the Renown power-curve table and reputation-rank resolver 2026-08-21 08:42:29 +02:00
Bastian Wagner
e14f6dcbce Fix two plan defects found in SDD pre-flight scan
1. inventory.service.ts (API production code) surfaces requiredLevel in
   its response DTO. Task 3 deletes the entity column, so this would
   fail to compile and keep shipping a dead field. No task covered it:
   Task 10 swept only test fixtures, Task 12 only the web model. Folded
   into Task 10 as a new Step 0.

2. No web-side sweep task existed. Task 12's model changes break
   CharacterResponse/InventoryItem fixtures in app.spec.ts,
   world.store.spec.ts, and inventory.store.spec.ts, none of which
   Tasks 13-16 touch. Added Task 17 mirroring Task 10's sweep rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:39:43 +02:00
Bastian Wagner
a376fb7128 Add implementation plan for Playable Slice 0.6.5 (Renown & Reputation)
16 tasks: pure-function power curve and rank resolver, one migration
covering Character/ItemDefinition/ItemType/CombatReward changes plus
five new tables, three new domain services (Renown, Reputation,
TurnIn), XP removal from the combat reward pipeline, requiredLevel
gate removal, seed content for Grenzwacht/Räuberabzeichen/turn-ins,
a fixture sweep, and the web-side Renown/Reputation surfaces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:34:22 +02:00
Bastian Wagner
65dfb466cd Add design doc for Playable Slice 0.6.5 (Renown & Reputation Foundation)
Records every judgment call the spec left open — Renown/Reputation
data model, service signatures, migration strategy, combat reward
pipeline changes, and API/frontend surface — as an explicit, auditable
ruling with its cost if wrong, per instruction to implement this slice
without confirmation stops.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:21:14 +02:00
Bastian Wagner
62d6677298 fix(combat): show the potion heal immediately instead of folding it into the monster's reply 2026-08-20 23:54:02 +02:00
Bastian Wagner
1aac3416fa fix(combat): clear the monster's pending action once it is defeated 2026-08-20 23:10:47 +02:00
Bastian Wagner
e2f29d5eb6 fix(combat): keep the busy() re-entrancy guard, split the action-mapping test instead
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 22:38:52 +02:00
Bastian Wagner
0126d1dea1 feat(combat): add the five-action bar, telegraph banner, and generalized round animation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 22:19:13 +02:00
Bastian Wagner
b8d00278b8 feat(combat): generalize the web combat action and expose potions/monster intent 2026-08-20 22:01:28 +02:00
Bastian Wagner
c7b9601eb4 feat(combat): validate potions, persist telegraph state, and expose both on the combat DTO
Also updates the pre-existing exact-equality playerState assertion in
combat-equipment-integration.spec.ts, which broke from the new
potionsRemaining field but wasn't listed in the task brief's file scope.
2026-08-20 21:48:51 +02:00
Bastian Wagner
43d2085d2c feat(combat): add the COMBAT_NO_POTIONS_REMAINING domain error 2026-08-20 21:40:56 +02:00
Bastian Wagner
6eb0f21360 test(combat): cover DEFEND mitigating a resolving Heavy Attack 2026-08-20 21:37:36 +02:00
Bastian Wagner
b3dac8f61c feat(combat): implement HEAVY_STRIKE, SHIELD_BASH, DEFEND, POTION, and monster telegraphing 2026-08-20 21:27:12 +02:00
Bastian Wagner
fc1873e41f feat(combat): model monster intent and potion count in combat state types 2026-08-20 21:21:08 +02:00
Bastian Wagner
922c12f20f feat(combat): migrate combat_event_type_enum for Slice 0.6 event types 2026-08-20 21:17:37 +02:00
Bastian Wagner
6f1afaf5a3 feat(combat): add the Slice 0.6 action and event-type enum members 2026-08-20 21:12:46 +02:00
Bastian Wagner
eed247d318 feat(combat): support a damage multiplier in calculateDamage 2026-08-20 21:06:54 +02:00
Bastian Wagner
96d04d8ba3 Fix plan defect found in SDD pre-flight scan: Task 8 fixture patch
hunt-page.component.spec.ts and resume-combat.spec.ts each build a
direct type-annotated Combat literal that the new required
CombatPlayer/CombatMonster fields would break at compile time.
Verified with an isolated tsc --strict probe, and used the same probe
to confirm the combat.service.spec.ts `as Combat` fixtures do NOT
break (structural widening through the type assertion), so no change
needed there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 21:04:19 +02:00
Bastian Wagner
67df86a818 Ignore .worktrees/ for isolated feature-branch workspaces
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 20:52:36 +02:00
Bastian Wagner
9d396b7d96 Add implementation plan for Playable Slice 0.6 (Full First Combat)
Covers the engine rewrite for HEAVY_STRIKE/SHIELD_BASH/DEFEND/POTION,
deterministic monster telegraphing/interrupt, the DB migration for the
new combat event types, and the web action bar + telegraph banner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 20:51:17 +02:00
Bastian Wagner
49a0008a2b Merge branch 'worktree-playable-slice-0.5-first-upgrade'
# Conflicts:
#	apps/web/src/app/core/api/game-api.service.ts
#	apps/web/src/app/features/combat/combat-page/combat-page.component.html
#	apps/web/src/app/features/combat/combat-page/combat-page.component.scss
#	apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
#	apps/web/src/app/features/combat/combat-page/combat-page.component.ts
2026-08-20 19:42:10 +02:00
Bastian Wagner
0ce3b420e6 fix: address final-review findings (TopBar hydration, snapshot test, armor doc, inventory refresh test)
Fixes 4 Important findings from the final whole-branch review:
- /inventory never called WorldStore.load(), leaving the TopBar stuck on
  "loading" and characterLevel() silently defaulting to 1 for any character
  above level 1. Mirrors the same guard already used in HuntPageComponent.
- The combat/equipment snapshot-immutability integration test asserted only
  status/round, never the actual playerState snapshot the whole test claims
  to prove is untouched after a post-fight equip.
- Documented (comment only, no behavior change) that the demo character's
  armor dropping from the old hardcoded 6 to 0 is an intentional,
  spec-sanctioned tradeoff (Slice 0.5 spec Section19), not a bug.
- inventory.store.spec.ts's equip test used an identical inventory fixture
  before and after equip(), so a regression dropping the post-equip
  inventory re-fetch would still have passed. Now asserts the refetched
  fixture is actually reflected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:44:39 +02:00
Bastian Wagner
7f031ac1ce test(web): update app.spec.ts for the now-enabled Inventar nav button 2026-08-20 18:15:30 +02:00
Bastian Wagner
b1968da754 feat(web): add Inventar öffnen button to the victory screen 2026-08-20 18:04:13 +02:00
Bastian Wagner
724443de1e feat(web): route and enable the Inventar nav entry 2026-08-20 17:58:32 +02:00
Bastian Wagner
fdd8ae4e41 test(web): cover the same-slot equipped-item filter in InventoryPageComponent
The equippedItemInSelectedSlot computed is the riskiest logic this
component owns, but every existing test mocked selectedItem() as null,
so the slot-matching branch never ran and a regression to "any equipped
item" would have gone unnoticed. Adds a test with three items across two
slots that asserts the detail panel receives the same-slot equipped item,
not just any equipped item.
2026-08-20 17:54:38 +02:00
Bastian Wagner
9cb0158d80 import aufgeräumt 2026-08-20 17:52:04 +02:00
Bastian Wagner
1015505f38 feat(web): add inventory page with grid, detail panel, and equipment overview 2026-08-20 17:46:09 +02:00
Bastian Wagner
973d4e3ab4 feat(web): add inventory item detail/comparison panel 2026-08-20 17:39:46 +02:00
Bastian Wagner
10dd838465 feat(web): add InventoryStore 2026-08-20 17:32:52 +02:00
Bastian Wagner
c994a4c46f feat(web): add inventory/equipment API models and client methods 2026-08-20 17:27:15 +02:00
Bastian Wagner
90094ba1ae fix(api): key starting-sword seed idempotency on its real unique index
The CharacterItem idempotency check was looking up by the seed's own
literal id instead of the (characterId, itemDefinitionId) unique index
that CharacterItem actually enforces. If the demo character had already
looted a worn-short-sword naturally, re-running the seed would miss
that row and try to insert a colliding duplicate, breaking seeding
instead of being a safe no-op. Look up by the real domain key and reuse
whatever id is found when wiring up the CharacterEquipment row.
2026-08-20 17:23:45 +02:00
Bastian Wagner
6178b88573 feat(api): seed the starting sword as a real, equipped CharacterItem 2026-08-20 17:11:18 +02:00
Bastian Wagner
2526ac230d items 2026-08-20 17:07:40 +02:00
Bastian Wagner
2859a00597 test(api): prove equipping a weapon upgrade increases future combat damage 2026-08-20 17:01:45 +02:00
Bastian Wagner
820c69704f feat(api): add inventory API (GET /api/inventory) 2026-08-20 16:50:50 +02:00
Bastian Wagner
d1c7ffea86 Merge branch 'worktree-local-location-view' 2026-08-20 16:42:57 +02:00
Bastian Wagner
9df740e7d4 feat(api): add equipment API (GET/POST /api/equipment) 2026-08-20 16:39:23 +02:00
Bastian Wagner
3ee107694c address code review: POI order matches plan, self-documenting height reserve
Reorders the Verbrannte Straße POIs to plan §8's authored sequence
(hunt, investigate, search, then the scout) — purely a keyboard tab-order
fix, since hotspots are placed by percentage, not list order.

Rewrites the location page's viewport-height reserve as a calc() over the
same rem values the top bar and footer already declare as their own
min-block-size, with file:line pointers to both, instead of an opaque
191px constant. Doesn't remove the underlying coupling (still no
ResizeObserver / shared token), but a future edit to either component's
minimum height now has a documented, unit-matching term to update instead
of an unexplained magic number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:33:42 +02:00
Bastian Wagner
f45f4b03cb feat(api): combat snapshots now use CharacterStatsService 2026-08-20 16:32:15 +02:00
Bastian Wagner
e5746dec5c feat(api): retire CharacterCombatStatsService; characters/me returns effective stats 2026-08-20 16:26:33 +02:00
Bastian Wagner
67c29e783f feat(api): add CharacterStatsService as authoritative effective-stat source 2026-08-20 16:20:44 +02:00
Bastian Wagner
8a5f57fa1c specs 2026-08-20 16:17:19 +02:00
Bastian Wagner
8debb6f550 feat(api): add equipment domain errors 2026-08-20 16:16:44 +02:00
Bastian Wagner
59e88bcf9c feat(api): add character_equipment table and entity 2026-08-20 16:13:19 +02:00
Bastian Wagner
2cbb7cb235 docs: add Playable Slice 0.5 implementation plan 2026-08-20 16:08:51 +02:00
Bastian Wagner
c158260623 Merge branch 'master' into worktree-local-location-view
Brings in the encounter-status feature (cleared/resumed hunt encounters)
and its own independent Verwilderter Straßenhund / Verkohlter Plünderer
assets. Both branches added the same two monsters at the same time;
resolved by keeping master's asset set as canonical (images/combat/icons/,
images/combat/sprites/) rather than maintaining a parallel copy under
images/monsters/icons/ — dropped that directory and pointed the seed's
MonsterDefinition.iconPath, the local-view test fixtures and the frontend
icon lookup at the existing combat/icons paths instead. Kept this
branch's COMBAT_MONSTER_SCALE entries for the two monsters, since master
never added them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:03:59 +02:00
Bastian Wagner
60e1734f59 fix(web): keep the primary action bar on screen at every desktop width
The shell sizes itself with min-block-size everywhere, which is a floor,
not a ceiling. Against that indefinite ancestor, the location page's
minmax(0, 1fr) scene row fell back to content-based sizing instead of
being bounded, so the artwork could grow tall enough to push the action
bar off screen — confirmed visually at 1920 and 1024px widths, where the
bar was fully or partially clipped.

Gives the location page its own definite, viewport-bounded height
(reserve = stable top bar + footer + own padding) instead of touching the
shared shell, which other screens still size freely. The narrow/tablet
breakpoint had a second instance of the same class of bug: the sidebar's
auto-sized row claimed its full content height before the 1fr main row
saw any space at all, collapsing the action bar to 0px height. Swapping
which row is auto vs. 1fr — main first — fixes it the same way.

Also re-anchors the four Verbrannte Straße hotspots to painted detail in
the real artwork (cart, roadside grave, road, cracked stones) rather than
the composition-reference coordinates, and lets primary-action labels
stay on one line via clamp() instead of wrapping unevenly across widths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:50:31 +02:00
Bastian Wagner
ec91f3ac7b grafiken 2026-08-20 15:11:17 +02:00
Bastian Wagner
d31d064d36 images 2026-08-20 13:23:55 +02:00
Bastian Wagner
9b839623ce feat(web): route hunt, combat and arrival back to the location
A finished journey now opens the location view instead of leaving the
player on the map, and backing out of the hunt returns to the place the
hunt happens in. The victory and defeat screens gain "Zum Ort" alongside
"Weiter jagen", so the location is always reachable without costing the
hunt loop its one-click rhythm.

The store raises the arrival only after the server-owned current location
has been re-read, and does not navigate itself — timers, arrival times and
the server-side completion are untouched; only the screen that shows the
result changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:58:46 +02:00
Bastian Wagner
6f0020137b feat(web): add the local location view at /location
The screen a player stands on between activities: name, region, scene
artwork with hotspots pinned by percentage, a four-button action bar and
a context sidebar covering identity, danger, encounters, interactions and
rewards.

It owns no knowledge of any particular place. Hotspots and actions are
routed by interaction type: HUNT and MAP hand off to the existing hunt
and map screens, and everything that reveals text goes through the
server-authoritative interaction endpoint. A second location therefore
renders by supplying different content, which the Südtor case in the page
spec exercises.

The shell drops its generic area rail on /location, where the screen's
own sidebar says the same thing better, and Ort joins the navigation as
its first entry. Root and unknown routes now land on the location rather
than the map: arriving somewhere should mean arriving at a place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:51:08 +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
c7e7e97252 Merge branch 'master' into worktree-local-location-view
Brings in the First Loot slice. Resolved additively:

- MonsterDefinition keeps both the new iconPath and master's lootTableId.
- The seed keeps the four-monster pool and the local view content, and
  gives the two new monsters existing loot tables — the road dog shares
  the beast table, the charred looter the raider table.
- The local location view migration moves to 1788700000000 so it orders
  deterministically after the loot migration, which claimed the same
  timestamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:38:18 +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
2664495ef4 feat(web): add local location contracts, shared fixtures and content glyphs
Mirrors the extended current-location payload and the interaction endpoint
in the web API client. Replaces the per-spec CurrentLocationResponse
literals with one shared fixture factory, so a contract change is answered
in a single place. Adds the drawn glyph set used by hotspots, actions and
reward previews.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:34:41 +02:00
Bastian Wagner
624c47e5a6 Merge branch 'worktree-slice-0.4-first-loot' 2026-08-20 10:33:39 +02:00
Bastian Wagner
c85a70484f feat(world): serve local location view content from the API
Adds the server side of the local location view: location_definitions
carries region naming, a location type, a scene-level description and
artwork, plus JSONB points of interest, primary actions and a reward
preview. Locations become content, so a second location renders through
the same components with different data.

GET /api/world/current-location gains those fields, a recommendation
label and a danger rating derived from the weighted average of the
location's own monster pool — a rare elite no longer makes a beginner
road read as lethal. The encounter preview is derived from that same
pool rather than duplicating it.

POST /api/world/current-location/interactions/:key reveals a hotspot's
authored result. The location is resolved from the character, never from
the request, and result text never ships with the location payload, so a
caller cannot read or trigger a hotspot it has not travelled to.

Seeds the Verbrannte Straße with its four hotspots and the Südtor with
its own transition content. Adds Verwilderter Straßenhund and Verkohlter
Plünderer to the road's pool, including combat sprites, so the preview
shows encounters the hunt can actually roll. Medallion icons move to
images/monsters/icons, where both combat and the location view read them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:30:01 +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
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
414 changed files with 67704 additions and 2272 deletions

2
.gitignore vendored
View File

@@ -13,3 +13,5 @@ coverage/
.vscode/
.DS_Store
.worktrees/

1175
AGENTS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,8 @@ A dark-fantasy browser RPG built as an npm-workspace modular monolith:
- `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.
world/travel loop between two locations (Graufurt South Gate and Burned Road)
for one demo character, with no login required.
## Prerequisites
@@ -30,6 +30,10 @@ Straße) for one demo character, with no login required.
## Local setup
Run from the repository root:
```
docker run --name ashen-postgres -e POSTGRES_USER=ashen -e POSTGRES_PASSWORD=ashen -e POSTGRES_DB=ashen_realms -p 5432:5432 -d postgres:16
```
```powershell
npm install
@@ -47,8 +51,8 @@ running. Once both are up:
- 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.
Open `http://localhost:4200/world` to see Aric Duskwalker at the Graufurt
South Gate and travel to the Burned Road and back.
### Configuration (`.env`)

View File

@@ -1,8 +1,18 @@
import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module';
import { ConditionsModule } from './conditions/conditions.module';
import { DatabaseModule } from './database/database.module';
import { EquipmentModule } from './equipment/equipment.module';
import { ExchangesModule } from './exchanges/exchanges.module';
import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module';
import { InventoryModule } from './inventory/inventory.module';
import { LootBagsModule } from './loot-bags/loot-bags.module';
import { NpcsModule } from './npcs/npcs.module';
import { RenownModule } from './renown/renown.module';
import { ReputationModule } from './reputation/reputation.module';
import { ShopsModule } from './shops/shops.module';
import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module';
@@ -14,6 +24,16 @@ import { WorldModule } from './world/world.module';
TravelModule,
WorldModule,
HuntingModule,
CombatModule,
EquipmentModule,
InventoryModule,
LootBagsModule,
RenownModule,
ReputationModule,
ConditionsModule,
NpcsModule,
ShopsModule,
ExchangesModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,170 @@
// apps/api/src/characters/character-stats.service.spec.ts
import { DataSource } from 'typeorm';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterVitalsService } from './character-vitals.service';
type EquippedFixture = {
slot: EquipmentSlot;
item: Partial<ItemDefinition>;
};
function fakeScope(
equipped: EquippedFixture[],
): Pick<DataSource, 'getRepository'> {
const rows = equipped.map((entry) => ({
slot: entry.slot,
characterItem: {
itemDefinition: {
weaponDamage: 0,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
...entry.item,
},
},
}));
return {
getRepository: () => ({ find: async () => rows }) as never,
};
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: 'character-1',
baseHp: 100,
baseAttack: 6,
currentHp: 90,
hpRegenSince: null,
...overrides,
} as Character;
}
describe('CharacterStatsService', () => {
const characterVitals = new CharacterVitalsService({
now: () => new Date('2026-08-21T12:00:00.000Z'),
});
const service = new CharacterStatsService({} as DataSource, characterVitals);
it('derives stats from the starting weapon alone', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.attack).toBe(6);
expect(stats.weaponDamage).toBe(8);
expect(stats.maxHp).toBe(100);
expect(stats.armor).toBe(0);
});
it("applies the Bandit Blade's weapon damage and bonus attack", async () => {
const scope = fakeScope([
{
slot: EquipmentSlot.WEAPON,
item: { weaponDamage: 11, bonusAttack: 1 },
},
]);
const stats = await service.calculate(character(), scope);
expect(stats.attack).toBe(7);
expect(stats.weaponDamage).toBe(11);
});
it('sums bonusArmor across multiple equipped armor pieces', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } },
{ slot: EquipmentSlot.CHEST, item: { bonusArmor: 7 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.armor).toBe(10);
});
it('sums bonusHp across equipped items on top of base HP', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.HEAD, item: { bonusHp: 5 } },
{ slot: EquipmentSlot.CHEST, item: { bonusHp: 10 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.maxHp).toBe(115);
});
it('reports weaponDamage as 0 when no weapon is equipped', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.weaponDamage).toBe(0);
});
it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => {
const scope = fakeScope([
{
slot: EquipmentSlot.WEAPON,
item: { weaponDamage: 11, bonusAttack: 1 },
},
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3, bonusHp: 5 } },
]);
const stats = await service.calculate(character(), scope);
// maxHp=105, attack=7, weaponDamage=11, armor=3
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
});
it('returns the raw current HP unchanged while regeneration is paused', async () => {
const scope = fakeScope([]);
const stats = await service.calculate(
character({ currentHp: 42, hpRegenSince: null }),
scope,
);
expect(stats.currentHp).toBe(42);
});
it('adds elapsed regeneration, clamped to maxHp, when a regen anchor is set', async () => {
const scope = fakeScope([]);
const regenerating = await service.calculate(
character({
currentHp: 40,
hpRegenSince: new Date('2026-08-21T11:59:30.000Z'),
}),
scope,
);
expect(regenerating.currentHp).toBe(70);
const clamped = await service.calculate(
character({
currentHp: 40,
hpRegenSince: new Date('2026-08-21T11:40:00.000Z'),
}),
scope,
);
expect(clamped.currentHp).toBe(100);
});
it('reports the regeneration rate and anchor alongside the effective stats', async () => {
const scope = fakeScope([]);
const anchor = new Date('2026-08-21T11:59:30.000Z');
const stats = await service.calculate(
character({ hpRegenSince: anchor }),
scope,
);
expect(stats.hpRegenPerSecond).toBe(1);
expect(stats.hpRegenSince).toEqual(anchor);
});
});

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
import { CharacterVitalsService } from './character-vitals.service';
import { Character } from './entities/character.entity';
export interface EffectiveCharacterStats {
maxHp: number;
currentHp: number;
attack: number;
weaponDamage: number;
armor: number;
combatPower: number;
hpRegenPerSecond: number;
hpRegenSince: Date | null;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
/**
* Single authoritative source of effective character stats (spec §18).
* Replaces the Slice 0.3 `CharacterCombatStatsService` shortcut.
*/
@Injectable()
export class CharacterStatsService {
constructor(
private readonly dataSource: DataSource,
private readonly characterVitals: CharacterVitalsService,
) {}
async calculate(
character: Character,
scope?: RepositoryScope,
): Promise<EffectiveCharacterStats> {
const db = scope ?? this.dataSource;
const equipped = await db.getRepository(CharacterEquipment).find({
where: { characterId: character.id },
relations: { characterItem: { itemDefinition: true } },
});
let weaponDamage = 0;
let bonusHp = 0;
let bonusAttack = 0;
let bonusArmor = 0;
for (const slot of equipped) {
const definition = slot.characterItem.itemDefinition;
if (slot.slot === EquipmentSlot.WEAPON) {
weaponDamage = definition.weaponDamage;
}
bonusHp += definition.bonusHp;
bonusAttack += definition.bonusAttack;
bonusArmor += definition.bonusArmor;
}
const maxHp = character.baseHp + bonusHp;
const attack = character.baseAttack + bonusAttack;
const armor = bonusArmor;
return {
maxHp,
currentHp: this.characterVitals.effectiveHp(character, maxHp),
attack,
weaponDamage,
armor,
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
hpRegenPerSecond: HP_REGEN_PER_SECOND,
hpRegenSince: character.hpRegenSince,
};
}
}

View File

@@ -0,0 +1 @@
export const HP_REGEN_PER_SECOND = 1;

View File

@@ -0,0 +1,139 @@
import { Clock } from '../shared/clock';
import { CharacterVitalsService } from './character-vitals.service';
import { Character } from './entities/character.entity';
function fakeClock(initialIso: string): {
clock: Clock;
advanceSeconds: (seconds: number) => void;
} {
let current = Date.parse(initialIso);
return {
clock: { now: () => new Date(current) },
advanceSeconds: (seconds: number) => {
current += seconds * 1000;
},
};
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: 'character-1',
currentHp: 50,
hpRegenSince: null,
...overrides,
} as Character;
}
describe('CharacterVitalsService', () => {
describe('effectiveHp', () => {
it('returns the raw current HP when regeneration is paused', () => {
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const hp = service.effectiveHp(
character({ currentHp: 37, hpRegenSince: null }),
100,
);
expect(hp).toBe(37);
});
it('adds one HP per elapsed second since the anchor', () => {
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 40, hpRegenSince: anchor });
advanceSeconds(25);
expect(service.effectiveHp(target, 100)).toBe(65);
});
it('floors partial seconds instead of rounding up', () => {
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 40, hpRegenSince: anchor });
advanceSeconds(1.9);
expect(service.effectiveHp(target, 100)).toBe(41);
});
it('clamps regeneration at maxHp', () => {
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 90, hpRegenSince: anchor });
advanceSeconds(50);
expect(service.effectiveHp(target, 100)).toBe(100);
});
it('never lets HP fall if the clock moves backwards', () => {
const clock: Clock = { now: () => new Date('2026-08-21T11:59:00.000Z') };
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 40, hpRegenSince: anchor });
expect(service.effectiveHp(target, 100)).toBe(40);
});
});
describe('pause', () => {
it('freezes current HP at the given value and clears the anchor', () => {
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const target = character({
currentHp: 100,
hpRegenSince: new Date('2026-08-21T11:00:00.000Z'),
});
service.pause(target, 62);
expect(target.currentHp).toBe(62);
expect(target.hpRegenSince).toBeNull();
});
});
describe('resume', () => {
it('sets current HP and anchors regeneration at now', () => {
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const target = character({ currentHp: 0, hpRegenSince: null });
service.resume(target, 15);
expect(target.currentHp).toBe(15);
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:00.000Z'));
});
});
describe('settle', () => {
it('re-anchors at the current effective value without changing it', () => {
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 40, hpRegenSince: anchor });
advanceSeconds(10);
service.settle(target, 100);
expect(target.currentHp).toBe(50);
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:10.000Z'));
});
it('does not gift overflow past the pre-change maxHp when re-anchoring', () => {
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 100, hpRegenSince: anchor });
advanceSeconds(600);
service.settle(target, 100);
expect(target.currentHp).toBe(100);
});
});
});

View File

@@ -0,0 +1,46 @@
import { Inject, Injectable } from '@nestjs/common';
import { CLOCK } from '../shared/clock';
import type { Clock } from '../shared/clock';
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
import { Character } from './entities/character.entity';
/**
* The only place that turns (current_hp, hp_regen_since) into an effective
* HP value, or moves that pair. `current_hp` is exact only while the anchor
* is null; everything else must go through here (persistent-hp-and-
* regeneration design, R3).
*/
@Injectable()
export class CharacterVitalsService {
constructor(@Inject(CLOCK) private readonly clock: Clock) {}
effectiveHp(
character: Pick<Character, 'currentHp' | 'hpRegenSince'>,
maxHp: number,
): number {
if (character.hpRegenSince === null) {
return Math.min(maxHp, character.currentHp);
}
const elapsedSeconds = Math.max(
0,
(this.clock.now().getTime() - character.hpRegenSince.getTime()) / 1000,
);
const regenerated = Math.floor(elapsedSeconds * HP_REGEN_PER_SECOND);
return Math.min(maxHp, character.currentHp + regenerated);
}
pause(character: Character, value: number): void {
character.currentHp = value;
character.hpRegenSince = null;
}
resume(character: Character, value: number): void {
character.currentHp = value;
character.hpRegenSince = this.clock.now();
}
settle(character: Character, maxHp: number): void {
this.resume(character, this.effectiveHp(character, maxHp));
}
}

View File

@@ -1,5 +1,8 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CLOCK, systemClock } from '../shared/clock';
import { CharacterStatsService } from './character-stats.service';
import { CharacterVitalsService } from './character-vitals.service';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { Character } from './entities/character.entity';
@@ -7,6 +10,12 @@ import { Character } from './entities/character.entity';
@Module({
imports: [TypeOrmModule.forFeature([Character])],
controllers: [CharactersController],
providers: [CharactersService],
providers: [
CharactersService,
CharacterStatsService,
CharacterVitalsService,
{ provide: CLOCK, useValue: systemClock },
],
exports: [CharacterStatsService, CharacterVitalsService],
})
export class CharactersModule {}

View File

@@ -2,54 +2,98 @@ 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 { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
import { CharactersService } from './characters.service';
function fakeCharacterStats(
overrides: Partial<{ maxHp: number; attack: number }> = {},
): CharacterStatsService {
return {
calculate: jest.fn().mockResolvedValue({
maxHp: overrides.maxHp ?? 100,
currentHp: 100,
attack: overrides.attack ?? 6,
weaponDamage: 8,
armor: 0,
combatPower: 0,
hpRegenPerSecond: 1,
hpRegenSince: new Date('2026-08-18T09:00:00.000Z'),
}),
} as unknown as CharacterStatsService;
}
describe('CharactersService', () => {
it('returns the demo character with its current location summary', async () => {
it('returns the demo character with effective attack/HP and its location summary', async () => {
const repository = {
findOne: jest.fn().mockResolvedValue({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
renown: 1,
silver: 0,
currentHp: 100,
baseHp: 100,
baseAttack: 6,
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
name: 'Graufurt South Gate',
},
}),
} as unknown as Repository<Character>;
const service = new CharactersService(repository);
const characterStats = fakeCharacterStats({ maxHp: 115, attack: 7 });
const service = new CharactersService(repository, characterStats);
await expect(service.getDemoCharacter()).resolves.toEqual({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
renown: 1,
silver: 0,
currentHp: 100,
maxHp: 100,
attack: 6,
maxHp: 115,
attack: 7,
hpRegenPerSecond: 1,
hpRegenSince: '2026-08-18T09:00:00.000Z',
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
name: 'Graufurt South Gate',
},
});
expect(repository.findOne).toHaveBeenCalledWith({
where: { id: DEMO_CHARACTER_ID },
relations: { currentLocation: true },
expect(characterStats.calculate).toHaveBeenCalledWith(
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
);
});
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',
renown: 5,
silver: 18,
currentHp: 100,
baseHp: 100,
baseAttack: 6,
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'Graufurt South Gate',
},
}),
} as unknown as Repository<Character>;
const service = new CharactersService(repository, fakeCharacterStats());
await expect(service.getDemoCharacter()).resolves.toEqual(
expect.objectContaining({ renown: 5, 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);
const service = new CharactersService(repository, fakeCharacterStats());
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
NotFoundException,

View File

@@ -2,6 +2,7 @@ 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 { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
@Injectable()
@@ -9,6 +10,7 @@ export class CharactersService {
constructor(
@InjectRepository(Character)
private readonly characters: Repository<Character>,
private readonly characterStats: CharacterStatsService,
) {}
async getDemoCharacter() {
@@ -21,14 +23,20 @@ export class CharactersService {
throw new NotFoundException('Demo character has not been seeded');
}
const stats = await this.characterStats.calculate(character);
return {
id: character.id,
name: character.name,
level: character.level,
experience: character.experience,
currentHp: character.currentHp,
maxHp: character.baseHp,
attack: character.baseAttack,
renown: character.renown,
silver: character.silver,
currentHp: stats.currentHp,
maxHp: stats.maxHp,
attack: stats.attack,
hpRegenPerSecond: stats.hpRegenPerSecond,
hpRegenSince: stats.hpRegenSince
? stats.hpRegenSince.toISOString()
: null,
currentLocation: {
id: character.currentLocation.id,
key: character.currentLocation.key,

View File

@@ -17,11 +17,11 @@ export class Character {
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'level', type: 'integer' })
level!: number;
@Column({ name: 'renown', type: 'integer' })
renown!: number;
@Column({ name: 'experience', type: 'integer' })
experience!: number;
@Column({ name: 'silver', type: 'integer' })
silver!: number;
@Column({ name: 'base_hp', type: 'integer' })
baseHp!: number;
@@ -32,6 +32,12 @@ export class Character {
@Column({ name: 'current_hp', type: 'integer' })
currentHp!: number;
// `current_hp` is only exact while this is null (regeneration paused, e.g.
// mid-combat). Otherwise it's the HP as of this timestamp -- read it
// through CharacterVitalsService.effectiveHp(), never directly.
@Column({ name: 'hp_regen_since', type: 'timestamptz', nullable: true })
hpRegenSince!: Date | null;
@Column({ name: 'current_location_id', type: 'uuid' })
currentLocationId!: string;

View File

@@ -0,0 +1,7 @@
export enum CombatAction {
ATTACK = 'ATTACK',
HEAVY_STRIKE = 'HEAVY_STRIKE',
SHIELD_BASH = 'SHIELD_BASH',
DEFEND = 'DEFEND',
POTION = 'POTION',
}

View File

@@ -0,0 +1,28 @@
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);
});
it('applies a damage multiplier before the minimum-1 floor', () => {
// raw = 27; mitigated = 20.25; *1.6 = 32.4 -> rounds to 32
expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20, 1.6)).toBe(32);
});
it('still floors at 1 damage even with a small multiplier', () => {
expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000, 0.5)).toBe(
1,
);
});
});

View File

@@ -0,0 +1,18 @@
export interface DamageAttacker {
attack: number;
weaponDamage?: number;
}
const ARMOR_MITIGATION_CONSTANT = 60;
export function calculateDamage(
attacker: DamageAttacker,
targetArmor: number,
multiplier = 1,
): 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 * multiplier));
}

View File

@@ -0,0 +1,728 @@
import { CombatAction } from './combat-action.enum';
import {
CombatEngineService,
UnsupportedCombatActionError,
} from './combat-engine.service';
import {
CombatEngineCombatant,
CombatEngineState,
} from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
import { Combatant } from './combatant.enum';
import { StatusEffectType } from './status-effect.enum';
import type { MonsterAbilities } from '../monsters/monster-abilities';
function baseState(
overrides: Partial<CombatEngineState> = {},
): CombatEngineState {
return {
status: CombatStatus.ACTIVE,
round: 1,
player: {
currentHp: 100,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6 },
},
// The Ash Rat baseline: no telegraph, no status effect (spec §4).
monster: monster(),
...overrides,
};
}
function monster(
overrides: Partial<CombatEngineCombatant> = {},
): CombatEngineCombatant {
return {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0 },
...overrides,
};
}
/**
* A monster whose content grants it an ability. Every special behaviour is
* configuration now (spec §4, §8), so a test that wants a telegraph or a
* bleed has to say so -- there is no rule every enemy shares any more.
*/
function withAbilities(
abilities: MonsterAbilities,
overrides: Partial<CombatEngineCombatant> = {},
): CombatEngineCombatant {
const base = monster(overrides);
return { ...base, stats: { ...base.stats, abilities } };
}
const ROAD_BANDIT_ABILITIES: MonsterAbilities = {
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
};
const ROAD_HOUND_ABILITIES: MonsterAbilities = {
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
};
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: 'FLEE' as CombatAction }),
).toThrow(UnsupportedCombatActionError);
});
it('HEAVY_STRIKE deals 160% damage to the monster', () => {
const result = engine.resolveAction(baseState(), {
action: CombatAction.HEAVY_STRIKE,
});
// raw = 14; 14 * 1.6 = 22.4 -> rounds to 22
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 22,
});
expect(result.state.monster.currentHp).toBe(45 - 22);
});
it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => {
const result = engine.resolveAction(baseState(), {
action: CombatAction.SHIELD_BASH,
});
// raw = 14; 14 * 0.7 = 9.8 -> rounds to 10
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 10,
});
expect(
result.events.some((event) => event.type === CombatEventType.INTERRUPT),
).toBe(false);
});
it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => {
const state = baseState({
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
}),
});
const result = engine.resolveAction(state, {
action: CombatAction.SHIELD_BASH,
});
expect(result.events).toEqual([
{
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 10,
},
{
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.INTERRUPT,
},
]);
expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.state.player.currentHp).toBe(100);
expect(result.state.round).toBe(2);
});
it('DEFEND deals no damage and halves the monster normal attack this round', () => {
const result = engine.resolveAction(baseState(), {
action: CombatAction.DEFEND,
});
expect(result.state.monster.currentHp).toBe(45);
// raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2
expect(result.events).toEqual([
{
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.DEFEND,
},
{
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 2,
},
]);
expect(result.state.player.currentHp).toBe(98);
});
it('POTION heals 35% of max HP, decrements potionsRemaining, and the monster still acts', () => {
const state = baseState({
player: {
currentHp: 60,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 2 },
},
});
const result = engine.resolveAction(state, { action: CombatAction.POTION });
// 35% of 100 = 35
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.HEAL,
amount: 35,
});
expect(result.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 5,
});
// 60 + 35 healed - 5 monster hit = 90
expect(result.state.player.currentHp).toBe(90);
expect(result.state.player.stats.potionsRemaining).toBe(1);
});
it('caps POTION healing at the maximum HP', () => {
const state = baseState({
player: {
currentHp: 90,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 1 },
},
});
const result = engine.resolveAction(state, { action: CombatAction.POTION });
// 35% of 100 = 35, but only 10 HP is missing
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.HEAL,
amount: 10,
});
});
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
const round3State = baseState({
round: 3,
monster: withAbilities(ROAD_BANDIT_ABILITIES),
});
const telegraphResult = engine.resolveAction(round3State, {
action: CombatAction.ATTACK,
});
expect(telegraphResult.state.player.currentHp).toBe(100);
expect(telegraphResult.state.monster.stats.pendingAction).toBe(
'HEAVY_ATTACK',
);
expect(telegraphResult.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.TELEGRAPH,
});
expect(telegraphResult.state.round).toBe(4);
const resolveResult = engine.resolveAction(telegraphResult.state, {
action: CombatAction.ATTACK,
});
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7
expect(resolveResult.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 7,
});
expect(resolveResult.state.monster.stats.pendingAction).toBeUndefined();
expect(resolveResult.state.player.currentHp).toBe(100 - 7);
});
it('does not resolve a pending Heavy Attack when the monster is killed this round', () => {
const state = baseState({
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
currentHp: 10,
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
}),
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(result.state.status).toBe(CombatStatus.WON);
expect(result.state.player.currentHp).toBe(100);
expect(result.state.monster.stats.pendingAction).toBeUndefined();
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('is deterministic for HEAVY_STRIKE as well', () => {
const state = baseState();
const first = engine.resolveAction(state, {
action: CombatAction.HEAVY_STRIKE,
});
const second = engine.resolveAction(state, {
action: CombatAction.HEAVY_STRIKE,
});
expect(first).toEqual(second);
});
it('DEFEND mitigates a resolving Heavy Attack by half', () => {
const state = baseState({
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
}),
});
const result = engine.resolveAction(state, { action: CombatAction.DEFEND });
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27; * defend 0.5 = 3.636 -> rounds to 4
expect(result.events).toEqual([
{
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.DEFEND,
},
{
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 4,
},
]);
expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.state.player.currentHp).toBe(100 - 4);
});
describe('content-driven monster abilities (spec §4, §8)', () => {
it('never telegraphs for a monster whose content grants it no telegraph', () => {
// The Ash Rat is "pure baseline combat": round 3 must be an ordinary
// exchange for it, even though it is the Road Bandit's wind-up round.
const result = engine.resolveAction(baseState({ round: 3 }), {
action: CombatAction.ATTACK,
});
expect(
result.events.some((event) => event.type === CombatEventType.TELEGRAPH),
).toBe(false);
expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.state.player.currentHp).toBe(100 - 5);
});
it('telegraphs on the cadence its own content sets, not a shared one', () => {
// The Charred Raider winds up every second round rather than every
// third, which is the whole of its "noticeably stronger" mechanic.
const raider = withAbilities({
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
});
const round2 = engine.resolveAction(
baseState({ round: 2, monster: raider }),
{ action: CombatAction.ATTACK },
);
const round3 = engine.resolveAction(
baseState({ round: 3, monster: raider }),
{ action: CombatAction.ATTACK },
);
expect(round2.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
expect(round3.state.monster.stats.pendingAction).toBeUndefined();
});
it('uses the damage multiplier from the monster content when the Heavy Attack lands', () => {
const state = baseState({
monster: withAbilities(
{ telegraph: { roundInterval: 3, damageMultiplier: 3 } },
{ stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' } },
),
});
const result = engine.resolveAction(state, {
action: CombatAction.ATTACK,
});
// raw = 5; mitigated = 4.545...; * 3 = 13.6 -> rounds to 14
expect(result.events[1]).toMatchObject({
type: CombatEventType.DAMAGE,
amount: 14,
});
});
});
describe('Bleeding (spec §4)', () => {
it('applies Bleeding on top of a normal bite, then ticks it the same round', () => {
const state = baseState({
round: 3,
monster: withAbilities(ROAD_HOUND_ABILITIES),
});
const result = engine.resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.events.slice(1)).toEqual([
{
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 5,
},
{
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.STATUS_APPLIED,
amount: 2,
statusEffect: StatusEffectType.BLEED,
},
{
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.STATUS_DAMAGE,
amount: 5,
statusEffect: StatusEffectType.BLEED,
},
]);
// The bite (5) and the first bleed tick (5) both land this round.
expect(result.state.player.currentHp).toBe(100 - 10);
expect(result.state.player.stats.statusEffects).toEqual([
{
type: StatusEffectType.BLEED,
remainingRounds: 1,
damagePerRound: 5,
},
]);
});
it('keeps ticking on later rounds and expires once its duration runs out', () => {
const state = baseState({
round: 4,
monster: withAbilities(ROAD_HOUND_ABILITIES),
player: {
currentHp: 90,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 6,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 1,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.events.map((event) => event.type)).toEqual([
CombatEventType.DAMAGE,
CombatEventType.DAMAGE,
CombatEventType.STATUS_DAMAGE,
CombatEventType.STATUS_EXPIRED,
]);
// Monster bite (5) plus the last bleed tick (5).
expect(result.state.player.currentHp).toBe(90 - 10);
expect(result.state.player.stats.statusEffects).toEqual([]);
});
it('ignores armor, because a bleed is an open wound and not a blow', () => {
const armoured = baseState({
round: 4,
player: {
currentHp: 100,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 999,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 2,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(armoured, {
action: CombatAction.ATTACK,
});
expect(
result.events.find(
(event) => event.type === CombatEventType.STATUS_DAMAGE,
),
).toMatchObject({ amount: 5 });
});
it('still ticks in a round where SHIELD_BASH interrupted the monster', () => {
// Bleeding sits on the player: interrupting its source stops the next
// blow, not the wound already open.
const state = baseState({
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
}),
player: {
currentHp: 100,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 6,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 2,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(state, {
action: CombatAction.SHIELD_BASH,
});
expect(result.events.map((event) => event.type)).toEqual([
CombatEventType.DAMAGE,
CombatEventType.INTERRUPT,
CombatEventType.STATUS_DAMAGE,
]);
expect(result.state.player.currentHp).toBe(95);
});
it('refreshes an existing Bleeding rather than stacking a second one', () => {
const state = baseState({
round: 3,
monster: withAbilities(ROAD_HOUND_ABILITIES),
player: {
currentHp: 100,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 6,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 1,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.state.player.stats.statusEffects).toHaveLength(1);
// Re-applied to 2 rounds, then aged by this round's own tick.
expect(result.state.player.stats.statusEffects?.[0].remainingRounds).toBe(
1,
);
});
it('ends the combat as LOST when a bleed tick empties the player', () => {
const state = baseState({
round: 4,
player: {
currentHp: 4,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 999,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 2,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(state, {
action: CombatAction.DEFEND,
});
expect(result.state.player.currentHp).toBe(0);
expect(result.state.status).toBe(CombatStatus.LOST);
expect(result.events.at(-1)).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.COMBAT_LOST,
});
});
it('does not tick a bleed once the killing blow has already won the fight', () => {
const state = baseState({
monster: monster({ currentHp: 10 }),
player: {
currentHp: 3,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 6,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 2,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.state.status).toBe(CombatStatus.WON);
expect(result.state.player.currentHp).toBe(3);
});
it('does not mutate the state it was given', () => {
const state = baseState({
round: 3,
monster: withAbilities(ROAD_HOUND_ABILITIES),
});
const snapshot = structuredClone(state);
engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(state).toEqual(snapshot);
});
});
});

View File

@@ -0,0 +1,382 @@
import { Injectable } from '@nestjs/common';
import { CombatAction } from './combat-action.enum';
import { calculateDamage } from './combat-damage';
import { Combatant } from './combatant.enum';
import {
ActiveStatusEffect,
CombatActionInput,
CombatEngineCombatant,
CombatEngineEvent,
CombatEngineResult,
CombatEngineState,
} from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
import { StatusEffectType } from './status-effect.enum';
import type {
MonsterBleedAbility,
MonsterTelegraphAbility,
} from '../monsters/monster-abilities';
export class UnsupportedCombatActionError extends Error {
constructor(action: string) {
super(`Unsupported combat action: ${action}`);
}
}
// Player-side action modifiers. These are character abilities rather than
// monster content, so they stay constants here (Playable Slice 0.6 spec
// §10/§11). What the *monster* does now comes from its own definition
// (Playable Slice 0.7 V2 spec §4, §8) instead of a rule every enemy shares.
const HEAVY_ATTACK_MULTIPLIER = 1.6;
const SHIELD_BASH_MULTIPLIER = 0.7;
const DEFEND_MITIGATION_MULTIPLIER = 0.5;
const POTION_HEAL_FRACTION = 0.35;
@Injectable()
export class CombatEngineService {
resolveAction(
state: CombatEngineState,
input: CombatActionInput,
): CombatEngineResult {
switch (input.action) {
case CombatAction.ATTACK:
return this.resolvePlayerStrike(state, 1);
case CombatAction.HEAVY_STRIKE:
return this.resolvePlayerStrike(state, HEAVY_ATTACK_MULTIPLIER);
case CombatAction.SHIELD_BASH:
return this.resolveShieldBash(state);
case CombatAction.DEFEND:
return this.resolveDefend(state);
case CombatAction.POTION:
return this.resolvePotion(state);
default:
throw new UnsupportedCombatActionError(input.action);
}
}
private resolvePlayerStrike(
state: CombatEngineState,
multiplier: number,
): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [];
const damage = calculateDamage(
player.stats,
monster.stats.armor,
multiplier,
);
monster.currentHp = Math.max(0, monster.currentHp - damage);
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: damage,
});
return this.finishRound(state, player, monster, events, false);
}
private resolveShieldBash(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [];
const damage = calculateDamage(
player.stats,
monster.stats.armor,
SHIELD_BASH_MULTIPLIER,
);
monster.currentHp = Math.max(0, monster.currentHp - damage);
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: damage,
});
let interrupted = false;
if (monster.stats.pendingAction) {
monster.stats.pendingAction = undefined;
interrupted = true;
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.INTERRUPT,
});
}
return this.finishRound(state, player, monster, events, false, interrupted);
}
private resolveDefend(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [
{
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.DEFEND,
},
];
return this.finishRound(state, player, monster, events, true);
}
private resolvePotion(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const rawHeal = Math.round(player.maxHp * POTION_HEAL_FRACTION);
const healed = Math.min(rawHeal, player.maxHp - player.currentHp);
player.currentHp += healed;
player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1;
const events: CombatEngineEvent[] = [
{
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.HEAL,
amount: healed,
},
];
return this.finishRound(state, player, monster, events, false);
}
/**
* Closes the round: monster reply, then ongoing effects, then outcome.
*
* Status effects tick after the monster has acted and regardless of whether
* it acted at all -- Bleeding sits on the player and does not care that
* SHIELD_BASH interrupted the enemy that caused it. An effect applied this
* round therefore also deals its first tick this round.
*/
private finishRound(
state: CombatEngineState,
player: CombatEngineCombatant,
monster: CombatEngineCombatant,
events: CombatEngineEvent[],
defended: boolean,
interrupted = false,
): CombatEngineResult {
if (monster.currentHp <= 0) {
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.COMBAT_WON,
});
const defeatedMonster = {
...monster,
stats: { ...monster.stats, pendingAction: undefined },
};
return {
state: {
...state,
player,
monster: defeatedMonster,
status: CombatStatus.WON,
},
events,
};
}
if (!interrupted) {
this.resolveMonsterTurn(state.round, monster, player, defended, events);
}
if (player.currentHp > 0) {
this.tickStatusEffects(player, events);
}
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,
};
}
private resolveMonsterTurn(
round: number,
monster: CombatEngineCombatant,
player: CombatEngineCombatant,
defended: boolean,
events: CombatEngineEvent[],
): void {
const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1;
const abilities = monster.stats.abilities ?? {};
if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
monster.stats.pendingAction = undefined;
this.strikePlayer(
monster,
player,
(abilities.telegraph?.damageMultiplier ?? HEAVY_ATTACK_MULTIPLIER) *
defendMultiplier,
events,
);
return;
}
if (this.shouldTrigger(abilities.telegraph, round)) {
monster.stats.pendingAction = 'HEAVY_ATTACK';
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.TELEGRAPH,
});
return;
}
this.strikePlayer(monster, player, defendMultiplier, events);
// The bite lands first and then tears: Bleeding is applied on top of a
// normal attack, not instead of one (spec §4).
const bleed = abilities.bleed;
if (bleed && this.shouldTrigger(bleed, round)) {
this.applyBleed(player, bleed, events);
}
}
private strikePlayer(
monster: CombatEngineCombatant,
player: CombatEngineCombatant,
multiplier: number,
events: CombatEngineEvent[],
): void {
const damage = calculateDamage(
monster.stats,
player.stats.armor,
multiplier,
);
player.currentHp = Math.max(0, player.currentHp - damage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: damage,
});
}
/**
* A content-configured ability fires on every round divisible by its
* interval. A fixed cadence rather than a hidden roll keeps the fight
* readable and the engine deterministic (AGENTS §10).
*/
private shouldTrigger(
ability: MonsterTelegraphAbility | MonsterBleedAbility | undefined,
round: number,
): boolean {
return (
ability !== undefined &&
ability.roundInterval > 0 &&
round % ability.roundInterval === 0
);
}
/** Re-applying refreshes the duration rather than stacking a second wound. */
private applyBleed(
player: CombatEngineCombatant,
bleed: MonsterBleedAbility,
events: CombatEngineEvent[],
): void {
const effects = player.stats.statusEffects ?? [];
const existing = effects.find(
(effect) => effect.type === StatusEffectType.BLEED,
);
if (existing) {
existing.remainingRounds = bleed.durationRounds;
existing.damagePerRound = bleed.damagePerRound;
} else {
effects.push({
type: StatusEffectType.BLEED,
remainingRounds: bleed.durationRounds,
damagePerRound: bleed.damagePerRound,
});
}
player.stats.statusEffects = effects;
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.STATUS_APPLIED,
amount: bleed.durationRounds,
statusEffect: StatusEffectType.BLEED,
});
}
/**
* Deals one round of every active effect, then ages it. Bleeding ignores
* armor: it is an open wound, not a blow that can be turned aside.
*/
private tickStatusEffects(
combatant: CombatEngineCombatant,
events: CombatEngineEvent[],
): void {
const effects = combatant.stats.statusEffects ?? [];
if (effects.length === 0) {
return;
}
const surviving: ActiveStatusEffect[] = [];
for (const effect of effects) {
const damage = Math.max(0, effect.damagePerRound);
combatant.currentHp = Math.max(0, combatant.currentHp - damage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.STATUS_DAMAGE,
amount: damage,
statusEffect: effect.type,
});
const remainingRounds = effect.remainingRounds - 1;
if (remainingRounds > 0) {
surviving.push({ ...effect, remainingRounds });
} else {
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.STATUS_EXPIRED,
statusEffect: effect.type,
});
}
}
combatant.stats.statusEffects = surviving;
}
private cloneCombatant(
combatant: CombatEngineCombatant,
): CombatEngineCombatant {
return {
...combatant,
stats: {
...combatant.stats,
statusEffects: combatant.stats.statusEffects?.map((effect) => ({
...effect,
})),
},
};
}
}

View File

@@ -0,0 +1,73 @@
import { Combatant } from './combatant.enum';
import { CombatAction } from './combat-action.enum';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
import { StatusEffectType } from './status-effect.enum';
import type { MonsterAbilities } from '../monsters/monster-abilities';
// Only HEAVY_ATTACK needs telegraphing today; NORMAL_ATTACK resolves
// immediately and is never held as a pending intent (Playable Slice 0.6
// spec §5). Add members here as future slices add more prepared actions.
export type CombatIntent = 'HEAVY_ATTACK';
/**
* An ongoing effect carried between rounds (Playable Slice 0.7 V2 spec §4).
*
* Duration and damage are copied from the applying monster's content at the
* moment it lands, so the effect keeps ticking on its own terms even if the
* definition is retuned mid-fight. Server-authoritative: the client only
* ever renders what this says.
*/
export interface ActiveStatusEffect {
type: StatusEffectType;
remainingRounds: number;
damagePerRound: number;
}
export interface CombatEngineCombatantStats {
attack: number;
weaponDamage?: number;
armor: number;
// Player-only: seeded at combat start, decremented by POTION. Optional
// because the monster's stats never carry it.
potionsRemaining?: number;
// Monster-only: set when it telegraphs, cleared when the action resolves
// or is interrupted. Optional because the player's stats never carry it.
pendingAction?: CombatIntent;
// Monster-only: the content-authored mechanics this enemy fights with.
// Absent (or empty) means a plain attacker with no special behaviour.
abilities?: MonsterAbilities;
// Effects currently ticking on this combatant. Only the player carries
// any today -- nothing in this slice bleeds a monster.
statusEffects?: ActiveStatusEffect[];
}
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;
statusEffect?: StatusEffectType;
}
export interface CombatEngineResult {
state: CombatEngineState;
events: CombatEngineEvent[];
}

View File

@@ -0,0 +1,443 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
import { Character } from '../characters/entities/character.entity';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { EquipmentService } from '../equipment/equipment.service';
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 { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.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 { CombatService } from './combat.service';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
const WORN_SWORD_DEFINITION_ID = '50000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_DEFINITION_ID = '50000000-0000-4000-8000-000000000002';
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
interface FakeState {
characters: Character[];
hunts: Hunt[];
huntEncounters: HuntEncounter[];
monsters: MonsterDefinition[];
combats: Combat[];
combatEvents: CombatEvent[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
characterEquipment: CharacterEquipment[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
private readonly target: EntityTarget<T>,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
lock?: { mode: string };
}): Promise<T | null> {
const row =
this.rows().find((candidate) => this.matches(candidate, options.where)) ??
null;
return Promise.resolve(
row ? this.withRelations(row, options.relations) : 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>;
relations?: Record<string, unknown>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> {
const matched = this.rows().filter((row) =>
this.matches(row, options.where),
);
return Promise.resolve(
matched.map((row) => this.withRelations(row, options.relations)),
);
}
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 withRelations(row: T, relations?: Record<string, unknown>): T {
if (!relations) {
return row;
}
const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem)
.itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find(
(d) => d.id === itemDefinitionId,
);
}
if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment)
.characterItemId;
const characterItem = this.state.characterItems.find(
(ci) => ci.id === characterItemId,
);
copy['characterItem'] = characterItem
? {
...characterItem,
itemDefinition: this.state.itemDefinitions.find(
(d) => d.id === characterItem.itemDefinitionId,
),
}
: undefined;
}
return copy;
}
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[];
if (this.target === ItemDefinition)
return this.state.itemDefinitions as T[];
if (this.target === CharacterItem) return this.state.characterItems as T[];
if (this.target === CharacterEquipment)
return this.state.characterEquipment 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 FakeDataSource {
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, this);
}
async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target),
} as unknown as EntityManager);
}
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',
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
hpRegenSince: null,
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: 'road-bandit',
name: 'Road Bandit',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as MonsterDefinition;
}
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 encounter(
id: string,
overrides: Partial<HuntEncounter> = {},
): HuntEncounter {
return {
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 itemDefinition(
overrides: Partial<ItemDefinition> = {},
): ItemDefinition {
return {
id: WORN_SWORD_DEFINITION_ID,
key: 'worn-short-sword',
name: 'Worn Shortsword',
description: '',
type: ItemType.EQUIPMENT,
equipmentSlot: EquipmentSlot.WEAPON,
rarity: ItemRarity.COMMON,
tier: 1,
weaponDamage: 8,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
sellPrice: 0,
iconPath: '/images/items/worn-short-sword.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as ItemDefinition;
}
function fakeTravelService(): TravelService {
return {
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
} as unknown as TravelService;
}
function fakeRewardService(): CombatRewardService {
return {
grantVictoryRewards: jest.fn().mockResolvedValue({ silver: 0, items: [] }),
loadRewards: jest.fn().mockResolvedValue(null),
} as unknown as CombatRewardService;
}
function createHarness() {
const state: FakeState = {
characters: [character()],
hunts: [hunt()],
huntEncounters: [],
monsters: [monster()],
combats: [],
combatEvents: [],
itemDefinitions: [
itemDefinition(),
itemDefinition({
id: BANDIT_BLADE_DEFINITION_ID,
key: 'bandit-blade',
name: 'Bandit Blade',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
}),
],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: WORN_SWORD_DEFINITION_ID,
quantity: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: BANDIT_BLADE_DEFINITION_ID,
quantity: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterItem,
],
characterEquipment: [
{
id: 'equip-1',
characterId: CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: WORN_SWORD_ITEM_ID,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterEquipment,
],
};
const dataSource = new FakeDataSource(state);
const characterVitals = new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
});
const characterStats = new CharacterStatsService(
dataSource as unknown as DataSource,
characterVitals,
);
const equipmentService = new EquipmentService(
dataSource as unknown as DataSource,
characterStats,
characterVitals,
);
const combatService = new CombatService(
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
characterStats,
characterVitals,
fakeRewardService(),
);
return { state, equipmentService, combatService };
}
describe('equipping the Bandit Blade increases combat damage (spec §45, §60)', () => {
it('deals more damage against the same monster after the upgrade than before it', async () => {
const { state, combatService, equipmentService } = createHarness();
state.huntEncounters.push(encounter('encounter-1'));
const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
let beforeDamage = 0;
let beforeResult = before;
for (
let round = 0;
round < 10 && beforeResult.status === 'ACTIVE';
round += 1
) {
beforeResult = await combatService.performAction(
CHARACTER_ID,
before.id,
CombatAction.ATTACK,
);
if (round === 0) {
beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp;
}
}
// Equipment cannot change during an active combat (spec §46), so this
// first fight must be resolved to completion before equipping.
expect(beforeResult.status).not.toBe('ACTIVE');
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
state.huntEncounters.push(encounter('encounter-2'));
const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2');
const afterResult = await combatService.performAction(
CHARACTER_ID,
after.id,
CombatAction.ATTACK,
);
const afterDamage = after.monster.maxHp - afterResult.monster.currentHp;
// (6+8) vs 5 armor -> round(14 * 60/65) = 13
expect(beforeDamage).toBe(13);
// (7+11) vs 5 armor -> round(18 * 60/65) = 17
expect(afterDamage).toBe(17);
expect(afterDamage).toBeGreaterThan(beforeDamage);
});
it("rejects equipping during an active combat, and never retroactively rewrites a finished combat's snapshot", async () => {
const { state, combatService, equipmentService } = createHarness();
state.huntEncounters.push(encounter('encounter-1'));
const combat = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
// Equipment cannot change while this combat is ACTIVE (spec §46).
await expect(
equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID),
).rejects.toMatchObject({ code: 'CHARACTER_IN_COMBAT' });
// Resolve the fight, then equip — the already-finished combat's snapshot
// (status/round) must stay exactly what it was when the fight ended.
let result = combat;
for (let round = 0; round < 10 && result.status === 'ACTIVE'; round += 1) {
result = await combatService.performAction(
CHARACTER_ID,
combat.id,
CombatAction.ATTACK,
);
}
expect(result.status).not.toBe('ACTIVE');
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
// The finished combat's playerState snapshot (written once at startCombat)
// must not be retroactively rewritten by equipping after the fight ends.
expect(state.combats[0].playerState).toEqual({
attack: 6,
weaponDamage: 8,
armor: 0,
potionsRemaining: 2,
});
const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id);
expect(reloaded.status).toBe(result.status);
expect(reloaded.round).toBe(result.round);
});
});

View File

@@ -0,0 +1,12 @@
export enum CombatEventType {
DAMAGE = 'DAMAGE',
HEAL = 'HEAL',
DEFEND = 'DEFEND',
TELEGRAPH = 'TELEGRAPH',
INTERRUPT = 'INTERRUPT',
STATUS_APPLIED = 'STATUS_APPLIED',
STATUS_DAMAGE = 'STATUS_DAMAGE',
STATUS_EXPIRED = 'STATUS_EXPIRED',
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,140 @@
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: 'FLEE' })
.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,32 @@
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,105 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type CombatErrorCode =
| 'HUNT_ENCOUNTER_NOT_FOUND'
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
| 'INVALID_HUNT_ENCOUNTER'
| 'CHARACTER_TRAVELLING'
| 'CHARACTER_TOO_WOUNDED'
| 'COMBAT_ALREADY_ACTIVE'
| 'COMBAT_NOT_FOUND'
| 'COMBAT_ALREADY_FINISHED'
| 'COMBAT_STATE_INVALID'
| 'COMBAT_NO_POTIONS_REMAINING';
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 characterTooWounded(): CombatDomainError {
return new CombatDomainError(
'CHARACTER_TOO_WOUNDED',
HttpStatus.CONFLICT,
'The character is too wounded to fight.',
);
}
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 function combatNoPotionsRemaining(): CombatDomainError {
return new CombatDomainError(
'COMBAT_NO_POTIONS_REMAINING',
HttpStatus.CONFLICT,
'No potions remain in this combat.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,34 @@
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 {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.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 {
ActiveStatusEffect,
CombatEngineState,
CombatIntent,
} from './combat-engine.types';
import {
characterNotFound,
characterTooWounded,
characterTravelling,
combatAlreadyActive,
combatAlreadyFinished,
combatNoPotionsRemaining,
combatNotFound,
combatStateInvalid,
huntEncounterAlreadyConsumed,
huntEncounterNotFound,
invalidHuntEncounter,
} from './combat.errors';
import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity';
import {
Combat,
CombatMonsterState,
CombatPlayerState,
} from './entities/combat.entity';
import { StatusEffectType } from './status-effect.enum';
// Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the
// persistent consumable inventory.
const STARTING_POTION_COUNT = 2;
export interface CombatStatusEffectDto {
type: StatusEffectType;
remainingRounds: number;
damagePerRound: number;
}
export interface CombatPlayerDto {
name: string;
maxHp: number;
currentHp: number;
potionsRemaining: number;
potionsMax: number;
statusEffects: CombatStatusEffectDto[];
}
export interface CombatMonsterDto {
key: string;
name: string;
level: number;
maxHp: number;
currentHp: number;
artworkPath: string;
pendingIntent: CombatIntent | null;
}
export interface CombatEventDto {
round: number;
sequence: number;
type: string;
source: string;
target: string;
amount?: number;
statusEffect?: StatusEffectType;
}
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 characterStats: CharacterStatsService,
private readonly characterVitals: CharacterVitalsService,
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 = await this.characterStats.calculate(
character,
manager,
);
if (playerStats.currentHp < 1) {
throw characterTooWounded();
}
this.characterVitals.pause(character, playerStats.currentHp);
await characters.save(character);
const combat = combats.create({
characterId,
huntEncounterId: encounter.id,
monsterDefinitionId: monster.id,
status: CombatStatus.ACTIVE,
round: 1,
playerMaxHp: playerStats.maxHp,
playerCurrentHp: playerStats.currentHp,
monsterMaxHp: monster.maxHp,
monsterCurrentHp: monster.maxHp,
playerState: {
attack: playerStats.attack,
weaponDamage: playerStats.weaponDamage,
armor: playerStats.armor,
potionsRemaining: STARTING_POTION_COUNT,
},
monsterState: {
attack: monster.attack,
armor: monster.armor,
abilities: monster.abilities ?? {},
},
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). Keeping both code
// paths on the same order avoids a lock-order inversion that could
// deadlock two concurrent requests against the same character. Do not
// reorder this.
const character = 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();
}
if (
action === CombatAction.POTION &&
(combat.playerState.potionsRemaining ?? 0) <= 0
) {
throw combatNoPotionsRemaining();
}
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;
combat.playerState = result.state.player.stats as CombatPlayerState;
combat.monsterState = result.state.monster.stats as CombatMonsterState;
if (combat.status !== CombatStatus.ACTIVE) {
combat.completedAt = new Date();
this.characterVitals.resume(character, combat.playerCurrentHp);
await this.settleEncounter(
manager.getRepository(HuntEncounter),
combat.huntEncounterId,
combat.status,
);
} else {
this.characterVitals.pause(character, combat.playerCurrentHp);
}
await characters.save(character);
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,
statusEffect: event.statusEffect ?? 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 [reloadedCharacter, 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,
reloadedCharacter.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,
potionsRemaining: combat.playerState.potionsRemaining,
potionsMax: STARTING_POTION_COUNT,
statusEffects: this.toStatusEffectDtos(
combat.playerState.statusEffects,
),
},
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
maxHp: combat.monsterMaxHp,
currentHp: combat.monsterCurrentHp,
artworkPath: monster.artworkPath,
pendingIntent: combat.monsterState.pendingAction ?? null,
},
events: events.map((event) => ({
round: event.round,
sequence: event.sequence,
type: event.type,
source: event.source,
target: event.target,
amount: event.amount ?? undefined,
statusEffect: event.statusEffect ?? undefined,
})),
rewards,
};
}
private toStatusEffectDtos(
effects: ActiveStatusEffect[] | undefined,
): CombatStatusEffectDto[] {
return (effects ?? []).map((effect) => ({
type: effect.type,
remainingRounds: effect.remainingRounds,
damagePerRound: effect.damagePerRound,
}));
}
}

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,76 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Combatant } from '../combatant.enum';
import { CombatEventType } from '../combat-event-type.enum';
import { StatusEffectType } from '../status-effect.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;
// Which ongoing effect a STATUS_* event is about. Null for every other
// event type (spec §4).
@Column({
name: 'status_effect',
type: 'enum',
enum: StatusEffectType,
enumName: 'status_effect_type_enum',
nullable: true,
})
statusEffect!: StatusEffectType | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => Combat, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'combat_id' })
combat!: Combat;
}

View File

@@ -0,0 +1,102 @@
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 { ActiveStatusEffect, CombatIntent } from '../combat-engine.types';
import { CombatStatus } from '../combat-status.enum';
import type { MonsterAbilities } from '../../monsters/monster-abilities';
export interface CombatCombatantState {
attack: number;
armor: number;
pendingAction?: CombatIntent;
statusEffects?: ActiveStatusEffect[];
}
export interface CombatMonsterState extends CombatCombatantState {
// Snapshotted from MonsterDefinition when the fight starts, so retuning
// content mid-fight cannot change the rules of a running combat.
abilities: MonsterAbilities;
}
export interface CombatPlayerState extends CombatCombatantState {
weaponDamage: number;
potionsRemaining: 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!: CombatMonsterState;
@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,49 @@
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,9 @@
/**
* Ongoing effects a combatant can carry between rounds (spec §4).
*
* Only the Feral Road Hound's Bleeding exists in this slice; the enum is the
* extension point later status effects hang off.
*/
export enum StatusEffectType {
BLEED = 'BLEED',
}

View File

@@ -0,0 +1,32 @@
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 { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { GameConditionService } from './game-condition.service';
/**
* The shared gate engine (NPC spec §21).
*
* `forFeature` is required even though the service resolves its repositories
* off the DataSource: the runtime config uses `autoLoadEntities`, which only
* registers entities a module actually declares.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Character,
CharacterItem,
ItemDefinition,
CharacterNpcState,
CharacterReputation,
ReputationFaction,
]),
],
providers: [GameConditionService],
exports: [GameConditionService],
})
export class ConditionsModule {}

View File

@@ -0,0 +1,317 @@
import { DataSource } from '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 { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { GameConditionService } from './game-condition.service';
import { ComparisonOperator, GameConditionType } from './game-condition.types';
const CHARACTER_ID = 'character-1';
const NPC_ID = 'npc-1';
const FACTION_ID = 'faction-1';
interface Fixture {
renown?: number;
reputation?: number;
factionEnabled?: boolean;
flags?: Record<string, boolean | string | number> | null;
itemQuantity?: number;
}
function createService(fixture: Fixture = {}): GameConditionService {
const dataSource = {
getRepository: (entity: unknown) => {
if (entity === Character) {
return {
findOneBy: () =>
Promise.resolve({
id: CHARACTER_ID,
renown: fixture.renown ?? 1,
}),
};
}
if (entity === ReputationFaction) {
return {
findOneBy: (criteria: { key: string; enabled: boolean }) =>
Promise.resolve(
criteria.key === 'border-guard' &&
(fixture.factionEnabled ?? true) === criteria.enabled
? { id: FACTION_ID, key: 'border-guard' }
: null,
),
};
}
if (entity === CharacterReputation) {
return {
findOneBy: () =>
Promise.resolve(
fixture.reputation === undefined
? null
: { reputation: fixture.reputation },
),
};
}
if (entity === CharacterNpcState) {
return {
findOneBy: () =>
Promise.resolve(
fixture.flags === undefined || fixture.flags === null
? null
: { flags: fixture.flags },
),
};
}
if (entity === ItemDefinition) {
return {
findOneBy: (criteria: { key: string }) =>
Promise.resolve(
criteria.key === 'ash-pelt' ? { id: 'item-1' } : null,
),
};
}
if (entity === CharacterItem) {
return {
findOneBy: () =>
Promise.resolve(
fixture.itemQuantity === undefined
? null
: { quantity: fixture.itemQuantity },
),
};
}
throw new Error('Unexpected repository');
},
} as unknown as DataSource;
return new GameConditionService(dataSource);
}
describe('GameConditionService', () => {
it('treats an empty condition list as met, so ungated content stays open', async () => {
await expect(
createService().evaluate({ characterId: CHARACTER_ID }, []),
).resolves.toBe(true);
await expect(
createService().evaluate({ characterId: CHARACTER_ID }, null),
).resolves.toBe(true);
});
it('compares regional reputation against the required value (spec §20)', async () => {
const service = createService({ reputation: 30 });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 25,
},
]),
).resolves.toBe(true);
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 31,
},
]),
).resolves.toBe(false);
});
it('reads a faction the character never met as 0, not as a pass', async () => {
// No CharacterReputation row exists. The gate must still evaluate, and it
// must evaluate against zero.
const service = createService({ reputation: undefined });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 1,
},
]),
).resolves.toBe(false);
});
it('compares World Renown against the character rank', async () => {
const service = createService({ renown: 3 });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 3,
},
]),
).resolves.toBe(true);
});
it('reads a per-NPC flag, and only when an NPC is in context (spec §7)', async () => {
const service = createService({ flags: { met: true } });
await expect(
service.evaluate({ characterId: CHARACTER_ID, npcId: NPC_ID }, [
{ type: GameConditionType.FLAG_SET, key: 'met', value: true },
]),
).resolves.toBe(true);
// Without an NPC there is no flag store to read, so the gate stays shut
// rather than guessing.
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.FLAG_SET, key: 'met', value: true },
]),
).resolves.toBe(false);
});
it('treats an unset flag as false, which is what makes a greeting node work', async () => {
// No state row at all: the character has never spoken to this NPC.
const service = createService({ flags: null });
await expect(
service.evaluate({ characterId: CHARACTER_ID, npcId: NPC_ID }, [
{ type: GameConditionType.FLAG_SET, key: 'met', value: false },
]),
).resolves.toBe(true);
});
it('defaults HAS_ITEM to "at least one"', async () => {
await expect(
createService({ itemQuantity: 1 }).evaluate(
{ characterId: CHARACTER_ID },
[{ type: GameConditionType.HAS_ITEM, key: 'ash-pelt' }],
),
).resolves.toBe(true);
await expect(
createService({ itemQuantity: undefined }).evaluate(
{ characterId: CHARACTER_ID },
[{ type: GameConditionType.HAS_ITEM, key: 'ash-pelt' }],
),
).resolves.toBe(false);
});
it('requires every condition to hold', async () => {
const service = createService({ renown: 1, reputation: 100 });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 10,
},
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 5,
},
]),
).resolves.toBe(false);
});
it('fails closed on a condition type nothing backs yet', async () => {
// Quests arrive in Slice 0.9. Until then a quest gate must lock content,
// never wave it through -- a gate that defaults open is not a gate.
const service = createService();
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.QUEST_COMPLETED, key: 'anything' },
]),
).resolves.toBe(false);
});
it('fails closed when a condition names no target', async () => {
const service = createService({ reputation: 999 });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
operator: ComparisonOperator.GTE,
value: 1,
},
]),
).resolves.toBe(false);
});
it('fails closed when the required value is not a number', async () => {
const service = createService({ reputation: 50 });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 'lots',
},
]),
).resolves.toBe(false);
});
it('reports each condition separately for a UI that explains a lock', async () => {
const service = createService({ renown: 1, reputation: 100 });
const outcomes = await service.describe({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 10,
},
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 5,
},
]);
expect(outcomes.map((outcome) => outcome.met)).toEqual([true, false]);
});
it('reports the current value behind each requirement', async () => {
const service = createService({ renown: 1, reputation: 14 });
const outcomes = await service.describe({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 25,
},
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 3,
},
]);
// "Current: 14" against "Requires 25" is the whole point of showing a
// locked offer rather than hiding it (slice §5).
expect(outcomes[0]).toMatchObject({ met: false, actual: 14 });
expect(outcomes[1]).toMatchObject({ met: false, actual: 1 });
});
it('reports a null current value for a condition with no scale', async () => {
const service = createService({ renown: 1, reputation: 100 });
const outcomes = await service.describe(
{ characterId: CHARACTER_ID, npcId: NPC_ID },
[{ type: GameConditionType.FLAG_SET, key: 'met', value: true }],
);
expect(outcomes[0].actual).toBeNull();
});
});

View File

@@ -0,0 +1,246 @@
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from '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 { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import {
ComparisonOperator,
compare,
GameCondition,
GameConditionType,
SUPPORTED_CONDITION_TYPES,
} from './game-condition.types';
/**
* What a condition is being evaluated *about*.
*
* `npcId` scopes FLAG_SET, because a dialogue flag is per-NPC player state
* (spec §7) rather than a global switch.
*/
export interface ConditionContext {
characterId: string;
npcId?: string;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
/**
* One condition's result, plus the number it was measured against.
*
* `actual` is null for conditions with no scale -- a flag is set or it is not,
* and "Current: 0" would be a lie about a boolean.
*/
interface ConditionEvaluation {
met: boolean;
actual: number | null;
}
export interface ConditionOutcome {
condition: GameCondition;
met: boolean;
actual: number | null;
}
/**
* Evaluates content-defined conditions server-side (NPC spec §21).
*
* Reused by dialogue, shop offers and exchange rules so unlock logic exists
* once. The client never evaluates a condition (spec §37.9); it is only ever
* told the outcome.
*
* Everything fails closed. An unknown type, a missing key, a condition whose
* backing system does not exist yet -- all read as "not met". For a gate, the
* safe direction of a bug is locked, never opened.
*/
@Injectable()
export class GameConditionService {
constructor(private readonly dataSource: DataSource) {}
/** True only when every condition holds. An empty list is always true. */
async evaluate(
context: ConditionContext,
conditions: GameCondition[] | null | undefined,
manager?: EntityManager,
): Promise<boolean> {
if (!conditions || conditions.length === 0) {
return true;
}
const scope: RepositoryScope = manager ?? this.dataSource;
for (const condition of conditions) {
const evaluation = await this.evaluateOne(context, condition, scope);
if (!evaluation.met) {
return false;
}
}
return true;
}
/**
* Reports each condition individually, so a caller can say *why* something
* is locked instead of only hiding it. Slice 0.8.5 builds its locked-offer
* presentation on this.
*/
async describe(
context: ConditionContext,
conditions: GameCondition[] | null | undefined,
manager?: EntityManager,
): Promise<ConditionOutcome[]> {
if (!conditions || conditions.length === 0) {
return [];
}
const scope: RepositoryScope = manager ?? this.dataSource;
const outcomes: ConditionOutcome[] = [];
for (const condition of conditions) {
const evaluation = await this.evaluateOne(context, condition, scope);
outcomes.push({
condition,
met: evaluation.met,
actual: evaluation.actual,
});
}
return outcomes;
}
private async evaluateOne(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<ConditionEvaluation> {
if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) {
return { met: false, actual: null };
}
switch (condition.type) {
case GameConditionType.REGION_REPUTATION:
return this.evaluateRegionReputation(context, condition, scope);
case GameConditionType.WORLD_RENOWN:
return this.evaluateWorldRenown(context, condition, scope);
case GameConditionType.FLAG_SET:
return this.evaluateFlag(context, condition, scope);
case GameConditionType.HAS_ITEM:
return this.evaluateHasItem(context, condition, scope);
default:
return { met: false, actual: null };
}
}
private async evaluateRegionReputation(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<ConditionEvaluation> {
if (!condition.key) {
return { met: false, actual: null };
}
const faction = await scope
.getRepository(ReputationFaction)
.findOneBy({ key: condition.key, enabled: true });
if (!faction) {
return { met: false, actual: null };
}
const row = await scope.getRepository(CharacterReputation).findOneBy({
characterId: context.characterId,
factionId: faction.id,
});
// A faction the character never interacted with reads as 0, not as absent
// -- the same rule ReputationService.getCharacterReputation applies.
const reputation = row?.reputation ?? 0;
return {
met: this.compareNumeric(reputation, condition),
actual: reputation,
};
}
private async evaluateWorldRenown(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<ConditionEvaluation> {
const character = await scope
.getRepository(Character)
.findOneBy({ id: context.characterId });
if (!character) {
return { met: false, actual: null };
}
return {
met: this.compareNumeric(character.renown, condition),
actual: character.renown,
};
}
private async evaluateFlag(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<ConditionEvaluation> {
// Flags are per-NPC player state (spec §7). Without an NPC in context
// there is nothing to read, so the gate stays shut.
if (!condition.key || !context.npcId) {
return { met: false, actual: null };
}
const state = await scope.getRepository(CharacterNpcState).findOneBy({
characterId: context.characterId,
npcId: context.npcId,
});
const expected = condition.value ?? true;
return {
met: (state?.flags?.[condition.key] ?? false) === expected,
actual: null,
};
}
private async evaluateHasItem(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<ConditionEvaluation> {
if (!condition.key) {
return { met: false, actual: null };
}
const definition = await scope
.getRepository(ItemDefinition)
.findOneBy({ key: condition.key });
if (!definition) {
return { met: false, actual: null };
}
const owned = await scope.getRepository(CharacterItem).findOneBy({
characterId: context.characterId,
itemDefinitionId: definition.id,
});
// "Has item" without an explicit comparison means "at least one".
const quantity = owned?.quantity ?? 0;
return {
met: this.compareNumeric(quantity, {
...condition,
operator: condition.operator ?? ComparisonOperator.GTE,
value: condition.value ?? 1,
}),
actual: quantity,
};
}
private compareNumeric(actual: number, condition: GameCondition): boolean {
const expected = Number(condition.value);
if (!Number.isFinite(expected)) {
return false;
}
return compare(
actual,
condition.operator ?? ComparisonOperator.GTE,
expected,
);
}
}

View File

@@ -0,0 +1,76 @@
/**
* The shared condition vocabulary (NPC spec §19, §20).
*
* One engine gates dialogue, shop offers and exchanges alike, so a new gate is
* a row of content rather than another bespoke `if` in a service (spec §21,
* "Keine separaten Condition-Systeme für jedes Feature bauen").
*/
export enum GameConditionType {
QUEST_ACTIVE = 'QUEST_ACTIVE',
QUEST_COMPLETED = 'QUEST_COMPLETED',
HAS_ITEM = 'HAS_ITEM',
REGION_REPUTATION = 'REGION_REPUTATION',
WORLD_RENOWN = 'WORLD_RENOWN',
FLAG_SET = 'FLAG_SET',
BOSS_DEFEATED = 'BOSS_DEFEATED',
LOCATION_DISCOVERED = 'LOCATION_DISCOVERED',
}
export enum ComparisonOperator {
EQ = 'EQ',
NEQ = 'NEQ',
GT = 'GT',
GTE = 'GTE',
LT = 'LT',
LTE = 'LTE',
}
/**
* Stored generically so content can express a new gate without a schema
* change (spec §20). `key` names the target (a faction key, a flag name),
* `operator`/`value` the comparison.
*/
export interface GameCondition {
type: GameConditionType;
key?: string;
operator?: ComparisonOperator;
value?: string | number | boolean;
}
/**
* Condition types this build can actually answer.
*
* The remaining types are part of the V1 vocabulary (spec §19) but have no
* backing system yet: quests arrive in Slice 0.9, bosses in 0.11, and location
* discovery is not tracked per character at all. They are listed in the enum
* so content and migrations do not need rewriting later, and rejected at
* evaluation time so an unbacked gate can never silently read as "passed".
*/
export const SUPPORTED_CONDITION_TYPES: ReadonlySet<GameConditionType> =
new Set([
GameConditionType.REGION_REPUTATION,
GameConditionType.WORLD_RENOWN,
GameConditionType.FLAG_SET,
GameConditionType.HAS_ITEM,
]);
export function compare(
left: number,
operator: ComparisonOperator,
right: number,
): boolean {
switch (operator) {
case ComparisonOperator.EQ:
return left === right;
case ComparisonOperator.NEQ:
return left !== right;
case ComparisonOperator.GT:
return left > right;
case ComparisonOperator.GTE:
return left >= right;
case ComparisonOperator.LT:
return left < right;
case ComparisonOperator.LTE:
return left <= right;
}
}

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,176 @@
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,80 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds the local location view content to `location_definitions`.
*
* The existing `description`/`artwork_path` columns are deliberately left
* alone — the map and hunt screens still render them. Backfill defaults keep
* existing rows valid; the seed replaces them with authored content.
*/
export class CreateLocalLocationView1788700000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "region_tier_label" character varying(50) NOT NULL DEFAULT \'\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "location_type" character varying(50) NOT NULL DEFAULT \'TRANSITION\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "local_description" text NOT NULL DEFAULT \'\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "local_artwork_path" character varying(255) NOT NULL DEFAULT \'\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "local_points_of_interest" jsonb NOT NULL DEFAULT \'[]\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "local_primary_actions" jsonb NOT NULL DEFAULT \'[]\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "local_reward_preview" jsonb NOT NULL DEFAULT \'[]\'',
);
// Existing rows fall back to their map-level content until the seed runs,
// so the view never renders an empty breadcrumb or a missing artwork.
await queryRunner.query(
'UPDATE "location_definitions" SET "region_name" = "region_key", "local_description" = "description", "local_artwork_path" = "artwork_path" WHERE "region_name" = \'\'',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path" character varying(255) NOT NULL DEFAULT \'\'',
);
await queryRunner.query(
'UPDATE "monster_definitions" SET "icon_path" = "artwork_path" WHERE "icon_path" = \'\'',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "local_reward_preview"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "local_primary_actions"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "local_points_of_interest"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "local_artwork_path"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "local_description"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "location_type"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "region_tier_label"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "region_name"',
);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateEquipment1789000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Reuses the "equipment_slot_enum" type created by CreateLootAndRewards.
await queryRunner.query(`CREATE TABLE "character_equipment" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"slot" "equipment_slot_enum" NOT NULL,
"character_item_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_character_equipment" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_equipment_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_equipment_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_equipment_character_slot" ON "character_equipment" ("character_id", "slot")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_equipment_character_item" ON "character_equipment" ("character_item_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'DROP INDEX "IDX_character_equipment_character_item"',
);
await queryRunner.query(
'DROP INDEX "IDX_character_equipment_character_slot"',
);
await queryRunner.query('DROP TABLE "character_equipment"');
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ExtendCombatEventTypes1790000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'HEAL'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'DEFEND'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'TELEGRAPH'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'INTERRUPT'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Postgres has no "DROP VALUE"; rebuild the type from scratch instead.
// This fails if any row already uses one of the new values -- expected
// for a dev rollback, same tradeoff Postgres migrations always make here.
await queryRunner.query(
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`,
);
await queryRunner.query(`DROP TYPE "combat_event_type_enum"`);
await queryRunner.query(
`CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')`,
);
await queryRunner.query(
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`,
);
}
}

View File

@@ -0,0 +1,208 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateRenownAndReputation1791000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// --- Character: level/experience -> renown (spec §13, design R1/R6) ---
await queryRunner.query(
'ALTER TABLE "characters" ADD COLUMN "renown" integer NOT NULL DEFAULT 1',
);
await queryRunner.query(
'UPDATE "characters" SET "renown" = LEAST(GREATEST("level", 1), 15)',
);
await queryRunner.query(
'ALTER TABLE "characters" ADD CONSTRAINT "CHK_characters_renown" CHECK ("renown" >= 1 AND "renown" <= 15)',
);
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "level"');
await queryRunner.query(
'ALTER TABLE "characters" DROP COLUMN "experience"',
);
// --- ItemDefinition: drop requiredLevel (spec §14, design R4) ---
await queryRunner.query(
'ALTER TABLE "item_definitions" DROP COLUMN "required_level"',
);
// --- ItemType enum rebuild (spec §16, design R3): WEAPON/ARMOR -> ---
// EQUIPMENT, MATERIAL -> TRADE_GOOD, add TROPHY/QUEST_ITEM. Postgres has
// no ALTER TYPE ... RENAME VALUE across all supported versions here, so
// this converts the column to text, migrates the data, rebuilds the
// type, and converts back -- the same technique already used by
// 1790000000000-ExtendCombatEventTypes.ts's down().
await queryRunner.query(
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE varchar USING "type"::text',
);
await queryRunner.query(
`UPDATE "item_definitions" SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`,
);
await queryRunner.query(
`UPDATE "item_definitions" SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`,
);
await queryRunner.query('DROP TYPE "item_type_enum"');
await queryRunner.query(
`CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`,
);
await queryRunner.query(
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE "item_type_enum" USING "type"::"item_type_enum"',
);
// --- CombatReward: drop the XP audit column (spec §1, design R8) ---
await queryRunner.query(
'ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"',
);
// --- MonsterDefinition: XP is abolished as a concept (spec §1, design R7).
// `silver_min`/`silver_max` deliberately stay -- spec §15 keeps a direct
// currency drop available as a lore-valid exception -- but XP has no such
// carve-out, so the column goes with the concept. ---
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "experience_reward"',
);
// --- ReputationFaction (spec §9) ---
await queryRunner.query(`CREATE TABLE "reputation_factions" (
"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,
"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_reputation_factions" PRIMARY KEY ("id")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_reputation_factions_key" ON "reputation_factions" ("key")',
);
// --- CharacterReputation (spec §9) ---
await queryRunner.query(`CREATE TABLE "character_reputation" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"faction_id" uuid NOT NULL,
"reputation" integer NOT NULL DEFAULT 0,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_reputation" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_reputation_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_reputation_faction" FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_reputation_character_faction" ON "character_reputation" ("character_id", "faction_id")',
);
// --- RenownMilestoneDefinition (spec §5) ---
await queryRunner.query(`CREATE TABLE "renown_milestone_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,
"renown_reward" integer NOT NULL,
"repeatable" boolean NOT NULL DEFAULT false,
"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_renown_milestone_definitions" PRIMARY KEY ("id")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key" ON "renown_milestone_definitions" ("key")',
);
// --- CharacterRenownMilestone (spec §5) ---
await queryRunner.query(`CREATE TABLE "character_renown_milestones" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"milestone_id" uuid NOT NULL,
"completed_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"times_completed" integer NOT NULL DEFAULT 1,
CONSTRAINT "PK_character_renown_milestones" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_renown_milestones_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_renown_milestones_milestone" FOREIGN KEY ("milestone_id") REFERENCES "renown_milestone_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_renown_milestones_character_milestone" ON "character_renown_milestones" ("character_id", "milestone_id")',
);
// --- TurnInDefinition (spec §19) ---
await queryRunner.query(`CREATE TABLE "turn_in_definitions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"item_definition_id" uuid NOT NULL,
"faction_id" uuid NOT NULL,
"silver_reward_per_item" integer NOT NULL,
"reputation_reward_per_item" integer NOT NULL,
"repeatable" boolean NOT NULL DEFAULT true,
"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_turn_in_definitions" PRIMARY KEY ("id"),
CONSTRAINT "FK_turn_in_definitions_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_turn_in_definitions_faction" FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_turn_in_definitions_key" ON "turn_in_definitions" ("key")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_turn_in_definitions_key"');
await queryRunner.query('DROP TABLE "turn_in_definitions"');
await queryRunner.query(
'DROP INDEX "IDX_character_renown_milestones_character_milestone"',
);
await queryRunner.query('DROP TABLE "character_renown_milestones"');
await queryRunner.query(
'DROP INDEX "IDX_renown_milestone_definitions_key"',
);
await queryRunner.query('DROP TABLE "renown_milestone_definitions"');
await queryRunner.query(
'DROP INDEX "IDX_character_reputation_character_faction"',
);
await queryRunner.query('DROP TABLE "character_reputation"');
await queryRunner.query('DROP INDEX "IDX_reputation_factions_key"');
await queryRunner.query('DROP TABLE "reputation_factions"');
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "experience_reward" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE varchar USING "type"::text',
);
await queryRunner.query('DROP TYPE "item_type_enum"');
await queryRunner.query(
"CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')",
);
await queryRunner.query(
`UPDATE "item_definitions" SET "type" = 'MATERIAL' WHERE "type" = 'TRADE_GOOD'`,
);
// TROPHY/QUEST_ITEM/EQUIPMENT have no clean pre-image; best-effort revert
// for genuinely disposable dev data (design R5).
await queryRunner.query(
`UPDATE "item_definitions" SET "type" = 'WEAPON' WHERE "type" IN ('EQUIPMENT', 'TROPHY', 'QUEST_ITEM')`,
);
await queryRunner.query(
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE "item_type_enum" USING "type"::"item_type_enum"',
);
await queryRunner.query(
'ALTER TABLE "item_definitions" ADD COLUMN "required_level" integer NOT NULL DEFAULT 1',
);
await queryRunner.query(
'ALTER TABLE "characters" ADD COLUMN "level" integer NOT NULL DEFAULT 1',
);
await queryRunner.query(
'ALTER TABLE "characters" ADD COLUMN "experience" integer NOT NULL DEFAULT 0',
);
await queryRunner.query('UPDATE "characters" SET "level" = "renown"');
await queryRunner.query(
'ALTER TABLE "characters" DROP CONSTRAINT "CHK_characters_renown"',
);
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "renown"');
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddHpRegeneration1792000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "characters" ADD COLUMN "hp_regen_since" TIMESTAMP WITH TIME ZONE',
);
// Existing characters start regenerating immediately from their current
// HP. A character whose fight is still ACTIVE keeps regeneration paused
// until that fight resolves, matching the "no combat-time regen" rule
// (persistent-hp-and-regeneration design, R4) -- this migration must not
// gift them free healing mid-fight.
await queryRunner.query('UPDATE "characters" SET "hp_regen_since" = now()');
await queryRunner.query(`UPDATE "characters" AS "character"
SET "hp_regen_since" = NULL
FROM "combats" AS "combat"
WHERE "combat"."character_id" = "character"."id"
AND "combat"."status" = 'ACTIVE'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "characters" DROP COLUMN "hp_regen_since"',
);
}
}

View File

@@ -0,0 +1,100 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CompleteBurnedRoad1793000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// --- Monster content: mechanics and atmosphere as data (spec §4, §8) ---
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "flavor_text" text',
);
await queryRunner.query(
`ALTER TABLE "monster_definitions" ADD COLUMN "abilities" jsonb NOT NULL DEFAULT '{}'::jsonb`,
);
// --- No direct currency from a kill (spec §7) ---
// Slice 0.6.5 kept `silver_min`/`silver_max` as a deliberate carve-out for
// a possible lore-valid direct drop. Playable Slice 0.7 V2 closes that:
// normal kills grant no Silver at all, and Silver reaches the player
// through merchant/turn-in exchange instead. Nothing reads these columns
// any more, so they go rather than sit as a path that could quietly start
// paying out again. Every seeded monster already had them at 0, so no
// player balance moves with this.
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_min"',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_max"',
);
await queryRunner.query(
'ALTER TABLE "combat_rewards" DROP COLUMN "silver_granted"',
);
// --- Status effects (spec §4: Bleeding) ---
await queryRunner.query(
`CREATE TYPE "status_effect_type_enum" AS ENUM ('BLEED')`,
);
await queryRunner.query(
'ALTER TABLE "combat_events" ADD COLUMN "status_effect" "status_effect_type_enum"',
);
// Postgres allows ADD VALUE inside a transaction as long as the new value
// is not also used in it; this migration only declares them. Same
// technique as 1790000000000-ExtendCombatEventTypes.ts.
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_APPLIED'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_DAMAGE'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_EXPIRED'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Postgres has no "DROP VALUE"; rebuild the type from scratch instead.
// This fails if any row already uses one of the new values -- expected
// for a dev rollback, the same tradeoff the earlier enum migration makes.
await queryRunner.query(
'ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text',
);
await queryRunner.query('DROP TYPE "combat_event_type_enum"');
await queryRunner.query(
`CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'HEAL', 'DEFEND', 'TELEGRAPH', 'INTERRUPT', 'COMBAT_WON', 'COMBAT_LOST')`,
);
await queryRunner.query(
'ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"',
);
await queryRunner.query(
'ALTER TABLE "combat_events" DROP COLUMN "status_effect"',
);
await queryRunner.query('DROP TYPE "status_effect_type_enum"');
// The dropped currency columns come back at 0 -- their pre-image was 0 for
// every seeded monster, and no reward row recorded anything else.
await queryRunner.query(
'ALTER TABLE "combat_rewards" ADD COLUMN "silver_granted" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "combat_rewards" ALTER COLUMN "silver_granted" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_min" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_max" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ALTER COLUMN "silver_min" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ALTER COLUMN "silver_max" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "abilities"',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "flavor_text"',
);
}
}

View File

@@ -0,0 +1,133 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateLootBags1794000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// --- Categories as content (spec §3, §5) ---
await queryRunner.query(
`CREATE TYPE "loot_category_enum" AS ENUM ('HIDE', 'RAIDER_TROPHY')`,
);
await queryRunner.query(
`CREATE TYPE "monster_category_enum" AS ENUM ('BEAST', 'HUMANOID')`,
);
// Nullable: only trade goods belong to a carrying bucket. Equipment and
// consumables stay outside the system entirely (spec §8).
await queryRunner.query(
'ALTER TABLE "item_definitions" ADD COLUMN "loot_category" "loot_category_enum"',
);
// Every monster has a category, so this backfills before going NOT NULL.
// BEAST is the safe default for the pre-existing rows: the seed
// immediately reclassifies the two humanoids by their stable keys, and a
// wrong classification changes no behaviour in this slice — nothing
// branches on monster category yet.
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "monster_category" "monster_category_enum"',
);
await queryRunner.query(
`UPDATE "monster_definitions" SET "monster_category" = 'BEAST' WHERE "monster_category" IS NULL`,
);
await queryRunner.query(
`UPDATE "monster_definitions" SET "monster_category" = 'HUMANOID' WHERE "key" IN ('road-bandit', 'charred-looter')`,
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ALTER COLUMN "monster_category" SET NOT NULL',
);
// --- Bags (spec §6) ---
await queryRunner.query(`CREATE TABLE "loot_bag_definitions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"loot_category" "loot_category_enum" NOT NULL,
"capacity" integer NOT NULL,
"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_loot_bag_definitions" PRIMARY KEY ("id"),
CONSTRAINT "CHK_loot_bag_definitions_capacity" CHECK ("capacity" >= 1)
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_loot_bag_definitions_key" ON "loot_bag_definitions" ("key")',
);
await queryRunner.query(`CREATE TABLE "character_loot_bags" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"loot_bag_definition_id" uuid NOT NULL,
"active" 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_character_loot_bags" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_loot_bags_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_loot_bags_definition" FOREIGN KEY ("loot_bag_definition_id") REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
// A character holds any given bag at most once. "One *active* bag per
// category" is a service-level rule instead: the category lives on the
// definition, and copying it here to get a partial unique index would
// duplicate content into player state (AGENTS §7).
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_loot_bags_character_definition" ON "character_loot_bags" ("character_id", "loot_bag_definition_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_character_loot_bags_character" ON "character_loot_bags" ("character_id")',
);
// --- Left-behind loot is part of the record (spec §9) ---
await queryRunner.query(
'ALTER TABLE "combat_reward_items" ADD COLUMN "quantity_left_behind" integer NOT NULL DEFAULT 0',
);
// A drop that was refused outright creates no stack to point at.
await queryRunner.query(
'ALTER TABLE "combat_reward_items" ALTER COLUMN "character_item_id" DROP NOT NULL',
);
// Slice 0.4 required quantity >= 1, because back then a reward row could
// only mean "you got this". A fully refused drop is granted 0, so the
// floor moves to 0 -- but the row must still record *something*, hence
// the replacement constraint: no row may be all zeroes.
await queryRunner.query(
'ALTER TABLE "combat_reward_items" DROP CONSTRAINT "CHK_combat_reward_items_quantity"',
);
await queryRunner.query(
`ALTER TABLE "combat_reward_items" ADD CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 0 AND "quantity_left_behind" >= 0 AND "quantity" + "quantity_left_behind" >= 1)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Rows recording a fully-rejected drop have no character item and cannot
// satisfy the restored NOT NULL, so they go with the feature that created
// them. Nothing else can produce a null there.
await queryRunner.query(
'DELETE FROM "combat_reward_items" WHERE "character_item_id" IS NULL',
);
await queryRunner.query(
'ALTER TABLE "combat_reward_items" DROP CONSTRAINT "CHK_combat_reward_items_quantity"',
);
await queryRunner.query(
`ALTER TABLE "combat_reward_items" ADD CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1)`,
);
await queryRunner.query(
'ALTER TABLE "combat_reward_items" ALTER COLUMN "character_item_id" SET NOT NULL',
);
await queryRunner.query(
'ALTER TABLE "combat_reward_items" DROP COLUMN "quantity_left_behind"',
);
await queryRunner.query('DROP INDEX "IDX_character_loot_bags_character"');
await queryRunner.query(
'DROP INDEX "IDX_character_loot_bags_character_definition"',
);
await queryRunner.query('DROP TABLE "character_loot_bags"');
await queryRunner.query('DROP INDEX "IDX_loot_bag_definitions_key"');
await queryRunner.query('DROP TABLE "loot_bag_definitions"');
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "monster_category"',
);
await queryRunner.query(
'ALTER TABLE "item_definitions" DROP COLUMN "loot_category"',
);
await queryRunner.query('DROP TYPE "monster_category_enum"');
await queryRunner.query('DROP TYPE "loot_category_enum"');
}
}

View File

@@ -0,0 +1,266 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The NPC system and the merchant trade-in loop
* (NPC Specification V1 §29; Playable Slice 0.8).
*
* Also retires `turn_in_definitions` from Slice 0.6.5. Everything it could
* sell is now sold through the merchant instead -- at a better price, and with
* reputation and renown attached -- so the two are not left running as
* parallel payout paths for the same pelt (slice 0.8 §6).
*/
export class CreateNpcSystem1795000000000 implements MigrationInterface {
name = 'CreateNpcSystem1795000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "npc_definitions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"title" character varying(150),
"description" text,
"location_id" uuid NOT NULL,
"faction_key" character varying(100),
"portrait_path" character varying(255) NOT NULL,
"artwork_path" character varying(255),
"capabilities" jsonb NOT NULL DEFAULT '[]'::jsonb,
"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_npc_definitions" PRIMARY KEY ("id"),
CONSTRAINT "FK_npc_definitions_location" FOREIGN KEY ("location_id")
REFERENCES "location_definitions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_npc_definitions_key" ON "npc_definitions" ("key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_npc_definitions_location" ON "npc_definitions" ("location_id")`,
);
await queryRunner.query(`
CREATE TABLE "dialogue_nodes" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"npc_id" uuid NOT NULL,
"key" character varying(100) NOT NULL,
"text" text NOT NULL,
"priority" integer NOT NULL,
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"actions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"responses" jsonb NOT NULL DEFAULT '[]'::jsonb,
"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_dialogue_nodes" PRIMARY KEY ("id"),
CONSTRAINT "FK_dialogue_nodes_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_dialogue_nodes_npc_key" ON "dialogue_nodes" ("npc_id", "key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_dialogue_nodes_npc_priority" ON "dialogue_nodes" ("npc_id", "priority")`,
);
await queryRunner.query(`
CREATE TABLE "character_npc_states" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"character_id" uuid NOT NULL,
"npc_id" uuid NOT NULL,
"first_met_at" TIMESTAMP WITH TIME ZONE,
"last_interaction_at" TIMESTAMP WITH TIME ZONE,
"flags" jsonb NOT NULL DEFAULT '{}'::jsonb,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_npc_states" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_npc_states_character" FOREIGN KEY ("character_id")
REFERENCES "characters"("id") ON DELETE CASCADE,
CONSTRAINT "FK_character_npc_states_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_character_npc_states_character_npc" ON "character_npc_states" ("character_id", "npc_id")`,
);
await queryRunner.query(`
CREATE TABLE "npc_shops" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"npc_id" uuid NOT NULL,
"name" character varying(150) NOT NULL,
"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_npc_shops" PRIMARY KEY ("id"),
CONSTRAINT "FK_npc_shops_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_npc_shops_key" ON "npc_shops" ("key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_npc_shops_npc" ON "npc_shops" ("npc_id")`,
);
await queryRunner.query(`
CREATE TABLE "shop_offers" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"shop_id" uuid NOT NULL,
"item_definition_id" uuid NOT NULL,
"currency_type" character varying(50) NOT NULL,
"price" integer NOT NULL,
"quantity" integer NOT NULL DEFAULT 1,
"repeatable" boolean NOT NULL DEFAULT true,
"sort_order" integer NOT NULL DEFAULT 0,
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"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_shop_offers" PRIMARY KEY ("id"),
CONSTRAINT "CHK_shop_offers_price" CHECK ("price" >= 0),
CONSTRAINT "CHK_shop_offers_quantity" CHECK ("quantity" >= 1),
CONSTRAINT "FK_shop_offers_shop" FOREIGN KEY ("shop_id")
REFERENCES "npc_shops"("id") ON DELETE CASCADE,
CONSTRAINT "FK_shop_offers_item" FOREIGN KEY ("item_definition_id")
REFERENCES "item_definitions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")`,
);
await queryRunner.query(`
CREATE TABLE "npc_exchange_profiles" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"npc_id" uuid NOT NULL,
"name" character varying(150) NOT NULL,
"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_npc_exchange_profiles" PRIMARY KEY ("id"),
CONSTRAINT "FK_npc_exchange_profiles_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_npc_exchange_profiles_key" ON "npc_exchange_profiles" ("key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_npc_exchange_profiles_npc" ON "npc_exchange_profiles" ("npc_id")`,
);
await queryRunner.query(`
CREATE TABLE "exchange_rules" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"profile_id" uuid NOT NULL,
"input_item_id" uuid NOT NULL,
"input_quantity" integer NOT NULL DEFAULT 1,
"faction_id" uuid NOT NULL,
"silver_reward" integer NOT NULL DEFAULT 0,
"region_reputation_reward" integer NOT NULL DEFAULT 0,
"renown_milestone_key" character varying(100),
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"sort_order" integer NOT NULL DEFAULT 0,
"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_exchange_rules" PRIMARY KEY ("id"),
CONSTRAINT "CHK_exchange_rules_input_quantity" CHECK ("input_quantity" >= 1),
CONSTRAINT "CHK_exchange_rules_rewards" CHECK ("silver_reward" >= 0 AND "region_reputation_reward" >= 0),
CONSTRAINT "FK_exchange_rules_profile" FOREIGN KEY ("profile_id")
REFERENCES "npc_exchange_profiles"("id") ON DELETE CASCADE,
CONSTRAINT "FK_exchange_rules_item" FOREIGN KEY ("input_item_id")
REFERENCES "item_definitions"("id") ON DELETE RESTRICT,
CONSTRAINT "FK_exchange_rules_faction" FOREIGN KEY ("faction_id")
REFERENCES "reputation_factions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_exchange_rules_profile_item" ON "exchange_rules" ("profile_id", "input_item_id")`,
);
// Retires Slice 0.6.5's `turn_in_definitions`.
//
// Nothing is carried across: exchange rules are seeded content, and the
// seed re-establishes an equivalent (better paying, reputation- and
// renown-aware) rule for every item that used to be turned in. Migrations
// run before seeds, so there is no exchange profile to attach carried rows
// to at this point anyway.
//
// Dropped rather than left standing, because a dormant second payout path
// for the same pelts is exactly the competing progression model slice 0.8
// §6 rules out -- and a table nothing reads is one someone re-wires later.
await queryRunner.query(`DROP TABLE "turn_in_definitions"`);
// Trading the last of a stack deletes the `character_items` row, and
// Slice 0.4 pinned reward bookkeeping to it with ON DELETE RESTRICT --
// correct when nothing could ever consume a stack, and a hard 500 the
// moment something could. This slice is that something.
//
// SET NULL keeps the reward history intact (it still records what
// dropped) while letting the stack itself go. The column has been
// nullable since 0.7.5, which already used null to mean "no live stack".
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
DROP CONSTRAINT "FK_combat_reward_items_character_item"
`);
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
ADD CONSTRAINT "FK_combat_reward_items_character_item"
FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id")
ON DELETE SET NULL ON UPDATE NO ACTION
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Rebuilt exactly as migration 1791 left it, so a rollback lands on the
// schema that migration expects to own.
await queryRunner.query(`
CREATE TABLE "turn_in_definitions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"item_definition_id" uuid NOT NULL,
"faction_id" uuid NOT NULL,
"silver_reward_per_item" integer NOT NULL,
"reputation_reward_per_item" integer NOT NULL,
"repeatable" boolean NOT NULL DEFAULT true,
"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_turn_in_definitions" PRIMARY KEY ("id"),
CONSTRAINT "FK_turn_in_definitions_item" FOREIGN KEY ("item_definition_id")
REFERENCES "item_definitions"("id") ON DELETE RESTRICT,
CONSTRAINT "FK_turn_in_definitions_faction" FOREIGN KEY ("faction_id")
REFERENCES "reputation_factions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_turn_in_definitions_key" ON "turn_in_definitions" ("key")`,
);
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
DROP CONSTRAINT "FK_combat_reward_items_character_item"
`);
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
ADD CONSTRAINT "FK_combat_reward_items_character_item"
FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id")
ON DELETE RESTRICT ON UPDATE NO ACTION
`);
await queryRunner.query(`DROP TABLE "exchange_rules"`);
await queryRunner.query(`DROP TABLE "npc_exchange_profiles"`);
await queryRunner.query(`DROP TABLE "shop_offers"`);
await queryRunner.query(`DROP TABLE "npc_shops"`);
await queryRunner.query(`DROP TABLE "character_npc_states"`);
await queryRunner.query(`DROP TABLE "dialogue_nodes"`);
await queryRunner.query(`DROP TABLE "npc_definitions"`);
}
}

View File

@@ -0,0 +1,101 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Lets a shop offer sell a loot bag, and lets one offer carry an exception
* (Playable Slice 0.8.5 §4, §7).
*
* A bag is a `loot_bag_definitions` row, not an item -- deliberately so since
* Slice 0.7.5 §6, because a bag is never equipped, never rolls as loot and has
* no combat stats. Selling one therefore needs a second target column rather
* than a fake item definition, which is exactly the "offer shape" the Slice 0.8
* seed said it had no reason to build yet.
*
* `bypass_conditions` is the minimal exception support Slice 0.9 needs: an
* offer opens when `conditions` hold *or* `bypass_conditions` hold. One column,
* OR semantics, no rule engine (slice §7, §11).
*/
export class SellableLootBags1796000000000 implements MigrationInterface {
name = 'SellableLootBags1796000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Shop offers are pure content -- nothing references them, and Slice 0.8
// inserted its two rows with generated uuids. The seed re-creates every
// offer with a stable id (AGENTS.md §8), which needs the old anonymous
// rows gone or they would linger as duplicates the upsert never matches.
await queryRunner.query(`DELETE FROM "shop_offers"`);
await queryRunner.query(`
ALTER TABLE "shop_offers"
ALTER COLUMN "item_definition_id" DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE "shop_offers"
ADD COLUMN "loot_bag_definition_id" uuid
`);
await queryRunner.query(`
ALTER TABLE "shop_offers"
ADD CONSTRAINT "FK_shop_offers_loot_bag"
FOREIGN KEY ("loot_bag_definition_id")
REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT
`);
await queryRunner.query(`
ALTER TABLE "shop_offers"
ADD COLUMN "bypass_conditions" jsonb NOT NULL DEFAULT '[]'::jsonb
`);
// Exactly one target. An offer selling nothing has no meaning, and one
// selling both would make the grant path ambiguous.
await queryRunner.query(`
ALTER TABLE "shop_offers"
ADD CONSTRAINT "CHK_shop_offers_single_target"
CHECK (num_nonnulls("item_definition_id", "loot_bag_definition_id") = 1)
`);
// Partial, because the column each one covers is now nullable and Postgres
// treats NULLs as distinct -- a plain unique index over (shop_id,
// item_definition_id) would happily accept a hundred bag offers.
await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_item"`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Bag offers cannot survive a rollback: the column that identifies what
// they sell is about to disappear, and the restored NOT NULL would reject
// them anyway.
await queryRunner.query(
`DELETE FROM "shop_offers" WHERE "loot_bag_definition_id" IS NOT NULL`,
);
await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_bag"`);
await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_item"`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")`,
);
await queryRunner.query(`
ALTER TABLE "shop_offers"
DROP CONSTRAINT "CHK_shop_offers_single_target"
`);
await queryRunner.query(`
ALTER TABLE "shop_offers" DROP COLUMN "bypass_conditions"
`);
await queryRunner.query(`
ALTER TABLE "shop_offers"
DROP CONSTRAINT "FK_shop_offers_loot_bag"
`);
await queryRunner.query(`
ALTER TABLE "shop_offers" DROP COLUMN "loot_bag_definition_id"
`);
await queryRunner.query(`
ALTER TABLE "shop_offers"
ALTER COLUMN "item_definition_id" SET NOT NULL
`);
}
}

View File

@@ -0,0 +1,18 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
describe('characters.hp_regen_since schema', () => {
it('stores the regeneration anchor as a nullable timestamptz', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === Character &&
candidate.propertyName === 'hpRegenSince',
);
expect(column).toBeDefined();
expect(column?.options.type).toBe('timestamptz');
expect(column?.options.nullable).toBe(true);
});
});

View File

@@ -0,0 +1,63 @@
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,195 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { CompleteBurnedRoad1793000000000 } from './1793000000000-CompleteBurnedRoad';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
import { CombatEventType } from '../../combat/combat-event-type.enum';
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { StatusEffectType } from '../../combat/status-effect.enum';
describe('CompleteBurnedRoad1793000000000', () => {
async function runUp() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new CompleteBurnedRoad1793000000000().up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runDown() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CompleteBurnedRoad1793000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
it('adds the monster content columns the slice drives its mechanics from', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "flavor_text" text',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "abilities" jsonb',
),
]),
);
});
it('defaults abilities to an empty object so existing monsters stay plain attackers', async () => {
const up = await runUp();
const abilities = up.find((sql) => sql.includes('"abilities"'));
expect(abilities).toContain("DEFAULT '{}'::jsonb");
expect(abilities).toContain('NOT NULL');
});
it('drops every direct currency reward path from a kill', async () => {
const up = await runUp();
// Spec §7: a normal kill grants no Silver. Leaving the columns in place
// would leave a path that could quietly start paying out again.
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_min"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_max"',
),
expect.stringContaining(
'ALTER TABLE "combat_rewards" DROP COLUMN "silver_granted"',
),
]),
);
});
it('creates the status effect type and hangs it off combat_events', async () => {
const up = await runUp();
const createIndex = up.findIndex((sql) =>
sql.includes('CREATE TYPE "status_effect_type_enum"'),
);
const columnIndex = up.findIndex((sql) =>
sql.includes('ADD COLUMN "status_effect"'),
);
expect(createIndex).toBeGreaterThanOrEqual(0);
expect(columnIndex).toBeGreaterThanOrEqual(0);
// The column cannot reference a type that does not exist yet.
expect(createIndex).toBeLessThan(columnIndex);
});
it('extends the combat event enum with the status effect events', async () => {
const up = await runUp();
for (const value of ['STATUS_APPLIED', 'STATUS_DAMAGE', 'STATUS_EXPIRED']) {
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
`ALTER TYPE "combat_event_type_enum" ADD VALUE '${value}'`,
),
]),
);
}
});
it('rebuilds the combat event enum without the new values on the way down', async () => {
const down = await runDown();
const rebuilt = down.find((sql) =>
sql.includes('CREATE TYPE "combat_event_type_enum"'),
);
expect(rebuilt).toBeDefined();
expect(rebuilt).not.toContain('STATUS_APPLIED');
// Everything the previous migrations added must survive the rollback.
for (const value of [
'DAMAGE',
'HEAL',
'DEFEND',
'TELEGRAPH',
'INTERRUPT',
'COMBAT_WON',
'COMBAT_LOST',
]) {
expect(rebuilt).toContain(value);
}
});
it('restores every dropped column on the way down', async () => {
const down = await runDown();
expect(down).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "combat_rewards" ADD COLUMN "silver_granted"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_min"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_max"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "abilities"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "flavor_text"',
),
]),
);
expect(down).toEqual(
expect.arrayContaining([
expect.stringContaining('DROP TYPE "status_effect_type_enum"'),
]),
);
});
});
describe('slice 0.7 entity schema', () => {
function column(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === target && candidate.propertyName === propertyName,
);
}
it('stores monster abilities as jsonb', () => {
expect(column(MonsterDefinition, 'abilities')?.options.type).toBe('jsonb');
});
it('allows a monster without a flavor line', () => {
const flavorText = column(MonsterDefinition, 'flavorText');
expect(flavorText?.options.type).toBe('text');
expect(flavorText?.options.nullable).toBe(true);
});
it('no longer models a currency range on a monster', () => {
expect(column(MonsterDefinition, 'silverMin')).toBeUndefined();
expect(column(MonsterDefinition, 'silverMax')).toBeUndefined();
});
it('no longer models granted Silver on a combat reward', () => {
expect(column(CombatReward, 'silverGranted')).toBeUndefined();
});
it('tags a combat event with the status effect it concerns', () => {
const statusEffect = column(CombatEvent, 'statusEffect');
expect(statusEffect?.options.enum).toBe(StatusEffectType);
expect(statusEffect?.options.nullable).toBe(true);
});
it('includes the status effect event types', () => {
expect(Object.values(CombatEventType)).toEqual(
expect.arrayContaining([
'STATUS_APPLIED',
'STATUS_DAMAGE',
'STATUS_EXPIRED',
]),
);
});
});

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,53 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
describe('character_equipment schema', () => {
it('stores slot as a non-nullable equipment_slot_enum column', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.propertyName === 'slot',
);
expect(column).toBeDefined();
expect(column?.options.type).toBe('enum');
expect(column?.options.enumName).toBe('equipment_slot_enum');
expect(column?.options.nullable).toBeFalsy();
});
it('enforces one equipped item per character per slot', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.columns?.includes('characterId') &&
candidate.columns?.includes('slot'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
it('forbids one CharacterItem from occupying more than one equipment slot', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.columns?.length === 1 &&
candidate.columns?.includes('characterItemId'),
);
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,28 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
import { CombatEventType } from '../../combat/combat-event-type.enum';
describe('combat_events.type enum', () => {
it('includes the Playable Slice 0.6 event types', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === CombatEvent && candidate.propertyName === 'type',
);
expect(column).toBeDefined();
expect(column?.options.enum).toBe(CombatEventType);
expect(Object.values(CombatEventType)).toEqual(
expect.arrayContaining([
'DAMAGE',
'HEAL',
'DEFEND',
'TELEGRAPH',
'INTERRUPT',
'COMBAT_WON',
'COMBAT_LOST',
]),
);
});
});

View File

@@ -0,0 +1,71 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { getMetadataArgsStorage } from 'typeorm';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
const MIGRATION_SQL = readFileSync(
join(__dirname, '1788700000000-CreateLocalLocationView.ts'),
'utf8',
);
function columnNames(target: unknown): string[] {
return getMetadataArgsStorage()
.columns.filter((column) => column.target === target)
.map((column) => column.options.name)
.filter((name): name is string => typeof name === 'string');
}
describe('local location view schema', () => {
const newLocationColumns = [
'region_name',
'region_tier_label',
'location_type',
'local_description',
'local_artwork_path',
'local_points_of_interest',
'local_primary_actions',
'local_reward_preview',
];
it.each(newLocationColumns)(
'maps %s on LocationDefinition',
(name: string) => {
expect(columnNames(LocationDefinition)).toContain(name);
},
);
it.each(newLocationColumns)('adds %s in the migration', (name: string) => {
expect(MIGRATION_SQL).toContain(
`ALTER TABLE "location_definitions" ADD COLUMN "${name}"`,
);
expect(MIGRATION_SQL).toContain(
`ALTER TABLE "location_definitions" DROP COLUMN "${name}"`,
);
});
it('adds the monster icon path in both the entity and the migration', () => {
expect(columnNames(MonsterDefinition)).toContain('icon_path');
expect(MIGRATION_SQL).toContain(
'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path"',
);
expect(MIGRATION_SQL).toContain(
'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
);
});
it('keeps the map-level columns untouched so the world screen is unaffected', () => {
expect(MIGRATION_SQL).not.toContain('DROP COLUMN "description"');
expect(MIGRATION_SQL).not.toContain('DROP COLUMN "artwork_path"');
expect(columnNames(LocationDefinition)).toEqual(
expect.arrayContaining(['description', 'artwork_path', 'region_key']),
);
});
it('backfills existing rows so no location renders an empty breadcrumb', () => {
expect(MIGRATION_SQL).toContain(
'UPDATE "location_definitions" SET "region_name" = "region_key"',
);
});
});

View File

@@ -0,0 +1,211 @@
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,
}),
// SET NULL since Slice 0.8: trading the last of a stack deletes the
// character_items row, and RESTRICT made that fail. The reward record
// survives with a null pointer rather than pinning the stack forever.
expect.objectContaining({
onDelete: 'SET NULL',
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,223 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { CreateLootBags1794000000000 } from './1794000000000-CreateLootBags';
import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
import { LootCategory } from '../../items/loot-category.enum';
import { MonsterCategory } from '../../monsters/monster-category.enum';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
describe('CreateLootBags1794000000000', () => {
async function runUp() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new CreateLootBags1794000000000().up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runDown() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateLootBags1794000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
it('creates both category enums before the columns that use them', async () => {
const up = await runUp();
const lootTypeIndex = up.findIndex((sql) =>
sql.includes('CREATE TYPE "loot_category_enum"'),
);
const lootColumnIndex = up.findIndex((sql) =>
sql.includes('"item_definitions" ADD COLUMN "loot_category"'),
);
const monsterTypeIndex = up.findIndex((sql) =>
sql.includes('CREATE TYPE "monster_category_enum"'),
);
const monsterColumnIndex = up.findIndex((sql) =>
sql.includes('"monster_definitions" ADD COLUMN "monster_category"'),
);
expect(lootTypeIndex).toBeGreaterThanOrEqual(0);
expect(monsterTypeIndex).toBeGreaterThanOrEqual(0);
expect(lootTypeIndex).toBeLessThan(lootColumnIndex);
expect(monsterTypeIndex).toBeLessThan(monsterColumnIndex);
});
it('leaves loot_category nullable, because only trade goods have one', async () => {
const up = await runUp();
const column = up.find((sql) =>
sql.includes('"item_definitions" ADD COLUMN "loot_category"'),
);
expect(column).not.toContain('NOT NULL');
});
it('backfills monster_category BEFORE making it NOT NULL', async () => {
const up = await runUp();
const backfillIndex = up.findIndex((sql) =>
sql.includes(`SET "monster_category" = 'BEAST'`),
);
const humanoidIndex = up.findIndex((sql) =>
sql.includes(`SET "monster_category" = 'HUMANOID'`),
);
const notNullIndex = up.findIndex((sql) =>
sql.includes('"monster_category" SET NOT NULL'),
);
expect(backfillIndex).toBeGreaterThanOrEqual(0);
// Reversing these would fail the migration on any existing row.
expect(backfillIndex).toBeLessThan(notNullIndex);
expect(humanoidIndex).toBeLessThan(notNullIndex);
});
it('creates the bag tables with their ownership keys', async () => {
const up = await runUp();
const joined = up.join('\n');
expect(joined).toContain('CREATE TABLE "loot_bag_definitions"');
expect(joined).toContain('CREATE TABLE "character_loot_bags"');
// A character cannot hold the same bag twice.
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_character_loot_bags_character_definition"',
);
// Deleting a character takes their bags; a bag definition in use cannot
// be deleted out from under them.
expect(joined).toContain(
'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE',
);
expect(joined).toContain(
'FOREIGN KEY ("loot_bag_definition_id") REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT',
);
});
it('refuses a bag that carries nothing', async () => {
const up = await runUp();
expect(up.join('\n')).toContain('CHECK ("capacity" >= 1)');
});
it('lets a reward item record a drop that was refused outright', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'"combat_reward_items" ADD COLUMN "quantity_left_behind" integer NOT NULL DEFAULT 0',
),
expect.stringContaining(
'"combat_reward_items" ALTER COLUMN "character_item_id" DROP NOT NULL',
),
]),
);
});
it('lowers the quantity floor to 0 but still forbids an empty row', async () => {
const up = await runUp();
const constraint = up
.filter((sql) => sql.includes('CHK_combat_reward_items_quantity'))
.join(' ');
// Slice 0.4's `quantity >= 1` would reject a fully refused drop outright,
// which is how this surfaced: the victory 500'd instead of recording what
// was left behind.
expect(constraint).toContain('DROP CONSTRAINT');
expect(constraint).toContain('"quantity" >= 0');
// A row still has to mean something: granted 0 and refused 0 is nonsense.
expect(constraint).toContain('"quantity" + "quantity_left_behind" >= 1');
});
it('restores the original quantity floor on the way down', async () => {
const down = await runDown();
const restored = down.find((sql) =>
sql.includes('ADD CONSTRAINT "CHK_combat_reward_items_quantity"'),
);
expect(restored).toContain('"quantity" >= 1');
expect(restored).not.toContain('quantity_left_behind');
});
it('clears the rows that cannot satisfy the restored NOT NULL on the way down', async () => {
const down = await runDown();
const deleteIndex = down.findIndex((sql) =>
sql.includes(
'DELETE FROM "combat_reward_items" WHERE "character_item_id" IS NULL',
),
);
const notNullIndex = down.findIndex((sql) =>
sql.includes('"character_item_id" SET NOT NULL'),
);
expect(deleteIndex).toBeGreaterThanOrEqual(0);
// Without the delete first, the rollback would simply fail.
expect(deleteIndex).toBeLessThan(notNullIndex);
});
it('drops the enum types only after the columns using them are gone', async () => {
const down = await runDown();
const dropColumnIndex = down.findIndex((sql) =>
sql.includes('"item_definitions" DROP COLUMN "loot_category"'),
);
const dropTypeIndex = down.findIndex((sql) =>
sql.includes('DROP TYPE "loot_category_enum"'),
);
const dropBagTableIndex = down.findIndex((sql) =>
sql.includes('DROP TABLE "loot_bag_definitions"'),
);
expect(dropColumnIndex).toBeLessThan(dropTypeIndex);
// loot_bag_definitions.loot_category uses the same type.
expect(dropBagTableIndex).toBeLessThan(dropTypeIndex);
});
});
describe('slice 0.7.5 entity schema', () => {
function column(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === target && candidate.propertyName === propertyName,
);
}
it('lets an item definition sit outside every loot category', () => {
const lootCategory = column(ItemDefinition, 'lootCategory');
expect(lootCategory?.options.enum).toBe(LootCategory);
expect(lootCategory?.options.nullable).toBe(true);
});
it('requires a category on every monster definition', () => {
const monsterCategory = column(MonsterDefinition, 'monsterCategory');
expect(monsterCategory?.options.enum).toBe(MonsterCategory);
expect(monsterCategory?.options.nullable).toBeUndefined();
});
it('models a bag definition as content with a category and a capacity', () => {
expect(column(LootBagDefinition, 'lootCategory')?.options.enum).toBe(
LootCategory,
);
expect(column(LootBagDefinition, 'capacity')?.options.type).toBe('integer');
});
it('models an owned bag as active-or-not player state', () => {
expect(column(CharacterLootBag, 'active')?.options.type).toBe('boolean');
expect(column(CharacterLootBag, 'characterId')?.options.type).toBe('uuid');
});
it('lets a reward item point at no character item', () => {
expect(column(CombatRewardItem, 'characterItemId')?.options.nullable).toBe(
true,
);
expect(column(CombatRewardItem, 'quantityLeftBehind')?.options.type).toBe(
'integer',
);
});
});

View File

@@ -0,0 +1,195 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { CreateNpcSystem1795000000000 } from './1795000000000-CreateNpcSystem';
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
import { CharacterNpcState } from '../../npcs/entities/character-npc-state.entity';
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcShop } from '../../shops/entities/npc-shop.entity';
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
async function runUp(): Promise<string[]> {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new CreateNpcSystem1795000000000().up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runDown(): Promise<string[]> {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateNpcSystem1795000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
describe('CreateNpcSystem1795000000000', () => {
it('creates every NPC-system table', async () => {
const joined = (await runUp()).join('\n');
for (const table of [
'npc_definitions',
'dialogue_nodes',
'character_npc_states',
'npc_shops',
'shop_offers',
'npc_exchange_profiles',
'exchange_rules',
]) {
expect(joined).toContain(`CREATE TABLE "${table}"`);
}
});
it('creates parent tables before the children that reference them', async () => {
const up = await runUp();
const indexOf = (needle: string) =>
up.findIndex((sql) => sql.includes(needle));
const npcs = indexOf('CREATE TABLE "npc_definitions"');
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "dialogue_nodes"'));
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "npc_shops"'));
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "npc_exchange_profiles"'));
expect(indexOf('CREATE TABLE "npc_shops"')).toBeLessThan(
indexOf('CREATE TABLE "shop_offers"'),
);
expect(indexOf('CREATE TABLE "npc_exchange_profiles"')).toBeLessThan(
indexOf('CREATE TABLE "exchange_rules"'),
);
});
it('gives every NPC-facing row a stable unique business key (spec §4)', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_npc_definitions_key" ON "npc_definitions" ("key")',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_npc_shops_key" ON "npc_shops" ("key")',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_npc_exchange_profiles_key" ON "npc_exchange_profiles" ("key")',
);
});
it('keeps one exchange rule per item, so a good has one price', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_exchange_rules_profile_item" ON "exchange_rules" ("profile_id", "input_item_id")',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")',
);
});
it('refuses content that would pay out nonsense', async () => {
const joined = (await runUp()).join('\n');
// A rule trading zero items would loop forever against any stack.
expect(joined).toContain('CHECK ("input_quantity" >= 1)');
expect(joined).toContain(
'CHECK ("silver_reward" >= 0 AND "region_reputation_reward" >= 0)',
);
expect(joined).toContain('CHECK ("price" >= 0)');
});
it('cascades player-owned rows and protects referenced content', async () => {
// Constraints are written across two lines for readability, so compare
// against whitespace-collapsed SQL rather than the literal formatting.
const joined = (await runUp()).join('\n').replace(/\s+/g, ' ');
// Deleting a character takes their NPC state with it.
expect(joined).toContain(
'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE',
);
// An item that is priced somewhere cannot be deleted out from under it.
expect(joined).toContain(
'FOREIGN KEY ("input_item_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT',
);
expect(joined).toContain(
'FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT',
);
});
it('retires the turn-in table that the exchange replaces', async () => {
const up = await runUp();
// Slice 0.6.5's turn-in and this exchange both convert a pelt into silver
// and reputation. Leaving both would be two prices for one pelt
// (slice 0.8 §6).
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining('DROP TABLE "turn_in_definitions"'),
]),
);
});
it('rebuilds the turn-in table on the way down', async () => {
const down = await runDown();
const joined = down.join('\n');
expect(joined).toContain('CREATE TABLE "turn_in_definitions"');
expect(joined).toContain('"silver_reward_per_item" integer NOT NULL');
});
it('drops children before parents on the way down', async () => {
const down = await runDown();
const indexOf = (needle: string) =>
down.findIndex((sql) => sql.includes(needle));
expect(indexOf('DROP TABLE "exchange_rules"')).toBeLessThan(
indexOf('DROP TABLE "npc_exchange_profiles"'),
);
expect(indexOf('DROP TABLE "shop_offers"')).toBeLessThan(
indexOf('DROP TABLE "npc_shops"'),
);
expect(indexOf('DROP TABLE "dialogue_nodes"')).toBeLessThan(
indexOf('DROP TABLE "npc_definitions"'),
);
});
});
describe('slice 0.8 entity schema', () => {
function column(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === target && candidate.propertyName === propertyName,
);
}
it('stores NPC capabilities as data rather than as subclasses (spec §2)', () => {
expect(column(NpcDefinition, 'capabilities')?.options.type).toBe('jsonb');
});
it('lets a dialogue node carry its own conditions and priority (spec §11)', () => {
expect(column(DialogueNode, 'priority')?.options.type).toBe('integer');
expect(column(DialogueNode, 'conditions')?.options.type).toBe('jsonb');
});
it('keeps per-character NPC state off the shared definition (spec §7)', () => {
expect(column(CharacterNpcState, 'characterId')?.options.type).toBe('uuid');
expect(column(CharacterNpcState, 'flags')?.options.type).toBe('jsonb');
// Personal relationship is explicitly out of V1 scope (spec §35).
expect(column(CharacterNpcState, 'relationValue')).toBeUndefined();
});
it('gives every shop offer its own conditions (spec §16)', () => {
expect(column(ShopOffer, 'conditions')?.options.type).toBe('jsonb');
});
it('names a renown milestone instead of paying renown per item', () => {
// Renown is a 1-15 power rank recomputed from a curve (Slice 0.6.5 §4),
// so it is awarded by milestone, not accumulated per pelt.
const milestone = column(ExchangeRule, 'renownMilestoneKey');
expect(milestone?.options.nullable).toBe(true);
expect(column(ExchangeRule, 'silverReward')?.options.type).toBe('integer');
});
it('keeps the exchange profile separate from the shop (spec §17)', () => {
expect(column(NpcExchangeProfile, 'key')?.options.type).toBe('varchar');
expect(column(NpcShop, 'key')?.options.type).toBe('varchar');
});
});

View File

@@ -0,0 +1,76 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { ItemType } from '../../items/item-type.enum';
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { CharacterReputation } from '../../reputation/entities/character-reputation.entity';
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
import { CharacterRenownMilestone } from '../../renown/entities/character-renown-milestone.entity';
function columnNames(target: unknown): string[] {
return getMetadataArgsStorage()
.columns.filter((column) => column.target === target)
.map((column) => column.propertyName);
}
function uniqueIndexFor(target: unknown, columns: string[]): boolean {
const index = getMetadataArgsStorage().indices.find(
(candidate) =>
candidate.target === target &&
columns.every((column) => candidate.columns?.includes(column)),
);
const meta = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
return (meta?.options?.unique ?? meta?.unique) === true;
}
describe('Slice 0.6.5 entity metadata', () => {
it('Character exposes renown and no longer exposes level/experience', () => {
const names = columnNames(Character);
expect(names).toContain('renown');
expect(names).not.toContain('level');
expect(names).not.toContain('experience');
});
it('ItemDefinition no longer exposes requiredLevel', () => {
expect(columnNames(ItemDefinition)).not.toContain('requiredLevel');
});
it('ItemType has exactly the five Slice 0.6.5 values', () => {
expect(Object.values(ItemType).sort()).toEqual(
['CONSUMABLE', 'EQUIPMENT', 'QUEST_ITEM', 'TRADE_GOOD', 'TROPHY'].sort(),
);
});
it('CombatReward no longer exposes experienceGranted', () => {
expect(columnNames(CombatReward)).not.toContain('experienceGranted');
});
it('ReputationFaction has a unique key', () => {
expect(uniqueIndexFor(ReputationFaction, ['key'])).toBe(true);
});
it('CharacterReputation enforces one row per character per faction', () => {
expect(
uniqueIndexFor(CharacterReputation, ['characterId', 'factionId']),
).toBe(true);
});
it('RenownMilestoneDefinition has a unique key', () => {
expect(uniqueIndexFor(RenownMilestoneDefinition, ['key'])).toBe(true);
});
it('CharacterRenownMilestone enforces one row per character per milestone', () => {
expect(
uniqueIndexFor(CharacterRenownMilestone, ['characterId', 'milestoneId']),
).toBe(true);
});
// TurnInDefinition's metadata assertion lived here until Slice 0.8 retired
// that entity in favour of ExchangeRule. Its replacement is covered by
// `npc-system.migration.spec.ts`.
});

View File

@@ -0,0 +1,206 @@
import { QueryRunner } from 'typeorm';
import { CreateRenownAndReputation1791000000000 } from './1791000000000-CreateRenownAndReputation';
describe('CreateRenownAndReputation1791000000000', () => {
async function runUp() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateRenownAndReputation1791000000000();
await migration.up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runUpThenDown() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateRenownAndReputation1791000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
it('replaces level/experience with a renown column on characters', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "renown"'),
expect.stringContaining('DROP COLUMN "level"'),
expect.stringContaining('DROP COLUMN "experience"'),
]),
);
});
it('backfills renown from level BEFORE dropping the level column', async () => {
const up = await runUp();
const backfillIndex = up.findIndex((sql) =>
sql.includes('LEAST(GREATEST("level", 1), 15)'),
);
const dropLevelIndex = up.findIndex((sql) =>
sql.includes('DROP COLUMN "level"'),
);
expect(backfillIndex).toBeGreaterThanOrEqual(0);
expect(dropLevelIndex).toBeGreaterThanOrEqual(0);
// Reversing these two would silently discard every character's progression.
expect(backfillIndex).toBeLessThan(dropLevelIndex);
});
it('adds the renown range constraint only AFTER the backfill has populated valid values', async () => {
const up = await runUp();
const backfillIndex = up.findIndex((sql) =>
sql.includes('LEAST(GREATEST("level", 1), 15)'),
);
const constraintIndex = up.findIndex((sql) =>
sql.includes('CHK_characters_renown'),
);
expect(backfillIndex).toBeGreaterThanOrEqual(0);
expect(constraintIndex).toBeGreaterThanOrEqual(0);
// ADD CONSTRAINT validates the whole table; running it before the backfill
// would only pass by coincidence of the column DEFAULT being in range.
expect(backfillIndex).toBeLessThan(constraintIndex);
});
it('drops required_level from item_definitions', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "item_definitions" DROP COLUMN "required_level"',
),
]),
);
});
it('rebuilds item_type_enum with exactly the five Slice 0.6.5 values, migrating existing rows', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
`SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`,
),
expect.stringContaining(
`SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`,
),
expect.stringContaining('DROP TYPE "item_type_enum"'),
expect.stringContaining(
`CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`,
),
]),
);
});
it('drops experience_granted from combat_rewards', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"',
),
]),
);
});
/**
* XP is abolished outright (design R7), so the column goes with the concept.
* It is NOT NULL with no default, so leaving it behind while the seed stops
* supplying a value would fail every monster insert against a real database
* -- a break no suite here can catch, since none of them connect to Postgres.
*/
it('drops experience_reward from monster_definitions', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "experience_reward"',
),
]),
);
});
it('keeps silver_min and silver_max on monster_definitions', async () => {
const up = await runUp();
expect(up.join(' ')).not.toContain('"silver_min"');
expect(up.join(' ')).not.toContain('"silver_max"');
});
it('creates all five new tables with their unique constraints', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining('CREATE TABLE "reputation_factions"'),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_reputation_factions_key"',
),
expect.stringContaining('CREATE TABLE "character_reputation"'),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_character_reputation_character_faction" ON "character_reputation" ("character_id", "faction_id")',
),
expect.stringContaining('CREATE TABLE "renown_milestone_definitions"'),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key"',
),
expect.stringContaining('CREATE TABLE "character_renown_milestones"'),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_character_renown_milestones_character_milestone" ON "character_renown_milestones" ("character_id", "milestone_id")',
),
expect.stringContaining('CREATE TABLE "turn_in_definitions"'),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_turn_in_definitions_key"',
),
]),
);
});
it('down() reverses every up() step in exact opposite order, dropping the five new tables first', async () => {
const down = await runUpThenDown();
expect(down).toEqual(
expect.arrayContaining([
expect.stringContaining('DROP TABLE "turn_in_definitions"'),
expect.stringContaining('DROP TABLE "character_renown_milestones"'),
expect.stringContaining('DROP TABLE "renown_milestone_definitions"'),
expect.stringContaining('DROP TABLE "character_reputation"'),
expect.stringContaining('DROP TABLE "reputation_factions"'),
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "experience_reward"',
),
expect.stringContaining(
'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted"',
),
expect.stringContaining(
'ALTER TABLE "item_definitions" ADD COLUMN "required_level"',
),
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "level"'),
expect.stringContaining(
'ALTER TABLE "characters" ADD COLUMN "experience"',
),
expect.stringContaining(
'ALTER TABLE "characters" DROP COLUMN "renown"',
),
]),
);
const turnInDropIndex = down.findIndex((sql) =>
sql.includes('DROP TABLE "turn_in_definitions"'),
);
const reputationFactionsDropIndex = down.findIndex((sql) =>
sql.includes('DROP TABLE "reputation_factions"'),
);
expect(turnInDropIndex).toBeGreaterThanOrEqual(0);
expect(reputationFactionsDropIndex).toBeGreaterThanOrEqual(0);
// turn_in_definitions has FK dependencies on reputation_factions and
// item_definitions that must be gone before those tables are touched.
expect(turnInDropIndex).toBeLessThan(reputationFactionsDropIndex);
});
});

View File

@@ -0,0 +1,120 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { SellableLootBags1796000000000 } from './1796000000000-SellableLootBags';
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
async function runUp(): Promise<string[]> {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new SellableLootBags1796000000000().up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runDown(): Promise<string[]> {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new SellableLootBags1796000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
describe('SellableLootBags1796000000000', () => {
it('adds the bag target and bypass columns', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain('"loot_bag_definition_id" uuid');
expect(joined).toContain('"bypass_conditions" jsonb');
expect(joined).toContain('FK_shop_offers_loot_bag');
});
it('makes the item target nullable so a bag offer can exist', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain('ALTER COLUMN "item_definition_id" DROP NOT NULL');
});
it('requires exactly one target per offer', async () => {
const joined = (await runUp()).join('\n');
// An offer that sells nothing, or sells both an item and a bag, is a
// content bug the database must not store.
expect(joined).toContain('CHK_shop_offers_single_target');
expect(joined).toContain('num_nonnulls');
});
it('keeps both target kinds unique per shop via partial indexes', async () => {
const joined = (await runUp()).join('\n');
// A plain unique index over a nullable column would let a shop hold
// unlimited bag offers, because Postgres treats NULLs as distinct.
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL',
);
});
it('clears content offers so the seed can own stable ids', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain('DELETE FROM "shop_offers"');
});
it('drops what it added and restores the original index on rollback', async () => {
const joined = (await runDown()).join('\n');
expect(joined).toContain('DROP COLUMN "loot_bag_definition_id"');
expect(joined).toContain('DROP COLUMN "bypass_conditions"');
expect(joined).toContain('ALTER COLUMN "item_definition_id" SET NOT NULL');
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")',
);
});
});
describe('slice 0.8.5 entity schema', () => {
function column(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === target && candidate.propertyName === propertyName,
);
}
it('lets the item target sit empty when the offer sells a bag instead', () => {
expect(column(ShopOffer, 'itemDefinitionId')?.options.nullable).toBe(true);
});
it('gives a bag offer its own nullable uuid target', () => {
const lootBagDefinitionId = column(ShopOffer, 'lootBagDefinitionId');
expect(lootBagDefinitionId?.options.type).toBe('uuid');
expect(lootBagDefinitionId?.options.nullable).toBe(true);
});
it('requires every offer to carry its bypass conditions, empty or not', () => {
const bypassConditions = column(ShopOffer, 'bypassConditions');
expect(bypassConditions?.options.type).toBe('jsonb');
expect(bypassConditions?.options.nullable).toBeFalsy();
});
it('declares both partial unique indexes with their WHERE clauses', () => {
const indices = getMetadataArgsStorage().indices.filter(
(candidate) => candidate.target === ShopOffer,
);
const itemIndex = indices.find(
(candidate) => candidate.name === 'IDX_shop_offers_shop_item',
);
const bagIndex = indices.find(
(candidate) => candidate.name === 'IDX_shop_offers_shop_bag',
);
expect(itemIndex?.unique).toBe(true);
expect(itemIndex?.where).toBe('"item_definition_id" IS NOT NULL');
expect(bagIndex?.unique).toBe(true);
expect(bagIndex?.where).toBe('"loot_bag_definition_id" IS NOT NULL');
});
});

View File

@@ -0,0 +1,293 @@
import { EquipmentSlot } from '../../items/equipment-slot.enum';
import { ItemRarity } from '../../items/item-rarity.enum';
import { ItemType } from '../../items/item-type.enum';
import { LootCategory } from '../../items/loot-category.enum';
import {
ASH_RAT_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID,
ITEM_IDS,
ItemKey,
ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants';
export interface SeedItemDefinition {
id: string;
key: ItemKey;
name: string;
description: string;
type: ItemType;
equipmentSlot: EquipmentSlot | null;
rarity: ItemRarity;
lootCategory: LootCategory | null;
tier: 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'
>
> = {},
// Only trade goods carry one; everything else is uncapped (0.7.5 §4, §8).
lootCategory: LootCategory | null = null,
): SeedItemDefinition {
return {
id: ITEM_IDS[key],
key,
name,
description,
type,
equipmentSlot,
rarity,
lootCategory,
tier: 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',
'Worn Shortsword',
"A recruit's blade, sharpened more often than it's been swung.",
ItemType.EQUIPMENT,
EquipmentSlot.WEAPON,
ItemRarity.COMMON,
{ weaponDamage: 8 },
),
item(
'bandit-blade',
'Bandit Blade',
'A roughly serrated blade, forged for quick raids.',
ItemType.EQUIPMENT,
EquipmentSlot.WEAPON,
ItemRarity.COMMON,
{ weaponDamage: 11, bonusAttack: 1 },
),
item(
'ash-blade',
'Ashen Blade',
'Tempered in the embers of the Ashen Fields; the edge still glows faintly.',
ItemType.EQUIPMENT,
EquipmentSlot.WEAPON,
ItemRarity.RARE,
{ weaponDamage: 15, bonusAttack: 2 },
),
item(
'bandit-hood',
'Bandit Hood',
"Scarred leather that hides both the wearer's face and their intent.",
ItemType.EQUIPMENT,
EquipmentSlot.HEAD,
ItemRarity.COMMON,
{ bonusArmor: 3, bonusHp: 5 },
),
item(
'reinforced-leather-jacket',
'Reinforced Leather Jacket',
'Leather sewn with iron plates, heavy and dependable.',
ItemType.EQUIPMENT,
EquipmentSlot.CHEST,
ItemRarity.RARE,
{ bonusArmor: 7, bonusHp: 10 },
),
item(
'raider-gloves',
'Plunderer Gloves',
"Studded gloves, worn smooth from handling other people's belongings.",
ItemType.EQUIPMENT,
EquipmentSlot.HANDS,
ItemRarity.COMMON,
{ bonusArmor: 3, bonusAttack: 1 },
),
item(
'guardsman-legs',
"Watchman's Leggings",
'Leg armor of the Border Watch, patched at the knees.',
ItemType.EQUIPMENT,
EquipmentSlot.LEGS,
ItemRarity.RARE,
{ bonusArmor: 5, bonusHp: 5 },
),
item(
'ash-boots',
'Ashen Boots',
'Boots that walked through smoldering fields and never quite left them behind.',
ItemType.EQUIPMENT,
EquipmentSlot.FEET,
ItemRarity.RARE,
{ bonusArmor: 4, bonusHp: 5 },
),
item(
'borderwatch-sigil',
'Mark of the Border Watch',
'The crest of a watchtower that no longer stands.',
ItemType.EQUIPMENT,
EquipmentSlot.AMULET,
ItemRarity.RARE,
{ bonusAttack: 3, bonusHp: 10 },
),
item(
'burned-captain-pendant',
"Charred Captain's Pendant",
'A skull cast in slag, its embers never quite gone out.',
ItemType.EQUIPMENT,
EquipmentSlot.AMULET,
ItemRarity.EPIC,
{ bonusAttack: 3, bonusHp: 15, bonusArmor: 2 },
),
// Seeded as content only. Slice 0.4 implements no consumable use, and the
// Road Bandit loot entry for it is deliberately deferred (spec §16).
item(
'small-healing-potion',
'Small Healing Potion',
'A bitter draught that makes wounds forgettable, if only for a breath.',
ItemType.CONSUMABLE,
null,
ItemRarity.COMMON,
),
// Trade Goods (spec §17): turned in to the Border Watch for Silver and
// reputation rather than crafted with. Playable Slice 0.7 V2 §5 makes one
// of these the guaranteed drop of every Burned Road enemy, which is why
// there are now four of them -- one per monster.
item(
'ash-pelt',
'Ashen Pelt',
'Singed pelt, tough as leather and grey with drifting ash.',
ItemType.TRADE_GOOD,
null,
ItemRarity.COMMON,
{},
LootCategory.HIDE,
),
item(
'tough-hide',
'Tough Hide',
'Road-hound hide, scarred over so often it barely takes a blade.',
ItemType.TRADE_GOOD,
null,
ItemRarity.COMMON,
{},
LootCategory.HIDE,
),
item(
'bandit-insignia',
'Raider Insignia',
'A roughly stamped token marking a road bandit as one of their band.',
ItemType.TROPHY,
null,
ItemRarity.COMMON,
{},
LootCategory.RAIDER_TROPHY,
),
item(
'charred-raider-insignia',
'Charred Raider Insignia',
'The same stamped token, warped by heat until the band mark is barely legible.',
ItemType.TROPHY,
null,
ItemRarity.RARE,
{},
LootCategory.RAIDER_TROPHY,
),
];
export const LOOT_TABLES = [
{ id: ASH_RAT_LOOT_TABLE_ID, key: 'ash-rat-loot', name: 'Ash Rat Loot' },
{
id: ROAD_BANDIT_LOOT_TABLE_ID,
key: 'road-bandit-loot',
name: 'Road Bandit Loot',
},
{
id: WILD_ROAD_DOG_LOOT_TABLE_ID,
key: 'wild-road-dog-loot',
name: 'Feral Road Hound Loot',
},
{
id: CHARRED_LOOTER_LOOT_TABLE_ID,
key: 'charred-looter-loot',
name: 'Charred Raider Loot',
},
];
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,
};
}
/**
* Equipment drop chances come from
* docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §2728; the trade goods
* are guaranteed by Playable Slice 0.7 V2 §5. Every entry is an independent
* roll (spec §17), rolled in `position` order, so the guaranteed trade good
* and the equipment chance never influence each other.
*
* `position` values of pre-existing entries are left where they were: the
* seed upserts on (lootTableId, itemDefinitionId) while `position` carries a
* unique index, so renumbering an existing table in one statement would
* collide with itself.
*
* DEFERRED: the Feral Road Hound is also meant to drop a Small Healing Potion
* "if potions are already lootable" (spec §6). They are not — combat potions
* are still a fixed per-fight count, not inventory-backed — so the entry waits
* rather than seeding an item that does nothing.
*/
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'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '0.6000'),
entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'tough-hide', 1, '0.6000'),
entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'ash-boots', 2, '0.0800'),
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'charred-raider-insignia', 1, '1.0000'),
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.1000'),
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'ash-boots', 3, '0.1000'),
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'borderwatch-sigil', 4, '0.0800'),
];

View File

@@ -0,0 +1,29 @@
// 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',
'bandit-insignia': '50000000-0000-4000-8000-00000000000d',
'tough-hide': '50000000-0000-4000-8000-00000000000e',
'charred-raider-insignia': '50000000-0000-4000-8000-00000000000f',
} as const;
export type ItemKey = keyof typeof ITEM_IDS;
// One table per monster (Playable Slice 0.7 V2 spec §5): each Burned Road
// enemy now has its own guaranteed trade good, so they can no longer share.
export const ASH_RAT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000001';
export const ROAD_BANDIT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000002';
export const WILD_ROAD_DOG_LOOT_TABLE_ID =
'60000000-0000-4000-8000-000000000003';
export const CHARRED_LOOTER_LOOT_TABLE_ID =
'60000000-0000-4000-8000-000000000004';

View File

@@ -0,0 +1,241 @@
import type {
LocationPointOfInterestContent,
LocationPrimaryActionContent,
LocationRewardPreviewContent,
LocationType,
} from '../../world/local-location.types';
/**
* Authored local-view content per location (plan §7§11).
*
* Coordinates are percentages of the artwork box, so a hotspot stays on the
* same painted detail at every viewport width. They are tuned against the real
* artwork in `apps/web/public/images/backgrounds/`, not against the
* composition mockup in `docs/references/`.
*/
export interface LocalLocationContent {
regionName: string;
regionTierLabel: string;
locationType: LocationType;
localDescription: string;
localArtworkPath: string;
localPointsOfInterest: LocationPointOfInterestContent[];
localPrimaryActions: LocationPrimaryActionContent[];
localRewardPreview: LocationRewardPreviewContent[];
}
export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = {
regionName: 'Ashen Fields',
regionTierLabel: 'Tier 1',
locationType: 'HUNTING_GROUND',
localDescription:
'An old trade road, burned to ash by fire and war. Charred carts, broken weapons, and silenced cries line the path into the Ashen Fields.',
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
// Anchored to painted detail in `Aschestrasse.png`: the burning horizon down
// the road, the standing gravestone on the left verge, the cracked stones in
// the near foreground, and the broken cart wheel on the right.
localPointsOfInterest: [
{
key: 'hunt-area',
title: 'Hunting Ground',
actionLabel: 'Begin Hunt',
type: 'HUNT',
iconKey: 'hunt',
xPercent: 57,
yPercent: 33,
enabled: true,
},
{
key: 'inspect-tracks',
title: 'Suspicious Tracks',
actionLabel: 'Investigate',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 40,
yPercent: 82,
enabled: true,
resultTitle: 'Suspicious Tracks',
resultText:
'Among the ash and broken stones you make out several fresh bootprints. They lead east, toward the abandoned watchpost.',
},
{
key: 'search-abandoned-wagon',
title: 'Abandoned Wagon',
actionLabel: 'Search',
type: 'SEARCH',
iconKey: 'search',
xPercent: 86,
yPercent: 70,
enabled: true,
resultTitle: 'Abandoned Wagon',
resultText:
'The wagon has been thoroughly looted. Among the charred planks you find only empty crates and signs of a hasty departure.',
},
{
key: 'wounded-scout',
title: 'Wounded Scout',
actionLabel: 'Talk',
type: 'NPC',
iconKey: 'speak',
xPercent: 17,
yPercent: 62,
enabled: true,
resultTitle: 'Wounded Scout',
resultText:
'"The road isn\'t safe anymore. The raiders are coming from the direction of the old watchpost. If you keep going, keep your eyes open."',
},
],
localPrimaryActions: [
{
key: 'start-hunt',
label: 'Begin Hunt',
description: 'Hunt in this area',
type: 'HUNT',
iconKey: 'hunt',
enabled: true,
},
{
key: 'investigate-tracks',
label: 'Investigate tracks',
description: 'Find clues',
type: 'INVESTIGATE',
iconKey: 'investigate',
enabled: true,
poiKey: 'inspect-tracks',
},
{
key: 'search-surroundings',
label: 'Search surroundings',
description: 'Find loot',
type: 'SEARCH',
iconKey: 'search',
enabled: true,
poiKey: 'search-abandoned-wagon',
},
{
key: 'open-map',
label: 'To Map',
description: 'Change area',
type: 'MAP',
iconKey: 'map',
enabled: true,
},
],
// Only categories the loot tables on this road actually back: gear from the
// raiders, pelts from the beasts. Named items stay out — the view may not
// promise a drop the roll does not guarantee (spec §8, "Mögliche
// Belohnungen").
//
// Silver and experience were both dropped from this preview by slice 0.6.5,
// and Playable Slice 0.7 V2 §7 makes that permanent: a normal kill grants
// no XP, Silver, regional reputation or World Renown, and the monster
// definitions no longer carry a currency range at all. Silver reaches the
// player through turn-ins instead. Leaving either entry in place would
// break this list's own rule.
localRewardPreview: [
{ key: 'equipment', label: 'Equipment', iconKey: 'equipment' },
{ key: 'material', label: 'Trade Goods', iconKey: 'material' },
],
};
export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
regionName: 'Ashen Fields',
regionTierLabel: 'Tier 1',
locationType: 'TRANSITION',
localDescription:
"At the black South Gate, Graufurt's protection ends. Beyond the watch-fires begins the silent expanse of the Ashen Fields.",
localArtworkPath: '/images/backgrounds/Suedtor.png',
localPointsOfInterest: [
{
key: 'gate-notice',
title: 'Notice Board',
actionLabel: 'Read',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 22,
yPercent: 42,
enabled: true,
resultTitle: 'Notice Board',
resultText:
'Weathered notices flutter in the wind. A fresh one warns of raiders on the Burned Road and promises silver for every bandit killed.',
resultImg: '/images/environment/notice-board.png',
},
{
key: 'gate-watch',
title: 'Gate Watch',
actionLabel: 'Talk',
type: 'NPC',
iconKey: 'speak',
xPercent: 45,
yPercent: 52,
enabled: true,
resultTitle: 'Gate Watch',
resultText:
'"Beyond the gate, Graufurt\'s protection ends. Whoever heads south does so at their own risk — and rarely comes back the way they left."',
resultImg: '/images/npcs/graufurt-gate-watch.png',
},
// Borin stands at the gate rather than deeper in a town that does not
// exist yet as a location. Carries `npcKey` instead of result text, so
// this hotspot opens his screen (Playable Slice 0.8 §2).
{
key: 'borin',
title: 'Borin, Quartermaster',
actionLabel: 'Trade',
type: 'NPC',
iconKey: 'speak',
xPercent: 30,
yPercent: 66,
enabled: true,
npcKey: 'borin-quartermaster',
},
{
key: 'south-road',
title: 'Road South',
actionLabel: 'To Map',
type: 'MAP',
iconKey: 'map',
xPercent: 66,
yPercent: 72,
enabled: true,
},
],
localPrimaryActions: [
{
key: 'talk-to-watch',
label: 'Talk to the watch',
description: 'Ask about the situation',
type: 'NPC',
iconKey: 'speak',
enabled: true,
poiKey: 'gate-watch',
},
{
key: 'trade-with-borin',
label: 'Trade with Borin',
description: 'Sell goods, buy supplies',
type: 'NPC',
iconKey: 'speak',
enabled: true,
poiKey: 'borin',
npcKey: 'borin-quartermaster',
},
{
key: 'read-notice',
label: 'Read the notice',
description: 'Find clues',
type: 'INVESTIGATE',
iconKey: 'investigate',
enabled: true,
poiKey: 'gate-notice',
},
{
key: 'open-map',
label: 'To Map',
description: 'Change area',
type: 'MAP',
iconKey: 'map',
enabled: true,
},
],
localRewardPreview: [],
};

View File

@@ -0,0 +1,38 @@
import { LootCategory } from '../../items/loot-category.enum';
export const BASIC_HIDE_BAG_ID = 'a0000000-0000-4000-8000-000000000001';
export const BASIC_TROPHY_POUCH_ID = 'a0000000-0000-4000-8000-000000000002';
export interface SeedLootBagDefinition {
id: string;
key: string;
name: string;
lootCategory: LootCategory;
capacity: number;
iconPath: string;
}
/**
* The starter bag per category (Playable Slice 0.7.5 §7).
*
* Capacity 5 against a bagless default of 1: enough that a hunt is worth
* making, small enough that the trip still ends.
*/
export const LOOT_BAG_DEFINITIONS: SeedLootBagDefinition[] = [
{
id: BASIC_HIDE_BAG_ID,
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
lootCategory: LootCategory.HIDE,
capacity: 5,
iconPath: '/images/items/basic-hide-bag.png',
},
{
id: BASIC_TROPHY_POUCH_ID,
key: 'basic-trophy-pouch',
name: 'Basic Trophy Pouch',
lootCategory: LootCategory.RAIDER_TROPHY,
capacity: 5,
iconPath: '/images/items/basic-trophy-pouch.png',
},
];

View File

@@ -0,0 +1,440 @@
import {
ComparisonOperator,
GameCondition,
GameConditionType,
} from '../../conditions/game-condition.types';
import { NpcCapability } from '../../npcs/npc.types';
import type {
DialogueAction,
DialogueResponseContent,
} from '../../npcs/npc.types';
import { ITEM_IDS } from './item.constants';
import { BASIC_HIDE_BAG_ID, BASIC_TROPHY_POUCH_ID } from './loot-bag-content';
import { BORDER_GUARD_FACTION_ID } from './reputation-content';
export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001';
export const BORIN_SHOP_ID = 'b1000000-0000-4000-8000-000000000001';
export const BORIN_EXCHANGE_PROFILE_ID = 'b2000000-0000-4000-8000-000000000001';
export const BORIN_KEY = 'borin-quartermaster';
export const BORIN_SHOP_KEY = 'borin-supplies';
export const BORIN_EXCHANGE_KEY = 'borin-trade-in';
// Stable offer ids so re-seeding re-tunes a price instead of inserting a
// second row (AGENTS.md §8). Needed here in particular because an offer has
// two possible targets and no single natural key across both shapes.
export const BORIN_OFFER_IDS = {
potion: 'b4000000-0000-4000-8000-000000000001',
shortsword: 'b4000000-0000-4000-8000-000000000002',
trophyPouch: 'b4000000-0000-4000-8000-000000000003',
hideBag: 'b4000000-0000-4000-8000-000000000004',
banditBlade: 'b4000000-0000-4000-8000-000000000005',
} as const;
/** The flag the Slice 0.9 warden sets when she sends the player to Borin. */
export const SOUTH_GATE_REFERRAL_FLAG = 'referred-by-south-gate-warden';
/**
* The Renown 2 milestone (Playable Slice 0.6.5 §6).
*
* 0.6.5 names "first meaningful trophy returned" as what should move a
* character from Renown 1 to 2, and this is the first slice where returning a
* trophy is possible at all. Non-repeatable: it fires on the first trade and
* never again, which is how routine trading keeps paying silver and reputation
* without inflating a power rank (0.6.5 §2.2).
*/
export const FIRST_TRADE_MILESTONE_KEY = 'first-goods-returned';
export const FIRST_TRADE_MILESTONE_ID = 'b3000000-0000-4000-8000-000000000001';
export interface SeedRenownMilestone {
id: string;
key: string;
name: string;
description: string;
renownReward: number;
repeatable: boolean;
enabled: boolean;
}
export const RENOWN_MILESTONES: SeedRenownMilestone[] = [
{
id: FIRST_TRADE_MILESTONE_ID,
key: FIRST_TRADE_MILESTONE_KEY,
name: 'Something Worth Bringing Back',
description:
'You returned from the Ashen Fields with goods worth trading, and the Border Watch noticed.',
renownReward: 1,
repeatable: false,
enabled: true,
},
];
export interface SeedNpcDefinition {
id: string;
key: string;
name: string;
title: string | null;
description: string | null;
locationKey: string;
factionKey: string | null;
portraitPath: string;
artworkPath: string | null;
capabilities: NpcCapability[];
enabled: boolean;
}
/**
* Borin, the Graufurt quartermaster (Playable Slice 0.8 §2).
*
* Placed at `south-gate` because that is Graufurt in the current content --
* the only safe, non-hunting location, named "Graufurt South Gate". No new
* town location is invented for him; when Graufurt proper is built out he
* moves by changing this one key.
*
* His capabilities list MERCHANT and RESOURCE_EXCHANGE together, which is the
* whole point of the composition model: one person, two functions, no
* `MerchantNpc` subclass (NPC spec §2, §26).
*/
export const NPC_DEFINITIONS: SeedNpcDefinition[] = [
{
id: BORIN_NPC_ID,
key: BORIN_KEY,
name: 'Borin',
title: 'Quartermaster of the Border Watch',
description:
'A broad, grey-bearded man who has outlasted three captains and every fashion of optimism. He weighs what you bring him without ceremony, and pays what it is worth.',
locationKey: 'south-gate',
factionKey: 'border-guard',
portraitPath: '/images/npcs/borin.png',
artworkPath: null,
capabilities: [
NpcCapability.DIALOGUE,
NpcCapability.MERCHANT,
NpcCapability.RESOURCE_EXCHANGE,
],
enabled: true,
},
];
export interface SeedDialogueNode {
npcId: string;
key: string;
text: string;
priority: number;
conditions: GameCondition[];
actions: DialogueAction[];
responses: DialogueResponseContent[];
enabled: boolean;
}
/**
* Borin's lines, chosen by priority (NPC spec §11).
*
* Three nodes, deliberately overlapping: the greeting outranks the standard
* line but only survives the first visit, and the reputation line outranks
* both once the Border Watch actually knows the player. Nothing in
* `NpcService` knows any of this -- it takes the highest node whose conditions
* hold.
*/
export const DIALOGUE_NODES: SeedDialogueNode[] = [
{
npcId: BORIN_NPC_ID,
key: 'borin-first-meeting',
text: 'You have the look of someone who has been south of the gate. Most who go out there come back with nothing but ash in their boots. If you did better than that, I will weigh it and pay you fairly. Not generously. Fairly.',
priority: 800,
// Fires only before the visit is recorded -- `NpcService` sets `met` after
// dialogue is resolved, so this node is unreachable from the second visit.
conditions: [
{
type: GameConditionType.FLAG_SET,
key: 'met',
value: false,
},
],
actions: [],
responses: [],
enabled: true,
},
{
npcId: BORIN_NPC_ID,
key: 'borin-trusted',
text: 'Back again, and still walking. The Watch has started using your name without spitting afterward — that is as close to praise as we get here. Show me what you brought.',
priority: 500,
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 25,
},
],
actions: [],
responses: [],
enabled: true,
},
{
npcId: BORIN_NPC_ID,
key: 'borin-default',
text: 'Pelts, hides, raider trinkets — I take all of it, and the Watch asks no questions about where it came from. Silver for goods, and a word in the right ear if the goods are good.',
priority: 100,
conditions: [],
actions: [],
responses: [],
enabled: true,
},
];
export interface SeedNpcShop {
id: string;
key: string;
npcId: string;
name: string;
enabled: boolean;
}
export const NPC_SHOPS: SeedNpcShop[] = [
{
id: BORIN_SHOP_ID,
key: BORIN_SHOP_KEY,
npcId: BORIN_NPC_ID,
name: "Quartermaster's Supplies",
enabled: true,
},
];
export interface SeedShopOffer {
id: string;
shopId: string;
itemDefinitionId: string | null;
lootBagDefinitionId: string | null;
currencyType: string;
price: number;
quantity: number;
repeatable: boolean;
sortOrder: number;
conditions: GameCondition[];
bypassConditions: GameCondition[];
enabled: boolean;
}
/**
* What Borin sells (Playable Slice 0.8 §12, Slice 0.8.5 §4).
*
* Two open offers so Silver always has somewhere to go, and three gated ones
* so reputation visibly changes what the player can do (0.8.5 §1). The locked
* offers stay listed rather than hidden: a reward you can see is a goal, and a
* reward you cannot see is nothing (0.8.5 §5).
*
* Thresholds are balancing data (0.8.5 §4). The exchange pays 2-12 reputation
* per trade good, so 25 is three or four good hunts away -- close enough to
* pull, far enough to matter -- and 40 is deliberately further out, which is
* what makes the Slice 0.9 referral read as a favour rather than a shortcut
* around nothing.
*
* The Bandit Blade sits behind World Renown 3, which today's content cannot
* reach: there is exactly one renown milestone, worth +1. That is intentional
* and not a balancing oversight -- it is the long-horizon goal on the shelf
* until Slice 0.11 adds the milestones that reach it.
*/
export const SHOP_OFFERS: SeedShopOffer[] = [
{
id: BORIN_OFFER_IDS.potion,
shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['small-healing-potion'],
lootBagDefinitionId: null,
currencyType: 'SILVER',
price: 12,
quantity: 1,
repeatable: true,
sortOrder: 1,
conditions: [],
bypassConditions: [],
enabled: true,
},
{
id: BORIN_OFFER_IDS.shortsword,
shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
lootBagDefinitionId: null,
currencyType: 'SILVER',
price: 30,
quantity: 1,
repeatable: true,
sortOrder: 2,
conditions: [],
bypassConditions: [],
enabled: true,
},
{
// The first gate a player meets, and the one 0.8.5 §5 uses as its worked
// example: 40 Silver, Ashen Fields reputation 25.
id: BORIN_OFFER_IDS.trophyPouch,
shopId: BORIN_SHOP_ID,
itemDefinitionId: null,
lootBagDefinitionId: BASIC_TROPHY_POUCH_ID,
currencyType: 'SILVER',
price: 40,
quantity: 1,
repeatable: false,
sortOrder: 3,
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 25,
},
],
bypassConditions: [],
enabled: true,
},
{
// Reputation 40 is out of reach for a new character on purpose. Slice 0.9
// sends the player here with the warden's word instead, which is the one
// exception the offer system supports (0.8.5 §7).
id: BORIN_OFFER_IDS.hideBag,
shopId: BORIN_SHOP_ID,
itemDefinitionId: null,
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
currencyType: 'SILVER',
price: 35,
quantity: 1,
repeatable: false,
sortOrder: 4,
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 40,
},
],
bypassConditions: [
{
type: GameConditionType.FLAG_SET,
key: SOUTH_GATE_REFERRAL_FLAG,
value: true,
},
],
enabled: true,
},
{
id: BORIN_OFFER_IDS.banditBlade,
shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['bandit-blade'],
lootBagDefinitionId: null,
currencyType: 'SILVER',
price: 60,
quantity: 1,
repeatable: true,
sortOrder: 5,
conditions: [
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 3,
},
],
bypassConditions: [],
enabled: true,
},
];
export interface SeedNpcExchangeProfile {
id: string;
key: string;
npcId: string;
name: string;
enabled: boolean;
}
export const NPC_EXCHANGE_PROFILES: SeedNpcExchangeProfile[] = [
{
id: BORIN_EXCHANGE_PROFILE_ID,
key: BORIN_EXCHANGE_KEY,
npcId: BORIN_NPC_ID,
name: 'Border Watch Trade-In',
enabled: true,
},
];
export interface SeedExchangeRule {
profileId: string;
inputItemId: string;
inputQuantity: number;
factionId: string;
silverReward: number;
regionReputationReward: number;
renownMilestoneKey: string | null;
conditions: GameCondition[];
sortOrder: number;
enabled: boolean;
}
/**
* What Borin pays (Playable Slice 0.8 §4, §5).
*
* All four Burned Road trade goods are accepted, and the rare Charred Raider
* Insignia is worth visibly more than the common Ashen Pelt -- 30 silver
* against 5, six times the value for a drop that comes off a 2%-weight
* encounter (slice §5).
*
* Silver is up from the old turn-in values (4 and 12) because this is now the
* only way to earn it: normal kills pay nothing at all (slice 0.7 §7), so the
* exchange has to carry the whole economy on its own (slice §7).
*
* Every rule pays Border Watch reputation, the Ashen Fields faction. Only the
* pelt carries the renown milestone: it is the guaranteed Ash Rat drop, so
* the very first trade any player makes will fire it, whatever they hunted.
* Provisional balancing values (slice §4).
*/
export const EXCHANGE_RULES: SeedExchangeRule[] = [
{
profileId: BORIN_EXCHANGE_PROFILE_ID,
inputItemId: ITEM_IDS['ash-pelt'],
inputQuantity: 1,
factionId: BORDER_GUARD_FACTION_ID,
silverReward: 5,
regionReputationReward: 2,
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
conditions: [],
sortOrder: 1,
enabled: true,
},
{
profileId: BORIN_EXCHANGE_PROFILE_ID,
inputItemId: ITEM_IDS['tough-hide'],
inputQuantity: 1,
factionId: BORDER_GUARD_FACTION_ID,
silverReward: 8,
regionReputationReward: 3,
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
conditions: [],
sortOrder: 2,
enabled: true,
},
{
profileId: BORIN_EXCHANGE_PROFILE_ID,
inputItemId: ITEM_IDS['bandit-insignia'],
inputQuantity: 1,
factionId: BORDER_GUARD_FACTION_ID,
silverReward: 14,
regionReputationReward: 5,
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
conditions: [],
sortOrder: 3,
enabled: true,
},
{
profileId: BORIN_EXCHANGE_PROFILE_ID,
inputItemId: ITEM_IDS['charred-raider-insignia'],
inputQuantity: 1,
factionId: BORDER_GUARD_FACTION_ID,
silverReward: 30,
regionReputationReward: 12,
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
conditions: [],
sortOrder: 4,
enabled: true,
},
];

View File

@@ -0,0 +1,25 @@
export const BORDER_GUARD_FACTION_ID = '80000000-0000-4000-8000-000000000001';
export interface SeedReputationFaction {
id: string;
key: string;
name: string;
description: string;
regionKey: string;
enabled: boolean;
}
// Only the Border Watch (Grenzwacht in the spec) is seeded and usable in
// Slice 0.6.5 (spec §8). Dämmerjäger ('dusk-hunters') and Letzte Wacht
// ('last-watch') are future-region keys, intentionally not seeded yet.
export const REPUTATION_FACTIONS: SeedReputationFaction[] = [
{
id: BORDER_GUARD_FACTION_ID,
key: 'border-guard',
name: 'Border Watch',
description:
'The last organized watch of the Ashen Fields, guarding what remains of the trade routes and outposts.',
regionKey: 'ashen-fields',
enabled: true,
},
];

View File

@@ -2,3 +2,5 @@ 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';
export const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
export const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';

View File

@@ -1,9 +1,38 @@
import { DataSource } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../../items/entities/character-item.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 { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.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 { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcShop } from '../../shops/entities/npc-shop.entity';
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
import {
ComparisonOperator,
GameConditionType,
} from '../../conditions/game-condition.types';
import {
ASH_RAT_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID,
ITEM_IDS,
ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants';
import { BASIC_HIDE_BAG_ID } from './loot-bag-content';
import { BORIN_OFFER_IDS, SHOP_OFFERS } from './npc-content';
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
type Row = Record<string, unknown>;
@@ -13,6 +42,8 @@ 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';
const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
class InMemoryRepository {
readonly rows: Row[] = [];
@@ -67,24 +98,45 @@ function createDataSource(
characterRepository: InMemoryRepository,
monsterRepository: InMemoryRepository,
locationMonsterRepository: InMemoryRepository,
itemRepository: InMemoryRepository = new InMemoryRepository(),
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
reputationFactionRepository: InMemoryRepository = new InMemoryRepository(),
renownMilestoneRepository: InMemoryRepository = new InMemoryRepository(),
lootBagDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
characterLootBagRepository: InMemoryRepository = new InMemoryRepository(),
npcDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
dialogueNodeRepository: InMemoryRepository = new InMemoryRepository(),
npcShopRepository: InMemoryRepository = new InMemoryRepository(),
shopOfferRepository: InMemoryRepository = new InMemoryRepository(),
npcExchangeProfileRepository: InMemoryRepository = new InMemoryRepository(),
exchangeRuleRepository: 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 === 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;
if (entity === CharacterItem) return characterItemRepository;
if (entity === CharacterEquipment) return characterEquipmentRepository;
if (entity === ReputationFaction) return reputationFactionRepository;
if (entity === RenownMilestoneDefinition)
return renownMilestoneRepository;
if (entity === NpcDefinition) return npcDefinitionRepository;
if (entity === DialogueNode) return dialogueNodeRepository;
if (entity === NpcShop) return npcShopRepository;
if (entity === ShopOffer) return shopOfferRepository;
if (entity === NpcExchangeProfile) return npcExchangeProfileRepository;
if (entity === ExchangeRule) return exchangeRuleRepository;
if (entity === LootBagDefinition) return lootBagDefinitionRepository;
if (entity === CharacterLootBag) return characterLootBagRepository;
throw new Error('Unexpected repository');
}),
@@ -110,7 +162,10 @@ describe('seedVisibleVerticalSlice', () => {
Object.assign(characterRepository.rows[0], {
currentLocationId: BURNED_ROAD_ID,
currentHp: 57,
experience: 39,
// Deliberately NOT the seed default of 1: if a re-seed clobbered player
// state back to defaults, an assertion on the default value could not
// tell the difference.
renown: 5,
});
await seedVisibleVerticalSlice(dataSource);
@@ -149,57 +204,194 @@ describe('seedVisibleVerticalSlice', () => {
expect.objectContaining({
currentLocationId: BURNED_ROAD_ID,
currentHp: 57,
experience: 39,
renown: 5,
}),
);
expect(monsterRepository.insert).toHaveBeenCalledTimes(2);
expect(monsterRepository.rows).toHaveLength(2);
expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
expect(monsterRepository.rows).toHaveLength(4);
expect(monsterRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'ash-rat',
name: 'Aschenratte',
name: 'Ash Rat',
monsterCategory: 'BEAST',
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
// Pure baseline combat: no telegraph, no status effect (spec §4).
abilities: {},
}),
expect.objectContaining({
key: 'road-bandit',
name: 'Straßenräuber',
name: 'Road Bandit',
monsterCategory: 'HUMANOID',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
abilities: { telegraph: { roundInterval: 3, damageMultiplier: 1.6 } },
}),
expect.objectContaining({
key: 'wild-road-dog',
name: 'Feral Road Hound',
monsterCategory: 'BEAST',
level: 1,
artworkPath: '/images/monsters/wild-road-dog.png',
iconPath: '/images/combat/icons/wild-road-dog-128.png',
abilities: {
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
},
}),
expect.objectContaining({
key: 'charred-looter',
name: 'Charred Raider',
monsterCategory: 'HUMANOID',
level: 2,
artworkPath: '/images/monsters/charred-looter.png',
iconPath: '/images/combat/icons/charred-looter-128.png',
// The rare encounter reuses the known telegraph on a tighter
// cadence rather than introducing a new subsystem (spec §4).
abilities: { telegraph: { roundInterval: 2, damageMultiplier: 1.6 } },
}),
]),
);
// Every monster carries an atmosphere line for its encounter card
// (spec §9).
for (const row of monsterRepository.rows) {
expect(typeof row.flavorText).toBe('string');
}
// No monster carries a currency range any more: a normal kill grants no
// Silver (spec §7).
for (const row of monsterRepository.rows) {
expect(row).not.toHaveProperty('silverMin');
expect(row).not.toHaveProperty('silverMax');
}
expect(locationMonsterRepository.upsert).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: ASH_RAT_MONSTER_ID,
weight: 70,
weight: 50,
encounterType: 'NORMAL',
}),
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: WILD_ROAD_DOG_MONSTER_ID,
weight: 30,
encounterType: 'NORMAL',
}),
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: ROAD_BANDIT_MONSTER_ID,
weight: 30,
weight: 18,
encounterType: 'NORMAL',
}),
// The rare find, at 2 out of 100 (spec §3).
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: CHARRED_LOOTER_MONSTER_ID,
weight: 2,
encounterType: 'RARE',
}),
]),
['locationId', 'monsterId'],
);
expect(locationMonsterRepository.rows).toHaveLength(2);
expect(locationMonsterRepository.rows).toHaveLength(4);
});
it('seeds the local view content of the Burned Road with four points of interest', async () => {
const locationRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
);
await seedVisibleVerticalSlice(dataSource);
const burnedRoad = locationRepository.rows.find(
(row) => row.key === 'burned-road',
) as Row;
expect(burnedRoad).toEqual(
expect.objectContaining({
regionName: 'Ashen Fields',
regionTierLabel: 'Tier 1',
locationType: 'HUNTING_GROUND',
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
}),
);
expect(burnedRoad.localDescription).toContain(
'An old trade road, burned to ash by fire and war.',
);
const pointsOfInterest = burnedRoad.localPointsOfInterest as {
key: string;
type: string;
}[];
expect(pointsOfInterest.map((poi) => poi.key)).toEqual([
'hunt-area',
'inspect-tracks',
'search-abandoned-wagon',
'wounded-scout',
]);
expect(pointsOfInterest.map((poi) => poi.type)).toEqual([
'HUNT',
'INVESTIGATE',
'SEARCH',
'NPC',
]);
const primaryActions = burnedRoad.localPrimaryActions as {
label: string;
}[];
expect(primaryActions.map((action) => action.label)).toEqual([
'Begin Hunt',
'Investigate tracks',
'Search surroundings',
'To Map',
]);
});
it('gives the South Gate its own local content so a second location needs no new component', async () => {
const locationRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
);
await seedVisibleVerticalSlice(dataSource);
const southGate = locationRepository.rows.find(
(row) => row.key === 'south-gate',
) as Row;
expect(southGate).toEqual(
expect.objectContaining({
locationType: 'TRANSITION',
localArtworkPath: '/images/backgrounds/Suedtor.png',
}),
);
// Four since Slice 0.8 gave Borin a hotspot at the gate.
expect(southGate.localPointsOfInterest).toHaveLength(4);
// A transition location offers no hunt, so no HUNT hotspot may appear.
expect(
(southGate.localPointsOfInterest as { type: string }[]).some(
(poi) => poi.type === 'HUNT',
),
).toBe(false);
});
it('preserves existing location IDs and uses them for the directed connections', async () => {
@@ -212,7 +404,7 @@ describe('seedVisibleVerticalSlice', () => {
locationRepository.rows.push({
id: persistedSouthGateId,
key: 'south-gate',
name: 'Veraltetes Südtor',
name: 'Outdated South Gate',
});
const dataSource = createDataSource(
locationRepository,
@@ -229,7 +421,7 @@ describe('seedVisibleVerticalSlice', () => {
expect.objectContaining({
id: persistedSouthGateId,
key: 'south-gate',
name: 'Südtor von Graufurt',
name: 'Graufurt South Gate',
}),
expect.objectContaining({
id: BURNED_ROAD_ID,
@@ -256,4 +448,609 @@ describe('seedVisibleVerticalSlice', () => {
}),
]);
});
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(15);
expect(itemRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'bandit-blade',
name: 'Bandit Blade',
type: 'EQUIPMENT',
equipmentSlot: 'WEAPON',
rarity: 'COMMON',
weaponDamage: 11,
bonusAttack: 1,
sellPrice: 0,
iconPath: '/images/items/bandit-blade.png',
}),
expect.objectContaining({
key: 'ash-pelt',
name: 'Ashen Pelt',
type: 'TRADE_GOOD',
equipmentSlot: null,
lootCategory: 'HIDE',
}),
expect.objectContaining({
key: 'tough-hide',
name: 'Tough Hide',
type: 'TRADE_GOOD',
equipmentSlot: null,
lootCategory: 'HIDE',
}),
expect.objectContaining({
key: 'bandit-insignia',
name: 'Raider Insignia',
type: 'TROPHY',
equipmentSlot: null,
lootCategory: 'RAIDER_TROPHY',
}),
expect.objectContaining({
key: 'charred-raider-insignia',
name: 'Charred Raider Insignia',
type: 'TROPHY',
equipmentSlot: null,
lootCategory: 'RAIDER_TROPHY',
}),
]),
);
// Equipment and consumables stay outside the carrying system entirely
// (0.7.5 §8), so they must not pick up a category by accident.
for (const row of itemRepository.rows) {
if (row.type === 'EQUIPMENT' || row.type === 'CONSUMABLE') {
expect(row.lootCategory).toBeNull();
}
}
// One table per monster now that each has its own guaranteed trade good.
expect(lootTableRepository.rows).toHaveLength(4);
expect(lootEntryRepository.rows).toHaveLength(12);
// Every Burned Road enemy guarantees exactly one trade good (spec §5).
const guaranteedTradeGoods: Array<[string, string]> = [
[ASH_RAT_LOOT_TABLE_ID, ITEM_IDS['ash-pelt']],
[WILD_ROAD_DOG_LOOT_TABLE_ID, ITEM_IDS['tough-hide']],
[ROAD_BANDIT_LOOT_TABLE_ID, ITEM_IDS['bandit-insignia']],
[CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS['charred-raider-insignia']],
];
for (const [lootTableId, itemDefinitionId] of guaranteedTradeGoods) {
expect(lootEntryRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
lootTableId,
itemDefinitionId,
dropChance: '1.0000',
minQuantity: 1,
maxQuantity: 1,
enabled: true,
}),
]),
);
}
// Equipment stays a roll, so it can never be confused with the
// guaranteed good (spec §6).
expect(lootEntryRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
itemDefinitionId: ITEM_IDS['bandit-blade'],
position: 1,
dropChance: '0.1800',
}),
expect.objectContaining({
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
itemDefinitionId: ITEM_IDS['ash-boots'],
dropChance: '0.0800',
}),
expect.objectContaining({
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
itemDefinitionId: ITEM_IDS['reinforced-leather-jacket'],
dropChance: '0.1000',
}),
]),
);
// 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: 'wild-road-dog',
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
}),
expect.objectContaining({
key: 'road-bandit',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
}),
expect.objectContaining({
key: 'charred-looter',
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
}),
]),
);
});
it('seeds both starter loot bags idempotently', async () => {
const lootBagDefinitionRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
lootBagDefinitionRepository,
);
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(lootBagDefinitionRepository.rows).toHaveLength(2);
expect(lootBagDefinitionRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
lootCategory: 'HIDE',
capacity: 5,
}),
expect.objectContaining({
key: 'basic-trophy-pouch',
name: 'Basic Trophy Pouch',
lootCategory: 'RAIDER_TROPHY',
capacity: 5,
}),
]),
);
});
it('gives the demo character only the Hide Bag, idempotently', async () => {
const characterLootBagRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterLootBagRepository,
);
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
// Slice 0.8.5 decision: the Trophy Pouch is now a reputation-gated offer,
// so handing it to the demo character for free would undercut the
// showcase. The Hide Bag stays -- without it, HIDE capacity would drop to
// the bagless default of 1 with no way to raise it before Slice 0.9 grants
// it through the warden's referral. See the ASSUMPTION note in the seed.
expect(characterLootBagRepository.rows).toHaveLength(1);
expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([
true,
]);
expect(
characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId),
).toEqual(['a0000000-0000-4000-8000-000000000001']);
});
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(characterItemRepository.rows).toHaveLength(1);
expect(characterItemRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
}),
);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
slot: 'WEAPON',
characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
}),
);
});
it('never re-equips the starting sword once the player has equipped different gear', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
await seedVisibleVerticalSlice(dataSource);
// Simulate the player having equipped earned loot instead.
characterEquipmentRepository.rows[0]['characterItemId'] =
'earned-bandit-blade-item-id';
await seedVisibleVerticalSlice(dataSource);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe(
'earned-bandit-blade-item-id',
);
expect(characterItemRepository.rows).toHaveLength(1);
});
it('reuses a naturally-looted starting sword instead of inserting a duplicate CharacterItem', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
// Simulate the demo character having already looted a worn-short-sword
// naturally, under a DB-generated id that differs from the seed's
// stable literal constant.
const naturallyLootedItemId = 'naturally-looted-sword-item-id';
characterItemRepository.rows.push({
id: naturallyLootedItemId,
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
});
await seedVisibleVerticalSlice(dataSource);
expect(characterItemRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
slot: 'WEAPON',
characterItemId: naturallyLootedItemId,
}),
);
});
it('seeds the Border Watch faction', async () => {
const reputationFactionRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
reputationFactionRepository,
);
await seedVisibleVerticalSlice(dataSource);
const faction = await dataSource
.getRepository(ReputationFaction)
.findOneBy({ key: 'border-guard' });
expect(faction).toMatchObject({ name: 'Border Watch', enabled: true });
});
it('seeds Borin with his shop and every trade-in rule', async () => {
const npcDefinitionRepository = new InMemoryRepository();
const npcShopRepository = new InMemoryRepository();
const exchangeRuleRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
npcDefinitionRepository,
new InMemoryRepository(),
npcShopRepository,
new InMemoryRepository(),
new InMemoryRepository(),
exchangeRuleRepository,
);
// Twice: seeds must be idempotent (NPC spec §32).
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(npcDefinitionRepository.rows).toHaveLength(1);
expect(npcDefinitionRepository.rows[0]).toMatchObject({
key: 'borin-quartermaster',
enabled: true,
});
// Placed in Graufurt via the location key, not a hardcoded id.
expect(npcDefinitionRepository.rows[0].locationId).toBe(SOUTH_GATE_ID);
expect(npcShopRepository.rows).toHaveLength(1);
// All four Burned Road trade goods are accepted (slice 0.8 §5), and the
// rare Charred Raider Insignia pays visibly more than the common pelt.
expect(exchangeRuleRepository.rows).toHaveLength(4);
const silverByItem = new Map(
exchangeRuleRepository.rows.map((row: Row) => [
row.inputItemId,
row.silverReward,
]),
);
expect(
silverByItem.get(ITEM_IDS['charred-raider-insignia']),
).toBeGreaterThan(silverByItem.get(ITEM_IDS['ash-pelt']));
});
it('seeds the renown milestone the first trade-in completes', async () => {
const renownMilestoneRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
renownMilestoneRepository,
);
await seedVisibleVerticalSlice(dataSource);
// Non-repeatable, so routine trading cannot pump a power rank
// (Slice 0.6.5 §2.2).
expect(renownMilestoneRepository.rows).toHaveLength(1);
expect(renownMilestoneRepository.rows[0]).toMatchObject({
key: 'first-goods-returned',
repeatable: false,
enabled: true,
});
});
it('seeds two visibly locked bag offers and one renown-gated weapon', async () => {
const gated = SHOP_OFFERS.filter((offer) => offer.conditions.length > 0);
expect(gated).toHaveLength(3);
expect(gated.map((offer) => offer.conditions[0].type).sort()).toEqual([
GameConditionType.REGION_REPUTATION,
GameConditionType.REGION_REPUTATION,
GameConditionType.WORLD_RENOWN,
]);
});
it('gives the Hide Bag a referral bypass for Slice 0.9', async () => {
const hideBag = SHOP_OFFERS.find(
(offer) => offer.lootBagDefinitionId === BASIC_HIDE_BAG_ID,
);
expect(hideBag?.bypassConditions).toEqual([
{
type: GameConditionType.FLAG_SET,
key: 'referred-by-south-gate-warden',
value: true,
},
]);
});
it('sells every bag as a one-off', async () => {
// A bag is one object; a second copy raises no capacity.
for (const offer of SHOP_OFFERS.filter(
(candidate) => candidate.lootBagDefinitionId !== null,
)) {
expect(offer.repeatable).toBe(false);
}
});
it('gives every offer exactly one target', async () => {
for (const offer of SHOP_OFFERS) {
const targets = [
offer.itemDefinitionId,
offer.lootBagDefinitionId,
].filter((target) => target !== null);
expect(targets).toHaveLength(1);
}
});
it('gives every offer a stable id so re-seeding cannot duplicate it', async () => {
const ids = SHOP_OFFERS.map((offer) => offer.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids.every((id) => id.length === 36)).toBe(true);
});
it('still holds exactly five offers after a re-seed, at the tuned numbers', async () => {
const shopOfferRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
shopOfferRepository,
);
// Twice, because that is the whole point of the stable ids (NPC spec §32).
// Both bag offers carry `itemDefinitionId: null`, so the pre-0.8.5 conflict
// target of (shopId, itemDefinitionId) would fold them into a single row
// here -- and would blow up outright on Postgres, where NULL never equals
// NULL in a unique index.
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(shopOfferRepository.rows).toHaveLength(5);
expect(
shopOfferRepository.rows.map((row: Row) => String(row.id)).sort(),
).toEqual(
[
BORIN_OFFER_IDS.potion,
BORIN_OFFER_IDS.shortsword,
BORIN_OFFER_IDS.trophyPouch,
BORIN_OFFER_IDS.hideBag,
BORIN_OFFER_IDS.banditBlade,
].sort(),
);
// Prices and thresholds are balancing decisions, not incidentals, so a
// refactor must not be able to drift one silently (AGENTS.md §39).
const byId = new Map<string, Row>(
shopOfferRepository.rows.map((row: Row): [string, Row] => [
String(row.id),
row,
]),
);
expect(byId.get(BORIN_OFFER_IDS.potion)).toMatchObject({
price: 12,
conditions: [],
});
expect(byId.get(BORIN_OFFER_IDS.shortsword)).toMatchObject({
price: 30,
conditions: [],
});
expect(byId.get(BORIN_OFFER_IDS.trophyPouch)).toMatchObject({
price: 40,
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 25,
},
],
});
expect(byId.get(BORIN_OFFER_IDS.hideBag)).toMatchObject({
price: 35,
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 40,
},
],
});
expect(byId.get(BORIN_OFFER_IDS.banditBlade)).toMatchObject({
price: 60,
conditions: [
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 3,
},
],
});
});
});

View File

@@ -1,16 +1,66 @@
import { DataSource } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
import {
DEMO_CHARACTER_ID,
DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
} from '../../demo/demo-character.constants';
import { Character } from '../../characters/entities/character.entity';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { EquipmentSlot } from '../../items/equipment-slot.enum';
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 { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
import { EncounterType } from '../../monsters/entities/encounter-type.enum';
import { MonsterCategory } from '../../monsters/monster-category.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 { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcShop } from '../../shops/entities/npc-shop.entity';
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
import {
ITEM_DEFINITIONS,
LOOT_TABLES,
LOOT_TABLE_ENTRIES,
} from './item-content';
import {
ASH_RAT_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID,
ITEM_IDS,
ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants';
import {
BURNED_ROAD_LOCAL_CONTENT,
SOUTH_GATE_LOCAL_CONTENT,
} from './local-location.content';
import { BASIC_HIDE_BAG_ID, LOOT_BAG_DEFINITIONS } from './loot-bag-content';
import { REPUTATION_FACTIONS } from './reputation-content';
import {
DIALOGUE_NODES,
EXCHANGE_RULES,
NPC_DEFINITIONS,
NPC_EXCHANGE_PROFILES,
NPC_SHOPS,
RENOWN_MILESTONES,
SHOP_OFFERS,
} from './npc-content';
import {
ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID,
CHARRED_LOOTER_MONSTER_ID,
ROAD_BANDIT_MONSTER_ID,
SOUTH_GATE_ID,
WILD_ROAD_DOG_MONSTER_ID,
} from './vertical-slice.constants';
export async function seedVisibleVerticalSlice(
@@ -21,14 +71,31 @@ export async function seedVisibleVerticalSlice(
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 lootBagDefinitionRepository =
dataSource.getRepository(LootBagDefinition);
const reputationFactionRepository =
dataSource.getRepository(ReputationFaction);
const renownMilestoneRepository = dataSource.getRepository(
RenownMilestoneDefinition,
);
const npcDefinitionRepository = dataSource.getRepository(NpcDefinition);
const dialogueNodeRepository = dataSource.getRepository(DialogueNode);
const npcShopRepository = dataSource.getRepository(NpcShop);
const shopOfferRepository = dataSource.getRepository(ShopOffer);
const npcExchangeProfileRepository =
dataSource.getRepository(NpcExchangeProfile);
const exchangeRuleRepository = dataSource.getRepository(ExchangeRule);
const locations = [
{
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'Südtor von Graufurt',
name: 'Graufurt South Gate',
description:
'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.',
"At the black South Gate, Graufurt's protection ends. Beyond the watch-fires begins the silent expanse of the Ashen Fields.",
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
@@ -36,13 +103,14 @@ export async function seedVisibleVerticalSlice(
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
...SOUTH_GATE_LOCAL_CONTENT,
},
{
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Straße',
name: 'Burned Road',
description:
'Die alte Handelsstraße führt durch verkohlte Felder. Zwischen Asche und zerbrochenen Wagen warten die ersten Gefahren.',
'The old trade road cuts through charred fields. Among the ash and broken wagons, the first dangers wait.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 2,
@@ -50,17 +118,14 @@ export async function seedVisibleVerticalSlice(
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
...BURNED_ROAD_LOCAL_CONTENT,
},
];
let southGateId = SOUTH_GATE_ID;
let burnedRoadId = BURNED_ROAD_ID;
const locationIds = new Map<string, string>();
for (const location of locations) {
const existing = await locationRepository.findOneBy({
key: location.key,
});
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);
@@ -68,13 +133,12 @@ export async function seedVisibleVerticalSlice(
await locationRepository.insert(location);
}
if (key === 'south-gate') {
southGateId = persistedId;
} else {
burnedRoadId = persistedId;
}
locationIds.set(key, existing?.id ?? id);
}
const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID;
await connectionRepository.upsert(
[
{
@@ -95,43 +159,109 @@ export async function seedVisibleVerticalSlice(
['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',
]);
await lootBagDefinitionRepository.upsert(LOOT_BAG_DEFINITIONS, ['key']);
await reputationFactionRepository.upsert(REPUTATION_FACTIONS, ['key']);
await renownMilestoneRepository.upsert(RENOWN_MILESTONES, ['key']);
// Burned Road roster (Playable Slice 0.7 V2 §2, §4). Each enemy has to be
// mechanically and economically distinct, so the differences live in
// `abilities` (what it does in a fight) and `lootTableId` (what it is worth)
// rather than in engine or UI branches.
const monsters = [
{
id: ASH_RAT_MONSTER_ID,
key: 'ash-rat',
name: 'Aschenratte',
name: 'Ash Rat',
monsterCategory: MonsterCategory.BEAST,
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
flavorText: 'Scrawny and quick, it feeds on whatever the fires left.',
// Pure baseline combat: no telegraph, no status effect, short fight.
abilities: {},
artworkPath: '/images/monsters/ash-rat.png',
iconPath: '/images/combat/icons/ash-rat-128.png',
lootTableId: ASH_RAT_LOOT_TABLE_ID,
},
{
id: WILD_ROAD_DOG_MONSTER_ID,
key: 'wild-road-dog',
name: 'Feral Road Hound',
monsterCategory: MonsterCategory.BEAST,
level: 1,
maxHp: 55,
attack: 7,
armor: 0,
flavorText:
'It stopped waiting for scraps a long time ago. Its bites do not close.',
// Introduces Bleeding: every third round its bite also opens a wound
// that ticks for two rounds. Windows of clean rounds in between keep
// the effect readable instead of permanent.
abilities: {
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
},
artworkPath: '/images/monsters/wild-road-dog.png',
iconPath: '/images/combat/icons/wild-road-dog-128.png',
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
},
{
id: ROAD_BANDIT_MONSTER_ID,
key: 'road-bandit',
name: 'Straßenräuber',
name: 'Road Bandit',
monsterCategory: MonsterCategory.HUMANOID,
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
flavorText:
'This one has done it before, and winds up the swing where you can see it.',
// Reinforces telegraphing: winds up a Heavy Strike every third round,
// which Shield Bash can interrupt and Defend can blunt.
abilities: {
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
},
artworkPath: '/images/monsters/road-bandit.png',
iconPath: '/images/combat/icons/road-bandit-128.png',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
},
{
id: CHARRED_LOOTER_MONSTER_ID,
key: 'charred-looter',
name: 'Charred Raider',
monsterCategory: MonsterCategory.HUMANOID,
level: 2,
maxHp: 85,
attack: 11,
armor: 6,
flavorText:
'Burned armor, a burned face, and no interest at all in your reasons.',
// The rare encounter: no new subsystem, just the known telegraph on a
// tighter cadence on top of higher stats. It is winding up almost
// constantly, so the player has to interrupt or brace.
abilities: {
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
},
artworkPath: '/images/monsters/charred-looter.png',
iconPath: '/images/combat/icons/charred-looter-128.png',
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
},
];
let ashRatId = ASH_RAT_MONSTER_ID;
let roadBanditId = ROAD_BANDIT_MONSTER_ID;
const monsterIds = new Map<string, string>();
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);
@@ -139,30 +269,35 @@ export async function seedVisibleVerticalSlice(
await monsterRepository.insert(monster);
}
if (key === 'ash-rat') {
ashRatId = persistedId;
} else {
roadBanditId = persistedId;
}
monsterIds.set(key, existingMonster?.id ?? id);
}
// Weights read as "how often you meet this on the road" (spec §3). They also
// drive the location's danger rating, which is computed from the weighted
// average of the pool rather than from its single worst entry.
//
// The Charred Raider is deliberately the odd one out at weight 2: it is a
// rare find, and `EncounterType.RARE` is what the hunt card reads to mark it
// as such.
const encounterPool: ReadonlyArray<{
key: string;
weight: number;
encounterType: EncounterType;
}> = [
{ key: 'ash-rat', weight: 50, encounterType: EncounterType.NORMAL },
{ key: 'wild-road-dog', weight: 30, encounterType: EncounterType.NORMAL },
{ key: 'road-bandit', weight: 18, encounterType: EncounterType.NORMAL },
{ key: 'charred-looter', weight: 2, encounterType: EncounterType.RARE },
];
await locationMonsterRepository.upsert(
[
{
encounterPool.map(({ key, weight, encounterType }) => ({
locationId: burnedRoadId,
monsterId: ashRatId,
weight: 70,
encounterType: EncounterType.NORMAL,
monsterId: monsterIds.get(key) as string,
weight,
encounterType,
enabled: true,
},
{
locationId: burnedRoadId,
monsterId: roadBanditId,
weight: 30,
encounterType: EncounterType.NORMAL,
enabled: true,
},
],
})),
['locationId', 'monsterId'],
);
@@ -174,12 +309,107 @@ export async function seedVisibleVerticalSlice(
await characterRepository.insert({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
renown: 1,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
hpRegenSince: new Date(),
currentLocationId: southGateId,
});
}
const characterItemRepository = dataSource.getRepository(CharacterItem);
const characterEquipmentRepository =
dataSource.getRepository(CharacterEquipment);
// Starting loadout is weapon-only -- no starter armor piece exists in
// content yet -- so the demo character's effective armor (sum of equipped
// bonusArmor) is 0 until the player loots and equips bandit-hood (+3
// armor). This is a deliberate tradeoff, not a bug: Slice 0.5 spec §19
// says to preserve the existing demo balance "as closely as the
// implemented content allows" and explicitly forbids fabricating a full
// starter gear set just to hit the old hardcoded TEMPORARY_ARMOR = 6.
const existingStartingSword = await characterItemRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
});
const startingSwordItemId =
existingStartingSword?.id ?? DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID;
if (!existingStartingSword) {
await characterItemRepository.insert({
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
});
}
const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
});
if (!existingWeaponEquipment) {
await characterEquipmentRepository.insert({
id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
characterId: DEMO_CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: startingSwordItemId,
});
}
// ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag
// and no longer starts with the Trophy Pouch.
//
// The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over
// for free would make the slice's own showcase pointless. The Hide Bag stays
// until Slice 0.9 grants it through the warden's referral -- removing both
// now would drop HIDE capacity to the bagless default of 1 with no way to
// raise it, and the hunting loop would be unplayable in between.
//
// Delete this block once 0.9 hands the Hide Bag over in the quest.
const characterLootBagRepository = dataSource.getRepository(CharacterLootBag);
const existingBag = await characterLootBagRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
});
if (!existingBag) {
await characterLootBagRepository.insert({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
active: true,
});
}
// NPC content (NPC Specification V1 §32; Playable Slice 0.8).
//
// Seeded after the character so a fresh database has somewhere to stand and
// someone to trade with in one pass. Every link uses a stable business key
// rather than a generated id (spec §4), and every write is an upsert, so
// re-running the seed re-tunes prices without duplicating Borin.
for (const npc of NPC_DEFINITIONS) {
const locationId = locationIds.get(npc.locationKey);
if (!locationId) {
throw new Error(
`NPC ${npc.key} references unknown location ${npc.locationKey}.`,
);
}
const { locationKey, ...definition } = npc;
await npcDefinitionRepository.upsert({ ...definition, locationId }, [
'key',
]);
}
await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']);
await npcShopRepository.upsert(NPC_SHOPS, ['key']);
// By id, not by (shop, item): an offer may now sell a bag instead of an
// item, so the old pair is null for half the rows and useless as a conflict
// target. Migration 1796 cleared the anonymous 0.8 rows for exactly this.
await shopOfferRepository.upsert(SHOP_OFFERS, ['id']);
await npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']);
await exchangeRuleRepository.upsert(EXCHANGE_RULES, [
'profileId',
'inputItemId',
]);
}

View File

@@ -1 +1,5 @@
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
export const DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID =
'10000000-0000-4000-8000-000000000002';
export const DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID =
'10000000-0000-4000-8000-000000000003';

View File

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

View File

@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { EquipmentSlot } from '../../items/equipment-slot.enum';
/**
* One equipped item in one slot for one character (spec §12).
*
* `characterItemId` must belong to `characterId` — enforced by
* `EquipmentService`, never by the client (spec §13).
*/
@Entity({ name: 'character_equipment' })
@Index('IDX_character_equipment_character_slot', ['characterId', 'slot'], {
unique: true,
})
@Index('IDX_character_equipment_character_item', ['characterItemId'], {
unique: true,
})
export class CharacterEquipment {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({
name: 'slot',
type: 'enum',
enum: EquipmentSlot,
enumName: 'equipment_slot_enum',
})
slot!: EquipmentSlot;
@Column({ name: 'character_item_id', type: 'uuid' })
characterItemId!: string;
@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(() => CharacterItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_item_id' })
characterItem!: CharacterItem;
}

View File

@@ -0,0 +1,22 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { EquipItemDto } from './dto/equip-item.dto';
import { EquipmentResponseDto, EquipmentService } from './equipment.service';
@Controller('equipment')
export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {}
@Get()
getEquipment(): Promise<EquipmentResponseDto> {
return this.equipmentService.getEquipment(DEMO_CHARACTER_ID);
}
@Post()
equip(@Body() request: EquipItemDto): Promise<EquipmentResponseDto> {
return this.equipmentService.equip(
DEMO_CHARACTER_ID,
request.characterItemId,
);
}
}

View File

@@ -0,0 +1,62 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type EquipmentErrorCode =
| 'CHARACTER_ITEM_NOT_FOUND'
| 'ITEM_NOT_OWNED'
| 'ITEM_NOT_EQUIPPABLE'
| 'INVALID_EQUIPMENT_SLOT'
| 'CHARACTER_IN_COMBAT';
export class EquipmentDomainError extends HttpException {
constructor(
public readonly code: EquipmentErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function characterItemNotFound(): EquipmentDomainError {
return new EquipmentDomainError(
'CHARACTER_ITEM_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This item could not be found.',
);
}
export function itemNotOwned(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_NOT_OWNED',
HttpStatus.FORBIDDEN,
'This item does not belong to the character.',
);
}
export function itemNotEquippable(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_NOT_EQUIPPABLE',
HttpStatus.BAD_REQUEST,
'This item cannot be equipped.',
);
}
// Defensive: slot is always derived from the item definition server-side, so
// this is unreachable in practice (spec §27 still names it explicitly).
export function invalidEquipmentSlot(): EquipmentDomainError {
return new EquipmentDomainError(
'INVALID_EQUIPMENT_SLOT',
HttpStatus.BAD_REQUEST,
'This item does not target a valid equipment slot.',
);
}
export function characterInCombat(): EquipmentDomainError {
return new EquipmentDomainError(
'CHARACTER_IN_COMBAT',
HttpStatus.CONFLICT,
'Equipment cannot be changed during an active combat.',
);
}
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 { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterEquipment } from './entities/character-equipment.entity';
import { EquipmentController } from './equipment.controller';
import { EquipmentService } from './equipment.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Character,
Combat,
CharacterItem,
ItemDefinition,
CharacterEquipment,
]),
CharactersModule,
],
controllers: [EquipmentController],
providers: [EquipmentService],
exports: [EquipmentService],
})
export class EquipmentModule {}

View File

@@ -0,0 +1,519 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
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 { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { CharacterEquipment } from './entities/character-equipment.entity';
import { EquipmentDomainError } from './equipment.errors';
import { EquipmentService } from './equipment.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
const BANDIT_HOOD_ITEM_ID = '70000000-0000-4000-8000-000000000003';
interface State {
characters: Character[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
characterEquipment: CharacterEquipment[];
combats: Combat[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: State,
private readonly target: EntityTarget<T>,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
lock?: { mode: string };
}): Promise<T | null> {
const row =
this.rows().find((candidate) => this.matches(candidate, options.where)) ??
null;
return Promise.resolve(
row ? this.withRelations(row, options.relations) : 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>;
relations?: Record<string, unknown>;
}): Promise<T[]> {
const matched = this.rows().filter((row) =>
this.matches(row, options.where),
);
return Promise.resolve(
matched.map((row) => this.withRelations(row, options.relations)),
);
}
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 withRelations(row: T, relations?: Record<string, unknown>): T {
if (!relations) {
return row;
}
const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem)
.itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find(
(d) => d.id === itemDefinitionId,
);
}
if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment)
.characterItemId;
const characterItem = this.state.characterItems.find(
(ci) => ci.id === characterItemId,
);
copy['characterItem'] = characterItem
? {
...characterItem,
itemDefinition: this.state.itemDefinitions.find(
(d) => d.id === characterItem.itemDefinitionId,
),
}
: undefined;
}
return copy;
}
private rows(): T[] {
if (this.target === Character) return this.state.characters as T[];
if (this.target === ItemDefinition)
return this.state.itemDefinitions as T[];
if (this.target === CharacterItem) return this.state.characterItems as T[];
if (this.target === CharacterEquipment)
return this.state.characterEquipment as T[];
if (this.target === Combat) return this.state.combats 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 FakeDataSource {
private readonly idCounters = new Map<string, number>();
constructor(public state: State) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, this);
}
async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target),
} as unknown as EntityManager);
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function itemDefinition(
overrides: Partial<ItemDefinition> = {},
): ItemDefinition {
return {
id: 'def-worn-sword',
key: 'worn-short-sword',
name: 'Worn Shortsword',
description: '',
type: ItemType.EQUIPMENT,
equipmentSlot: EquipmentSlot.WEAPON,
rarity: ItemRarity.COMMON,
tier: 1,
weaponDamage: 8,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
sellPrice: 0,
iconPath: '/images/items/worn-short-sword.png',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
baseHp: 100,
baseAttack: 6,
currentHp: 100,
hpRegenSince: null,
...overrides,
} as Character;
}
function createHarness(state: Partial<State> = {}) {
const fullState: State = {
characters: [character()],
itemDefinitions: [],
characterItems: [],
characterEquipment: [],
combats: [],
...state,
};
const dataSource = new FakeDataSource(fullState);
const characterVitals = new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
});
const characterStats = new CharacterStatsService(
dataSource as unknown as DataSource,
characterVitals,
);
const service = new EquipmentService(
dataSource as unknown as DataSource,
characterStats,
characterVitals,
);
return { state: fullState, service };
}
async function expectEquipmentDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(EquipmentDomainError);
if (!(error instanceof EquipmentDomainError)) {
throw new Error('Expected EquipmentDomainError');
}
expect(error.code).toBe(code);
}
describe('EquipmentService', () => {
describe('equip', () => {
it('equips an owned weapon into the WEAPON slot', async () => {
const wornSword = itemDefinition();
const { state, service } = createHarness({
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
});
const result = await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
expect(result.slots.WEAPON).toEqual({
characterItemId: WORN_SWORD_ITEM_ID,
item: {
key: 'worn-short-sword',
name: 'Worn Shortsword',
rarity: 'COMMON',
iconPath: '/images/items/worn-short-sword.png',
},
});
expect(state.characterEquipment).toHaveLength(1);
});
it('replaces the equipped weapon without deleting the old CharacterItem', async () => {
const wornSword = itemDefinition();
const banditBlade = itemDefinition({
id: 'def-bandit-blade',
key: 'bandit-blade',
name: 'Bandit Blade',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
});
const { state, service } = createHarness({
itemDefinitions: [wornSword, banditBlade],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: banditBlade.id,
quantity: 1,
} as CharacterItem,
],
characterEquipment: [
{
id: 'equip-1',
characterId: CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: WORN_SWORD_ITEM_ID,
} as CharacterEquipment,
],
});
const result = await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
expect(state.characterEquipment).toHaveLength(1);
expect(
state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID),
).toBeDefined();
});
it('rejects equipping an item owned by a different character', async () => {
const wornSword = itemDefinition();
const { service } = createHarness({
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: OTHER_CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
'ITEM_NOT_OWNED',
);
});
it('rejects equipping an unknown CharacterItem id', async () => {
const { service } = createHarness();
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, 'unknown-item'),
'CHARACTER_ITEM_NOT_FOUND',
);
});
it('equips an owned item regardless of the item content that used to carry a level requirement', async () => {
const highTierHelm = itemDefinition({
id: BANDIT_HOOD_ITEM_ID,
key: 'bandit-hood',
equipmentSlot: EquipmentSlot.HEAD,
tier: 5,
});
const { service } = createHarness({
itemDefinitions: [highTierHelm],
characterItems: [
{
id: BANDIT_HOOD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: highTierHelm.id,
quantity: 1,
} as CharacterItem,
],
});
const result = await service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID);
expect(result.slots.HEAD?.characterItemId).toBe(BANDIT_HOOD_ITEM_ID);
});
it('rejects equipping a non-equippable item', async () => {
const material = itemDefinition({
id: 'def-ash-pelt',
key: 'ash-pelt',
type: ItemType.TRADE_GOOD,
equipmentSlot: null,
});
const { service } = createHarness({
itemDefinitions: [material],
characterItems: [
{
id: 'item-ash-pelt',
characterId: CHARACTER_ID,
itemDefinitionId: material.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, 'item-ash-pelt'),
'ITEM_NOT_EQUIPPABLE',
);
});
it('never produces two equipped weapons when the same slot is equipped repeatedly', async () => {
const wornSword = itemDefinition();
const banditBlade = itemDefinition({
id: 'def-bandit-blade',
key: 'bandit-blade',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
});
const { state, service } = createHarness({
itemDefinitions: [wornSword, banditBlade],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: banditBlade.id,
quantity: 1,
} as CharacterItem,
],
});
// Sequential repeats stand in for the concurrent case here (a real race
// is guarded by the DB's UNIQUE(character_id, slot) constraint from
// Task 1, which a synchronous fake repository cannot exercise).
await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
expect(state.characterEquipment).toHaveLength(1);
expect(state.characterEquipment[0].characterItemId).toBe(
BANDIT_BLADE_ITEM_ID,
);
});
it('rejects equipping while the character has an active combat', async () => {
const wornSword = itemDefinition();
const { service } = createHarness({
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
combats: [
{
id: 'combat-1',
characterId: CHARACTER_ID,
status: CombatStatus.ACTIVE,
} as Combat,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
'CHARACTER_IN_COMBAT',
);
});
it('re-anchors HP regeneration so a later max-HP increase does not gift accumulated overflow', async () => {
const bonusHpHelm = itemDefinition({
id: 'def-bonus-hp-helm',
key: 'bonus-hp-helm',
name: 'Padded Helm',
equipmentSlot: EquipmentSlot.HEAD,
bonusHp: 20,
weaponDamage: 0,
});
const { state, service } = createHarness({
characters: [
character({
currentHp: 90,
hpRegenSince: new Date('2026-08-18T08:50:00.000Z'),
}),
],
itemDefinitions: [bonusHpHelm],
characterItems: [
{
id: BANDIT_HOOD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: bonusHpHelm.id,
quantity: 1,
} as CharacterItem,
],
});
await service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID);
expect(state.characters[0].currentHp).toBe(100);
});
});
describe('getEquipment', () => {
it('returns empty slots and base stats when nothing is equipped', async () => {
const { service } = createHarness();
const result = await service.getEquipment(CHARACTER_ID);
expect(result.slots).toEqual({
WEAPON: null,
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
});
expect(result.stats).toEqual({
maxHp: 100,
attack: 6,
weaponDamage: 0,
armor: 0,
});
});
});
});

View File

@@ -0,0 +1,179 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
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 { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { CharacterEquipment } from './entities/character-equipment.entity';
import {
characterInCombat,
characterItemNotFound,
characterNotFound,
itemNotEquippable,
itemNotOwned,
} from './equipment.errors';
export interface EquipmentSlotItemDto {
characterItemId: string;
item: {
key: string;
name: string;
rarity: ItemRarity;
iconPath: string;
};
}
export type EquipmentSlotsDto = Record<
EquipmentSlot,
EquipmentSlotItemDto | null
>;
export interface EquipmentStatsDto {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
export interface EquipmentResponseDto {
slots: EquipmentSlotsDto;
stats: EquipmentStatsDto;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
@Injectable()
export class EquipmentService {
constructor(
private readonly dataSource: DataSource,
private readonly characterStats: CharacterStatsService,
private readonly characterVitals: CharacterVitalsService,
) {}
async getEquipment(characterId: string): Promise<EquipmentResponseDto> {
const character = await this.dataSource
.getRepository(Character)
.findOneBy({ id: characterId });
if (!character) {
throw characterNotFound();
}
return this.buildResponse(character, this.dataSource);
}
/**
* Equips (or replaces) one slot for `characterId` with `characterItemId`
* (spec §14, §28). Runs in one transaction: the old item is unequipped by
* being overwritten, never deleted (spec §15).
*/
async equip(
characterId: string,
characterItemId: string,
): Promise<EquipmentResponseDto> {
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const combats = manager.getRepository(Combat);
const characterItems = manager.getRepository(CharacterItem);
const equipmentRepo = manager.getRepository(CharacterEquipment);
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const activeCombat = await combats.findOne({
where: { characterId, status: CombatStatus.ACTIVE },
});
if (activeCombat) {
throw characterInCombat();
}
const characterItem = await characterItems.findOne({
where: { id: characterItemId },
relations: { itemDefinition: true },
});
if (!characterItem) {
throw characterItemNotFound();
}
if (characterItem.characterId !== characterId) {
throw itemNotOwned();
}
const definition = characterItem.itemDefinition;
if (!definition.equipmentSlot) {
throw itemNotEquippable();
}
const statsBeforeChange = await this.characterStats.calculate(
character,
manager,
);
this.characterVitals.settle(character, statsBeforeChange.maxHp);
await characters.save(character);
const existing = await equipmentRepo.findOne({
where: { characterId, slot: definition.equipmentSlot },
lock: { mode: 'pessimistic_write' },
});
if (existing) {
existing.characterItemId = characterItem.id;
await equipmentRepo.save(existing);
} else {
await equipmentRepo.save(
equipmentRepo.create({
characterId,
slot: definition.equipmentSlot,
characterItemId: characterItem.id,
}),
);
}
return this.buildResponse(character, manager);
});
}
private async buildResponse(
character: Character,
scope: RepositoryScope,
): Promise<EquipmentResponseDto> {
const equipped = await scope.getRepository(CharacterEquipment).find({
where: { characterId: character.id },
relations: { characterItem: { itemDefinition: true } },
});
const slots = Object.fromEntries(
Object.values(EquipmentSlot).map((slot) => [slot, null]),
) as EquipmentSlotsDto;
for (const row of equipped) {
const definition = row.characterItem.itemDefinition;
slots[row.slot] = {
characterItemId: row.characterItemId,
item: {
key: definition.key,
name: definition.name,
rarity: definition.rarity,
iconPath: definition.iconPath,
},
};
}
const stats = await this.characterStats.calculate(character, scope);
return {
slots,
stats: {
maxHp: stats.maxHp,
attack: stats.attack,
weaponDamage: stats.weaponDamage,
armor: stats.armor,
},
};
}
}

View File

@@ -0,0 +1,35 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsInt,
IsString,
Min,
ValidateNested,
} from 'class-validator';
export class ExchangeItemDto {
@IsString()
itemKey!: string;
@IsInt()
@Min(1)
quantity!: number;
}
export class ExchangeRequestDto {
/**
* Item keys and quantities only.
*
* Prices and rewards are never accepted from the client -- the server reads
* them from the exchange rules (slice §8, NPC spec §37.9). The upper bound
* keeps one request from turning into an unbounded row-by-row transaction.
*/
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => ExchangeItemDto)
items!: ExchangeItemDto[];
}

View File

@@ -0,0 +1,120 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import type { GameCondition } from '../../conditions/game-condition.types';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { NpcExchangeProfile } from './npc-exchange-profile.entity';
/**
* What one accepted material is worth (NPC spec §17, Playable Slice 0.8 §4).
*
* This is the hinge of the 0.8 loop: the kill produces the object, and this
* row is where the object becomes economy and progression (slice §4). Values
* are balancing content, tuned here rather than in `ExchangeService`.
*
* Supersedes `TurnInDefinition` from Slice 0.6.5, which mapped an item to
* silver and reputation with no NPC, no renown and no conditions. Both alive
* at once would have been two competing ways to sell the same pelt at
* different prices -- exactly the second progression model slice 0.8 §6
* forbids.
*/
@Entity({ name: 'exchange_rules' })
@Index('IDX_exchange_rules_profile_item', ['profileId', 'inputItemId'], {
unique: true,
})
export class ExchangeRule {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'profile_id', type: 'uuid' })
profileId!: string;
@Column({ name: 'input_item_id', type: 'uuid' })
inputItemId!: string;
/**
* How many the rule trades in one step. A rule with `inputQuantity` 5 can
* only be redeemed in multiples of five, which is what makes spec §17's
* "5 x Aschenfell -> 18 silver" expressible as a batch rather than as five
* separate single-pelt payouts.
*/
@Column({ name: 'input_quantity', type: 'integer', default: 1 })
inputQuantity!: number;
/** Which faction's regional reputation this rule pays into (slice §6). */
@Column({ name: 'faction_id', type: 'uuid' })
factionId!: string;
@Column({ name: 'silver_reward', type: 'integer', default: 0 })
silverReward!: number;
@Column({ name: 'region_reputation_reward', type: 'integer', default: 0 })
regionReputationReward!: number;
/**
* World Renown, granted as a milestone rather than as points per pelt.
*
* Slice 0.8 §4 sketches `worldRenownPerUnit or batch rule`, and §9's example
* response shows a flat `worldRenown: 2`. A per-unit grant is not compatible
* with the Renown system that already exists: Slice 0.6.5 made Renown a
* 1-15 *rank* that reassigns baseHp/baseAttack from a fixed power curve on
* every change, and warns that "repeatedly killing weak monsters must not be
* an efficient way to increase Renown" (0.6.5 §2.2, §4, §5). Paying renown
* by the pelt would run a player from rank 1 to the rank-15 stat ceiling in
* a handful of trips and flatten the whole progression curve.
*
* So this names a `RenownMilestoneDefinition` key instead, which is the
* "batch rule" half of slice §4 and matches 0.6.5 §6 exactly -- it names
* "first meaningful trophy returned" as the Renown 2 milestone. Non-
* repeatable milestones fire once and are then silently skipped, so routine
* trading keeps paying silver and reputation without touching renown.
*
* This is also literally the field 0.6.5 §19 parked for later: it lists
* `firstTurnInMilestoneKey` as an optional addition with "do not add these
* unless actually needed". Slice 0.8 is when it is needed.
*
* Null means this rule grants no renown at all, which is the normal case.
*/
@Column({
name: 'renown_milestone_key',
type: 'varchar',
length: 100,
nullable: true,
})
renownMilestoneKey!: string | null;
@Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" })
conditions!: GameCondition[];
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder!: number;
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => NpcExchangeProfile, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'profile_id' })
profile!: NpcExchangeProfile;
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'input_item_id' })
inputItem!: ItemDefinition;
@ManyToOne(() => ReputationFaction, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'faction_id' })
faction!: ReputationFaction;
}

View File

@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
/**
* The set of materials one NPC accepts (NPC spec §17).
*
* Kept apart from `NpcShop` on purpose: handing over five pelts for silver and
* reputation is not a purchase, and modelling it as a negative-price shop
* offer would smear two different systems together (spec §17, §37.7).
*/
@Entity({ name: 'npc_exchange_profiles' })
@Index('IDX_npc_exchange_profiles_key', ['key'], { unique: true })
@Index('IDX_npc_exchange_profiles_npc', ['npcId'])
export class NpcExchangeProfile {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'npc_id', type: 'uuid' })
npcId!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => NpcDefinition, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'npc_id' })
npc!: NpcDefinition;
}

View File

@@ -0,0 +1,41 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { ExchangeRequestDto } from './dto/exchange.dto';
import {
ExchangeResultDto,
ExchangeService,
ExchangeViewDto,
} from './exchange.service';
/**
* Trade-in endpoints, addressed by merchant key (Playable Slice 0.8 §9).
*
* The character is taken from the session-stand-in, never from the request,
* so a caller cannot trade out of somebody else's inventory.
*/
@Controller('merchants/:merchantKey')
export class ExchangeController {
constructor(private readonly exchangeService: ExchangeService) {}
@Get('trade-in')
getTradeIn(
@Param('merchantKey') merchantKey: string,
): Promise<ExchangeViewDto> {
return this.exchangeService.getMerchantExchangeView(
DEMO_CHARACTER_ID,
merchantKey,
);
}
@Post('trade-in')
tradeIn(
@Param('merchantKey') merchantKey: string,
@Body() request: ExchangeRequestDto,
): Promise<ExchangeResultDto> {
return this.exchangeService.exchangeWithMerchant(
DEMO_CHARACTER_ID,
merchantKey,
request.items,
);
}
}

View File

@@ -0,0 +1,71 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type ExchangeErrorCode =
| 'EXCHANGE_NOT_FOUND'
| 'EXCHANGE_DISABLED'
| 'EXCHANGE_ITEM_NOT_ACCEPTED'
| 'EXCHANGE_INVALID_QUANTITY'
| 'EXCHANGE_INSUFFICIENT_QUANTITY'
| 'EXCHANGE_EMPTY_REQUEST';
export class ExchangeDomainError extends HttpException {
constructor(
public readonly code: ExchangeErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function exchangeNotFound(): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This merchant does not trade in goods.',
);
}
export function exchangeDisabled(): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_DISABLED',
HttpStatus.CONFLICT,
'This merchant is not trading right now.',
);
}
export function exchangeItemNotAccepted(itemKey: string): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_ITEM_NOT_ACCEPTED',
HttpStatus.CONFLICT,
`This merchant does not accept ${itemKey}.`,
);
}
export function exchangeInvalidQuantity(): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_INVALID_QUANTITY',
HttpStatus.BAD_REQUEST,
'Quantity must be a positive whole number.',
);
}
export function exchangeInsufficientQuantity(
itemKey: string,
): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_INSUFFICIENT_QUANTITY',
HttpStatus.CONFLICT,
`You are not carrying that many ${itemKey}.`,
);
}
export function exchangeEmptyRequest(): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_EMPTY_REQUEST',
HttpStatus.BAD_REQUEST,
'Select at least one item to trade.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,577 @@
import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service';
import { CharacterItem } from '../items/entities/character-item.entity';
import { LootCapacityService } from '../loot-bags/loot-capacity.service';
import { NpcService } from '../npcs/npc.service';
import { RenownService } from '../renown/renown.service';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationService } from '../reputation/reputation.service';
import { ExchangeRule } from './entities/exchange-rule.entity';
import { NpcExchangeProfile } from './entities/npc-exchange-profile.entity';
import { ExchangeService } from './exchange.service';
const CHARACTER_ID = 'character-1';
const PROFILE_ID = 'profile-1';
const FACTION_ID = 'faction-1';
const PROFILE_KEY = 'borin-trade-in';
interface ItemRow {
id: string;
characterId: string;
itemDefinitionId: string;
quantity: number;
}
interface WorldFixture {
silver?: number;
renown?: number;
carried?: ItemRow[];
profileEnabled?: boolean;
milestoneAlreadyDone?: boolean;
ruleConditionsMet?: boolean;
}
class MilestoneAlreadyCompleted extends Error {
readonly code = 'RENOWN_MILESTONE_ALREADY_COMPLETED';
}
function rule(overrides: Partial<ExchangeRule> = {}): ExchangeRule {
return {
id: 'rule-pelt',
profileId: PROFILE_ID,
inputItemId: 'item-pelt',
inputQuantity: 1,
factionId: FACTION_ID,
silverReward: 5,
regionReputationReward: 2,
renownMilestoneKey: 'first-goods-returned',
conditions: [],
sortOrder: 1,
enabled: true,
inputItem: { id: 'item-pelt', key: 'ash-pelt', name: 'Ashen Pelt' },
...overrides,
} as unknown as ExchangeRule;
}
function createWorld(fixture: WorldFixture = {}) {
const character = {
id: CHARACTER_ID,
silver: fixture.silver ?? 0,
renown: fixture.renown ?? 1,
} as Character;
const items: ItemRow[] = (fixture.carried ?? []).map((row) => ({ ...row }));
const removed: ItemRow[] = [];
const rules = [
rule(),
rule({
id: 'rule-insignia',
inputItemId: 'item-insignia',
silverReward: 30,
regionReputationReward: 12,
inputItem: {
id: 'item-insignia',
key: 'charred-raider-insignia',
name: 'Charred Raider Insignia',
},
} as Partial<ExchangeRule>),
rule({
id: 'rule-batch',
inputItemId: 'item-batch',
inputQuantity: 5,
silverReward: 40,
regionReputationReward: 10,
renownMilestoneKey: null,
inputItem: { id: 'item-batch', key: 'tough-hide', name: 'Tough Hide' },
} as Partial<ExchangeRule>),
];
const repositories = (entity: unknown) => {
if (entity === Character) {
return {
findOne: () => Promise.resolve(character),
findOneBy: () => Promise.resolve(character),
findOneByOrFail: () => Promise.resolve(character),
save: (row: Character) => Promise.resolve(row),
};
}
if (entity === NpcExchangeProfile) {
return {
findOne: () =>
Promise.resolve({
id: PROFILE_ID,
key: PROFILE_KEY,
npcId: 'npc-1',
name: 'Border Watch Trade-In',
enabled: fixture.profileEnabled ?? true,
npc: { key: 'borin-quartermaster' },
}),
findOneBy: () =>
Promise.resolve({
id: PROFILE_ID,
key: PROFILE_KEY,
npcId: 'npc-1',
name: 'Border Watch Trade-In',
enabled: fixture.profileEnabled ?? true,
}),
};
}
if (entity === ExchangeRule) {
return {
find: () =>
Promise.resolve(
rules.map((entry) => ({
...entry,
faction: { key: 'border-guard', name: 'Border Watch' },
})),
),
};
}
if (entity === CharacterItem) {
return {
find: () => Promise.resolve(items),
findOne: (options: { where: { itemDefinitionId: string } }) =>
Promise.resolve(
items.find(
(row) => row.itemDefinitionId === options.where.itemDefinitionId,
) ?? null,
),
remove: async (row: ItemRow) => {
const index = items.findIndex((candidate) => candidate.id === row.id);
if (index >= 0) {
removed.push(items.splice(index, 1)[0]);
}
},
save: (row: ItemRow) => Promise.resolve(row),
};
}
if (entity === ReputationFaction) {
return {
findOneBy: () =>
Promise.resolve({ id: FACTION_ID, key: 'border-guard' }),
};
}
throw new Error('Unexpected repository');
};
const manager = { getRepository: repositories } as unknown as EntityManager;
const dataSource = {
getRepository: repositories,
transaction: async <T>(run: (m: EntityManager) => Promise<T>) =>
run(manager),
} as unknown as DataSource;
const reputationGrants: Array<{ factionKey: string; amount: number }> = [];
const reputation = {
grantReputation: jest.fn(
async (_characterId: string, factionKey: string, amount: number) => {
reputationGrants.push({ factionKey, amount });
return {
factionKey,
previousReputation: 0,
newReputation: amount,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
};
},
),
} as unknown as ReputationService;
const milestoneCalls: string[] = [];
const renown = {
completeMilestone: jest.fn(async (_characterId: string, key: string) => {
milestoneCalls.push(key);
if (fixture.milestoneAlreadyDone) {
throw new MilestoneAlreadyCompleted();
}
character.renown += 1;
return {
milestoneKey: key,
previousRenown: character.renown - 1,
newRenown: character.renown,
renownGranted: true,
};
}),
} as unknown as RenownService;
const lootCapacity = {
getCapacities: jest.fn(() =>
Promise.resolve([
{ category: 'HIDE', current: 0, capacity: 5, bag: null },
]),
),
} as unknown as LootCapacityService;
const conditions = {
evaluate: jest.fn(() => Promise.resolve(fixture.ruleConditionsMet ?? true)),
} as unknown as GameConditionService;
const npcs = {
requireReachableNpc: jest.fn(() => Promise.resolve({ id: 'npc-1' })),
} as unknown as NpcService;
const service = new ExchangeService(
dataSource,
reputation,
renown,
lootCapacity,
conditions,
npcs,
);
return {
service,
character,
items,
removed,
reputationGrants,
milestoneCalls,
reputation,
};
}
describe('ExchangeService', () => {
it('pays Silver and regional reputation from content, not from the request', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 5,
},
],
});
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 5 },
]);
// 5 pelts at 5 silver and 2 reputation each.
expect(result.rewards.silver).toBe(25);
expect(result.rewards.regionalReputation).toBe(10);
expect(world.character.silver).toBe(25);
expect(world.reputationGrants).toEqual([
{ factionKey: 'border-guard', amount: 10 },
]);
});
it('removes exactly the traded quantity and leaves the rest', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 5,
},
],
});
await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 2 },
]);
expect(world.items[0].quantity).toBe(3);
});
it('deletes the stack when the last one is traded, freeing bag capacity', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 3,
},
],
});
await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 3 },
]);
// Capacity is derived from owned items, so removing the stack is what
// frees the bag (slice §11).
expect(world.items).toHaveLength(0);
expect(world.removed).toHaveLength(1);
});
it('rejects a quantity the character does not carry', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 2,
},
],
});
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 3 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_INSUFFICIENT_QUANTITY' });
expect(world.character.silver).toBe(0);
expect(world.items[0].quantity).toBe(2);
});
it('rejects an item this merchant does not accept', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-sword',
quantity: 1,
},
],
});
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'worn-short-sword', quantity: 1 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_ITEM_NOT_ACCEPTED' });
});
it('cannot be tricked into overdrawing a stack by repeating one item', async () => {
// Each line would individually pass against an untouched stack of three.
// Folding the request together first is what stops six pelts leaving a
// three-pelt stack.
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 3,
},
],
});
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 3 },
{ itemKey: 'ash-pelt', quantity: 3 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_INSUFFICIENT_QUANTITY' });
expect(world.character.silver).toBe(0);
expect(world.items[0].quantity).toBe(3);
});
it('trades several different goods in one handover', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 2,
},
{
id: 'ci-2',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-insignia',
quantity: 1,
},
],
});
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 2 },
{ itemKey: 'charred-raider-insignia', quantity: 1 },
]);
expect(result.rewards.silver).toBe(2 * 5 + 30);
expect(result.consumed).toHaveLength(2);
});
it('only trades a batch rule in whole steps', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-batch',
quantity: 7,
},
],
});
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'tough-hide', quantity: 3 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_INVALID_QUANTITY' });
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'tough-hide', quantity: 5 },
]);
expect(result.rewards.silver).toBe(40);
});
it('grants the renown milestone on the first trade', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 1,
},
],
});
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 1 },
]);
expect(result.rewards.worldRenown).toBe(1);
expect(result.renownMilestonesCompleted).toEqual(['first-goods-returned']);
expect(result.balances.worldRenown).toBe(2);
});
it('keeps paying Silver once the renown milestone is spent', async () => {
// The second trade onward: the milestone is done, which is normal rather
// than an error. Silver and reputation must still land.
const world = createWorld({
milestoneAlreadyDone: true,
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 4,
},
],
});
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 4 },
]);
expect(result.rewards.worldRenown).toBe(0);
expect(result.renownMilestonesCompleted).toEqual([]);
expect(result.rewards.silver).toBe(20);
expect(result.balances.worldRenown).toBe(1);
});
it('claims one milestone once even when several goods name it', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 1,
},
{
id: 'ci-2',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-insignia',
quantity: 1,
},
],
});
await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 1 },
{ itemKey: 'charred-raider-insignia', quantity: 1 },
]);
expect(world.milestoneCalls).toEqual(['first-goods-returned']);
});
it('refuses an empty request rather than committing a no-op trade', async () => {
const world = createWorld();
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, []),
).rejects.toMatchObject({ code: 'EXCHANGE_EMPTY_REQUEST' });
});
it('refuses a zero or fractional quantity', async () => {
const world = createWorld();
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 0 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_INVALID_QUANTITY' });
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 1.5 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_INVALID_QUANTITY' });
});
it('refuses to trade into a disabled profile', async () => {
const world = createWorld({ profileEnabled: false });
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 1 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_DISABLED' });
});
it('refuses a rule whose conditions are not met, server-side', async () => {
const world = createWorld({
ruleConditionsMet: false,
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 1,
},
],
});
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 1 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_ITEM_NOT_ACCEPTED' });
});
it('shows what is carried and what it is worth', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 4,
},
],
});
const view = await world.service.getExchangeView(CHARACTER_ID, PROFILE_KEY);
const pelt = view.offers.find((offer) => offer.itemKey === 'ash-pelt');
expect(pelt).toMatchObject({ quantityCarried: 4, silverPerStep: 5 });
// Goods the character is not carrying still appear, so the player can see
// what this merchant would take.
expect(
view.offers.find((offer) => offer.itemKey === 'tough-hide'),
).toMatchObject({ quantityCarried: 0 });
});
it('hides a locked rule from the view entirely', async () => {
const world = createWorld({ ruleConditionsMet: false });
const view = await world.service.getExchangeView(CHARACTER_ID, PROFILE_KEY);
expect(view.offers).toHaveLength(0);
});
});

View File

@@ -0,0 +1,506 @@
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, In } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service';
import { CharacterItem } from '../items/entities/character-item.entity';
import {
LootCapacityDto,
LootCapacityService,
} from '../loot-bags/loot-capacity.service';
import { NpcService } from '../npcs/npc.service';
import { RenownService } from '../renown/renown.service';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationService } from '../reputation/reputation.service';
import { ExchangeRule } from './entities/exchange-rule.entity';
import { NpcExchangeProfile } from './entities/npc-exchange-profile.entity';
import {
characterNotFound,
exchangeDisabled,
exchangeEmptyRequest,
exchangeInsufficientQuantity,
exchangeInvalidQuantity,
exchangeItemNotAccepted,
exchangeNotFound,
} from './exchange.errors';
export interface ExchangeOfferDto {
itemKey: string;
itemName: string;
iconPath: string;
/** How many the character is carrying right now. */
quantityCarried: number;
/** Smallest tradeable step. Quantities must be a multiple of this. */
inputQuantity: number;
silverPerStep: number;
reputationPerStep: number;
factionKey: string;
factionName: string;
/** The renown milestone this rule can still award, if any. */
renownMilestoneKey: string | null;
}
export interface ExchangeViewDto {
profileKey: string;
profileName: string;
npcKey: string;
offers: ExchangeOfferDto[];
capacities: LootCapacityDto[];
}
export interface ExchangeRequestItem {
itemKey: string;
quantity: number;
}
export interface ExchangeConsumedDto {
itemKey: string;
itemName: string;
quantity: number;
}
export interface ExchangeResultDto {
profileKey: string;
consumed: ExchangeConsumedDto[];
rewards: {
silver: number;
regionalReputation: number;
worldRenown: number;
};
balances: {
silver: number;
regionalReputation: number;
worldRenown: number;
};
/** Ranks crossed by this trade, so the UI can call them out. */
reputationRankChanged: boolean;
newReputationRank: string | null;
/** Milestone keys this trade completed. Empty on every later trade. */
renownMilestonesCompleted: string[];
capacities: LootCapacityDto[];
}
/**
* Turns carried materials into Silver, regional reputation and World Renown
* (NPC spec §17, §30; Playable Slice 0.8 §4, §8).
*
* This is the only place a normal hunt becomes progression: kills grant no
* money or reputation at all (slice 0.7 §7), so everything the player earns
* passes through here.
*
* Every reward is computed from persisted content. The request carries item
* keys and quantities and nothing else -- never a price, never a reward
* (spec §37.9).
*/
@Injectable()
export class ExchangeService {
constructor(
private readonly dataSource: DataSource,
private readonly reputation: ReputationService,
private readonly renown: RenownService,
private readonly lootCapacity: LootCapacityService,
private readonly conditions: GameConditionService,
private readonly npcs: NpcService,
) {}
/**
* The trade-in view for a merchant, addressed by NPC key (slice §9).
*
* Reachability is checked first: the character must be standing where the
* merchant is, so a client cannot sell into Graufurt from the Burned Road.
*/
async getMerchantExchangeView(
characterId: string,
merchantKey: string,
): Promise<ExchangeViewDto> {
const profileKey = await this.resolveMerchantProfileKey(
characterId,
merchantKey,
);
return this.getExchangeView(characterId, profileKey);
}
/** Trades goods in with a merchant addressed by NPC key (slice §9). */
async exchangeWithMerchant(
characterId: string,
merchantKey: string,
requested: ExchangeRequestItem[],
): Promise<ExchangeResultDto> {
const profileKey = await this.resolveMerchantProfileKey(
characterId,
merchantKey,
);
return this.exchange(characterId, profileKey, requested);
}
private async resolveMerchantProfileKey(
characterId: string,
merchantKey: string,
): Promise<string> {
const npc = await this.npcs.requireReachableNpc(characterId, merchantKey);
const profile = await this.dataSource
.getRepository(NpcExchangeProfile)
.findOneBy({ npcId: npc.id, enabled: true });
if (!profile) {
throw exchangeNotFound();
}
return profile.key;
}
/** What this merchant will take, and what the character is carrying. */
async getExchangeView(
characterId: string,
profileKey: string,
): Promise<ExchangeViewDto> {
const profile = await this.dataSource
.getRepository(NpcExchangeProfile)
.findOne({ where: { key: profileKey }, relations: { npc: true } });
if (!profile) {
throw exchangeNotFound();
}
if (!profile.enabled) {
throw exchangeDisabled();
}
const rules = await this.dataSource.getRepository(ExchangeRule).find({
where: { profileId: profile.id, enabled: true },
relations: { inputItem: true, faction: true },
order: { sortOrder: 'ASC' },
});
const carried = await this.loadCarriedQuantities(
characterId,
rules.map((rule) => rule.inputItemId),
this.dataSource,
);
const offers: ExchangeOfferDto[] = [];
for (const rule of rules) {
const unlocked = await this.conditions.evaluate(
{ characterId, npcId: profile.npcId },
rule.conditions,
);
if (!unlocked) {
continue;
}
offers.push({
itemKey: rule.inputItem.key,
itemName: rule.inputItem.name,
iconPath: rule.inputItem.iconPath,
quantityCarried: carried.get(rule.inputItemId) ?? 0,
inputQuantity: rule.inputQuantity,
silverPerStep: rule.silverReward,
reputationPerStep: rule.regionReputationReward,
factionKey: rule.faction.key,
factionName: rule.faction.name,
renownMilestoneKey: rule.renownMilestoneKey,
});
}
return {
profileKey: profile.key,
profileName: profile.name,
npcKey: profile.npc.key,
offers,
capacities: await this.lootCapacity.getCapacities(characterId),
};
}
/**
* Hands goods over and pays out, all or nothing (slice §8).
*
* The whole thing runs in one transaction: goods are removed, Silver
* credited, reputation granted and any renown milestone completed together,
* so a failure anywhere leaves the character exactly as they were and the
* same pelt can never be sold twice.
*/
async exchange(
characterId: string,
profileKey: string,
requested: ExchangeRequestItem[],
): Promise<ExchangeResultDto> {
const merged = this.mergeRequest(requested);
return this.dataSource.transaction(async (manager) => {
const character = await manager.getRepository(Character).findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const profile = await manager
.getRepository(NpcExchangeProfile)
.findOneBy({ key: profileKey });
if (!profile) {
throw exchangeNotFound();
}
if (!profile.enabled) {
throw exchangeDisabled();
}
const rules = await manager.getRepository(ExchangeRule).find({
where: { profileId: profile.id, enabled: true },
relations: { inputItem: true },
});
const rulesByItemKey = new Map(
rules.map((rule) => [rule.inputItem.key, rule]),
);
let silverGranted = 0;
const reputationByFaction = new Map<string, number>();
const milestoneKeys: string[] = [];
const consumed: ExchangeConsumedDto[] = [];
for (const [itemKey, quantity] of merged) {
const rule = rulesByItemKey.get(itemKey);
if (!rule) {
throw exchangeItemNotAccepted(itemKey);
}
const unlocked = await this.conditions.evaluate(
{ characterId, npcId: profile.npcId },
rule.conditions,
manager,
);
if (!unlocked) {
throw exchangeItemNotAccepted(itemKey);
}
// A batch rule only trades in whole steps: five pelts for a fixed
// payout cannot be redeemed three at a time.
if (quantity % rule.inputQuantity !== 0) {
throw exchangeInvalidQuantity();
}
await this.consumeItems(manager, characterId, rule, quantity, itemKey);
const steps = quantity / rule.inputQuantity;
silverGranted += rule.silverReward * steps;
reputationByFaction.set(
rule.factionId,
(reputationByFaction.get(rule.factionId) ?? 0) +
rule.regionReputationReward * steps,
);
if (rule.renownMilestoneKey) {
milestoneKeys.push(rule.renownMilestoneKey);
}
consumed.push({
itemKey,
itemName: rule.inputItem.name,
quantity,
});
}
character.silver += silverGranted;
await manager.getRepository(Character).save(character);
const reputationResult = await this.grantReputation(
manager,
characterId,
reputationByFaction,
);
const renownResult = await this.grantRenown(
manager,
characterId,
milestoneKeys,
);
// Re-read: RenownService rewrites base stats when a milestone lands, so
// the row loaded at the top of the transaction is already stale.
const settled = await manager
.getRepository(Character)
.findOneByOrFail({ id: characterId });
return {
profileKey,
consumed,
rewards: {
silver: silverGranted,
regionalReputation: reputationResult.granted,
worldRenown: renownResult.granted,
},
balances: {
silver: settled.silver,
regionalReputation: reputationResult.balance,
worldRenown: settled.renown,
},
reputationRankChanged: reputationResult.rankChanged,
newReputationRank: reputationResult.newRank,
renownMilestonesCompleted: renownResult.completed,
capacities: await this.lootCapacity.getCapacities(characterId, manager),
};
});
}
/**
* Folds a request down to one entry per item.
*
* Without this, a client could send the same key twice and have each line
* pass the ownership check against the same untouched stack -- trading three
* pelts twice while carrying three.
*/
private mergeRequest(requested: ExchangeRequestItem[]): Map<string, number> {
if (!requested || requested.length === 0) {
throw exchangeEmptyRequest();
}
const merged = new Map<string, number>();
for (const entry of requested) {
if (!Number.isInteger(entry.quantity) || entry.quantity <= 0) {
throw exchangeInvalidQuantity();
}
merged.set(
entry.itemKey,
(merged.get(entry.itemKey) ?? 0) + entry.quantity,
);
}
return merged;
}
/** Removes exactly `quantity`, deleting the stack when it empties. */
private async consumeItems(
manager: EntityManager,
characterId: string,
rule: ExchangeRule,
quantity: number,
itemKey: string,
): Promise<void> {
const characterItems = manager.getRepository(CharacterItem);
const owned = await characterItems.findOne({
where: { characterId, itemDefinitionId: rule.inputItemId },
lock: { mode: 'pessimistic_write' },
});
if (!owned || owned.quantity < quantity) {
throw exchangeInsufficientQuantity(itemKey);
}
if (owned.quantity === quantity) {
await characterItems.remove(owned);
return;
}
owned.quantity -= quantity;
await characterItems.save(owned);
}
private async grantReputation(
manager: EntityManager,
characterId: string,
amountByFaction: Map<string, number>,
): Promise<{
granted: number;
balance: number;
rankChanged: boolean;
newRank: string | null;
}> {
let granted = 0;
let balance = 0;
let rankChanged = false;
let newRank: string | null = null;
for (const [factionId, amount] of amountByFaction) {
if (amount <= 0) {
continue;
}
const faction = await manager
.getRepository(ReputationFaction)
.findOneBy({ id: factionId });
if (!faction) {
throw exchangeNotFound();
}
const result = await this.reputation.grantReputation(
characterId,
faction.key,
amount,
manager,
);
granted += amount;
// Slice 0.8 reports a single regional reputation figure. Every seeded
// rule pays the same region, so this is that region's balance; if a
// profile ever spans factions, the last one wins and the response shape
// needs to grow into a list.
balance = result.newReputation;
rankChanged = rankChanged || result.rankChanged;
newRank = result.rankChanged ? result.newRank : newRank;
}
return { granted, balance, rankChanged, newRank };
}
/**
* Completes any renown milestones this trade earned.
*
* A milestone that is already done is not an error -- it is the normal case
* from the second trade onward -- so `RenownService`'s "already completed"
* rejection is swallowed rather than allowed to fail the trade.
*/
private async grantRenown(
manager: EntityManager,
characterId: string,
milestoneKeys: string[],
): Promise<{ granted: number; completed: string[] }> {
let granted = 0;
const completed: string[] = [];
for (const key of new Set(milestoneKeys)) {
try {
const result = await this.renown.completeMilestone(
characterId,
key,
manager,
);
if (result.renownGranted) {
granted += result.newRenown - result.previousRenown;
completed.push(key);
}
} catch (error) {
if (!this.isAlreadyCompleted(error)) {
throw error;
}
}
}
return { granted, completed };
}
private isAlreadyCompleted(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'RENOWN_MILESTONE_ALREADY_COMPLETED'
);
}
private async loadCarriedQuantities(
characterId: string,
itemDefinitionIds: string[],
scope: Pick<DataSource, 'getRepository'>,
): Promise<Map<string, number>> {
if (itemDefinitionIds.length === 0) {
return new Map();
}
const owned = await scope.getRepository(CharacterItem).find({
where: { characterId, itemDefinitionId: In(itemDefinitionIds) },
});
const totals = new Map<string, number>();
for (const item of owned) {
totals.set(
item.itemDefinitionId,
(totals.get(item.itemDefinitionId) ?? 0) + item.quantity,
);
}
return totals;
}
}

View File

@@ -0,0 +1,42 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { ConditionsModule } from '../conditions/conditions.module';
import { CharacterItem } from '../items/entities/character-item.entity';
import { LootBagsModule } from '../loot-bags/loot-bags.module';
import { NpcsModule } from '../npcs/npcs.module';
import { RenownModule } from '../renown/renown.module';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationModule } from '../reputation/reputation.module';
import { ExchangeRule } from './entities/exchange-rule.entity';
import { NpcExchangeProfile } from './entities/npc-exchange-profile.entity';
import { ExchangeController } from './exchange.controller';
import { ExchangeService } from './exchange.service';
/**
* Materials in, progression out (NPC spec §17; Playable Slice 0.8).
*
* Pulls in reputation and renown rather than reimplementing either, so the
* trade-in feeds the progression systems that already exist instead of
* inventing a competing one (slice §6).
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Character,
CharacterItem,
ExchangeRule,
NpcExchangeProfile,
ReputationFaction,
]),
ConditionsModule,
LootBagsModule,
NpcsModule,
RenownModule,
ReputationModule,
],
controllers: [ExchangeController],
providers: [ExchangeService],
exports: [ExchangeService],
})
export class ExchangesModule {}

View File

@@ -6,7 +6,7 @@ const CHARACTER = { attack: 6, armor: 0, hp: 100 };
const CHARACTER_POWER = 44;
describe('calculateDangerRating', () => {
it('rates the seeded Aschenratte as MATCH', () => {
it('rates the seeded Ash Rat 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
@@ -15,7 +15,7 @@ describe('calculateDangerRating', () => {
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.MATCH);
});
it('rates the seeded Straßenräuber as STRONG', () => {
it('rates the seeded Road Bandit 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

View File

@@ -7,6 +7,7 @@ import {
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' })
@@ -23,6 +24,17 @@ export class HuntEncounter {
@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;

View File

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

View File

@@ -10,15 +10,17 @@ 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 },
useValue: { startHunt, getActiveHunt },
},
],
}).compile();
@@ -35,7 +37,7 @@ describe('HuntingController', () => {
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' },
location: { id: 'loc-1', key: 'burned-road', name: 'Burned Road' },
encounters: [],
};
startHunt.mockResolvedValue(huntResult);
@@ -47,4 +49,42 @@ describe('HuntingController', () => {
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: 'Burned Road' },
encounters: [
{
id: 'encounter-1',
monster: {
key: 'ash-rat',
name: 'Ash Rat',
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

@@ -1,4 +1,4 @@
import { Controller, Post } from '@nestjs/common';
import { Controller, Get, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { HuntResultDto, HuntingService } from './hunting.service';
@@ -10,4 +10,9 @@ export class HuntingController {
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

@@ -8,7 +8,7 @@ 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 './random-source';
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
@Module({
imports: [

View File

@@ -9,18 +9,23 @@ 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 './random-source';
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 MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Ash Rat
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Road Bandit
const MONSTER_C_ID = '30000000-0000-4000-8000-000000000003'; // Feral Road Hound
const MONSTER_D_ID = '30000000-0000-4000-8000-000000000004'; // Charred Raider
const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001';
const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002';
const LOCATION_MONSTER_C_ID = '40000000-0000-4000-8000-000000000003';
const LOCATION_MONSTER_D_ID = '40000000-0000-4000-8000-000000000004';
interface FakeState {
characters: Character[];
@@ -29,6 +34,15 @@ interface FakeState {
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 };
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
@@ -56,10 +70,24 @@ class FakeRepository<T extends { id: string }> {
);
}
find(options: { where: Partial<T> }): Promise<T[]> {
return Promise.resolve(
this.rows().filter((row) => this.matches(row, options.where)),
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 {
@@ -167,7 +195,7 @@ function huntingLocation(): LocationDefinition {
return {
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
name: 'Burned Road',
description: 'A burned road.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
@@ -188,7 +216,7 @@ function safeLocation(): LocationDefinition {
return {
id: SAFE_LOCATION_ID,
key: 'south-gate',
name: 'Suedtor von Graufurt',
name: 'Graufurt South Gate',
description: 'A safe gate.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
@@ -209,8 +237,8 @@ function character(currentLocation: LocationDefinition): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
renown: 1,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
@@ -235,9 +263,8 @@ function monsterDefinition(
maxHp: 20,
attack: 3,
armor: 0,
experienceReward: 10,
silverMin: 1,
silverMax: 3,
flavorText: null,
abilities: {},
artworkPath: `/assets/monsters/${key}.webp`,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
@@ -251,18 +278,63 @@ function locationMonster(
monster: MonsterDefinition,
weight: number,
enabled = true,
encounterType: EncounterType = EncounterType.NORMAL,
): LocationMonster {
return {
id,
locationId,
monsterId: monster.id,
weight,
encounterType: EncounterType.NORMAL,
encounterType,
enabled,
monster,
} as LocationMonster;
}
/**
* The real Burned Road pool (spec §3): four distinct monsters, the Charred
* Raider rare at weight 2 out of 100.
*/
function burnedRoadPool(): LocationMonster[] {
return [
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
monsterDefinition(MONSTER_A_ID, 'ash-rat', 'Ash Rat'),
50,
),
locationMonster(
LOCATION_MONSTER_C_ID,
HUNTING_LOCATION_ID,
monsterDefinition(MONSTER_C_ID, 'wild-road-dog', 'Feral Road Hound'),
30,
),
locationMonster(
LOCATION_MONSTER_B_ID,
HUNTING_LOCATION_ID,
monsterDefinition(MONSTER_B_ID, 'road-bandit', 'Road Bandit'),
18,
),
locationMonster(
LOCATION_MONSTER_D_ID,
HUNTING_LOCATION_ID,
monsterDefinition(MONSTER_D_ID, 'charred-looter', 'Charred Raider'),
2,
true,
EncounterType.RARE,
),
];
}
/** Puts the character on the hunting ground with the given encounter pool. */
function stateAtHuntingGround(pool: LocationMonster[]): FakeState {
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = pool;
return state;
}
function createState(): FakeState {
return {
characters: [character(safeLocation())],
@@ -341,23 +413,7 @@ describe('HuntingService', () => {
});
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 state = stateAtHuntingGround(burnedRoadPool());
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
@@ -371,7 +427,7 @@ describe('HuntingService', () => {
expect(result.location).toEqual({
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
name: 'Burned Road',
});
expect(dataSource.state.hunts).toHaveLength(1);
expect(dataSource.state.hunts[0]).toMatchObject({
@@ -421,52 +477,109 @@ describe('HuntingService', () => {
});
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 state = stateAtHuntingGround(burnedRoadPool());
// Weights 50/30/18/2. Each pick is removed from the pool before the next
// roll, so the totals shrink: 0.1*100=10 -> Ash Rat (<50); the hound and
// the bandit remain in front of the rare, so 0.1*50=5 -> Feral Road Hound
// and 0.1*20=2 -> Road Bandit.
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters.map((e) => e.monster.key)).toEqual([
'aschenratte',
'strassenraeuber',
'aschenratte',
'ash-rat',
'wild-road-dog',
'road-bandit',
]);
const persisted = [...dataSource.state.huntEncounters].sort(
(a, b) => a.position - b.position,
);
expect(persisted.map((e) => e.monsterDefinitionId)).toEqual([
MONSTER_A_ID,
MONSTER_C_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',
it('rolls the rare Charred Raider from a canned roll inside its 2% band', async () => {
const state = stateAtHuntingGround(burnedRoadPool());
// 0.99*100 = 99, past the 98 the three common entries cover, so the last
// 2 points of weight -- the rare -- take the first card.
const { service } = createService({
state,
randomSource: fakeRandomSource([0.99, 0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters[0].monster.key).toBe('charred-looter');
expect(result.encounters[0].encounterType).toBe(EncounterType.RARE);
// The common entries stay NORMAL, so the card only marks the real find.
expect(result.encounters.slice(1).map((e) => e.encounterType)).toEqual([
EncounterType.NORMAL,
EncounterType.NORMAL,
]);
});
it('never puts the same monster on two cards of one hunt', async () => {
const state = stateAtHuntingGround(burnedRoadPool());
// Every roll aims at the lowest band, which without removal would return
// the Ash Rat three times over.
const { service } = createService({
state,
randomSource: fakeRandomSource([0, 0, 0]),
});
const result = await service.startHunt(CHARACTER_ID);
const keys = result.encounters.map((e) => e.monster.key);
expect(new Set(keys).size).toBe(keys.length);
});
it('returns fewer encounters when the pool has fewer distinct monsters', async () => {
const state = stateAtHuntingGround(burnedRoadPool().slice(0, 2));
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
// "2-3 distinct encounter records where possible" (spec §3): a
// two-monster pool yields two cards rather than repeating one.
expect(result.encounters).toHaveLength(2);
expect(dataSource.state.huntEncounters).toHaveLength(2);
});
it('passes the monster flavor text through to the encounter', async () => {
const monsterA = monsterDefinition(MONSTER_A_ID, 'ash-rat', 'Ash Rat', {
flavorText: 'Scrawny and quick, it feeds on whatever the fires left.',
});
const state = stateAtHuntingGround([
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
monsterA,
100,
),
]);
const { service } = createService({
state,
randomSource: fakeRandomSource([0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters[0].monster.flavorText).toBe(
'Scrawny and quick, it feeds on whatever the fires left.',
);
});
it('supersedes the previous active hunt when a new hunt is started', async () => {
const monsterA = monsterDefinition(MONSTER_A_ID, 'aschenratte', 'Ash Rat');
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
@@ -498,26 +611,10 @@ describe('HuntingService', () => {
});
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 state = stateAtHuntingGround(burnedRoadPool());
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
randomSource: fakeRandomSource([0.99, 0.1, 0.1]),
});
await service.startHunt(CHARACTER_ID);
@@ -527,16 +624,16 @@ describe('HuntingService', () => {
);
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);
expect(encounters[0].monsterDefinitionId).toBe(MONSTER_D_ID);
expect(encounters[1].monsterDefinitionId).toBe(MONSTER_A_ID);
expect(encounters[2].monsterDefinitionId).toBe(MONSTER_C_ID);
});
it('computes a danger rating per encounter from the real monster stats', async () => {
const weakMonster = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
'Ash Rat',
{
attack: 1,
armor: 0,
@@ -567,4 +664,120 @@ describe('HuntingService', () => {
expect(encounter.dangerRating).toBe(DangerRating.WEAK);
}
});
it('marks every freshly rolled encounter as AVAILABLE', async () => {
const state = stateAtHuntingGround(burnedRoadPool());
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',
'Ash Rat',
);
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: 'Burned Road',
});
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

@@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, Repository } 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 type { LocationSummary } from '../travel/travel.service';
@@ -9,6 +10,7 @@ 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,
@@ -16,20 +18,25 @@ import {
huntingNotAvailable,
noHuntEncountersAvailable,
} from './hunting.errors';
import { RANDOM_SOURCE } from './random-source';
import type { RandomSource } from './random-source';
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;
/** Short atmosphere line for the encounter card; null when unauthored. */
flavorText: string | null;
}
export interface HuntEncounterDto {
id: string;
monster: MonsterSummary;
dangerRating: DangerRating;
status: HuntEncounterStatus;
/** Lets the card mark a rare find without knowing any monster keys. */
encounterType: EncounterType;
}
export interface HuntResultDto {
@@ -38,7 +45,9 @@ export interface HuntResultDto {
encounters: HuntEncounterDto[];
}
const ENCOUNTER_COUNT = 3;
// Upper bound only: a pool with fewer distinct monsters yields fewer cards
// (spec §3, "2-3 distinct encounter records where possible").
const MAX_ENCOUNTER_COUNT = 3;
@Injectable()
export class HuntingService {
@@ -101,37 +110,27 @@ export class HuntingService {
});
await txHunts.save(hunt);
const pickedMonsters = this.rollEncounters(pool, ENCOUNTER_COUNT);
const picked = this.rollEncounters(pool, MAX_ENCOUNTER_COUNT);
const encounterDtos: HuntEncounterDto[] = [];
for (let position = 0; position < pickedMonsters.length; position += 1) {
const monster = pickedMonsters[position];
for (let position = 0; position < picked.length; position += 1) {
const entry = picked[position];
const encounter = txEncounters.create({
huntId: hunt.id,
monsterDefinitionId: monster.id,
monsterDefinitionId: entry.monster.id,
position,
status: HuntEncounterStatus.AVAILABLE,
});
await txEncounters.save(encounter);
const dangerRating = calculateDangerRating(
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
{
attack: monster.attack,
armor: monster.armor,
hp: monster.maxHp,
},
encounterDtos.push(
this.toEncounterDto(
encounter,
entry.monster,
character,
entry.encounterType,
),
);
encounterDtos.push({
id: encounter.id,
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
artworkPath: monster.artworkPath,
},
dangerRating,
});
}
return {
@@ -143,31 +142,127 @@ export class HuntingService {
}
/**
* 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.
* 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' },
});
// The encounter type belongs to the location's pool, not to the rolled
// row, so it is re-read here rather than duplicated onto every encounter.
const encounterTypes = await this.loadEncounterTypes(hunt.locationId);
return {
id: hunt.id,
location: this.toLocationSummary(character.currentLocation),
encounters: encounters.map((encounter) =>
this.toEncounterDto(
encounter,
encounter.monster,
character,
encounterTypes.get(encounter.monsterDefinitionId) ??
EncounterType.NORMAL,
),
),
};
}
private async loadEncounterTypes(
locationId: string,
): Promise<Map<string, EncounterType>> {
const locationMonsters = this.dataSource.getRepository(LocationMonster);
const pool = await locationMonsters.find({ where: { locationId } });
return new Map(pool.map((entry) => [entry.monsterId, entry.encounterType]));
}
private toEncounterDto(
encounter: HuntEncounter,
monster: MonsterDefinition,
character: Character,
encounterType: EncounterType,
): HuntEncounterDto {
return {
id: encounter.id,
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
artworkPath: monster.artworkPath,
flavorText: monster.flavorText ?? null,
},
dangerRating: calculateDangerRating(
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
{ attack: monster.attack, armor: monster.armor, hp: monster.maxHp },
),
status: encounter.status,
encounterType,
};
}
/**
* Draws up to `count` *distinct* monsters from `pool` (spec §3).
*
* Each slot is one weighted pick over the entries still available, and the
* winner is then removed so the same monster cannot fill two cards -- the
* player is supposed to be choosing between different enemies, not looking
* at the same rat three times. A pool with fewer entries than `count`
* simply yields fewer cards ("where possible").
*
* The pick walks the remaining pool in the order it was supplied,
* accumulating weight, and takes the first entry whose cumulative weight
* exceeds the roll (roll < cumulative). Pure and deterministic given a
* RandomSource, so canned `next()` values make even the rare encounter
* reproducible in tests.
*/
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) {
): LocationMonster[] {
const remaining = [...pool];
const picks: LocationMonster[] = [];
while (picks.length < count && remaining.length > 0) {
const totalWeight = remaining.reduce((sum, e) => sum + e.weight, 0);
const roll = this.randomSource.next() * totalWeight;
let cumulative = 0;
let picked: LocationMonster = pool[pool.length - 1];
for (const entry of pool) {
cumulative += entry.weight;
let pickedIndex = remaining.length - 1;
for (let index = 0; index < remaining.length; index += 1) {
cumulative += remaining[index].weight;
if (roll < cumulative) {
picked = entry;
pickedIndex = index;
break;
}
}
picks.push(picked.monster);
picks.push(remaining.splice(pickedIndex, 1)[0]);
}
return picks;
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { InventoryResponseDto, InventoryService } from './inventory.service';
@Controller('inventory')
export class InventoryController {
constructor(private readonly inventoryService: InventoryService) {}
@Get()
getInventory(): Promise<InventoryResponseDto> {
return this.inventoryService.getInventory(DEMO_CHARACTER_ID);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
@Module({
imports: [TypeOrmModule.forFeature([CharacterItem, CharacterEquipment])],
controllers: [InventoryController],
providers: [InventoryService],
})
export class InventoryModule {}

View File

@@ -0,0 +1,104 @@
import { Repository } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { InventoryService } from './inventory.service';
const CHARACTER_ID = 'character-1';
function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
return {
id: 'item-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'def-1',
quantity: 1,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
itemDefinition: {
key: 'worn-short-sword',
name: 'Worn Shortsword',
description:
"A recruit's blade, sharpened more often than it's been swung.",
rarity: ItemRarity.COMMON,
type: ItemType.EQUIPMENT,
equipmentSlot: EquipmentSlot.WEAPON,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
...overrides,
} as CharacterItem;
}
describe('InventoryService', () => {
it("returns only the current character's items with definition data, quantity, and equipped state", async () => {
const items = [
characterItem({ id: 'item-1', quantity: 1 }),
characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }),
];
const characterItems = {
find: jest.fn().mockResolvedValue(items),
} as unknown as Repository<CharacterItem>;
const equipment = {
find: jest.fn().mockResolvedValue([
{
characterItemId: 'item-1',
slot: EquipmentSlot.WEAPON,
} as CharacterEquipment,
]),
} as unknown as Repository<CharacterEquipment>;
const service = new InventoryService(characterItems, equipment);
const result = await service.getInventory(CHARACTER_ID);
expect(characterItems.find).toHaveBeenCalledWith({
where: { characterId: CHARACTER_ID },
relations: { itemDefinition: true },
order: { createdAt: 'ASC' },
});
expect(result.items).toEqual([
{
id: 'item-1',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Worn Shortsword',
description:
"A recruit's blade, sharpened more often than it's been swung.",
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-2',
quantity: 3,
equipped: false,
item: expect.objectContaining({ key: 'worn-short-sword' }),
},
]);
});
it('returns an empty list when the character owns nothing', async () => {
const characterItems = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<CharacterItem>;
const equipment = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<CharacterEquipment>;
const service = new InventoryService(characterItems, equipment);
await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({
items: [],
});
});
});

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