Files
gogobee/internal/plugin/zone_graph_underdark_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

146 lines
4.6 KiB
Go

package plugin
import "testing"
func TestUnderdarkGraph_Registered(t *testing.T) {
g, ok := zoneGraphRegistry[ZoneUnderdark]
if !ok {
t.Fatal("zoneUnderdarkGraph not registered")
}
// Long-expedition D1-d widened this zone from 10 → 46 nodes so the
// longest entry→boss walk lands in the T4 [28,34] traversal band.
if len(g.Nodes) != 46 {
t.Errorf("nodes = %d, want 46", len(g.Nodes))
}
}
func TestUnderdarkGraph_LongestPathInBand(t *testing.T) {
g := zoneUnderdarkGraph()
got := graphLongestPath(g)
if got < 28 || got > 34 {
t.Errorf("longest path = %d, want in T4 band [28,34]", got)
}
}
// TestUnderdarkGraph_AllNodesHaveRegion confirms Underdark is the
// canonical multi-region zone — every node carries a non-empty RegionID
// matching the dnd_expedition_region.go registry.
func TestUnderdarkGraph_AllNodesHaveRegion(t *testing.T) {
g := zoneUnderdarkGraph()
validRegions := map[string]bool{
"underdark_surface_tunnels": true,
"underdark_drow_outpost": true,
"underdark_illithid_warren": true,
"underdark_deep_throne": true,
}
for id, n := range g.Nodes {
if n.RegionID == "" {
t.Errorf("node %s has empty RegionID — Underdark requires region authoring on every node", id)
}
if !validRegions[n.RegionID] {
t.Errorf("node %s RegionID = %q, not in dnd_expedition_region.go registry", id, n.RegionID)
}
}
}
// TestUnderdarkGraph_AllFourRegionsRepresented confirms each authored
// region has at least one node on the canonical entry→boss path.
func TestUnderdarkGraph_AllFourRegionsRepresented(t *testing.T) {
g := zoneUnderdarkGraph()
regions := map[string]int{}
for _, n := range g.Nodes {
regions[n.RegionID]++
}
for _, r := range []string{
"underdark_surface_tunnels",
"underdark_drow_outpost",
"underdark_illithid_warren",
"underdark_deep_throne",
} {
if regions[r] == 0 {
t.Errorf("region %q has no nodes — multi-region invariant broken", r)
}
}
}
// TestUnderdarkGraph_ForkCrossesRegionBoundaries verifies the fork
// edges actually traverse region boundaries (this is what makes the
// G6 fireGraphRegionTransition hook fire end-to-end on this zone).
func TestUnderdarkGraph_ForkCrossesRegionBoundaries(t *testing.T) {
g := zoneUnderdarkGraph()
fork := g.Nodes["underdark.fork1"]
if fork.RegionID != "underdark_surface_tunnels" {
t.Fatalf("fork1 region = %q, want surface_tunnels", fork.RegionID)
}
for _, e := range g.outgoingEdges("underdark.fork1") {
toNode := g.Nodes[e.To]
switch e.To {
case "underdark.drow_patrol":
if toNode.RegionID != "underdark_drow_outpost" {
t.Errorf("drow_patrol region = %q", toNode.RegionID)
}
case "underdark.psionic_corridor":
if toNode.RegionID != "underdark_illithid_warren" {
t.Errorf("psionic_corridor region = %q", toNode.RegionID)
}
case "underdark.deep_chasm":
// Deep_chasm intentionally stays in R1 (surface_tunnels) — the
// vertical-shaft path explores R1 deeper before joining R4.
if toNode.RegionID != "underdark_surface_tunnels" {
t.Errorf("deep_chasm region = %q, want surface_tunnels (intentional R1 stay)", toNode.RegionID)
}
}
}
}
func TestUnderdarkGraph_AllArmsReachBoss(t *testing.T) {
g := zoneUnderdarkGraph()
for _, leaf := range []string{
"underdark.drow_captain",
"underdark.mind_flayer",
"underdark.deep_chasm",
} {
if !reachable(g, leaf, "underdark.boss") {
t.Errorf("%s unreachable to boss", leaf)
}
}
}
// TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing R1 Trap
// (collapsed_arch) and D10 added the illithid-arm Trap (silenced_chamber).
func TestUnderdarkGraph_TrapAnchor(t *testing.T) {
g := zoneUnderdarkGraph()
var trapCount int
for _, n := range g.Nodes {
if n.Kind == NodeKindTrap {
trapCount++
}
}
if trapCount != 2 {
t.Errorf("trap nodes = %d, want 2 (collapsed_arch + D10 silenced_chamber)", trapCount)
}
if g.Nodes["underdark.silenced_chamber"].Kind != NodeKindTrap {
t.Error("D10: silenced_chamber (illithid arm) should be a Trap")
}
}
// TestUnderdarkGraph_EliteAnchors verifies the per-arm elites (drow
// Captain, illithid Mind Flayer) plus the D10 region-guardian elite at
// the drow→throne boundary (drow_gate), so the drow arm carries two
// elites while the chasm spur stays anchor-light.
func TestUnderdarkGraph_EliteAnchors(t *testing.T) {
g := zoneUnderdarkGraph()
var eliteCount int
for _, n := range g.Nodes {
if n.Kind == NodeKindElite {
eliteCount++
}
}
if eliteCount != 3 {
t.Errorf("elite nodes = %d, want 3 (drow_captain + mind_flayer + D10 drow_gate)", eliteCount)
}
if g.Nodes["underdark.drow_gate"].Kind != NodeKindElite {
t.Error("D10: drow_gate (R2→R4 boundary) should be a region-guardian Elite")
}
}