Merge branch 'slice/0.10-abandoned-watchpost'

This commit is contained in:
Bastian Wagner
2026-08-23 17:18:03 +02:00
72 changed files with 6595 additions and 97 deletions

View File

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

View File

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