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