Files
gogobee/gogobee_branching_zones_plan.md
prosolis 7ec78f3cd9 Branching zones G9c: drop legacy columns + add purge gate
Final G9 step. The schema no longer creates current_room or
room_seq_json on fresh installs, and existing prod schemas drop them
via purgeLegacyZoneRunColumns() once the operator flips
GOGOBEE_BRANCHING_PURGE=1.

- CREATE TABLE dnd_zone_run loses both columns; comment updated to
  point at G1's columnMigrations for the graph-mode columns.
- cleanup_g9.go mirrors the L5 idempotent-purge shape: ALTER TABLE
  DROP COLUMN per legacy column, "no such column" treated as already
  done, default-off.
- Wired into adventure Init alongside purgeLegacyAdvCharacterTable.
- Plan doc marks G9 complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:23:08 -07:00

380 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Gogobee — Branching Zones (Option A: Greenfield Zone Graph)
**Status:** plan, not yet implemented.
**Branch convention:** carve a new branch off `adv-2.0` (e.g. `branching-zones` or `phase-G`).
**Prerequisite:** none — Phase L is deployed; nothing else blocks this.
**Owner:** future session.
---
## 1. Goal & non-goals
### Goal
Replace the linear room model (`room_seq_json` flat array + `current_room INTEGER` index) with a **graph model**: zones are directed graphs of nodes, players navigate by choosing outgoing edges, edges can be gated by Perception checks / keys / level / cleared regions. Enables:
- Slay-the-Spire-style "pick your next room" forks.
- Optional side rooms (secret chambers behind Perception walls).
- Shortcuts that skip rooms.
- Multiple paths converging on a shared boss antechamber.
- Loop-back edges (off by default; opt-in per zone).
### Non-goals
- Rewriting combat. The combat engine (`combat_engine.go` / `combat_stats.go`) is shared infra per `gogobee_legacy_migration.md` §7.4 and stays untouched.
- Changing zone *contents* (enemy rosters, loot tables, boss stat blocks). Only the topology of how rooms connect changes.
- Reworking the multi-region system. Regions remain the coarse-grain layer; the graph lives **inside** a region. A zone like Underdark has 4 regions and each region has its own subgraph.
---
## 2. Schema
Two new tables. **Naming**: avoid the `dnd_` prefix per the project feedback note about trademark exposure — use `zone_node` / `zone_edge`. Existing `dnd_zone_run` is grandfathered.
### `zone_node`
```sql
CREATE TABLE zone_node (
node_id TEXT PRIMARY KEY, -- "crypt_valdris.entry", globally unique
zone_id TEXT NOT NULL, -- FK-ish: matches ZoneID
region_id TEXT NOT NULL DEFAULT '', -- empty for single-region zones
kind TEXT NOT NULL, -- entry|exploration|trap|elite|boss|harvest|rest_camp|secret|fork|merge
label TEXT NOT NULL DEFAULT '', -- human-readable for !zone map ("Side Chapel")
is_entry INTEGER NOT NULL DEFAULT 0, -- exactly one per zone (or per region for multi-region)
is_boss INTEGER NOT NULL DEFAULT 0, -- exactly one boss reachable per zone
pos_x INTEGER NOT NULL DEFAULT 0, -- ASCII map layout hint
pos_y INTEGER NOT NULL DEFAULT 0,
content_json TEXT NOT NULL DEFAULT '{}' -- opaque per-node payload (loot bias, encounter override, harvest ref)
);
CREATE INDEX idx_zone_node_zone ON zone_node(zone_id);
```
### `zone_edge`
```sql
CREATE TABLE zone_edge (
edge_id INTEGER PRIMARY KEY AUTOINCREMENT,
zone_id TEXT NOT NULL,
from_node TEXT NOT NULL, -- FK-ish: zone_node.node_id
to_node TEXT NOT NULL, -- FK-ish: zone_node.node_id
lock_kind TEXT NOT NULL DEFAULT 'none', -- none|perception_check|key_required|level_min|region_clear|stat_check
lock_data_json TEXT NOT NULL DEFAULT '{}', -- {"dc": 12} | {"key_id": "skull_key"} | {"min_level": 5} | etc
hint TEXT NOT NULL DEFAULT '', -- player-visible hint when lock active ("A faint breeze suggests a passage")
weight INTEGER NOT NULL DEFAULT 1 -- for ordering choices in the prompt
);
CREATE INDEX idx_zone_edge_from ON zone_edge(zone_id, from_node);
```
### Run-state additions on `dnd_zone_run`
```sql
ALTER TABLE dnd_zone_run ADD COLUMN current_node TEXT NOT NULL DEFAULT '';
ALTER TABLE dnd_zone_run ADD COLUMN visited_nodes TEXT NOT NULL DEFAULT '[]'; -- JSON array of node_ids
ALTER TABLE dnd_zone_run ADD COLUMN node_choices TEXT NOT NULL DEFAULT '{}'; -- JSON: pending fork prompt state
```
`current_room` and `room_seq_json` stay during the migration; retired in Phase G9.
### Runtime (Go) shapes
```go
// internal/plugin/zone_graph.go (new file)
type ZoneNodeKind string
const (
NodeKindEntry ZoneNodeKind = "entry"
NodeKindExploration ZoneNodeKind = "exploration"
NodeKindTrap ZoneNodeKind = "trap"
NodeKindElite ZoneNodeKind = "elite"
NodeKindBoss ZoneNodeKind = "boss"
NodeKindHarvest ZoneNodeKind = "harvest"
NodeKindRestCamp ZoneNodeKind = "rest_camp"
NodeKindSecret ZoneNodeKind = "secret"
NodeKindFork ZoneNodeKind = "fork" // pure choice node, no encounter
NodeKindMerge ZoneNodeKind = "merge" // paths converge, no encounter
)
type ZoneNode struct {
NodeID string // "crypt_valdris.entry"
ZoneID ZoneID
RegionID string // "" for single-region
Kind ZoneNodeKind
Label string // "Side Chapel"
IsEntry bool
IsBoss bool
PosX, PosY int
Content ZoneNodeContent // typed wrapper around content_json
}
type ZoneNodeContent struct {
EncounterOverride string // bestiary_id; if "", roll from zone roster
HarvestRef string // harvest table id
LootBias float64 // multiplier for end-of-room loot roll
Narration string // optional bespoke entry text
}
type ZoneEdgeLockKind string
const (
LockNone ZoneEdgeLockKind = "none"
LockPerception ZoneEdgeLockKind = "perception_check"
LockKey ZoneEdgeLockKind = "key_required"
LockLevelMin ZoneEdgeLockKind = "level_min"
LockRegionClear ZoneEdgeLockKind = "region_clear"
LockStatCheck ZoneEdgeLockKind = "stat_check" // {"stat":"str","dc":14}
)
type ZoneEdge struct {
From string
To string
Lock ZoneEdgeLockKind
LockData map[string]any
Hint string
Weight int
}
type ZoneGraph struct {
ZoneID ZoneID
Nodes map[string]ZoneNode // keyed by NodeID
Edges map[string][]ZoneEdge // keyed by from_node
Entry string // entry NodeID
Boss string // boss NodeID (must be reachable)
}
```
---
## 3. Authoring model
Zones author their graph as Go literals, registered alongside the existing `ZoneDefinition`. Two convenience builders:
```go
// BuildLinearGraph compiles a flat room sequence (the existing model) into
// a graph with N-1 unconditional edges. Used for legacy compat and for
// zones that intentionally stay linear.
func BuildLinearGraph(zoneID ZoneID, seq []ZoneNodeKind) ZoneGraph
// BuildGraph is the explicit authoring path: pass nodes + edges directly.
// Validates: exactly one entry, exactly one boss, boss reachable from
// entry, no orphan nodes, no self-loops unless explicitly opted in.
func BuildGraph(zoneID ZoneID, nodes []ZoneNode, edges []ZoneEdge) ZoneGraph
```
Authoring example for a branched Crypt of Valdris (POC):
```go
// entry → corridor → FORK
// ├─[lockNone]── main_hall ──┐
// │ ├── antechamber → boss
// └─[perception DC 12]──── side_chapel ──┘ (also: secret edge to crypt_loot)
// secret_chamber dangles off side_chapel via perception DC 15
func zoneCryptValdrisGraph() ZoneGraph {
nodes := []ZoneNode{
{NodeID: "crypt_valdris.entry", Kind: NodeKindEntry, IsEntry: true, Label: "Crypt Threshold"},
{NodeID: "crypt_valdris.corridor", Kind: NodeKindExploration, Label: "Bone Corridor"},
{NodeID: "crypt_valdris.fork", Kind: NodeKindFork, Label: "Branching Passage"},
{NodeID: "crypt_valdris.main_hall", Kind: NodeKindElite, Label: "Main Hall"},
{NodeID: "crypt_valdris.side_chapel", Kind: NodeKindExploration, Label: "Side Chapel"},
{NodeID: "crypt_valdris.secret_chamber", Kind: NodeKindSecret, Label: "Hidden Reliquary",
Content: ZoneNodeContent{LootBias: 2.0}},
{NodeID: "crypt_valdris.antechamber", Kind: NodeKindMerge, Label: "Antechamber"},
{NodeID: "crypt_valdris.boss", Kind: NodeKindBoss, IsBoss: true, Label: "Valdris's Sanctum"},
}
edges := []ZoneEdge{
{From: "crypt_valdris.entry", To: "crypt_valdris.corridor", Lock: LockNone},
{From: "crypt_valdris.corridor", To: "crypt_valdris.fork", Lock: LockNone},
{From: "crypt_valdris.fork", To: "crypt_valdris.main_hall", Lock: LockNone, Weight: 1},
{From: "crypt_valdris.fork", To: "crypt_valdris.side_chapel", Lock: LockPerception, LockData: map[string]any{"dc": 12}, Hint: "A faint draft from a crack in the wall.", Weight: 2},
{From: "crypt_valdris.main_hall", To: "crypt_valdris.antechamber", Lock: LockNone},
{From: "crypt_valdris.side_chapel", To: "crypt_valdris.antechamber", Lock: LockNone},
{From: "crypt_valdris.side_chapel", To: "crypt_valdris.secret_chamber", Lock: LockPerception, LockData: map[string]any{"dc": 15}, Hint: "A faint scratching behind the altar."},
{From: "crypt_valdris.secret_chamber", To: "crypt_valdris.antechamber", Lock: LockNone},
{From: "crypt_valdris.antechamber", To: "crypt_valdris.boss", Lock: LockNone},
}
return BuildGraph(ZoneCryptValdris, nodes, edges)
}
```
Registration parallels existing `registerZone`:
```go
var zoneGraphRegistry = map[ZoneID]ZoneGraph{}
func registerZoneGraph(g ZoneGraph) {
if _, dup := zoneGraphRegistry[g.ZoneID]; dup {
panic("duplicate zone graph: " + g.ZoneID)
}
if err := validateZoneGraph(g); err != nil {
panic("invalid zone graph for " + g.ZoneID + ": " + err.Error())
}
zoneGraphRegistry[g.ZoneID] = g
}
```
---
## 4. Migration phases
### G1 — Schema introduction *(infra; no behavior change)*
- Add `zone_node`, `zone_edge` CREATE statements to `internal/db/db.go` schema.
- Add `current_node` / `visited_nodes` / `node_choices` columns to `dnd_zone_run` via `columnMigrations`.
- No code reads from these yet.
- **Test:** `TestProdDBMigrations` passes against a fresh copy of `data/gogobee.db`.
### G2 — Graph types + validator *(infra)*
- New file `internal/plugin/zone_graph.go` defining the types in §2.
- Implement `BuildLinearGraph`, `BuildGraph`, `validateZoneGraph`.
- Validator checks:
- Exactly one node has `IsEntry=true`.
- Exactly one node has `IsBoss=true`.
- Boss is reachable from entry via BFS.
- No orphan nodes (every non-entry node has an incoming edge).
- No self-loops unless `Content.AllowSelfLoop` flag is set (off by default).
- **Test:** unit tests with hand-rolled graphs, including failure cases.
### G3 — Legacy zone compiler + dual-mode loader *(infra; no behavior change)*
- For every existing `ZoneDefinition`, compile its `room_seq_json` template into a linear graph at registry init:
```go
func compileLegacyZoneGraph(z ZoneDefinition) ZoneGraph {
// Read the room-type template that generateRoomSequence currently uses,
// produce a linear chain of nodes with unconditional edges. Entry node
// = first room, boss node = last room.
}
```
- `loadZoneGraph(zoneID)` returns the registered graph if present, else falls back to the legacy-compiled one.
- All zones now have a graph representation, but nothing reads it yet.
- **Test:** every existing zone compiles cleanly; node count = legacy room count.
### G4 — Run-state model + reader hot-swap *(behavior parity)*
- `dnd_zone_run` reader populates `current_node` from `current_room` if `current_node` is empty (one-time per-row warm-up).
- New helpers:
- `loadZoneRunNode(runID) (currentNode string, visited []string, ...)`
- `advanceZoneRunNode(runID, nextNode string)` — appends to `visited_nodes`, sets `current_node`, also bumps `current_room` for any legacy code still reading it.
- Run-state writes: every existing site that mutates `current_room` now also calls `advanceZoneRunNode` with the corresponding linear-graph node.
- **Test:** an in-progress run loaded post-deploy continues to advance correctly under the dual-write scheme.
### G5 — Navigation surface (the main UX change)
- `enterRoom` / room-advance code branches on `len(outgoingEdges(currentNode))`:
- 1 edge: auto-advance (current behavior).
- 0 edges: end-of-zone state (boss cleared OR dead-end secret).
- 2+ edges: write a fork prompt to `node_choices`, DM the player a numbered menu of unlocked edges, plus hints for locked ones.
- New command `!zone go <n>` (also `!zone choose <n>`) consumes the pending fork choice. Validates the selection is unlocked given current state (Perception roll, key inventory, level, etc.).
- Edge unlock evaluation:
- `LockNone` — always available.
- `LockPerception` — auto-roll Perception when player reaches the fork; success unlocks the edge silently (still must be picked); failure shows the hint as a teaser ("you sense something but can't place it"). Re-roll never; choice is committed at fork-arrival.
- `LockKey` — checks player inventory for `lock_data.key_id`.
- `LockLevelMin``dnd_character.dnd_level >= lock_data.min_level`.
- `LockRegionClear` — region progression (multi-region zones).
- `LockStatCheck` — generic stat-vs-DC roll.
- `!zone status` reports current node label + outgoing edge previews.
- **Test:** end-to-end forks, locked-edge teasing, unlock paths, secret-chamber gating.
### G6 — Dependent surfaces
- **Harvest nodes** (`dnd_expedition_harvest.go`): currently keyed by `(region_id, room_idx)`. Re-key on `node_id`. Author harvest content into `ZoneNodeContent.HarvestRef`. Migration: legacy harvest_node rows get their `node_id` derived from `room_idx` via the linear-graph compiler.
- **Narration cadence** (`dnd_zone_narration.go`): mood asides, threat ticks, TwinBee interjections counted by `len(visited_nodes)` instead of `current_room`.
- **`!zone map` renderer**: rewrite to render the graph using `pos_x` / `pos_y` hints. Visited nodes filled, current node highlighted, locked edges shown dashed, secrets hidden until discovered.
- **Boss flow** unchanged in essence — entering the boss node still triggers `bossEncounter`. The path to it is what changed.
- **Region transitions** (`dnd_expedition_region.go`): when `nextNode.RegionID != currentNode.RegionID`, fire the existing region-transition flow.
### G7 — POC zone (Crypt of Valdris) *(shipped)*
- `zoneCryptValdrisGraph()` lives in `internal/plugin/zone_graph_crypt_valdris.go` (graph topology per §3, including a Perception DC 15 secret reliquary off `side_chapel` with `LootBias=2.0`). Self-registers at init.
- Two surgical bridges so new runs actually traverse the authored graph when the gate is on:
- `startZoneRun` seeds `current_node = g.Entry` instead of `<zone>.r1` when `GOGOBEE_BRANCHING_ZONES=1` and the zone has a hand-authored graph in `zoneGraphRegistry`.
- `DungeonRun.CurrentRoomType()` resolves via `g.Nodes[CurrentNode].Kind` under the same gate, so divergent paths (e.g. the secret_chamber) dispatch to the right room resolver — RoomSeq can't represent off-path nodes.
- Gate off = pre-G7 behavior bit-identical. Legacy zones with the gate on still route through the linear-compiled graph (auto-advance throughout) and use the legacy `<zone>.r{N+1}` node IDs unchanged.
- Tests: registration + boss reachability via both paths, fork edge layout (weight + lock), secret-chamber DC + loot bias, `CurrentRoomType` graph-vs-RoomSeq behavior under both gate states.
- Playtest open: forks render, perception teasers fire, boss reachable via main_hall and side_chapel.
### G8 — Migrate remaining zones one at a time
- Each zone gets its own `zone<Name>Graph()` function.
- Authoring guidelines (drop into `gogobee_dungeon_zones.md` §X):
- Aim for one fork per zone for T1, two forks for T2+.
- Secret rooms should have meaningful loot (LootBias ≥ 1.5) since they cost a Perception roll.
- Locked paths should always have a hint (cruel design otherwise).
- Boss must be reachable from entry without relying on luck (no Perception walls on the only path).
- Per-zone PR pattern:
- Add `zoneXxxGraph()`.
- Test: validator passes, all paths reach boss, no orphan nodes.
- Flip `GOGOBEE_BRANCHING_ZONES=1` for that zone.
- Remove env gate when all zones converted.
### G9 — Retire linear model ✅
- **G9a** — `GOGOBEE_BRANCHING_ZONES` POC gate removed; graph mode is the only runtime path.
- **G9b** — `current_room` / `room_seq_json` no longer persisted. `CurrentRoom` derived from `len(VisitedNodes)-1` on read; `RoomSeq` is in-memory only and unused at runtime.
- **G9c** — Columns dropped from `CREATE TABLE dnd_zone_run`. `purgeLegacyZoneRunColumns()` in `cleanup_g9.go` mirrors the L5 shape (gated by `GOGOBEE_BRANCHING_PURGE=1`, idempotent, runs in adventure init). Operator flips after final soak.
---
## 5. Testing strategy
- **Unit:** validator failure cases, edge unlock evaluation, fork prompt rendering, legacy-zone compiler shape.
- **Integration (in-process DB):** end-to-end run through a forked zone, including Perception-gated branch, secret room discovery, region transition.
- **Prod-DB simulation:** new test in `proddb_simulation_test.go` shape — load a real character, start a graph-mode zone run, exercise a fork choice, verify state persists across reload (overlay-layer parity).
- **Backward compat smoke:** test that a legacy zone (kept on the linear template) renders identically to pre-G7 output.
---
## 6. Risk inventory
| Risk | Mitigation |
|---|---|
| In-flight runs at deploy | G4 dual-write + warm-up populates `current_node` from `current_room` lazily. No run breakage. |
| Validator panics in prod | Run validator in CI; staging soak; `registerZoneGraph` panics at Init so bad authoring never reaches a player. |
| Fork-choice race (player sends `!zone go 1` while another DM is mid-fly) | Same lock mechanism existing zone advance uses (`p.advUserLock`). |
| `!zone map` render churn | Keep legacy renderer as fallback; switch by graph-presence flag during G6. |
| Player frustration with locked edges they can never see | Always emit `Hint` text on locked edges, even when the lock fails — "you sense something" teasers. |
| Regions integration drift | Keep `current_region` updates inside `advanceZoneRunNode`; zone-graph nodes carry `RegionID`, so transitions are derivable. |
---
## 7. Surface inventory (files likely to change)
- `internal/db/db.go` — schema, ALTER TABLEs (G1)
- `internal/plugin/zone_graph.go` — new (G2)
- `internal/plugin/dnd_zone.go` — register graphs alongside zones (G3+G7+G8)
- `internal/plugin/dnd_zone_run.go` (or wherever run state lives) — node-aware loaders/savers (G4)
- `internal/plugin/dnd_zone_cmd.go``!zone go`, fork rendering (G5)
- `internal/plugin/dnd_zone_combat.go` — entry-point shifts from room-idx to node-id (G5)
- `internal/plugin/dnd_zone_narration.go` — cadence reads from `visited_nodes` (G6)
- `internal/plugin/dnd_expedition_harvest.go` — harvest keying (G6)
- `internal/plugin/dnd_expedition_region.go` — region-on-node-transition (G6)
- `internal/plugin/dnd_zone_map.go` (or wherever `!zone map` renders) — graph render (G6)
- `gogobee_dungeon_zones.md` — authoring guidelines update (G8)
---
## 8. Open authoring questions (decide during G7)
1. **Backtracking** — opt-in per-edge or globally off? **Default proposed: off.**
2. **Secret-chamber discovery memory** — once found, persisted to character forever, or per-run? **Default proposed: per-run** (re-roll each entry, keeps replay tension).
3. **Fork timeout** — if player goes idle at a fork, default to first-listed edge after N hours? **Default proposed: yes, 6h, with a "TwinBee picked for you" DM.**
4. **Hidden node visibility on `!zone map`** — show as `?` glyph, hidden entirely, or revealed only after Perception success? **Default proposed: hidden until Perception success.**
---
## 9. First-session checklist
When the new session starts:
1. `git checkout -b branching-zones` off `adv-2.0`.
2. Read this file end-to-end.
3. Read `gogobee_dungeon_zones.md` (existing zone design) + `internal/plugin/dnd_zone.go` for current shape.
4. Run G1 (schema) + G2 (types) + G3 (legacy compiler) + G4 (run state). All four are infra; ship together with no behavior change.
5. Land G5 navigation behind `GOGOBEE_BRANCHING_ZONES=1` env gate.
6. **G6 (done)** — dependent surfaces: harvest re-keyed on `node_id` (with legacy `room_idx` read-fallback + drop-on-save migration), narration cadence salted on `len(visited_nodes)-1` via `narrationCadence(run)`, `!zone map` graph render gated by `GOGOBEE_BRANCHING_ZONES=1` (locked edges → ``, secrets hidden until visited), and a region-boundary hook in graph advance/`!zone go` that mirrors `!region travel` flavor + log without burning supplies (graph is run-state authoritative).
7. **G7 (done)** — POC graph for Crypt of Valdris shipped. `zoneCryptValdrisGraph()` lives in `internal/plugin/zone_graph_crypt_valdris.go` and self-registers at init (8 nodes incl. fork + DC-15 secret reliquary). `startZoneRun` now seeds `current_node = g.Entry` whenever `GOGOBEE_BRANCHING_ZONES=1` and the zone has a hand-authored graph, so new runs traverse the authored topology instead of falling off into `<zone>.r1`. `DungeonRun.CurrentRoomType()` resolves via the live `CurrentNode`'s graph kind under the same gate (essential for divergent paths — RoomSeq can't represent secret_chamber). Gate off = pre-G7 behavior bit-identical.
8. Pause for playtest before G8 mass migration.