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 } // graphLongestPath returns the number of nodes on the longest simple // path from Entry to Boss. Used by generateRoomSequence so the // "Room X/Y" display tracks the actual graph traversal length rather // than a dice-rolled stub. Returns 0 if the graph has no entry/boss. func graphLongestPath(g ZoneGraph) int { if g.Entry == "" || g.Boss == "" { return 0 } memo := map[string]int{} var dfs func(node string, onPath map[string]bool) int dfs = func(node string, onPath map[string]bool) int { if node == g.Boss { return 1 } if v, ok := memo[node]; ok && !onPath[node] { // memo is safe only when the cached subpath doesn't // revisit a node currently on the active path. With DAG // zones (the norm) this is always safe; the onPath guard // keeps us correct if a future zone authors a cycle. return v } best := 0 for _, e := range g.Edges[node] { if onPath[e.To] { continue } onPath[e.To] = true sub := dfs(e.To, onPath) delete(onPath, e.To) if sub > 0 && sub+1 > best { best = sub + 1 } } memo[node] = best return best } return dfs(g.Entry, map[string]bool{g.Entry: true}) } // 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 }