Records the seven decisions taken while brainstorming the slice: a per-character discovery table gating the Ash Pit route, a stub Ash Pit so the route has a real target, guard and enrage as content-driven combat abilities, a POI rather than a quest as the investigation, two new trade goods above the Burned Road tier, a dedicated WorldDiscoveryService, and the deliberate crossing of the two raider artwork files so each enemy matches its role. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NjEPjZ8R8e9c23z3vR8bt
16 KiB
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.
- Per-character world discovery. There is no such state today —
location_connections.enabledis a single global boolean, andTravelService.startTravelreads nothing else. A gated route needs player-owned state. - Two combat abilities,
guardandenrage, added to the content-driven ability set the engine already reads (telegraph,bleed). - 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:
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:
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>— insertsON CONFLICT DO NOTHING, returns the location on first discovery andnullwhen 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>—truewhenrequiresDiscoveryis 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:
discoversLocationKey?: string;
WorldService.runLocalInteraction calls discover(...) before returning, and
the result grows one field:
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:
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
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:
activeGuard?: { remainingRounds: number; armorBonus: number };
- When
guardtriggers, the monster raises its guard instead of attacking and emitsGUARD_RAISED. - While active,
armorBonusis added to the monster's armor in everycalculateDamagecall against it. - The counter decrements at the end of each round; at zero the field is cleared
and
GUARD_ENDEDis emitted. SHIELD_BASHclears it exactly as it clears a telegraph, emittingINTERRUPTandGUARD_ENDED. This is the same lesson the player already learned.
Priority in resolveMonsterTurn, top to bottom:
- resolve a pending Heavy Strike
- raise a telegraph
- raise a guard
- normal attack (plus
bleedif 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
CombatEventTypegainsGUARD_RAISED,GUARD_ENDED,ENRAGED; a migration appends them tocombat_event_type_enumfollowing migration 1790's pattern.CombatMonsterDtogainsguardRemainingRounds: number | nullandenraged: 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.tsgains cutout, icon, runtime and sprite-scale entries for the four new keys.- The interaction panel shows the discovered-route line when
discoveredLocationis 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:
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
durationRoundsand then clears - the round a guard goes up deals no damage to the player
SHIELD_BASHbreaks an active guard and emitsINTERRUPT+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
getCurrentLocationomits the Ash Pit route before discovery and includes it afterstartTravelto the Ash Pit throwsINVALID_TRAVEL_TARGETbefore discovery and succeeds after- running the investigation twice inserts one row and returns
discoveredLocation: nullthe 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_discoveryexists 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.