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) } } }