Merge branch 'slice/0.10-abandoned-watchpost'
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
3261
docs/superpowers/plans/2026-08-23-slice-0.10-abandoned-watchpost.md
Normal file
3261
docs/superpowers/plans/2026-08-23-slice-0.10-abandoned-watchpost.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
# Playable Slice 0.10 — Abandoned Watchpost — Design
|
||||
|
||||
**Date:** 2026-08-23
|
||||
**Slice document:** `docs/playable-slices/0.10-Abandoned-Watchpost.md`
|
||||
**Depends on:** Slice 0.9 (quests, bags), 0.8.5 (reputation-gated offers), 0.7.5 (loot categories)
|
||||
**Status:** Approved design, ready for implementation planning
|
||||
|
||||
---
|
||||
|
||||
## 1. What this slice adds
|
||||
|
||||
A second hunting location beyond the Burned Road, with a stronger encounter
|
||||
pool, its own trade goods, and the first piece of world progression that is not
|
||||
a level number: a route the player has to *discover* before they can walk it.
|
||||
|
||||
Three things are genuinely new to the codebase. Everything else is content.
|
||||
|
||||
1. **Per-character world discovery.** There is no such state today —
|
||||
`location_connections.enabled` is a single global boolean, and
|
||||
`TravelService.startTravel` reads nothing else. A gated route needs
|
||||
player-owned state.
|
||||
2. **Two combat abilities**, `guard` and `enrage`, added to the content-driven
|
||||
ability set the engine already reads (`telegraph`, `bleed`).
|
||||
3. **A derived-asset script for monsters.** Four new enemies need four
|
||||
derivatives each, and no script produces them today.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisions taken during design
|
||||
|
||||
| # | Decision | Rejected alternative |
|
||||
|---|---|---|
|
||||
| D1 | Discovery lives in its own table `character_location_discoveries`, gated by a `requires_discovery` column on the connection | A generic per-character key/value flag table; the Ash Pit route still needs a target location, so the extra indirection buys nothing |
|
||||
| D2 | The Ash Pit is seeded in 0.10 as a minimal stub location | Deferring the route to 0.11, which would leave §11 and §12 unmet |
|
||||
| D3 | `guard` is a timed armor increase that skips the monster's attack and is broken by `SHIELD_BASH` | A permanent low-HP stance (no player answer); a second damage-reduction axis beside armor |
|
||||
| D4 | The investigation is a POI that writes the discovery — no new NPC, no new quest | A follow-up quest chain (more content than the slice needs); a surviving-guard NPC (no portrait art exists) |
|
||||
| D5 | Two new trade goods, priced above the Burned Road tier | Reusing existing goods, which leaves §10 unmet and makes the longer trip pointless |
|
||||
| D6 | The filter lives in a dedicated `WorldDiscoveryService` | Inline checks in `WorldService` and `TravelService`, which would duplicate the rule |
|
||||
| D7 | `raider-scout.png` and `raider-veteran.png` are swapped when copied: the file named *scout* depicts the heavier, plated, spear-carrying figure and becomes the Veteran | Following the filenames, which would contradict §5's role descriptions |
|
||||
|
||||
---
|
||||
|
||||
## 3. Discovery subsystem
|
||||
|
||||
### 3.1 Data
|
||||
|
||||
New table, player state, no content:
|
||||
|
||||
```text
|
||||
character_location_discoveries
|
||||
id uuid pk
|
||||
character_id uuid → characters(id) ON DELETE CASCADE
|
||||
location_id uuid → location_definitions(id) ON DELETE CASCADE
|
||||
discovered_at timestamptz
|
||||
UNIQUE (character_id, location_id)
|
||||
```
|
||||
|
||||
New column on content:
|
||||
|
||||
```text
|
||||
location_connections.requires_discovery boolean NOT NULL DEFAULT false
|
||||
```
|
||||
|
||||
The connection carries its own gate. A location stays free of routing rules, so
|
||||
a place can be reachable by one road and gated on another.
|
||||
|
||||
### 3.2 `WorldDiscoveryService`
|
||||
|
||||
Lives in the `world` module. Three methods, no HTTP and no combat knowledge:
|
||||
|
||||
- `getDiscoveredLocationIds(characterId): Promise<Set<string>>`
|
||||
- `discover(characterId, locationKey, manager?): Promise<LocationSummary | null>`
|
||||
— inserts `ON CONFLICT DO NOTHING`, returns the location on first discovery
|
||||
and `null` when it was already known, so callers can tell a fresh reveal from
|
||||
a repeat (§30: duplicate requests must be safe).
|
||||
- `isTravelAllowed(characterId, connection, manager?): Promise<boolean>` —
|
||||
`true` when `requiresDiscovery` is false or the target is already discovered.
|
||||
|
||||
The optional `manager` lets `TravelService` run the check inside its existing
|
||||
transaction rather than on a second connection.
|
||||
|
||||
### 3.3 Two call sites
|
||||
|
||||
**Display** — `WorldService.getCurrentLocation` filters `connections` through
|
||||
`isTravelAllowed`. An undiscovered route never appears. The Angular map reads
|
||||
exactly this array, so the frontend needs no change for the gate.
|
||||
|
||||
**Enforcement** — `TravelService.startTravel` applies the same rule inside its
|
||||
transaction, right after it resolves the connection, and throws the existing
|
||||
`INVALID_TRAVEL_TARGET`. Without this the gate would be UI decoration; AGENTS §5
|
||||
requires the server to own it.
|
||||
|
||||
### 3.4 How discovery happens
|
||||
|
||||
`LocationPointOfInterestContent` gains an optional field:
|
||||
|
||||
```ts
|
||||
discoversLocationKey?: string;
|
||||
```
|
||||
|
||||
`WorldService.runLocalInteraction` calls `discover(...)` before returning, and
|
||||
the result grows one field:
|
||||
|
||||
```ts
|
||||
discoveredLocation: { key: string; name: string } | null;
|
||||
```
|
||||
|
||||
Non-null only on the interaction that first reveals the route. The endpoint is
|
||||
no longer read-only — deliberate, and idempotent by the unique constraint.
|
||||
|
||||
The interaction panel renders a short "New route discovered — Ash Pit" line when
|
||||
the field is set.
|
||||
|
||||
---
|
||||
|
||||
## 4. World content
|
||||
|
||||
### 4.1 Locations
|
||||
|
||||
**Abandoned Watchpost** (`abandoned-watchpost`)
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| region | `ashen-fields`, "Ashen Fields", Tier 1 |
|
||||
| locationType | `OUTPOST` |
|
||||
| recommended | 2–3 |
|
||||
| dangerLevel | 2 |
|
||||
| isSafe / huntingEnabled | false / true |
|
||||
| artwork | `/images/backgrounds/Wachturm.png` |
|
||||
|
||||
**Ash Pit stub** (`ash-pit`)
|
||||
|
||||
Seeded so the discovered route has a real target and §11 is testable.
|
||||
`locationType: 'TRANSITION'`, `huntingEnabled: false`, `isSafe: false`,
|
||||
`dangerLevel: 3`, artwork `/images/backgrounds/Aschengrube.png`, empty POI and
|
||||
primary-action sets apart from a `MAP` entry, empty reward preview. Slice 0.11
|
||||
fills it in.
|
||||
|
||||
### 4.2 Connections
|
||||
|
||||
| From | To | Duration | Ambush | requiresDiscovery |
|
||||
|---|---|---|---|---|
|
||||
| Burned Road | Abandoned Watchpost | 15 s | 0.1000 | false |
|
||||
| Abandoned Watchpost | Burned Road | 15 s | 0.1000 | false |
|
||||
| Abandoned Watchpost | Ash Pit | 20 s | 0.1500 | **true** |
|
||||
| Ash Pit | Abandoned Watchpost | 20 s | 0.1500 | false |
|
||||
|
||||
No level requirement anywhere (§2, §9). The return leg from the Ash Pit is
|
||||
ungated — a player who got there must always be able to leave.
|
||||
|
||||
### 4.3 Local view
|
||||
|
||||
Four points of interest on `Wachturm.png`:
|
||||
|
||||
| Key | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `hunt-area` | HUNT | opens the hunt |
|
||||
| `inspect-watchpost` | INVESTIGATE | carries `discoversLocationKey: 'ash-pit'` and the §8 clue text |
|
||||
| `search-guard-quarters` | SEARCH | flavour only, no reward |
|
||||
| `east-road` | MAP | back to the map |
|
||||
|
||||
The clue text is quoted from §8:
|
||||
|
||||
> "The raiders weren't using the watchpost as shelter. They were using it to
|
||||
> watch the road. Fresh tracks lead east, toward the old ash excavation."
|
||||
|
||||
Primary actions mirror the Burned Road's four-entry bar: Begin Hunt, Inspect the
|
||||
watchpost (`poiKey: inspect-watchpost`), Search the quarters, To Map.
|
||||
|
||||
Reward preview: Equipment, Trade Goods. No Silver, no experience — normal kills
|
||||
grant neither (§6).
|
||||
|
||||
---
|
||||
|
||||
## 5. Encounter pool
|
||||
|
||||
Only these five entries are attached to the Watchpost, so the pool is its own
|
||||
(§11).
|
||||
|
||||
| Monster | Key | Cat. | Lvl | HP | Atk | Armor | Weight | Type | Abilities |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Raider Scout | `raider-scout` | HUMANOID | 2 | 70 | 10 | 3 | 35 | NORMAL | — |
|
||||
| Burned Hound | `burned-hound` | BEAST | 3 | 80 | 12 | 2 | 28 | NORMAL | `bleed`, `enrage` |
|
||||
| Road Bandit | `road-bandit` | HUMANOID | 2 | 75 | 9 | 5 | 20 | NORMAL | `telegraph` |
|
||||
| Raider Veteran | `raider-veteran` | HUMANOID | 3 | 120 | 14 | 10 | 14 | NORMAL | `telegraph`, `guard` |
|
||||
| Raider Captain | `raider-captain` | HUMANOID | 4 | 160 | 17 | 12 | 3 | RARE | `telegraph`, `guard` |
|
||||
|
||||
The Road Bandit is reused deliberately: §4 names it, it bridges the two
|
||||
locations, and it keeps the Raider Insignia economy connected. Its row above
|
||||
restates the values it already has — this slice adds a pool entry for it and
|
||||
changes nothing about the monster.
|
||||
|
||||
Ability configuration:
|
||||
|
||||
```text
|
||||
burned-hound bleed { roundInterval: 2, damagePerRound: 6, durationRounds: 2 }
|
||||
enrage { hpThresholdPercent: 35, damageMultiplier: 1.4 }
|
||||
raider-veteran telegraph { roundInterval: 3, damageMultiplier: 1.6 }
|
||||
guard { roundInterval: 4, armorBonus: 10, durationRounds: 2 }
|
||||
raider-captain telegraph { roundInterval: 2, damageMultiplier: 1.7 }
|
||||
guard { roundInterval: 3, armorBonus: 12, durationRounds: 2 }
|
||||
```
|
||||
|
||||
The Veteran's intervals (3 and 4) are chosen so the two abilities collide only
|
||||
every twelfth round; when they do, the telegraph wins by fixed priority.
|
||||
|
||||
---
|
||||
|
||||
## 6. Loot and economy
|
||||
|
||||
### 6.1 New trade goods
|
||||
|
||||
| Key | Name | Category | Type | Silver | Region rep |
|
||||
|---|---|---|---|---|---|
|
||||
| `scorched-hide` | Scorched Hide | HIDE | TRADE_GOOD | 12 | 4 |
|
||||
| `raider-warband-mark` | Raider Warband Mark | RAIDER_TROPHY | TROPHY | 20 | 7 |
|
||||
|
||||
Both get an `ExchangeRule` on Borin's profile (`sortOrder` 5 and 6, faction
|
||||
`border-guard`, milestone `FIRST_TRADE_MILESTONE_KEY`, no conditions). Prices sit
|
||||
above the Burned Road tier (Ashen Pelt 5, Tough Hide 8, Raider Insignia 14,
|
||||
Charred Raider Insignia 30) so the longer trip pays, without passing the rare
|
||||
Charred Raider Insignia.
|
||||
|
||||
Both carrying systems matter here (§6, §12): the Burned Hound feeds HIDE, the
|
||||
three raiders feed RAIDER_TROPHY. A player without a Trophy Pouch hits the
|
||||
bagless capacity of 1 and is pushed back to Borin.
|
||||
|
||||
### 6.2 Loot tables
|
||||
|
||||
One table per monster (the established pattern). Every table has a guaranteed
|
||||
trade good and independent equipment rolls.
|
||||
|
||||
| Table | Entries |
|
||||
|---|---|
|
||||
| `raider-scout-loot` | `raider-warband-mark` 0.6000, `bandit-blade` 0.2500, `bandit-hood` 0.1800 |
|
||||
| `burned-hound-loot` | `scorched-hide` 0.6000, `ash-boots` 0.1500 |
|
||||
| `raider-veteran-loot` | `raider-warband-mark` 0.7000, `raider-gloves` 0.1500, `reinforced-leather-jacket` 0.2000, `guardsman-legs` 0.2200 |
|
||||
| `raider-captain-loot` | `raider-warband-mark` 1.0000, `reinforced-leather-jacket` 0.3000, `guardsman-legs` 0.3000, `borderwatch-sigil` 0.2000 |
|
||||
|
||||
The Road Bandit keeps its existing table unchanged — retuning it would change
|
||||
Burned Road balance, which this slice was not asked to touch (AGENTS §39).
|
||||
|
||||
All five §7 items are already seeded; this slice raises their availability
|
||||
rather than adding equipment. `borderwatch-sigil` is the Captain's focused
|
||||
desirable drop (§5).
|
||||
|
||||
---
|
||||
|
||||
## 7. Combat engine
|
||||
|
||||
### 7.1 New content abilities
|
||||
|
||||
```ts
|
||||
export interface MonsterGuardAbility {
|
||||
roundInterval: number;
|
||||
armorBonus: number;
|
||||
durationRounds: number;
|
||||
}
|
||||
|
||||
export interface MonsterEnrageAbility {
|
||||
hpThresholdPercent: number;
|
||||
damageMultiplier: number;
|
||||
}
|
||||
```
|
||||
|
||||
Both hang off `MonsterAbilities`. The engine keeps branching on configuration,
|
||||
never on a monster key (AGENTS §9).
|
||||
|
||||
### 7.2 Guard
|
||||
|
||||
State lives on the monster's stats, beside `pendingAction`:
|
||||
|
||||
```ts
|
||||
activeGuard?: { remainingRounds: number; armorBonus: number };
|
||||
```
|
||||
|
||||
- When `guard` triggers, the monster raises its guard **instead of attacking**
|
||||
and emits `GUARD_RAISED`.
|
||||
- While active, `armorBonus` is added to the monster's armor in every
|
||||
`calculateDamage` call against it.
|
||||
- The counter decrements at the end of each round; at zero the field is cleared
|
||||
and `GUARD_ENDED` is emitted.
|
||||
- `SHIELD_BASH` clears it exactly as it clears a telegraph, emitting `INTERRUPT`
|
||||
and `GUARD_ENDED`. This is the same lesson the player already learned.
|
||||
|
||||
Priority in `resolveMonsterTurn`, top to bottom:
|
||||
|
||||
1. resolve a pending Heavy Strike
|
||||
2. raise a telegraph
|
||||
3. raise a guard
|
||||
4. normal attack (plus `bleed` if due)
|
||||
|
||||
### 7.3 Enrage
|
||||
|
||||
When the monster's HP first falls to or below `hpThresholdPercent` of its
|
||||
maximum, `enraged: true` is set on its stats and `ENRAGED` is emitted once. From
|
||||
then on `damageMultiplier` applies to every strike it makes, including a
|
||||
telegraphed one. It never expires and never re-triggers — one deterministic
|
||||
state change, no hidden roll.
|
||||
|
||||
### 7.4 Contract changes
|
||||
|
||||
- `CombatEventType` gains `GUARD_RAISED`, `GUARD_ENDED`, `ENRAGED`; a migration
|
||||
appends them to `combat_event_type_enum` following migration 1790's pattern.
|
||||
- `CombatMonsterDto` gains `guardRemainingRounds: number | null` and
|
||||
`enraged: boolean`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend
|
||||
|
||||
Only the combat screen changes.
|
||||
|
||||
- A guard badge next to the existing telegraph indicator, showing the remaining
|
||||
rounds, and an enrage badge on the monster.
|
||||
- Combat-log lines for the three new event types.
|
||||
- `monster-artwork.ts` gains cutout, icon, runtime and sprite-scale entries for
|
||||
the four new keys.
|
||||
- The interaction panel shows the discovered-route line when
|
||||
`discoveredLocation` is set.
|
||||
|
||||
The map, travel panel and local view need no change — they already render
|
||||
whatever `connections` and `pointsOfInterest` the API returns.
|
||||
|
||||
---
|
||||
|
||||
## 9. Artwork
|
||||
|
||||
Sources committed in `2435d25`:
|
||||
|
||||
| Web key | Full art | Cutout | Icon |
|
||||
|---|---|---|---|
|
||||
| `raider-scout` | `art/enemies/raider-veteran.png` | transparent variant | cropped from cutout |
|
||||
| `raider-veteran` | `art/enemies/raider-scout.png` | transparent variant | cropped from cutout |
|
||||
| `burned-hound` | `art/enemies/burned-hound.png` | transparent variant | cropped from cutout |
|
||||
| `raider-captain` | `art/enemies/Pluendererhauptmann.png` | transparent variant | `art/enemies/PluendererhauptmannIcon.png` |
|
||||
|
||||
The first two rows are crossed on purpose (D7).
|
||||
|
||||
Backgrounds: `art/backgrounds/Wachturm.png` and `art/backgrounds/Aschengrube.png`
|
||||
copy to `apps/web/public/images/backgrounds/`, with 960px runtime JPEGs beside
|
||||
them.
|
||||
|
||||
New script `tools/derive-monster-assets.ps1`, written in the same style as
|
||||
`tools/extract-item-icons.ps1` (System.Drawing, re-runnable, overwrites only its
|
||||
own output). Per monster it produces:
|
||||
|
||||
```text
|
||||
apps/web/public/images/monsters/<key>.png
|
||||
apps/web/public/images/monsters/runtime/<key>-560.jpg
|
||||
apps/web/public/images/combat/sprites/<key>-<height>.png
|
||||
apps/web/public/images/combat/icons/<key>-128.png
|
||||
```
|
||||
|
||||
The runtime derivative is a JPEG because the source it comes from carries its
|
||||
own painted background. The existing `-560.png` entries are the exceptions, not
|
||||
the rule, and `monster-artwork.ts` records the real extension per key either
|
||||
way.
|
||||
|
||||
Crop rectangles for the icons are authored per monster in the script, not
|
||||
guessed at runtime. Where a hand-made icon exists it wins over a crop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Tests
|
||||
|
||||
Mapped to slice §11.
|
||||
|
||||
**Engine (unit, deterministic)**
|
||||
- guard raises armor for exactly `durationRounds` and then clears
|
||||
- the round a guard goes up deals no damage to the player
|
||||
- `SHIELD_BASH` breaks an active guard and emits `INTERRUPT` + `GUARD_ENDED`
|
||||
- telegraph beats guard when both are due in the same round
|
||||
- enrage fires exactly once at the threshold and persists to the end of combat
|
||||
- a monster with no new abilities behaves exactly as before
|
||||
|
||||
**Discovery**
|
||||
- `getCurrentLocation` omits the Ash Pit route before discovery and includes it
|
||||
after
|
||||
- `startTravel` to the Ash Pit throws `INVALID_TRAVEL_TARGET` before discovery
|
||||
and succeeds after
|
||||
- running the investigation twice inserts one row and returns
|
||||
`discoveredLocation: null` the second time
|
||||
- the return leg from the Ash Pit is never gated
|
||||
|
||||
**Seed**
|
||||
- the Watchpost pool contains exactly its five entries and no Burned Road
|
||||
location row is touched
|
||||
- each new trade good carries the right `LootCategory`
|
||||
- no Watchpost monster grants Silver or reputation directly
|
||||
- both new goods have an exchange rule on Borin's profile
|
||||
- re-running the seed produces no duplicates
|
||||
|
||||
**Migration**
|
||||
- the discovery table, its unique constraint and both foreign keys exist
|
||||
- `requires_discovery` exists with default false
|
||||
- the three new enum values exist on `combat_event_type_enum`
|
||||
|
||||
**Frontend**
|
||||
- the combat store renders guard and enrage badges from the DTO
|
||||
- the interaction panel shows the discovered-route line only when the field is
|
||||
set
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of scope
|
||||
|
||||
Per slice §13: no second region, no crafting, no procedural events, no stealth,
|
||||
no large dialogue trees, no area boss. The Ash Pit stays a stub with no encounter
|
||||
pool — Slice 0.11 owns it.
|
||||
Reference in New Issue
Block a user