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

21 KiB
Raw Blame History

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

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

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:

# 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.

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.