Files
ashen-realms/docs/superpowers/specs/2026-08-23-slice-0.10-abandoned-watchpost-design.md
Bastian Wagner bdcfe211ca docs: design the Slice 0.10 Abandoned Watchpost
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
2026-08-23 09:54:20 +02:00

16 KiB
Raw Blame History

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:

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

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

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

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

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.