Branching zones G5: navigation surface behind GOGOBEE_BRANCHING_ZONES=1

Wires the graph types from G2/G3/G4 into the !zone advance flow.
Default off; with the gate on, room-clear branches on outgoing-edge
count instead of the linear current_room+1 walk.

zone_graph_nav.go — env gate, edge unlock evaluation
(LockNone/Perception/Key/LevelMin/RegionClear/StatCheck), pendingFork
JSON shape with encode/decode helpers, perception roll seeded
deterministically from (runID, from, to) so reload doesn't grant
retries, plus fork prompt rendering and the
recordRoomCleared/advanceZoneRunNode/completeRunAtNode persistence
trio.

dnd_zone_cmd_graph.go — advanceTransitionGraph drives the graph-mode
branch in zoneCmdAdvance: 0 outs → run completes (boss_defeated set
when current node IsBoss); 1 unlocked out → auto-advance and emit
the same teaser the legacy path uses; 2+ outs (or a single locked
out) → write pending fork to node_choices and render the menu. New
!zone go <n> / !zone choose <n> consumes the prompt, validates the
chosen edge is unlocked, advances, and emits the arrival teaser.
Old "go" alias for "enter" was unused in tests — repurposed.

dnd_zone_cmd.go — extracts formatNextRoomMessage so the legacy and
graph-mode paths share the post-advance teaser. Adds the new
subcommand to the dispatch switch and help text.

Tests: pure-logic coverage for unlock evaluation, perception
determinism, pendingFork roundtrip + decode-edge-cases, choice
resolution, fork prompt rendering with hint vs no-hint locked
options, edge ordering by (Weight, To), and the env gate. Existing
zone tests pass with and without the gate set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 14:49:22 -07:00
parent a413c92844
commit 2d249d7d0a
4 changed files with 928 additions and 3 deletions

View File

@@ -0,0 +1,257 @@
package plugin
import (
"strings"
"testing"
)
func TestEvaluateEdgeLock_None(t *testing.T) {
e := ZoneEdge{From: "a", To: "b", Lock: LockNone}
ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{})
if !ok {
t.Fatal("LockNone should always unlock")
}
}
func TestEvaluateEdgeLock_Perception(t *testing.T) {
e := ZoneEdge{
From: "a",
To: "b",
Lock: LockPerception,
LockData: map[string]any{"dc": 1}, // trivially passes
}
ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{
RunID: "run", FromNode: "a",
AbilityMods: [6]int{0, 0, 0, 0, 5, 0},
})
if !ok {
t.Fatal("Perception roll+5 vs DC 1 should unlock")
}
// DC 99 + bad WIS → never unlock
e.LockData = map[string]any{"dc": 99}
ok, reason := evaluateEdgeLock(e, edgeUnlockCtx{
RunID: "run", FromNode: "a",
AbilityMods: [6]int{0, 0, 0, 0, -2, 0},
})
if ok {
t.Fatal("Perception vs DC 99 should fail")
}
if !strings.Contains(reason, "Perception") {
t.Errorf("reason = %q, want Perception phrasing", reason)
}
}
func TestPerceptionRollForEdge_Deterministic(t *testing.T) {
r1 := perceptionRollForEdge("run-x", "a", "b")
r2 := perceptionRollForEdge("run-x", "a", "b")
if r1 != r2 {
t.Fatalf("perception roll non-deterministic: %d vs %d", r1, r2)
}
if r1 < 1 || r1 > 20 {
t.Errorf("roll %d out of 1..20", r1)
}
r3 := perceptionRollForEdge("run-y", "a", "b")
r4 := perceptionRollForEdge("run-x", "a", "c")
// Just sanity-check the seed actually mixes inputs.
if r1 == r3 && r1 == r4 {
t.Error("perception roll insensitive to seed inputs")
}
}
func TestEvaluateEdgeLock_Key(t *testing.T) {
e := ZoneEdge{
From: "a",
To: "b",
Lock: LockKey,
LockData: map[string]any{"key_id": "Brass Key"},
}
ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{
InventoryNames: map[string]bool{"brass key": true},
})
if !ok {
t.Fatal("matching key should unlock")
}
ok, reason := evaluateEdgeLock(e, edgeUnlockCtx{
InventoryNames: map[string]bool{},
})
if ok {
t.Fatal("missing key should not unlock")
}
if !strings.Contains(reason, "key") {
t.Errorf("reason = %q", reason)
}
}
func TestEvaluateEdgeLock_LevelMin(t *testing.T) {
e := ZoneEdge{From: "a", To: "b", Lock: LockLevelMin, LockData: map[string]any{"min_level": 5}}
if ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{CharLevel: 5}); !ok {
t.Fatal("level 5 should pass min 5")
}
if ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{CharLevel: 4}); ok {
t.Fatal("level 4 should fail min 5")
}
}
func TestEvaluateEdgeLock_StatCheck(t *testing.T) {
e := ZoneEdge{
From: "a", To: "b", Lock: LockStatCheck,
LockData: map[string]any{"stat": "STR", "dc": 1},
}
ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{
RunID: "x", FromNode: "a",
AbilityMods: [6]int{5, 0, 0, 0, 0, 0},
})
if !ok {
t.Fatal("STR+5 vs DC 1 should unlock")
}
}
func TestPendingFork_Roundtrip(t *testing.T) {
pf := pendingFork{
PendingAt: "fork",
Options: []pendingChoice{
{Index: 1, To: "left", Label: "Left", Unlocked: true},
{Index: 2, To: "right", Label: "Right", Unlocked: false, Hint: "you sense a draft", Reason: "Perception 4 vs DC 12"},
},
}
m, err := encodePendingFork(pf)
if err != nil {
t.Fatalf("encode: %v", err)
}
got, err := decodePendingFork(m)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got == nil || got.PendingAt != "fork" || len(got.Options) != 2 {
t.Fatalf("decoded pendingFork mismatch: %+v", got)
}
if got.Options[0].To != "left" || got.Options[1].Hint != "you sense a draft" {
t.Errorf("decoded options bad: %+v", got.Options)
}
}
func TestDecodePendingFork_Empty(t *testing.T) {
if pf, _ := decodePendingFork(nil); pf != nil {
t.Error("nil map should decode to nil fork")
}
if pf, _ := decodePendingFork(map[string]any{}); pf != nil {
t.Error("empty map should decode to nil fork")
}
// Non-fork content (some future schema in the same column) is also nil.
if pf, _ := decodePendingFork(map[string]any{"unrelated": 1}); pf != nil {
t.Error("non-fork content should decode to nil fork")
}
}
func TestResolveForkChoice(t *testing.T) {
pf := &pendingFork{
PendingAt: "fork",
Options: []pendingChoice{
{Index: 1, To: "left", Unlocked: true},
{Index: 2, To: "right", Unlocked: false, Reason: "DC 12"},
},
}
if c, err := resolveForkChoice(pf, 1); err != nil || c.To != "left" {
t.Errorf("choice 1 = %+v err=%v", c, err)
}
if _, err := resolveForkChoice(pf, 2); err == nil {
t.Error("locked choice should error")
}
if _, err := resolveForkChoice(pf, 3); err == nil {
t.Error("out-of-range should error")
}
if _, err := resolveForkChoice(nil, 1); err == nil {
t.Error("nil fork should error")
}
}
func TestRenderForkPrompt_LockedHint(t *testing.T) {
zone := ZoneDefinition{ID: "z", Display: "Test"}
pf := pendingFork{
Options: []pendingChoice{
{Index: 1, Label: "Left", Unlocked: true},
{Index: 2, Label: "Right", Unlocked: false, Hint: "you sense a draft"},
{Index: 3, Label: "Hidden", Unlocked: false},
},
}
got := renderForkPrompt(zone, pf)
if !strings.Contains(got, "**1.** Left") {
t.Errorf("unlocked option not rendered: %q", got)
}
if !strings.Contains(got, "you sense a draft") {
t.Errorf("hint not rendered: %q", got)
}
if !strings.Contains(got, "**3.** Hidden _(locked)_") {
t.Errorf("plain locked option not rendered: %q", got)
}
}
func TestEvaluateForkEdges_OrdersByGraph(t *testing.T) {
g := BuildGraph("z",
[]ZoneNode{
{NodeID: "e", IsEntry: true, Kind: NodeKindEntry, Label: "Entry"},
{NodeID: "fork", Kind: NodeKindFork, Label: "Fork"},
{NodeID: "left", Kind: NodeKindExploration, Label: "Left Hall"},
{NodeID: "right", Kind: NodeKindExploration, Label: "Right Hall"},
{NodeID: "b", IsBoss: true, Kind: NodeKindBoss, Label: "Boss"},
},
[]ZoneEdge{
{From: "e", To: "fork"},
{From: "fork", To: "right", Weight: 2,
Lock: LockLevelMin, LockData: map[string]any{"min_level": 99}},
{From: "fork", To: "left", Weight: 1},
{From: "left", To: "b"},
{From: "right", To: "b"},
},
)
ctx := edgeUnlockCtx{RunID: "run-1", FromNode: "fork", CharLevel: 5}
choices := evaluateForkEdges(g, "fork", ctx)
if len(choices) != 2 {
t.Fatalf("want 2 choices, got %d", len(choices))
}
// Lowest weight first → left.
if choices[0].To != "left" || !choices[0].Unlocked {
t.Errorf("choice 0 = %+v", choices[0])
}
if choices[1].To != "right" || choices[1].Unlocked {
t.Errorf("choice 1 = %+v", choices[1])
}
if choices[0].Label != "Left Hall" {
t.Errorf("label not propagated: %q", choices[0].Label)
}
}
func TestNodeKindToRoomType(t *testing.T) {
cases := map[ZoneNodeKind]RoomType{
NodeKindEntry: RoomEntry,
NodeKindExploration: RoomExploration,
NodeKindSecret: RoomExploration,
NodeKindHarvest: RoomExploration,
NodeKindFork: RoomExploration,
NodeKindMerge: RoomExploration,
NodeKindTrap: RoomTrap,
NodeKindElite: RoomElite,
NodeKindBoss: RoomBoss,
}
for k, want := range cases {
if got := nodeKindToRoomType(k); got != want {
t.Errorf("%s → %s, want %s", k, got, want)
}
}
}
func TestBranchingZonesGate(t *testing.T) {
t.Setenv("GOGOBEE_BRANCHING_ZONES", "")
if branchingZonesEnabled() {
t.Error("gate should be off when env empty")
}
t.Setenv("GOGOBEE_BRANCHING_ZONES", "1")
if !branchingZonesEnabled() {
t.Error("gate should be on when env=1")
}
t.Setenv("GOGOBEE_BRANCHING_ZONES", "0")
if branchingZonesEnabled() {
t.Error("gate should be off when env=0")
}
}