diff --git a/docs/superpowers/specs/2026-08-23-slice-0.10-abandoned-watchpost-design.md b/docs/superpowers/specs/2026-08-23-slice-0.10-abandoned-watchpost-design.md new file mode 100644 index 0000000..35c02fa --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-slice-0.10-abandoned-watchpost-design.md @@ -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>` +- `discover(characterId, locationKey, manager?): Promise` + — 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` — + `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/.png +apps/web/public/images/monsters/runtime/-560.jpg +apps/web/public/images/combat/sprites/-.png +apps/web/public/images/combat/icons/-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.