From a413c92844ffe6f939710bdcf472c3f830d16efe Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 14:34:05 -0700 Subject: [PATCH] =?UTF-8?q?Branching=20zones=20G1=E2=80=93G4:=20schema,=20?= =?UTF-8?q?graph=20types,=20legacy=20compiler,=20run-state=20dual-write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G1 — schema. Adds zone_node + zone_edge tables and three columns to dnd_zone_run (current_node, visited_nodes, node_choices). Linear columns stay during the migration; G9 retires them. G2 — types + validator. New internal/plugin/zone_graph.go defines ZoneNode/ZoneEdge/ZoneGraph + ZoneNodeKind/ZoneEdgeLockKind. BuildGraph enforces: exactly one entry, exactly one boss, boss reachable via BFS, no orphan nodes, no self-loops without explicit opt-in. BuildLinearGraph synthesizes a chain for legacy zones. G3 — legacy compiler + dual-mode loader. compileLegacyZoneGraph turns a ZoneDefinition into a representative linear graph (MaxRooms shape). loadZoneGraph returns the registered graph if hand-authored (G7+), else the legacy fallback. compileRunGraph mirrors a per-run RoomSeq exactly for hot-swap derivations. G4 — run-state dual-write. DungeonRun gains CurrentNode / VisitedNodes / NodeChoices. scanZoneRun reads them and hot-swaps current_node from current_room when a row predates the migration (deriveLegacyNodeID matches BuildLinearGraph's ".r" scheme). startZoneRun and markRoomCleared write both columns. No behavior change yet — navigation surface (forks, locked edges, !zone go) lands in G5. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/db/db.go | 40 +++ internal/plugin/dnd_zone_run.go | 85 ++++++- internal/plugin/zone_graph.go | 375 +++++++++++++++++++++++++++++ internal/plugin/zone_graph_test.go | 223 +++++++++++++++++ 4 files changed, 718 insertions(+), 5 deletions(-) create mode 100644 internal/plugin/zone_graph.go create mode 100644 internal/plugin/zone_graph_test.go diff --git a/internal/db/db.go b/internal/db/db.go index 9c41bea..a02c49c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -306,6 +306,13 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE player_meta ADD COLUMN title TEXT NOT NULL DEFAULT ''`, `ALTER TABLE player_meta ADD COLUMN treasures_locked INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`, + // Adv 2.0 Phase G1 — branching zone graph run-state columns. + // current_node / visited_nodes / node_choices live alongside the + // legacy current_room / room_seq_json during the migration; the + // linear columns retire in G9 (gogobee_branching_zones_plan.md §2). + `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 '[]'`, + `ALTER TABLE dnd_zone_run ADD COLUMN node_choices TEXT NOT NULL DEFAULT '{}'`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -1809,6 +1816,39 @@ CREATE TABLE IF NOT EXISTS dnd_known_recipe ( PRIMARY KEY (user_id, recipe_id) ); +-- ── Adv 2.0 Phase G1 — branching zone graph (zones as directed graphs) ──── +-- Replaces the linear room_seq_json model with a directed-graph topology. +-- A zone has many nodes (rooms, forks, secrets, boss) and edges connecting +-- them. Edges may be locked behind Perception checks, keys, level gates, +-- or region-clear flags. Authoring lives in Go (registerZoneGraph); these +-- tables are the persisted shape for runtime lookups + future tooling. +-- See gogobee_branching_zones_plan.md §2 for the full schema rationale. +CREATE TABLE IF NOT EXISTS zone_node ( + node_id TEXT PRIMARY KEY, -- "crypt_valdris.entry" + zone_id TEXT NOT NULL, + 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 + is_entry INTEGER NOT NULL DEFAULT 0, + is_boss INTEGER NOT NULL DEFAULT 0, + pos_x INTEGER NOT NULL DEFAULT 0, + pos_y INTEGER NOT NULL DEFAULT 0, + content_json TEXT NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS idx_zone_node_zone ON zone_node(zone_id); + +CREATE TABLE IF NOT EXISTS 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 '{}', + hint TEXT NOT NULL DEFAULT '', + weight INTEGER NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_zone_edge_from ON zone_edge(zone_id, from_node); + -- ── Adv 2.0 Phase L2 step 5 — player_meta ────────────────────────────────── -- Holding pen for non-stat per-user state migrating off adventure_characters -- (gogobee_legacy_migration.md §2.1). Each L-phase ALTER TABLEs in the diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index bb216bd..eb11f63 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -45,6 +45,12 @@ const ( ) // DungeonRun is the in-memory shape of a dnd_zone_run row. +// +// Phase G4 adds CurrentNode / VisitedNodes / NodeChoices alongside the +// legacy CurrentRoom / RoomSeq fields. The graph columns dual-write +// during the migration; readers prefer CurrentNode but fall back to +// deriving it from CurrentRoom + RoomSeq (compileRunGraph) when a row +// predates the migration. The legacy fields retire in G9. type DungeonRun struct { RunID string UserID string @@ -60,6 +66,16 @@ type DungeonRun struct { StartedAt time.Time LastActionAt time.Time CompletedAt *time.Time + + // Phase G4 — branching zone graph run state. CurrentNode is the + // authoritative position once the graph rollout is complete; until + // then it dual-writes with CurrentRoom. VisitedNodes is the ordered + // path of node_ids the player has resolved. NodeChoices stores + // pending fork-prompt state (G5 surface) — populated when the player + // arrives at a fork with 2+ unlocked outgoing edges. + CurrentNode string + VisitedNodes []string + NodeChoices map[string]any } // IsActive reports whether this run is still ongoing (not boss-defeated, @@ -194,12 +210,22 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand) StartedAt: time.Now().UTC(), LastActionAt: time.Now().UTC(), } + // G4 dual-write: persist the entry node id and seed visited_nodes + // with it, so navigation surfaces in G5 can read graph state without + // further migration. + entryNode := deriveLegacyNodeID(zoneID, 0) + visitedJSON, _ := json.Marshal([]string{entryNode}) + run.CurrentNode = entryNode + run.VisitedNodes = []string{entryNode} + run.NodeChoices = map[string]any{} if _, err := db.Get().Exec(` INSERT INTO dnd_zone_run (run_id, user_id, zone_id, current_room, total_rooms, - room_seq_json, rooms_cleared, gm_mood) - VALUES (?, ?, ?, 0, ?, ?, '[]', ?)`, + room_seq_json, rooms_cleared, gm_mood, + current_node, visited_nodes, node_choices) + VALUES (?, ?, ?, 0, ?, ?, '[]', ?, ?, ?, '{}')`, run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON), startMood, + entryNode, string(visitedJSON), ); err != nil { return nil, fmt.Errorf("insert zone run: %w", err) } @@ -219,7 +245,8 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) { row := db.Get().QueryRow(` SELECT run_id, user_id, zone_id, current_room, total_rooms, room_seq_json, rooms_cleared, boss_defeated, abandoned, - loot_collected, gm_mood, started_at, last_action_at, completed_at + loot_collected, gm_mood, started_at, last_action_at, completed_at, + current_node, visited_nodes, node_choices FROM dnd_zone_run WHERE user_id = ? AND completed_at IS NULL @@ -247,7 +274,8 @@ func getZoneRun(runID string) (*DungeonRun, error) { row := db.Get().QueryRow(` SELECT run_id, user_id, zone_id, current_room, total_rooms, room_seq_json, rooms_cleared, boss_defeated, abandoned, - loot_collected, gm_mood, started_at, last_action_at, completed_at + loot_collected, gm_mood, started_at, last_action_at, completed_at, + current_node, visited_nodes, node_choices FROM dnd_zone_run WHERE run_id = ?`, runID) r, err := scanZoneRun(row) if errors.Is(err, sql.ErrNoRows) { @@ -271,11 +299,15 @@ func scanZoneRun(row scanner) (*DungeonRun, error) { bossDefeatedI int abandonedI int completedAtRaw sql.NullTime + currentNode string + visitedJSON string + choicesJSON string ) if err := row.Scan( &r.RunID, &r.UserID, &zoneID, &r.CurrentRoom, &r.TotalRooms, &seqJSON, &clearedJSON, &bossDefeatedI, &abandonedI, &lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw, + ¤tNode, &visitedJSON, &choicesJSON, ); err != nil { return nil, err } @@ -305,9 +337,43 @@ func scanZoneRun(row scanner) (*DungeonRun, error) { if r.LootCollected == nil { r.LootCollected = []string{} } + // G4 graph run state. visited_nodes / node_choices come straight + // from JSON; current_node is hot-swap-derived from current_room + // when empty (rows that predate the migration). + if visitedJSON != "" && visitedJSON != "[]" { + if err := json.Unmarshal([]byte(visitedJSON), &r.VisitedNodes); err != nil { + return nil, fmt.Errorf("decode visited_nodes: %w", err) + } + } + if r.VisitedNodes == nil { + r.VisitedNodes = []string{} + } + if choicesJSON != "" && choicesJSON != "{}" { + if err := json.Unmarshal([]byte(choicesJSON), &r.NodeChoices); err != nil { + return nil, fmt.Errorf("decode node_choices: %w", err) + } + } + if r.NodeChoices == nil { + r.NodeChoices = map[string]any{} + } + r.CurrentNode = currentNode + if r.CurrentNode == "" && len(r.RoomSeq) > 0 { + r.CurrentNode = deriveLegacyNodeID(r.ZoneID, r.CurrentRoom) + } return &r, nil } +// deriveLegacyNodeID returns the node id a linear graph would assign +// to position roomIdx (0-based) for the given zone. Mirrors +// BuildLinearGraph's ".r" scheme so hot-swapped rows align +// with newly-started runs. +func deriveLegacyNodeID(zoneID ZoneID, roomIdx int) string { + if roomIdx < 0 { + roomIdx = 0 + } + return fmt.Sprintf("%s.r%d", zoneID, roomIdx+1) +} + // markRoomCleared records that the current room has been resolved and // advances the player to the next room. Returns the new current room // type (or "" if the run completed via boss kill / final room). @@ -343,17 +409,26 @@ func markRoomCleared(runID string) (RoomType, error) { } return "", nil } + // G4 dual-write: advance current_node + append to visited_nodes + // alongside the legacy current_room bump. + nextNode := deriveLegacyNodeID(r.ZoneID, next) + visited := append(r.VisitedNodes, nextNode) + visitedJSON, _ := json.Marshal(visited) if _, err := db.Get().Exec(` UPDATE dnd_zone_run SET rooms_cleared = ?, current_room = ?, + current_node = ?, + visited_nodes = ?, last_action_at = CURRENT_TIMESTAMP WHERE run_id = ?`, - string(clearedJSON), next, runID, + string(clearedJSON), next, nextNode, string(visitedJSON), runID, ); err != nil { return "", err } r.CurrentRoom = next + r.CurrentNode = nextNode + r.VisitedNodes = visited return r.RoomSeq[next], nil } diff --git a/internal/plugin/zone_graph.go b/internal/plugin/zone_graph.go new file mode 100644 index 0000000..ce78b08 --- /dev/null +++ b/internal/plugin/zone_graph.go @@ -0,0 +1,375 @@ +package plugin + +// Phase G2 — branching zone graph types + validator. +// +// See gogobee_branching_zones_plan.md §2-§3. Zones author their topology +// as a directed graph of ZoneNode + ZoneEdge. The graph is registered +// alongside ZoneDefinition; runtime navigation lives in later phases. +// +// G2 is infra only: types, builders, validator, registry. Nothing reads +// these graphs yet. + +import ( + "errors" + "fmt" +) + +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" + NodeKindMerge ZoneNodeKind = "merge" +) + +// ZoneNodeContent — typed wrapper around the persisted content_json blob. +// EncounterOverride pins a specific bestiary id; if empty, the zone's +// roster roll is used. HarvestRef points at a harvest table id (G6 +// rewires dnd_expedition_harvest off room_idx onto node_id). LootBias +// multiplies the end-of-room loot roll (Secret rooms typically ≥ 1.5). +// Narration is bespoke entry text that overrides the zone-level pool. +// AllowSelfLoop opts out of the validator's self-loop ban. +type ZoneNodeContent struct { + EncounterOverride string + HarvestRef string + LootBias float64 + Narration string + AllowSelfLoop bool +} + +type ZoneNode struct { + NodeID string + ZoneID ZoneID + RegionID string + Kind ZoneNodeKind + Label string + IsEntry bool + IsBoss bool + PosX int + PosY int + Content ZoneNodeContent +} + +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" +) + +type ZoneEdge struct { + From string + To string + Lock ZoneEdgeLockKind + LockData map[string]any + Hint string + Weight int +} + +// ZoneGraph — fully validated graph for a zone. Nodes is keyed by +// NodeID; Edges is keyed by from-node so outgoing-edge lookup is O(1). +// Entry/Boss are the canonical node IDs; the validator guarantees both +// exist and that Boss is reachable from Entry. +type ZoneGraph struct { + ZoneID ZoneID + Nodes map[string]ZoneNode + Edges map[string][]ZoneEdge + Entry string + Boss string +} + +// BuildLinearGraph compiles a flat room sequence (the existing model) +// into a graph with N-1 unconditional edges. Used by the legacy zone +// compiler (G3) and by zones that intentionally stay linear. Node IDs +// are zoneID-prefixed and 1-indexed: ".r1", ".r2", ... +// First node is entry, last node is boss; if seq is empty or has no +// boss-typed final entry, the caller is expected to coerce. +func BuildLinearGraph(zoneID ZoneID, seq []ZoneNodeKind) ZoneGraph { + g := ZoneGraph{ + ZoneID: zoneID, + Nodes: map[string]ZoneNode{}, + Edges: map[string][]ZoneEdge{}, + } + if len(seq) == 0 { + return g + } + ids := make([]string, len(seq)) + for i, kind := range seq { + id := fmt.Sprintf("%s.r%d", zoneID, i+1) + ids[i] = id + n := ZoneNode{ + NodeID: id, + ZoneID: zoneID, + Kind: kind, + PosX: i, + } + if i == 0 { + n.IsEntry = true + n.Kind = NodeKindEntry + } + if i == len(seq)-1 { + n.IsBoss = true + n.Kind = NodeKindBoss + } + g.Nodes[id] = n + } + for i := 0; i < len(ids)-1; i++ { + g.Edges[ids[i]] = []ZoneEdge{{From: ids[i], To: ids[i+1], Lock: LockNone, Weight: 1}} + } + g.Entry = ids[0] + g.Boss = ids[len(ids)-1] + return g +} + +// BuildGraph is the explicit authoring path: pass nodes + edges +// directly. Validates: exactly one entry, exactly one boss, boss +// reachable from entry, no orphan nodes (every non-entry node has at +// least one incoming edge), no self-loops unless the node opts in via +// Content.AllowSelfLoop. Panics on invalid input — bad authoring +// should never reach a player. +func BuildGraph(zoneID ZoneID, nodes []ZoneNode, edges []ZoneEdge) ZoneGraph { + g := ZoneGraph{ + ZoneID: zoneID, + Nodes: make(map[string]ZoneNode, len(nodes)), + Edges: map[string][]ZoneEdge{}, + } + for _, n := range nodes { + n.ZoneID = zoneID + if _, dup := g.Nodes[n.NodeID]; dup { + panic(fmt.Sprintf("duplicate node id %q in zone %q", n.NodeID, zoneID)) + } + g.Nodes[n.NodeID] = n + if n.IsEntry { + g.Entry = n.NodeID + } + if n.IsBoss { + g.Boss = n.NodeID + } + } + for _, e := range edges { + if e.Weight == 0 { + e.Weight = 1 + } + if e.Lock == "" { + e.Lock = LockNone + } + g.Edges[e.From] = append(g.Edges[e.From], e) + } + if err := validateZoneGraph(g); err != nil { + panic(fmt.Sprintf("invalid zone graph for %q: %v", zoneID, err)) + } + return g +} + +// validateZoneGraph enforces the structural invariants. Surfaced as a +// returned error so unit tests can exercise failure modes; BuildGraph +// converts the error into a panic at registration time. +func validateZoneGraph(g ZoneGraph) error { + if len(g.Nodes) == 0 { + return errors.New("graph has no nodes") + } + var entries, bosses int + for _, n := range g.Nodes { + if n.IsEntry { + entries++ + } + if n.IsBoss { + bosses++ + } + } + if entries != 1 { + return fmt.Errorf("expected exactly 1 entry node, got %d", entries) + } + if bosses != 1 { + return fmt.Errorf("expected exactly 1 boss node, got %d", bosses) + } + // Edges must reference known nodes. Self-loops require opt-in. + for from, outs := range g.Edges { + if _, ok := g.Nodes[from]; !ok { + return fmt.Errorf("edge from unknown node %q", from) + } + for _, e := range outs { + if _, ok := g.Nodes[e.To]; !ok { + return fmt.Errorf("edge %q→%q targets unknown node", from, e.To) + } + if e.From == e.To && !g.Nodes[from].Content.AllowSelfLoop { + return fmt.Errorf("self-loop on node %q without AllowSelfLoop opt-in", from) + } + } + } + // No orphan nodes: every non-entry node must have at least one + // incoming edge. + incoming := map[string]int{} + for _, outs := range g.Edges { + for _, e := range outs { + incoming[e.To]++ + } + } + for id, n := range g.Nodes { + if n.IsEntry { + continue + } + if incoming[id] == 0 { + return fmt.Errorf("orphan node %q has no incoming edges", id) + } + } + // Boss reachable from entry via BFS. + if !reachable(g, g.Entry, g.Boss) { + return fmt.Errorf("boss %q not reachable from entry %q", g.Boss, g.Entry) + } + return nil +} + +func reachable(g ZoneGraph, from, to string) bool { + if from == to { + return true + } + seen := map[string]bool{from: true} + queue := []string{from} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for _, e := range g.Edges[cur] { + if e.To == to { + return true + } + if !seen[e.To] { + seen[e.To] = true + queue = append(queue, e.To) + } + } + } + return false +} + +// outgoingEdges returns the registered out-edges for a node in stable +// (Weight asc, then To asc) order, so fork prompts render +// deterministically. Consumers must apply lock evaluation themselves. +func (g ZoneGraph) outgoingEdges(from string) []ZoneEdge { + src := g.Edges[from] + out := make([]ZoneEdge, len(src)) + copy(out, src) + // Stable sort: weight asc, then To asc. Avoids importing sort for + // what is almost always a 1-3 element slice. + for i := 1; i < len(out); i++ { + for j := i; j > 0; j-- { + a, b := out[j-1], out[j] + less := a.Weight < b.Weight || (a.Weight == b.Weight && a.To < b.To) + if less { + break + } + out[j-1], out[j] = b, a + } + } + return out +} + +// zoneGraphRegistry — populated by registerZoneGraph at init (POC zones +// in G7 onward) and by the legacy compiler below (G3) for every other +// zone. loadZoneGraph returns the registered graph if present, else +// falls back to the linear graph synthesized from the ZoneDefinition. +var zoneGraphRegistry = map[ZoneID]ZoneGraph{} + +func registerZoneGraph(g ZoneGraph) { + if _, dup := zoneGraphRegistry[g.ZoneID]; dup { + panic("duplicate zone graph: " + string(g.ZoneID)) + } + if err := validateZoneGraph(g); err != nil { + panic("invalid zone graph for " + string(g.ZoneID) + ": " + err.Error()) + } + zoneGraphRegistry[g.ZoneID] = g +} + +// compileLegacyZoneGraph synthesizes a linear graph from a +// ZoneDefinition that hasn't been hand-authored as a branching graph. +// Uses the canonical room pattern from generateRoomSequence +// (entry → exploration ×N₁ → trap → exploration ×N₂ → elite → boss) +// with N₁+N₂ chosen so total = MaxRooms. The exact per-run sequence +// still varies in length; this representative graph exists so every +// zone has a graph at boot for the validator and runtime fallbacks. +// G4's run-state hot-swap uses the per-run RoomSeq when it needs the +// authoritative shape. +func compileLegacyZoneGraph(z ZoneDefinition) ZoneGraph { + total := z.MaxRooms + const fixed = 4 // entry + trap + elite + boss + if total < fixed+2 { + total = fixed + 2 + } + exps := total - fixed + preTrap := exps / 2 + if preTrap < 1 { + preTrap = 1 + } + postTrap := exps - preTrap + if postTrap < 1 { + postTrap = 1 + preTrap = exps - postTrap + } + seq := make([]ZoneNodeKind, 0, total) + seq = append(seq, NodeKindEntry) + for i := 0; i < preTrap; i++ { + seq = append(seq, NodeKindExploration) + } + seq = append(seq, NodeKindTrap) + for i := 0; i < postTrap; i++ { + seq = append(seq, NodeKindExploration) + } + seq = append(seq, NodeKindElite, NodeKindBoss) + return BuildLinearGraph(z.ID, seq) +} + +// compileRunGraph builds a linear graph that exactly mirrors a +// DungeonRun's RoomSeq. Used by the G4 hot-swap path to derive a +// node id from current_room when a row predates current_node. The +// returned graph is single-use (per run) and not registered. +func compileRunGraph(zoneID ZoneID, seq []RoomType) ZoneGraph { + if len(seq) == 0 { + return ZoneGraph{ZoneID: zoneID, Nodes: map[string]ZoneNode{}, Edges: map[string][]ZoneEdge{}} + } + kinds := make([]ZoneNodeKind, len(seq)) + for i, rt := range seq { + kinds[i] = roomTypeToNodeKind(rt) + } + return BuildLinearGraph(zoneID, kinds) +} + +func roomTypeToNodeKind(rt RoomType) ZoneNodeKind { + switch rt { + case RoomEntry: + return NodeKindEntry + case RoomExploration: + return NodeKindExploration + case RoomTrap: + return NodeKindTrap + case RoomElite: + return NodeKindElite + case RoomBoss: + return NodeKindBoss + } + return NodeKindExploration +} + +// loadZoneGraph returns the graph for a zone. Registered (hand-authored) +// graphs take precedence; otherwise the legacy linear compiler is used. +// Returns ok=false only for unknown zone IDs. +func loadZoneGraph(zoneID ZoneID) (ZoneGraph, bool) { + if g, ok := zoneGraphRegistry[zoneID]; ok { + return g, true + } + z, ok := getZone(zoneID) + if !ok { + return ZoneGraph{}, false + } + return compileLegacyZoneGraph(z), true +} diff --git a/internal/plugin/zone_graph_test.go b/internal/plugin/zone_graph_test.go new file mode 100644 index 0000000..e56c42e --- /dev/null +++ b/internal/plugin/zone_graph_test.go @@ -0,0 +1,223 @@ +package plugin + +import ( + "strings" + "testing" +) + +func TestBuildLinearGraph_BasicShape(t *testing.T) { + seq := []ZoneNodeKind{ + NodeKindEntry, + NodeKindExploration, + NodeKindElite, + NodeKindBoss, + } + g := BuildLinearGraph("test_zone", seq) + if err := validateZoneGraph(g); err != nil { + t.Fatalf("linear graph should validate: %v", err) + } + if len(g.Nodes) != 4 { + t.Fatalf("want 4 nodes, got %d", len(g.Nodes)) + } + if g.Entry == "" || g.Boss == "" { + t.Fatalf("entry/boss not set: entry=%q boss=%q", g.Entry, g.Boss) + } + if !reachable(g, g.Entry, g.Boss) { + t.Fatalf("boss unreachable from entry") + } +} + +func TestBuildLinearGraph_Empty(t *testing.T) { + g := BuildLinearGraph("empty", nil) + if err := validateZoneGraph(g); err == nil { + t.Fatalf("empty graph should fail validation") + } +} + +func TestValidateZoneGraph_OrphanNode(t *testing.T) { + nodes := []ZoneNode{ + {NodeID: "e", Kind: NodeKindEntry, IsEntry: true}, + {NodeID: "b", Kind: NodeKindBoss, IsBoss: true}, + {NodeID: "orphan", Kind: NodeKindExploration}, + } + edges := []ZoneEdge{{From: "e", To: "b"}} + g := ZoneGraph{ + ZoneID: "z", + Nodes: map[string]ZoneNode{}, + Edges: map[string][]ZoneEdge{}, + } + for _, n := range nodes { + g.Nodes[n.NodeID] = n + if n.IsEntry { + g.Entry = n.NodeID + } + if n.IsBoss { + g.Boss = n.NodeID + } + } + for _, e := range edges { + g.Edges[e.From] = append(g.Edges[e.From], e) + } + err := validateZoneGraph(g) + if err == nil || !strings.Contains(err.Error(), "orphan") { + t.Fatalf("expected orphan error, got %v", err) + } +} + +func TestValidateZoneGraph_BossUnreachable(t *testing.T) { + // b has an incoming self-loop (so it's not orphaned) but is not + // reachable from entry — exercises the BFS reachability check. + g := ZoneGraph{ + ZoneID: "z", + Nodes: map[string]ZoneNode{ + "e": {NodeID: "e", Kind: NodeKindEntry, IsEntry: true}, + "b": {NodeID: "b", Kind: NodeKindBoss, IsBoss: true, + Content: ZoneNodeContent{AllowSelfLoop: true}}, + }, + Edges: map[string][]ZoneEdge{ + "b": {{From: "b", To: "b"}}, + }, + Entry: "e", + Boss: "b", + } + err := validateZoneGraph(g) + if err == nil || !strings.Contains(err.Error(), "not reachable") { + t.Fatalf("expected unreachable error, got %v", err) + } +} + +func TestValidateZoneGraph_TwoEntries(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic on two entries") + } + }() + BuildGraph("z", + []ZoneNode{ + {NodeID: "e1", Kind: NodeKindEntry, IsEntry: true}, + {NodeID: "e2", Kind: NodeKindEntry, IsEntry: true}, + {NodeID: "b", Kind: NodeKindBoss, IsBoss: true}, + }, + []ZoneEdge{ + {From: "e1", To: "b"}, + {From: "e2", To: "b"}, + }, + ) +} + +func TestValidateZoneGraph_SelfLoopRejected(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic on self-loop without opt-in") + } + }() + BuildGraph("z", + []ZoneNode{ + {NodeID: "e", Kind: NodeKindEntry, IsEntry: true}, + {NodeID: "b", Kind: NodeKindBoss, IsBoss: true}, + }, + []ZoneEdge{ + {From: "e", To: "e"}, + {From: "e", To: "b"}, + }, + ) +} + +func TestValidateZoneGraph_SelfLoopAllowed(t *testing.T) { + g := BuildGraph("z", + []ZoneNode{ + {NodeID: "e", Kind: NodeKindEntry, IsEntry: true, + Content: ZoneNodeContent{AllowSelfLoop: true}}, + {NodeID: "b", Kind: NodeKindBoss, IsBoss: true}, + }, + []ZoneEdge{ + {From: "e", To: "e"}, + {From: "e", To: "b"}, + }, + ) + if err := validateZoneGraph(g); err != nil { + t.Fatalf("self-loop with opt-in should validate: %v", err) + } +} + +func TestBuildGraph_BranchedShape(t *testing.T) { + g := BuildGraph("crypt", + []ZoneNode{ + {NodeID: "entry", Kind: NodeKindEntry, IsEntry: true}, + {NodeID: "fork", Kind: NodeKindFork}, + {NodeID: "left", Kind: NodeKindElite}, + {NodeID: "right", Kind: NodeKindExploration}, + {NodeID: "merge", Kind: NodeKindMerge}, + {NodeID: "boss", Kind: NodeKindBoss, IsBoss: true}, + }, + []ZoneEdge{ + {From: "entry", To: "fork"}, + {From: "fork", To: "left", Weight: 1}, + {From: "fork", To: "right", Weight: 2, + Lock: LockPerception, LockData: map[string]any{"dc": 12}}, + {From: "left", To: "merge"}, + {From: "right", To: "merge"}, + {From: "merge", To: "boss"}, + }, + ) + outs := g.outgoingEdges("fork") + if len(outs) != 2 { + t.Fatalf("want 2 fork edges, got %d", len(outs)) + } + if outs[0].To != "left" { + t.Fatalf("weight ordering broken: %v", outs) + } +} + +func TestCompileLegacyZoneGraph_AllRegistered(t *testing.T) { + for _, z := range allZones() { + g, ok := loadZoneGraph(z.ID) + if !ok { + t.Errorf("loadZoneGraph(%q): not ok", z.ID) + continue + } + if err := validateZoneGraph(g); err != nil { + t.Errorf("zone %q: legacy graph invalid: %v", z.ID, err) + } + if g.Entry == "" || g.Boss == "" { + t.Errorf("zone %q: missing entry/boss", z.ID) + } + // Boss reachability is enforced by validate, but assert again for + // clarity. + if !reachable(g, g.Entry, g.Boss) { + t.Errorf("zone %q: boss unreachable from entry", z.ID) + } + } +} + +func TestDeriveLegacyNodeID_StableShape(t *testing.T) { + got := deriveLegacyNodeID("crypt_valdris", 0) + want := "crypt_valdris.r1" + if got != want { + t.Fatalf("want %q, got %q", want, got) + } + got = deriveLegacyNodeID("crypt_valdris", 4) + want = "crypt_valdris.r5" + if got != want { + t.Fatalf("want %q, got %q", want, got) + } +} + +func TestRegisterZoneGraph_DuplicateRejected(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic on duplicate registration") + } + // scrub registry side-effect from this test + delete(zoneGraphRegistry, "dup_test") + }() + g := BuildGraph("dup_test", + []ZoneNode{ + {NodeID: "e", Kind: NodeKindEntry, IsEntry: true}, + {NodeID: "b", Kind: NodeKindBoss, IsBoss: true}, + }, + []ZoneEdge{{From: "e", To: "b"}}, + ) + registerZoneGraph(g) + registerZoneGraph(g) +}