The 0.10 spec is the project owner's requirements document, not a place for an implementer's verification narrative. Move the per-criterion evidence added for the ten ticked boxes into the implementation notes as a new section 8, leaving the spec's Acceptance Criteria section as just the ten checkboxes. While moving it, make explicit that criterion 2's evidence (server-authoritative travel timing) is inference over a generic TravelService test plus this connection's seed values, not a test exercising the Burned Road to Watchpost leg directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
414 lines
21 KiB
Markdown
414 lines
21 KiB
Markdown
# Slice 0.10 – Implementation Notes
|
||
|
||
**Companion to:** `0.10-Abandoned-Watchpost.md`
|
||
**Status:** Implemented, verified except the two items listed under
|
||
"Outstanding manual verification" below.
|
||
|
||
This records what actually got built, and where it differs from the
|
||
specification. Read it alongside the slice document, not instead of it.
|
||
|
||
---
|
||
|
||
## 1. The discovery model
|
||
|
||
Slice 0.10 needed a gate that is not a level check: the route to the Ash Pit
|
||
should stay hidden until the player has actually found it (§9). That turned
|
||
into three pieces.
|
||
|
||
**`character_location_discoveries`** (migration `1798000000000`) is a plain
|
||
join table: `character_id`, `location_id`, `discovered_at`, with a unique
|
||
index on the pair. It is player state and nothing else — it says which
|
||
places a character has found, not which places are gated. A row is written
|
||
once and never updated, so the unique index is the whole concurrency story
|
||
(the insert uses `ON CONFLICT DO NOTHING` rather than a read-then-write).
|
||
|
||
**`requires_discovery`** is a boolean column added to `location_connections`.
|
||
This is the deliberate half of the design: whether a route is gated at all is
|
||
content, not player state, and it lives on the connection row, not on the
|
||
location. That means a place can be reachable by one road and hidden behind
|
||
another — the Watchpost → Ash Pit leg carries `requiresDiscovery: true`, and
|
||
every other seeded connection, including the return leg from the Ash Pit,
|
||
carries `false`. The way back is never gated.
|
||
|
||
**`WorldDiscoveryService`** (`apps/api/src/world/discovery/world-discovery.service.ts`)
|
||
is the one place that knows how to turn "does this character know about
|
||
location X" into a yes/no. It exposes:
|
||
|
||
- `getDiscoveredLocationIds(characterId)` — the character's known-location
|
||
set, loaded once per caller.
|
||
- `discover(characterId, locationKey)` — records a discovery, returns the
|
||
location the first time and `null` on a repeat, so a caller can tell a
|
||
fresh reveal from a re-click without a second query.
|
||
- `isTravelAllowed(characterId, connection)` — the convenient single-connection
|
||
check, used by `TravelService`.
|
||
- `isRouteOpen(discoveredLocationIds, connection)` — the same rule, but pure
|
||
and synchronous over an already-loaded set, used by `WorldService` when it
|
||
needs to filter a whole list of connections at once.
|
||
|
||
### Why the gate is enforced in two places
|
||
|
||
The map has to hide the Ash Pit route before it is discovered
|
||
(`WorldService.getCurrentLocation` filters `connections` through
|
||
`isRouteOpen`), and travel has to refuse it even if a client somehow requests
|
||
it anyway (`TravelService.travelTo` calls `isTravelAllowed` inside the same
|
||
transaction that locks the character). Two call sites, not one, because the
|
||
map is a hint and travel is the authority — a client cannot be trusted to
|
||
only ever request what its own map shows it.
|
||
|
||
The risk with two call sites is drift: someone tightens the rule in one and
|
||
forgets the other, and either a hidden route becomes travelable or a visible
|
||
one becomes untravelable. That risk is closed by having both call sites go
|
||
through the same predicate, `isRouteOpen`. `isTravelAllowed` is a two-line
|
||
wrapper around it (load the discovery set, call the predicate); `getCurrentLocation`
|
||
calls it directly per connection. There is exactly one place that decides
|
||
whether a route is open, and both consumers hand it the same discovery set
|
||
and the same connection shape (`toLocationId` + `requiresDiscovery`). A
|
||
change to the rule cannot land in one caller without landing in the other,
|
||
because there is only one caller of the rule itself.
|
||
|
||
`WorldService.runLocalInteraction` is the third piece: a point of interest
|
||
carrying a `discoversLocationKey` calls `discover()` before it returns its
|
||
result text, so the reveal and the narrative beat happen in the same request
|
||
(§3.4, §8). The Watchpost's `inspect-watchpost` hotspot is the only POI that
|
||
does this in this slice — see §3 below.
|
||
|
||
---
|
||
|
||
## 2. `guard` and `enrage`: content-driven combat abilities
|
||
|
||
Both are configuration on `Monster.abilities`, read by
|
||
`CombatEngineService.resolveMonsterTurn` and `checkEnrage` — no monster-specific
|
||
code, the same pattern the existing `telegraph` and `bleed` abilities already
|
||
used.
|
||
|
||
### guard
|
||
|
||
```ts
|
||
guard: { roundInterval: number; armorBonus: number; durationRounds: number }
|
||
```
|
||
|
||
On a round where `shouldTrigger(guard, round)` fires, the monster raises its
|
||
guard instead of attacking: `activeGuard = { remainingRounds, armorBonus }`,
|
||
and a `GUARD_RAISED` event is emitted. While active, `effectiveArmor()` adds
|
||
`armorBonus` on top of the monster's base armor for damage calculation.
|
||
`ageGuard` counts one round off at the start of the monster's turn, *before*
|
||
`resolveMonsterTurn` runs, so the round the guard is raised is not the round
|
||
it starts expiring — a guard raised with `durationRounds: 2` is still up two
|
||
full monster turns later, then drops (`GUARD_ENDED`).
|
||
|
||
Shield Bash (the player's existing interrupt action) breaks an active guard
|
||
the same way it breaks a pending Heavy Strike: one `INTERRUPT` event even if
|
||
it happens to break both at once, because the player made one interruptive
|
||
action, not two, followed by a `GUARD_ENDED` event for the guard specifically.
|
||
|
||
Configured on Raider Veteran (`roundInterval: 4, armorBonus: 10, durationRounds: 2`)
|
||
and Raider Captain (`roundInterval: 3, armorBonus: 12, durationRounds: 2`).
|
||
|
||
### enrage
|
||
|
||
```ts
|
||
enrage: { hpThresholdPercent: number; damageMultiplier: number }
|
||
```
|
||
|
||
`checkEnrage` runs at the start of `resolveMonsterTurn`, before the monster
|
||
acts. The first time the monster's current HP is at or below
|
||
`hpThresholdPercent` of its max HP, `enraged` latches permanently true and an
|
||
`ENRAGED` event fires. From then on, every hit the monster lands is scaled by
|
||
`damageMultiplier` in `strikePlayer`. It is checked before the monster's own
|
||
turn resolves, so the blow that wounded it below the threshold is already
|
||
answered in kind that same round.
|
||
|
||
Configured on Burned Hound (`hpThresholdPercent: 35, damageMultiplier: 1.4`).
|
||
|
||
### Priority inside a monster's turn
|
||
|
||
`resolveMonsterTurn` checks, in order, on every round:
|
||
|
||
1. **Pending Heavy Strike** — if last round's `telegraph` set
|
||
`pendingAction = 'HEAVY_ATTACK'`, it lands now, at the telegraphed
|
||
multiplier, and nothing else happens this turn.
|
||
2. **Telegraph** — if `shouldTrigger(telegraph, round)`, the monster winds up
|
||
(`pendingAction` set, `TELEGRAPH` event, turn ends).
|
||
3. **Guard** — if `shouldTrigger(guard, round)`, the monster raises its guard
|
||
(`GUARD_RAISED` event, turn ends).
|
||
4. **Normal attack** — otherwise the monster strikes normally, with `bleed`
|
||
(if configured) applied on top.
|
||
|
||
Because a telegraph check happens before the guard check in the same
|
||
function, **a telegraph wins when both abilities are due in the same round**
|
||
— the monster winds up instead of guarding, and the guard's own interval
|
||
simply is not re-checked until its next due round. Raider Veteran's
|
||
intervals (telegraph every 3 rounds, guard every 4) were chosen so the two
|
||
only actually coincide every twelfth round, keeping this edge case rare
|
||
without hiding it.
|
||
|
||
`CombatEngineCombatantStats.activeGuard` and `.enraged` are read back into
|
||
`CombatMonsterDto.guardRemainingRounds: number | null` and `enraged: boolean`
|
||
so the web client can render the guard/enrage banners without any extra
|
||
lookup.
|
||
|
||
---
|
||
|
||
## 3. Deviations from the slice document
|
||
|
||
### No surviving guard NPC (§3)
|
||
|
||
§3 lists "Speak with the remaining guard/NPC if present" among the minimum
|
||
Watchpost interactions. There is no such NPC in this slice: no portrait
|
||
artwork exists for a Watchpost guard, and inventing one purely to satisfy the
|
||
checklist would mean shipping a placeholder face the project has no art for.
|
||
|
||
Instead, the investigation §8 asks for is an inspectable hotspot —
|
||
`inspect-watchpost`, type `INVESTIGATE` — that delivers the §8 clue text
|
||
directly and triggers the Ash Pit discovery. The Watchpost also has a second,
|
||
flavour-only hotspot (`search-guard-quarters`) that gestures at the missing
|
||
guard without personifying them: "a duty roster with every name scratched
|
||
out but one." The location is not empty of story, it just tells it through
|
||
place rather than through a person §3 has no art budget for.
|
||
|
||
### The crossed raider artwork (design decision D7)
|
||
|
||
The hand-painted art files `art/enemies/raider-scout.png` and
|
||
`art/enemies/raider-veteran.png` are, by their content, swapped relative to
|
||
their filenames: the file named *scout* depicts the heavier, plated,
|
||
spear-carrying figure, and the file named *veteran* depicts the leaner one.
|
||
|
||
Rather than force the Veteran's guard-and-telegraph mechanics onto the art
|
||
that reads as a light skirmisher, the web-facing keys are crossed at
|
||
generation time: the runtime key `raider-scout` is derived from
|
||
`art/enemies/raider-veteran.png`, and `raider-veteran` from
|
||
`art/enemies/raider-scout.png`. This is deliberate and recorded at the point
|
||
it happens, in `tools/derive-monster-assets.ps1`:
|
||
|
||
```powershell
|
||
# NOTE the deliberate crossing on the first two rows: the file named
|
||
# raider-scout depicts the heavier, plated, spear-carrying figure and is the
|
||
# Veteran; raider-veteran depicts the leaner one and is the Scout. Slice 0.10
|
||
# design decision D7.
|
||
```
|
||
|
||
Approved by the project owner. The generated files under
|
||
`apps/web/public/images/...` are named correctly for their in-game role; only
|
||
the source art's own filenames are crossed.
|
||
|
||
---
|
||
|
||
## 4. The Ash Pit stub
|
||
|
||
The Ash Pit (`key: 'ash-pit'`) exists in this slice only as a destination the
|
||
discovery gate can point at — the place §8's clue promises, reachable once
|
||
found, but not yet a location with content of its own. Concretely:
|
||
|
||
- `huntingEnabled: false` — no encounter pool.
|
||
- `locationType: 'TRANSITION'`.
|
||
- One point of interest: a `MAP` hotspot back to the world map. Nothing to
|
||
investigate, nothing to fight, nothing to trade.
|
||
- Real location artwork (`Aschengrube.png`) and a description, so arriving
|
||
there does not feel like a broken link — it feels like a threshold.
|
||
|
||
This matches the slice document's own scope: §9 asks only that the route
|
||
become discoverable and travelable, and §13 explicitly rules a second region
|
||
out of Slice 0.10. Slice 0.11 (`0.11-Ash-Pit-and-Ashen-Band-Captain.md`) is
|
||
where the Ash Pit gets an encounter pool, its own trade goods, and the
|
||
Captain of the Ashen Band as an area boss — everything this slice's stub
|
||
deliberately left out.
|
||
|
||
---
|
||
|
||
## 5. `tools/derive-monster-assets.ps1`
|
||
|
||
Generates, per monster, the four web assets the game actually serves from
|
||
the hand-painted source art in `art/enemies` and `art/backgrounds`:
|
||
|
||
- `apps/web/public/images/monsters/<key>.png` — full painted artwork
|
||
- `apps/web/public/images/monsters/runtime/<key>-560.jpg` — downscaled web copy
|
||
- `apps/web/public/images/combat/sprites/<key>-<height>.png` — background-free
|
||
combat cutout
|
||
- `apps/web/public/images/combat/icons/<key>-128.png` — medallion icon,
|
||
cropped to frame the head (crop window tuned per monster)
|
||
|
||
Plus the two background plates (`Wachturm.png`, `Aschengrube.png`) and their
|
||
downscaled runtime copies.
|
||
|
||
The generated output is committed, so the script is not part of any build or
|
||
CI step. It only needs to be re-run when the **source art changes** — a new
|
||
or replaced file under `art/enemies` or `art/backgrounds`, a re-crop, or a
|
||
correction to the crossed-key mapping in §3 above. It is safe to re-run at
|
||
any time: it overwrites only its own generated output and touches nothing
|
||
else. The Raider Captain's icon is the one exception the script itself
|
||
documents — it resizes the hand-made `PluendererhauptmannIcon.png` rather
|
||
than generating a crop, because authored art beats a generated one, but it
|
||
still resizes it to 128×128 rather than shipping the 1254×1254 source
|
||
verbatim.
|
||
|
||
---
|
||
|
||
## 6. Known gaps
|
||
|
||
Carried over from the per-task reviews in the SDD ledger — real, but judged
|
||
not worth blocking the slice on. Grouped rather than listed one by one.
|
||
|
||
**Untested edge cases in the guard/enrage engine.** No test pins the exact
|
||
HP threshold boundary for enrage (`currentHp === threshold`, only
|
||
strictly-above and strictly-below are covered); no test covers Shield Bash
|
||
breaking a pending Heavy Strike *and* an active guard in the same action
|
||
(the single-`INTERRUPT` branch is verified only by inspection, see §2 above);
|
||
and a Shield Bash that drives the monster below its enrage threshold delays
|
||
the enrage latch by one round, because `resolveMonsterTurn` — and therefore
|
||
`checkEnrage` — is skipped on an interrupted turn. This is the engine's
|
||
existing skip-on-interrupt behavior, not new to this slice, but it was
|
||
previously undocumented.
|
||
|
||
**Weak coverage on data, not code.** No test protects the encounter-pool
|
||
weights or the Ash Pit legs' `travelDurationSeconds` / `ambushChance` values
|
||
— a mistyped weight or ambush chance would pass every test unnoticed. No
|
||
test pins the absence of `discoversLocationKey` on the client-facing POI
|
||
payload; the DTO's field whitelist makes leakage structurally impossible
|
||
today, but a future spread-based refactor could reintroduce it silently.
|
||
|
||
**Loose assertions on generated assets and events.** The four new
|
||
monster-artwork tests assert `toBeDefined()` on registry entries rather than
|
||
exact paths, and never touch the filesystem — a registration pointing at a
|
||
missing file would still pass. The `GUARD_RAISED` event's `amount` payload
|
||
(the guard's `durationRounds`) is never asserted, only its `type`. A latent
|
||
bug in `LocalLocationStore.runInteraction`, noted while wiring the discovery
|
||
reveal through: when an interaction discovers a location, the store
|
||
re-`load()`s so the newly-visible connection appears; if that reload throws,
|
||
its rejection lands in the same `catch` that already set a successful
|
||
`interactionResultState`, so `interactionErrorState` ends up set behind a
|
||
non-null result the template never surfaces. Untested and invisible today,
|
||
but a trap for a future consumer of `interactionError()`.
|
||
|
||
None of these were judged to change behavior a player can hit; they are
|
||
seams a future slice's tests should tighten, most likely whichever slice
|
||
next touches the combat engine or the seed's encounter-pool weights.
|
||
|
||
**Lint state.** `npm run lint --workspace=@ashen-realms/api` (scoped to the
|
||
whole API workspace) fails: roughly a hundred `@typescript-eslint` and
|
||
`prettier/prettier` errors remain, all in the quest system, the rewards
|
||
service, the shops module, migration-runner specs and other files this
|
||
slice never touched — confirmed pre-existing by `git blame` timestamps
|
||
predating this branch's base commit. This slice's own files pass lint
|
||
cleanly; the five formatting violations `eslint --fix` originally found in
|
||
`world-discovery.service.spec.ts`, `local-location-interaction.spec.ts` and
|
||
`vertical-slice.seed.spec.ts` were fixed by hand, scoped to just those
|
||
locations. The pre-existing errors are left alone, out of this slice's
|
||
scope and the AGENTS.md rule against unrelated refactoring.
|
||
|
||
---
|
||
|
||
## 7. Outstanding manual verification
|
||
|
||
Everything below could not be run in the environment this slice was built
|
||
and verified in: `.env` is gitignored and absent from this worktree, so
|
||
`DATABASE_URL` is unset and no PostgreSQL instance is reachable. Neither the
|
||
API nor the web dev server was started, and no migration or seed command was
|
||
run. The project owner must do both of the following before treating this
|
||
slice as done:
|
||
|
||
**1. Run the migration and seed against a real database.**
|
||
|
||
```bash
|
||
npm run db:migrate
|
||
npm run db:seed
|
||
npm run db:seed
|
||
```
|
||
|
||
Expected: the migration applies cleanly; the seed runs a second time with no
|
||
duplicate-key error and no duplicated rows (AGENTS.md §8).
|
||
|
||
**2. Walk the loop in the browser**, with the app started
|
||
(`npm run dev:api` and `npm run dev:web`), and confirm by hand:
|
||
|
||
1. The Burned Road shows a route to the Abandoned Watchpost; travelling
|
||
takes ~15 s.
|
||
2. The Watchpost map shows **no** Ash Pit route.
|
||
3. Inspecting the watchpost reveals the §8 clue and announces the new route.
|
||
4. The Ash Pit route now appears and can be travelled.
|
||
5. A hunt at the Watchpost only offers the five monsters from its own pool.
|
||
6. A Raider Veteran fight shows the guard banner; Shield Bash breaks it.
|
||
7. A Burned Hound below 35 % HP shows the enrage banner and hits harder.
|
||
8. Scorched Hide and Raider Warband Mark both drop and both sell to Borin.
|
||
|
||
---
|
||
|
||
## 8. Acceptance criteria evidence
|
||
|
||
`0.10-Abandoned-Watchpost.md` §12 has all ten criteria ticked. This is the
|
||
per-criterion evidence for each tick — moved here from the spec document
|
||
itself, which should record what a slice must satisfy, not the case for
|
||
whether it did. Evidence strength varies by criterion; most rest on a test
|
||
that names this exact slice's content directly, one rests on a more
|
||
indirect chain and is flagged as such below.
|
||
|
||
1. **Full playable location** — seeded as an `OUTPOST` with four points of
|
||
interest (hunt, investigate, search, map-out) and its own encounter pool
|
||
(`vertical-slice.seed.spec.ts`: "seeds the watchpost as a huntable
|
||
outpost"); local content in `local-location.content.ts`.
|
||
2. **Server-authoritative travel** — the weakest evidence chain of the ten.
|
||
The Burned Road ↔ Watchpost connection is seeded both ways at 15 s / 10 %
|
||
ambush (`"connects the burned road and the watchpost both ways without a
|
||
gate"`), but nothing exercises that specific leg through
|
||
`TravelService`. The server-authoritative mechanism itself — that
|
||
`arrivesAt` is computed server-side from the connection's
|
||
`travelDurationSeconds` — is covered only generically, by
|
||
`travel.service.spec.ts` tests that use other connections. The tick rests
|
||
on inference (the mechanism is generic and this connection's seed values
|
||
are correct) rather than a test that drives this exact route end to end.
|
||
3. **Stronger, distinct pool** — `"gives the watchpost its own encounter
|
||
pool"` seeds exactly Road Bandit, Raider Scout, Raider Veteran, Burned
|
||
Hound and the rare Raider Captain; `"marks only the captain as a rare
|
||
encounter"` confirms the rarity split.
|
||
4. **An enemy combining learned mechanics** — the Raider Veteran carries both
|
||
`telegraph` (existing, from the Burned Road) and the new `guard`
|
||
(`"arms the veteran with a telegraph and a guard on different
|
||
cadences"`); the priority between them is covered in
|
||
`combat-engine.service.spec.ts`.
|
||
5. **Both bag categories matter** — Scorched Hide is seeded `HIDE`, Raider
|
||
Warband Mark is seeded `RAIDER_TROPHY`
|
||
(`vertical-slice.seed.spec.ts`), and both categories were already
|
||
load-bearing bag mechanics before this slice (Slice 0.7.5/0.9).
|
||
6. **Tier-1 equipment improved** — the Raider Veteran's own loot table adds
|
||
Plunderer Gloves, Reinforced Leather Jacket and Watchman's Leggings on top
|
||
of the Raider Warband Mark, and the Raider Scout carries Bandit Hood at a
|
||
raised chance (`item-content.ts`); the Road Bandit's own table (already
|
||
present) is left untouched at the same values
|
||
(`"leaves the road bandit loot table untouched"` pins Bandit Blade at
|
||
`0.1800`), so the Watchpost's gear opportunities are additive, not a
|
||
rebalance of the Burned Road.
|
||
7. **Investigation points to the Ash Pit** — the `inspect-watchpost` hotspot
|
||
carries the §8 clue text verbatim and `discoversLocationKey: 'ash-pit'`
|
||
(`"points the watchpost investigation at the ash pit"`).
|
||
8. **Ash Pit discoverable without a level gate** — the gate is
|
||
`requiresDiscovery`, not `minRecommendedLevel`; `WorldDiscoveryService`
|
||
contains no level check at all. Covered end to end: the hotspot writes the
|
||
discovery (`local-location-interaction.spec.ts`: `"discovers the route the
|
||
hotspot points at"`), the map hides/reveals it
|
||
(`world.service.spec.ts`: `"hides a gated connection until the character
|
||
has discovered it"` / `"shows a gated connection once it has been
|
||
discovered"`), and travel itself refuses/allows it
|
||
(`world-discovery.service.spec.ts` and `travel.service.spec.ts`, both:
|
||
`"refuses a gated route the character has not discovered"` /
|
||
`"allows a gated route once it has been discovered"`).
|
||
9. **Merchant/reputation loop continues** — both new trade goods have
|
||
exchange rules paying Silver and regional reputation, and pay more than
|
||
their Burned Road equivalents (`"lets Borin buy both watchpost trade
|
||
goods"`, `"pays more for watchpost goods than for road goods"`). No new
|
||
code path grants Silver, reputation or Renown directly from a kill; the
|
||
pack-wide rule (README.md) that only the exchange grants those was already
|
||
enforced before this slice and nothing in Slice 0.10 bypasses it.
|
||
10. **English content** — every string seeded for the Watchpost and Ash Pit
|
||
(descriptions, hotspot titles and result text, monster flavour text) was
|
||
read during this review and is English.
|
||
|
||
None of the ten criteria needed the running app to produce this evidence —
|
||
even criterion 2's weaker chain is inference over existing automated tests
|
||
and seed data, not a claim that required starting the server. What none of
|
||
this covers, because it cannot be produced by static evidence: actually
|
||
applying migration `1798000000000` to a real PostgreSQL database, confirming
|
||
the second `db:seed` run is idempotent against real constraints, and a
|
||
hand-played pass through the loop in a browser — including actually walking
|
||
the Burned Road → Watchpost leg, which would also close criterion 2's gap.
|
||
Those are listed precisely in §7 above as outstanding work for the project
|
||
owner.
|