Files
ashen-realms/docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md
Bastian Wagner 67287faf96 docs: move acceptance-criteria evidence out of the spec document
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>
2026-08-23 17:34:08 +02:00

414 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.