diff --git a/docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md b/docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md new file mode 100644 index 0000000..0913a7d --- /dev/null +++ b/docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md @@ -0,0 +1,319 @@ +# 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. diff --git a/docs/playable-slices/0.10-Abandoned-Watchpost.md b/docs/playable-slices/0.10-Abandoned-Watchpost.md index a98ba35..ae01715 100644 --- a/docs/playable-slices/0.10-Abandoned-Watchpost.md +++ b/docs/playable-slices/0.10-Abandoned-Watchpost.md @@ -226,16 +226,85 @@ Graufurt ## 12. Acceptance Criteria -- [ ] Abandoned Watchpost exists as a full playable location. -- [ ] Travel from Burned Road works with server-authoritative timing. -- [ ] Location has a stronger, distinct encounter pool. -- [ ] At least one stronger enemy combines previously learned mechanics. -- [ ] Both HIDE and RAIDER_TROPHY carrying systems matter. -- [ ] Tier-1 equipment progression is meaningfully improved here. -- [ ] Story/investigation points toward the Ash Pit. -- [ ] Ash Pit route can be discovered without a level gate. -- [ ] Existing merchant/reputation loop continues to work. -- [ ] All player-facing content is English. +- [x] Abandoned Watchpost exists as a full playable location. +- [x] Travel from Burned Road works with server-authoritative timing. +- [x] Location has a stronger, distinct encounter pool. +- [x] At least one stronger enemy combines previously learned mechanics. +- [x] Both HIDE and RAIDER_TROPHY carrying systems matter. +- [x] Tier-1 equipment progression is meaningfully improved here. +- [x] Story/investigation points toward the Ash Pit. +- [x] Ash Pit route can be discovered without a level gate. +- [x] Existing merchant/reputation loop continues to work. +- [x] All player-facing content is English. + +### Verification status + +Every criterion above is supported by evidence from the automated test suite, +the build, or the seeded content itself — no criterion here needed the +running app to confirm structurally: + +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 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"`); `TravelService` computes + `arrivesAt` server-side and is covered generically by + `travel.service.spec.ts`. +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. + +What this status does **not** cover, 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. Those are +listed precisely in the implementation notes +(`0.10-Abandoned-Watchpost-implementation-notes.md`, §7) as outstanding work +for the project owner. ---