mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
258 lines
7.3 KiB
Go
258 lines
7.3 KiB
Go
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")
|
|
}
|
|
}
|