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