mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Branching zones G1–G4: schema, graph types, legacy compiler, run-state dual-write
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 "<zone>.r<n>" 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 title TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE player_meta ADD COLUMN treasures_locked INTEGER NOT NULL DEFAULT 0`,
|
`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`,
|
`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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
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)
|
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 ──────────────────────────────────
|
-- ── Adv 2.0 Phase L2 step 5 — player_meta ──────────────────────────────────
|
||||||
-- Holding pen for non-stat per-user state migrating off adventure_characters
|
-- 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
|
-- (gogobee_legacy_migration.md §2.1). Each L-phase ALTER TABLEs in the
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// DungeonRun is the in-memory shape of a dnd_zone_run row.
|
// 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 {
|
type DungeonRun struct {
|
||||||
RunID string
|
RunID string
|
||||||
UserID string
|
UserID string
|
||||||
@@ -60,6 +66,16 @@ type DungeonRun struct {
|
|||||||
StartedAt time.Time
|
StartedAt time.Time
|
||||||
LastActionAt time.Time
|
LastActionAt time.Time
|
||||||
CompletedAt *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,
|
// 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(),
|
StartedAt: time.Now().UTC(),
|
||||||
LastActionAt: 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(`
|
if _, err := db.Get().Exec(`
|
||||||
INSERT INTO dnd_zone_run
|
INSERT INTO dnd_zone_run
|
||||||
(run_id, user_id, zone_id, current_room, total_rooms,
|
(run_id, user_id, zone_id, current_room, total_rooms,
|
||||||
room_seq_json, rooms_cleared, gm_mood)
|
room_seq_json, rooms_cleared, gm_mood,
|
||||||
VALUES (?, ?, ?, 0, ?, ?, '[]', ?)`,
|
current_node, visited_nodes, node_choices)
|
||||||
|
VALUES (?, ?, ?, 0, ?, ?, '[]', ?, ?, ?, '{}')`,
|
||||||
run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON), startMood,
|
run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON), startMood,
|
||||||
|
entryNode, string(visitedJSON),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("insert zone run: %w", err)
|
return nil, fmt.Errorf("insert zone run: %w", err)
|
||||||
}
|
}
|
||||||
@@ -219,7 +245,8 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
|
|||||||
row := db.Get().QueryRow(`
|
row := db.Get().QueryRow(`
|
||||||
SELECT run_id, user_id, zone_id, current_room, total_rooms,
|
SELECT run_id, user_id, zone_id, current_room, total_rooms,
|
||||||
room_seq_json, rooms_cleared, boss_defeated, abandoned,
|
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
|
FROM dnd_zone_run
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
AND completed_at IS NULL
|
AND completed_at IS NULL
|
||||||
@@ -247,7 +274,8 @@ func getZoneRun(runID string) (*DungeonRun, error) {
|
|||||||
row := db.Get().QueryRow(`
|
row := db.Get().QueryRow(`
|
||||||
SELECT run_id, user_id, zone_id, current_room, total_rooms,
|
SELECT run_id, user_id, zone_id, current_room, total_rooms,
|
||||||
room_seq_json, rooms_cleared, boss_defeated, abandoned,
|
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)
|
FROM dnd_zone_run WHERE run_id = ?`, runID)
|
||||||
r, err := scanZoneRun(row)
|
r, err := scanZoneRun(row)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -271,11 +299,15 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
|
|||||||
bossDefeatedI int
|
bossDefeatedI int
|
||||||
abandonedI int
|
abandonedI int
|
||||||
completedAtRaw sql.NullTime
|
completedAtRaw sql.NullTime
|
||||||
|
currentNode string
|
||||||
|
visitedJSON string
|
||||||
|
choicesJSON string
|
||||||
)
|
)
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&r.RunID, &r.UserID, &zoneID, &r.CurrentRoom, &r.TotalRooms,
|
&r.RunID, &r.UserID, &zoneID, &r.CurrentRoom, &r.TotalRooms,
|
||||||
&seqJSON, &clearedJSON, &bossDefeatedI, &abandonedI,
|
&seqJSON, &clearedJSON, &bossDefeatedI, &abandonedI,
|
||||||
&lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw,
|
&lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw,
|
||||||
|
¤tNode, &visitedJSON, &choicesJSON,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -305,9 +337,43 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
|
|||||||
if r.LootCollected == nil {
|
if r.LootCollected == nil {
|
||||||
r.LootCollected = []string{}
|
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
|
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 "<zone>.r<n>" 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
|
// markRoomCleared records that the current room has been resolved and
|
||||||
// advances the player to the next room. Returns the new current room
|
// advances the player to the next room. Returns the new current room
|
||||||
// type (or "" if the run completed via boss kill / final room).
|
// type (or "" if the run completed via boss kill / final room).
|
||||||
@@ -343,17 +409,26 @@ func markRoomCleared(runID string) (RoomType, error) {
|
|||||||
}
|
}
|
||||||
return "", nil
|
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(`
|
if _, err := db.Get().Exec(`
|
||||||
UPDATE dnd_zone_run
|
UPDATE dnd_zone_run
|
||||||
SET rooms_cleared = ?,
|
SET rooms_cleared = ?,
|
||||||
current_room = ?,
|
current_room = ?,
|
||||||
|
current_node = ?,
|
||||||
|
visited_nodes = ?,
|
||||||
last_action_at = CURRENT_TIMESTAMP
|
last_action_at = CURRENT_TIMESTAMP
|
||||||
WHERE run_id = ?`,
|
WHERE run_id = ?`,
|
||||||
string(clearedJSON), next, runID,
|
string(clearedJSON), next, nextNode, string(visitedJSON), runID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
r.CurrentRoom = next
|
r.CurrentRoom = next
|
||||||
|
r.CurrentNode = nextNode
|
||||||
|
r.VisitedNodes = visited
|
||||||
return r.RoomSeq[next], nil
|
return r.RoomSeq[next], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
375
internal/plugin/zone_graph.go
Normal file
375
internal/plugin/zone_graph.go
Normal file
@@ -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: "<zone>.r1", "<zone>.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
|
||||||
|
}
|
||||||
223
internal/plugin/zone_graph_test.go
Normal file
223
internal/plugin/zone_graph_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user