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
This commit is contained in:
@@ -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 | 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/<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.
|
||||||
Reference in New Issue
Block a user