Fixed five prettier/prettier formatting errors in the three spec files Slice 0.10 wrote or edited (world-discovery.service.spec.ts, local-location-interaction.spec.ts, vertical-slice.seed.spec.ts). Scoped by hand to just those locations so the pre-existing errors elsewhere in the workspace, unrelated to this slice, are left alone. Also corrects the implementation notes, which previously understated the lint state as entirely pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16 KiB
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 andnullon 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 byTravelService.isRouteOpen(discoveredLocationIds, connection)— the same rule, but pure and synchronous over an already-loaded set, used byWorldServicewhen 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:
- Pending Heavy Strike — if last round's
telegraphsetpendingAction = 'HEAVY_ATTACK', it lands now, at the telegraphed multiplier, and nothing else happens this turn. - Telegraph — if
shouldTrigger(telegraph, round), the monster winds up (pendingActionset,TELEGRAPHevent, turn ends). - Guard — if
shouldTrigger(guard, round), the monster raises its guard (GUARD_RAISEDevent, turn ends). - 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
MAPhotspot 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 artworkapps/web/public/images/monsters/runtime/<key>-560.jpg— downscaled web copyapps/web/public/images/combat/sprites/<key>-<height>.png— background-free combat cutoutapps/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:
- The Burned Road shows a route to the Abandoned Watchpost; travelling takes ~15 s.
- The Watchpost map shows no Ash Pit route.
- Inspecting the watchpost reveals the §8 clue and announces the new route.
- The Ash Pit route now appears and can be travelled.
- A hunt at the Watchpost only offers the five monsters from its own pool.
- A Raider Veteran fight shows the guard banner; Shield Bash breaks it.
- A Burned Hound below 35 % HP shows the enrage banner and hits harder.
- Scorched Hide and Raider Warband Mark both drop and both sell to Borin.