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

File diff suppressed because it is too large Load Diff

View File

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