# 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/.png` — full painted artwork - `apps/web/public/images/monsters/runtime/-560.jpg` — downscaled web copy - `apps/web/public/images/combat/sprites/-.png` — background-free combat cutout - `apps/web/public/images/combat/icons/-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. --- ## 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.