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

153 lines
4.6 KiB
Go

package plugin
import "testing"
func TestAbyssPortalGraph_Registered(t *testing.T) {
g, ok := zoneGraphRegistry[ZoneAbyssPortal]
if !ok {
t.Fatal("zoneAbyssPortalGraph not registered")
}
// Long-expedition D1-e widened this zone from 13 → 51 nodes so the
// longest entry→boss walk lands in the T5 [36,44] traversal band.
if len(g.Nodes) != 51 {
t.Errorf("nodes = %d, want 51", len(g.Nodes))
}
}
func TestAbyssPortalGraph_LongestPathInBand(t *testing.T) {
g := zoneAbyssPortalGraph()
got := graphLongestPath(g)
if got < 36 || got > 44 {
t.Errorf("longest path = %d, want in T5 band [36,44]", got)
}
}
// TestAbyssPortalGraph_AllNodesHaveRegion confirms D1-e backfilled the
// missing RegionID authoring per dnd_expedition_region.go: every node
// carries a non-empty RegionID matching the registry.
func TestAbyssPortalGraph_AllNodesHaveRegion(t *testing.T) {
g := zoneAbyssPortalGraph()
validRegions := map[string]bool{
"abyss_outer_rift": true,
"abyss_demon_assembly": true,
"abyss_wardens_post": true,
"abyss_the_tear": true,
}
for id, n := range g.Nodes {
if n.RegionID == "" {
t.Errorf("node %s has empty RegionID — D1-e 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)
}
}
}
// TestAbyssPortalGraph_AllFourRegionsRepresented confirms each authored
// region has at least one node.
func TestAbyssPortalGraph_AllFourRegionsRepresented(t *testing.T) {
g := zoneAbyssPortalGraph()
regions := map[string]int{}
for _, n := range g.Nodes {
regions[n.RegionID]++
}
for _, r := range []string{
"abyss_outer_rift",
"abyss_demon_assembly",
"abyss_wardens_post",
"abyss_the_tear",
} {
if regions[r] == 0 {
t.Errorf("region %q has no nodes — multi-region invariant broken", r)
}
}
}
// TestAbyssPortalGraph_ThreeSequentialForks confirms the design shape:
// three fork nodes in series, each binary or ternary.
func TestAbyssPortalGraph_ThreeSequentialForks(t *testing.T) {
g := zoneAbyssPortalGraph()
wants := map[string]int{
"abyss_portal.fork1": 2,
"abyss_portal.fork2": 2,
"abyss_portal.fork3": 3,
}
for id, want := range wants {
if got := len(g.outgoingEdges(id)); got != want {
t.Errorf("%s outgoing = %d, want %d", id, got, want)
}
}
}
func TestAbyssPortalGraph_AllCapstoneLeavesReachBoss(t *testing.T) {
g := zoneAbyssPortalGraph()
for _, leaf := range []string{
"abyss_portal.direct_assault",
"abyss_portal.usurper_throne",
"abyss_portal.reality_seam",
} {
if !reachable(g, leaf, "abyss_portal.boss") {
t.Errorf("%s unreachable to boss", leaf)
}
}
}
// TestAbyssPortalGraph_FullStatRosterCoverage confirms the
// project-wide claim: by G8h, all six abilities (STR/DEX/CON/INT/WIS/
// CHA) appear as authored stat-check locks across shipping zones.
// CON is the missing one prior to this zone — locked here on
// mind_corridor.
func TestAbyssPortalGraph_FullStatRosterCoverage(t *testing.T) {
g := zoneAbyssPortalGraph()
conSeen := false
for _, outs := range g.Edges {
for _, e := range outs {
if e.Lock == LockStatCheck && lockDataString(e.LockData, "stat") == "CON" {
conSeen = true
}
}
}
if !conSeen {
t.Error("expected at least one CON stat-check edge — completes ability roster by G8h")
}
}
func TestAbyssPortalGraph_RealitySeamHighestBias(t *testing.T) {
g := zoneAbyssPortalGraph()
seam := g.Nodes["abyss_portal.reality_seam"]
if seam.Content.LootBias < 3.0 {
t.Errorf("reality_seam LootBias = %v, want >= 3.0 (Abyss capstone)", seam.Content.LootBias)
}
}
// TestAbyssPortalGraph_D10Anchors verifies the D10 anchor-variety pass.
// The Abyss shipped with no Trap node and a single fork2 Elite, so D10
// adds two branch Traps (Hush Corridor, Seam Threshold) and a main-path
// region-guardian Elite (Warden's Hall). Counts: 2 traps, 2 elites.
func TestAbyssPortalGraph_D10Anchors(t *testing.T) {
g := zoneAbyssPortalGraph()
var trapCount, eliteCount int
for _, n := range g.Nodes {
switch n.Kind {
case NodeKindTrap:
trapCount++
case NodeKindElite:
eliteCount++
}
}
if trapCount != 2 {
t.Errorf("trap nodes = %d, want 2 (D10 hush_corridor + seam_threshold)", trapCount)
}
if eliteCount != 2 {
t.Errorf("elite nodes = %d, want 2 (vrock_aerie + D10 wardens_hall)", eliteCount)
}
if g.Nodes["abyss_portal.wardens_hall"].Kind != NodeKindElite {
t.Error("D10: wardens_hall (R3 region-guardian) should be an Elite")
}
for _, id := range []string{"abyss_portal.hush_corridor", "abyss_portal.seam_threshold"} {
if g.Nodes[id].Kind != NodeKindTrap {
t.Errorf("D10: %s should be a Trap", id)
}
}
}