Files
gogobee/internal/plugin/zone_graph_test.go
prosolis 4934383a9a J3 D8-f #2: T4 difficulty lift (leaders-define-band) + feywild fork1 soft-lock fix
T4 monster tuning so martial leaders land in the 60-75% band (underdark
59/80, feywild 65/84 at L10/L12, n=50); casters trail (class-side gap that
monster tuning provably can't compress -- Pass 1 showed casters pinned at 0%
while martials moved). T5 deferred (walls everyone at its L12 floor; needs an
L15-16 corpus).

- dnd_bestiary.go: underdark elite/boss HP+AC up + caster-lethal proc cuts
  (mind_flayer/drow_mage/roper); feywild HP+AC up.
- bestiary_srd.go: feywild multiattack profiles (fomorian/night_hag/green_hag)
  + Thornmother 2->3 lashes -- HP/AC alone didn't move feywild's low-damage
  roster; multiattack is what pulls facerolling martials into band.
- zone_graph_feywild_crossing.go: free fork1's marsh edge. fork1 was the only
  fork in the game with every exit skill-locked (CHA+Perception, no LockNone)
  and deterministic no-retry rolls -- a prod SOFT-LOCK stranding ~60% of
  players (low CHA+WIS). Kept grove's CHA bargain as a bonus route.
- expedition_sim.go: firstUnlockedForkChoice -- sim fork policy picks the
  first unlocked option instead of blind 'go 1' (which looped forever on
  locked forks); halts fork_all_locked if none.
- tests: graph-wide TestZoneGraphs_NoSoftLockedFork + fork1 free-path guard.

Writeup: sim_results/d8f_findings.md
2026-05-28 19:02:58 -07:00

258 lines
6.6 KiB
Go

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)
}
}
}
// TestZoneGraphs_NoSoftLockedFork guards the invariant that every fork
// node offers at least one traversable (LockNone) exit. A fork whose
// every edge is skill-locked strands any player who fails all the checks
// — fork rolls are deterministic with no retry. D8-f part 2 found
// feywild fork1 violating this (it stranded ~60% of runs). Catch any
// future author who locks every exit of a fork.
func TestZoneGraphs_NoSoftLockedFork(t *testing.T) {
for _, z := range allZones() {
g, ok := loadZoneGraph(z.ID)
if !ok {
continue
}
for nodeID, node := range g.Nodes {
if node.Kind != NodeKindFork {
continue
}
outs := g.outgoingEdges(nodeID)
if len(outs) == 0 {
continue
}
free := false
for _, e := range outs {
if e.Lock == LockNone || e.Lock == "" {
free = true
break
}
}
if !free {
t.Errorf("zone %q fork %q: every exit is locked — soft-lock (a player failing all checks is stranded; add a LockNone fallback)", z.ID, nodeID)
}
}
}
}
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)
}