Files
gogobee/internal/plugin/zone_graph_feywild_crossing_test.go
prosolis d80b437525 J3 D10: T4/T5 anchor-room variety (2nd elite + trap per zone)
In-place node kind swaps on fork branches — no node added/removed, longest
path unchanged — adding one Elite + one Trap of anchor variety to each T4/T5
zone, placed to keep fork-choice risk/loot asymmetric:

  underdark   +Elite drow_gate (R2->R4 region-guardian)  +Trap silenced_chamber
  feywild     +Elite singing_orchard (grove branch)      +Trap mire_steps (marsh)
  dragons_lair+Elite coin_strewn_hall (treasure spur)    +Trap hidden_passage (hoard spur)
  abyss_portal+Elite wardens_hall (R3 guardian)          +Trap hush_corridor, seam_threshold

abyss_portal shipped with zero trap nodes; D10 gives it two. A/B sim corpus
(n=480/side): boss-clear flat (+1.5pp aggregate, per-cell within n=15 noise),
median day-counts stable, combats/run rose where anchors hit common paths.
New Test*Graph_*Anchors tests lock the per-zone trap/elite counts.
2026-05-28 20:00:09 -07:00

152 lines
5.3 KiB
Go

package plugin
import "testing"
func TestFeywildCrossingGraph_Registered(t *testing.T) {
g, ok := zoneGraphRegistry[ZoneFeywildCrossing]
if !ok {
t.Fatal("zoneFeywildCrossingGraph not registered")
}
// Long-expedition D1-d widened this zone from 9 → 53 nodes so the
// longest entry→boss walk lands in the T4 [28,34] traversal band.
if len(g.Nodes) != 53 {
t.Errorf("nodes = %d, want 53", len(g.Nodes))
}
}
func TestFeywildCrossingGraph_LongestPathInBand(t *testing.T) {
g := zoneFeywildCrossingGraph()
got := graphLongestPath(g)
if got < 28 || got > 34 {
t.Errorf("longest path = %d, want in T4 band [28,34]", got)
}
}
// TestFeywildCrossingGraph_HagCircleSharedReach verifies hag_circle
// has TWO incoming edges — one from each first-stage path. This is the
// woven-fork shape's signature; if a future edit splits hag_circle so
// each path has its own copy, this fails (and the shape comment needs
// to be re-authored).
func TestFeywildCrossingGraph_HagCircleSharedReach(t *testing.T) {
g := zoneFeywildCrossingGraph()
incoming := map[string][]string{}
for from, outs := range g.Edges {
for _, e := range outs {
incoming[e.To] = append(incoming[e.To], from)
}
}
hagSources := incoming["feywild_crossing.hag_circle"]
if len(hagSources) != 2 {
t.Errorf("hag_circle incoming = %d, want 2 (woven fork)", len(hagSources))
}
}
// TestFeywildCrossingGraph_PartialOverlap verifies time_eddy is
// reachable only via the grove and illusion_garden only via the marsh.
// This is what makes first-stage choice meaningful even though
// hag_circle is shared.
func TestFeywildCrossingGraph_PartialOverlap(t *testing.T) {
g := zoneFeywildCrossingGraph()
if reachable(g, "feywild_crossing.wisp_marsh", "feywild_crossing.time_eddy") {
t.Error("time_eddy should NOT be reachable from wisp_marsh — grove-exclusive")
}
if reachable(g, "feywild_crossing.glamoured_grove", "feywild_crossing.illusion_garden") {
t.Error("illusion_garden should NOT be reachable from glamoured_grove — marsh-exclusive")
}
}
// TestFeywildCrossingGraph_Fork1HasFreePath guards the no-soft-lock
// invariant: fork1 must offer at least one LockNone exit. The original
// design locked BOTH edges (CHA + Perception) with no fallback — fork
// rolls are deterministic with no retry, so a character failing both was
// permanently stranded (D8-f part 2 found this stranded ~60% of runs).
// Every other zone fork has a free path; fork1 must too.
func TestFeywildCrossingGraph_Fork1HasFreePath(t *testing.T) {
g := zoneFeywildCrossingGraph()
free := 0
for _, e := range g.outgoingEdges("feywild_crossing.fork1") {
if e.Lock == LockNone || e.Lock == "" {
free++
}
}
if free == 0 {
t.Error("fork1 has no free (LockNone) exit — soft-lock: a player failing every skill check is permanently stranded")
}
}
// TestFeywildCrossingGraph_FirstCHALock confirms this zone uses
// LockStatCheck CHA — completing CHA in lock-kind coverage by G8f.
func TestFeywildCrossingGraph_FirstCHALock(t *testing.T) {
g := zoneFeywildCrossingGraph()
found := false
for _, outs := range g.Edges {
for _, e := range outs {
if e.Lock == LockStatCheck && lockDataString(e.LockData, "stat") == "CHA" {
found = true
}
}
}
if !found {
t.Error("expected at least one LockStatCheck CHA edge — Feywild theme")
}
}
// TestFeywildCrossingGraph_TrapAnchor verifies D1-d added the missing
// Trap node. Original G8f graph had elite/secret but no trap.
// TestFeywildCrossingGraph_TrapAnchor verifies the preamble trap plus
// the D10 marsh-branch trap (Mire Steps).
func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) {
g := zoneFeywildCrossingGraph()
var trapCount int
for _, n := range g.Nodes {
if n.Kind == NodeKindTrap {
trapCount++
}
}
if trapCount != 2 {
t.Errorf("trap nodes = %d, want 2 (cursed_thicket + D10 mire_steps)", trapCount)
}
if g.Nodes["feywild_crossing.mire_steps"].Kind != NodeKindTrap {
t.Error("D10: mire_steps (marsh branch) should be a Trap")
}
}
// TestFeywildCrossingGraph_EliteAnchors verifies the shared hag_circle
// elite plus the D10 grove-branch elite (Singing Orchard), so the first
// fork carries a per-branch anchor (grove=elite, marsh=trap).
func TestFeywildCrossingGraph_EliteAnchors(t *testing.T) {
g := zoneFeywildCrossingGraph()
var eliteCount int
for _, n := range g.Nodes {
if n.Kind == NodeKindElite {
eliteCount++
}
}
if eliteCount != 2 {
t.Errorf("elite nodes = %d, want 2 (hag_circle + D10 singing_orchard)", eliteCount)
}
if g.Nodes["feywild_crossing.singing_orchard"].Kind != NodeKindElite {
t.Error("D10: singing_orchard (grove branch) should be an Elite")
}
}
// TestFeywildCrossingGraph_FaeCourtMerge verifies all three second-
// stage endings (hag_circle, time_eddy, illusion_garden) converge at
// the fae_court merge before the final boss approach. D1-d added this
// merge to avoid triplicating the long pre-boss walk.
func TestFeywildCrossingGraph_FaeCourtMerge(t *testing.T) {
g := zoneFeywildCrossingGraph()
for _, ending := range []string{
"feywild_crossing.hag_circle",
"feywild_crossing.time_eddy",
"feywild_crossing.illusion_garden",
} {
if !reachable(g, ending, "feywild_crossing.fae_court") {
t.Errorf("%s does not reach fae_court merge", ending)
}
}
if !reachable(g, "feywild_crossing.fae_court", "feywild_crossing.boss") {
t.Error("fae_court → boss path missing")
}
}