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.
This commit is contained in:
prosolis
2026-05-28 20:00:09 -07:00
parent 4934383a9a
commit d80b437525
8 changed files with 166 additions and 16 deletions

View File

@@ -56,6 +56,18 @@ package plugin
// Longest entry→boss walk: 10 (R1) + 10 (R2) + 10 (R3) + 10 (R4) = 40 // Longest entry→boss walk: 10 (R1) + 10 (R2) + 10 (R3) + 10 (R4) = 40
// nodes, inside [36, 44]. All four meaningful walks (fork1 × fork2 × // nodes, inside [36, 44]. All four meaningful walks (fork1 × fork2 ×
// capstone) hit the same node count by construction. // capstone) hit the same node count by construction.
//
// D10 anchor variety (in-place kind swaps, no length change): the Abyss
// shipped with NO trap node and only the fork2 vrock ELITE, so D10 adds
// two traps and a region-guardian elite —
// - Hush Corridor (fork1 silent_chambers Perception-loot spur) and
// Seam Threshold (fork3 reality_seam SECRET spur) become TRAPs, so
// the two loot/secret branches each carry a hazard.
// - Warden's Hall (R3 wardens_post buildup, main path) becomes an
// ELITE — the region literally named for its wardens finally has
// one, giving every walk a mid-zone region-guardian.
// The burning_wastes / mind_corridor / void_charge / usurper_throne
// routes stay clean, preserving the safe-vs-loot fork trade-off.
func zoneAbyssPortalGraph() ZoneGraph { func zoneAbyssPortalGraph() ZoneGraph {
r1 := "abyss_outer_rift" r1 := "abyss_outer_rift"
@@ -96,7 +108,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
{NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, RegionID: r2, {NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, RegionID: r2,
Label: "Silent Chambers", PosX: 10, PosY: 3}, Label: "Silent Chambers", PosX: 10, PosY: 3},
{NodeID: "abyss_portal.hush_corridor", Kind: NodeKindExploration, RegionID: r2, {NodeID: "abyss_portal.hush_corridor", Kind: NodeKindTrap, RegionID: r2,
Label: "Hush Corridor", PosX: 11, PosY: 3}, Label: "Hush Corridor", PosX: 11, PosY: 3},
{NodeID: "abyss_portal.listening_room", Kind: NodeKindExploration, RegionID: r2, {NodeID: "abyss_portal.listening_room", Kind: NodeKindExploration, RegionID: r2,
Label: "Listening Room", PosX: 12, PosY: 3}, Label: "Listening Room", PosX: 12, PosY: 3},
@@ -135,7 +147,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
// R3 buildup to fork3. // R3 buildup to fork3.
{NodeID: "abyss_portal.wardens_outer_post", Kind: NodeKindExploration, RegionID: r3, {NodeID: "abyss_portal.wardens_outer_post", Kind: NodeKindExploration, RegionID: r3,
Label: "Outer Warden Post", PosX: 23, PosY: 2}, Label: "Outer Warden Post", PosX: 23, PosY: 2},
{NodeID: "abyss_portal.wardens_hall", Kind: NodeKindExploration, RegionID: r3, {NodeID: "abyss_portal.wardens_hall", Kind: NodeKindElite, RegionID: r3,
Label: "Warden's Hall", PosX: 24, PosY: 2}, Label: "Warden's Hall", PosX: 24, PosY: 2},
{NodeID: "abyss_portal.wardens_chapel", Kind: NodeKindExploration, RegionID: r3, {NodeID: "abyss_portal.wardens_chapel", Kind: NodeKindExploration, RegionID: r3,
Label: "Warden's Chapel", PosX: 25, PosY: 2}, Label: "Warden's Chapel", PosX: 25, PosY: 2},
@@ -163,7 +175,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
{NodeID: "abyss_portal.claimed_path", Kind: NodeKindExploration, RegionID: r4, {NodeID: "abyss_portal.claimed_path", Kind: NodeKindExploration, RegionID: r4,
Label: "Claimed Path", PosX: 32, PosY: 2}, Label: "Claimed Path", PosX: 32, PosY: 2},
{NodeID: "abyss_portal.seam_threshold", Kind: NodeKindExploration, RegionID: r4, {NodeID: "abyss_portal.seam_threshold", Kind: NodeKindTrap, RegionID: r4,
Label: "Seam Threshold", PosX: 30, PosY: 3}, Label: "Seam Threshold", PosX: 30, PosY: 3},
{NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, RegionID: r4, {NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, RegionID: r4,
Label: "Reality Seam", PosX: 31, PosY: 3, Label: "Reality Seam", PosX: 31, PosY: 3,

View File

@@ -119,3 +119,34 @@ func TestAbyssPortalGraph_RealitySeamHighestBias(t *testing.T) {
t.Errorf("reality_seam LootBias = %v, want >= 3.0 (Abyss capstone)", seam.Content.LootBias) 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)
}
}
}

View File

@@ -52,6 +52,17 @@ package plugin
// Longest entry→boss walk: 10 (R1) + 10 (R2) + 9 (R3) + 10 (R4) = 39 // Longest entry→boss walk: 10 (R1) + 10 (R2) + 9 (R3) + 10 (R4) = 39
// nodes, inside [36, 44]. The two R2 spurs and three R4 capstones each // nodes, inside [36, 44]. The two R2 spurs and three R4 capstones each
// reach the boss in the same node count by construction. // reach the boss in the same node count by construction.
//
// D10 anchor variety (in-place kind swaps, no length change): the two
// loot-leaning branches each gain a guard, so "the richer route costs
// more" —
// - Coin-Strewn Hall (treasure_vault spur) becomes an ELITE; the
// Perception loot route is now guarded, mirroring the ash_bridge
// spur's TRAP so both fork1 branches carry an anchor.
// - Hidden Passage (hoard_pillar SECRET capstone spur) becomes a
// TRAP guarding the densest loot in the zone.
// The direct-confrontation and dragon-bargain routes stay clean, so the
// fork choice trades safety for loot.
func zoneDragonsLairGraph() ZoneGraph { func zoneDragonsLairGraph() ZoneGraph {
r1 := "dragons_lair_kobold_warrens" r1 := "dragons_lair_kobold_warrens"
@@ -110,7 +121,7 @@ func zoneDragonsLairGraph() ZoneGraph {
{NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration, RegionID: r2, {NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration, RegionID: r2,
Label: "Treasure Vault", PosX: 17, PosY: 2, Label: "Treasure Vault", PosX: 17, PosY: 2,
Content: ZoneNodeContent{LootBias: 1.5}}, Content: ZoneNodeContent{LootBias: 1.5}},
{NodeID: "dragons_lair.coin_strewn_hall", Kind: NodeKindExploration, RegionID: r2, {NodeID: "dragons_lair.coin_strewn_hall", Kind: NodeKindElite, RegionID: r2,
Label: "Coin-Strewn Hall", PosX: 18, PosY: 2}, Label: "Coin-Strewn Hall", PosX: 18, PosY: 2},
{NodeID: "dragons_lair.vault_passage", Kind: NodeKindExploration, RegionID: r2, {NodeID: "dragons_lair.vault_passage", Kind: NodeKindExploration, RegionID: r2,
Label: "Vault Passage", PosX: 19, PosY: 2}, Label: "Vault Passage", PosX: 19, PosY: 2},
@@ -152,7 +163,7 @@ func zoneDragonsLairGraph() ZoneGraph {
Label: "Audience Hall", PosX: 31, PosY: 1}, Label: "Audience Hall", PosX: 31, PosY: 1},
// R4 hoard_pillar spur (Perception 17, SECRET). // R4 hoard_pillar spur (Perception 17, SECRET).
{NodeID: "dragons_lair.hidden_passage", Kind: NodeKindExploration, RegionID: r4, {NodeID: "dragons_lair.hidden_passage", Kind: NodeKindTrap, RegionID: r4,
Label: "Hidden Passage", PosX: 29, PosY: 2}, Label: "Hidden Passage", PosX: 29, PosY: 2},
{NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret, RegionID: r4, {NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret, RegionID: r4,
Label: "Hidden Hoard Pillar", PosX: 30, PosY: 2, Label: "Hidden Hoard Pillar", PosX: 30, PosY: 2,

View File

@@ -111,3 +111,32 @@ func TestDragonsLairGraph_LootBiasEscalation(t *testing.T) {
t.Errorf("hoard_pillar LootBias = %v, want >= 2.5 (T5 secret)", hoard.Content.LootBias) t.Errorf("hoard_pillar LootBias = %v, want >= 2.5 (T5 secret)", hoard.Content.LootBias)
} }
} }
// TestDragonsLairGraph_D10Anchors verifies the D10 anchor-variety pass:
// the treasure_vault spur gains a guarding Elite (Coin-Strewn Hall) to
// mirror the ash_bridge spur's Trap, and the hoard_pillar SECRET capstone
// spur gains a Trap (Hidden Passage). Counts: 2 traps, 2 elites.
func TestDragonsLairGraph_D10Anchors(t *testing.T) {
g := zoneDragonsLairGraph()
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 (ash_bridge + D10 hidden_passage)", trapCount)
}
if eliteCount != 2 {
t.Errorf("elite nodes = %d, want 2 (wyrmlings_nest + D10 coin_strewn_hall)", eliteCount)
}
if g.Nodes["dragons_lair.coin_strewn_hall"].Kind != NodeKindElite {
t.Error("D10: coin_strewn_hall (treasure_vault spur) should be an Elite")
}
if g.Nodes["dragons_lair.hidden_passage"].Kind != NodeKindTrap {
t.Error("D10: hidden_passage (hoard_pillar spur) should be a Trap")
}
}

View File

@@ -18,6 +18,16 @@ package plugin
// Also new: cursed_thicket TRAP anchor in the preamble — every walk // Also new: cursed_thicket TRAP anchor in the preamble — every walk
// hits it. The original G8f graph had no Trap node. // hits it. The original G8f graph had no Trap node.
// //
// D10 anchor variety (in-place kind swaps, no length change): the two
// first-stage approaches each gain one distinct anchor so the fork
// choice carries a flavor, not just a skill gate —
// - Grove approach: Singing Orchard becomes an ELITE (the CHA-bonus
// route is guarded; you pay for the bargain in blood).
// - Marsh approach: Mire Steps becomes a TRAP (the free route is
// hazardous footing rather than a fight).
// The time_eddy / illusion_garden exclusive endings stay anchor-light so
// they keep their "skip the hag elite for loot" trade-off.
//
// Preamble (10 nodes, ending in fork1): // Preamble (10 nodes, ending in fork1):
// entry → twilight_path → veiled_glade → faerie_lights → // entry → twilight_path → veiled_glade → faerie_lights →
// cursed_thicket (TRAP) → revel_road → moonshadow_bridge → // cursed_thicket (TRAP) → revel_road → moonshadow_bridge →
@@ -87,7 +97,7 @@ func zoneFeywildCrossingGraph() ZoneGraph {
Label: "Grove Threshold", PosX: 10, PosY: 0}, Label: "Grove Threshold", PosX: 10, PosY: 0},
{NodeID: "feywild_crossing.starlight_path", Kind: NodeKindExploration, {NodeID: "feywild_crossing.starlight_path", Kind: NodeKindExploration,
Label: "Starlight Path", PosX: 11, PosY: 0}, Label: "Starlight Path", PosX: 11, PosY: 0},
{NodeID: "feywild_crossing.singing_orchard", Kind: NodeKindExploration, {NodeID: "feywild_crossing.singing_orchard", Kind: NodeKindElite,
Label: "Singing Orchard", PosX: 12, PosY: 0}, Label: "Singing Orchard", PosX: 12, PosY: 0},
{NodeID: "feywild_crossing.mirror_pond", Kind: NodeKindExploration, {NodeID: "feywild_crossing.mirror_pond", Kind: NodeKindExploration,
Label: "Mirror Pond", PosX: 13, PosY: 0}, Label: "Mirror Pond", PosX: 13, PosY: 0},
@@ -109,7 +119,7 @@ func zoneFeywildCrossingGraph() ZoneGraph {
Label: "Fog Basin", PosX: 12, PosY: 4}, Label: "Fog Basin", PosX: 12, PosY: 4},
{NodeID: "feywild_crossing.moaning_reeds", Kind: NodeKindExploration, {NodeID: "feywild_crossing.moaning_reeds", Kind: NodeKindExploration,
Label: "Moaning Reeds", PosX: 13, PosY: 4}, Label: "Moaning Reeds", PosX: 13, PosY: 4},
{NodeID: "feywild_crossing.mire_steps", Kind: NodeKindExploration, {NodeID: "feywild_crossing.mire_steps", Kind: NodeKindTrap,
Label: "Mire Steps", PosX: 14, PosY: 4}, Label: "Mire Steps", PosX: 14, PosY: 4},
{NodeID: "feywild_crossing.submerged_stones", Kind: NodeKindExploration, {NodeID: "feywild_crossing.submerged_stones", Kind: NodeKindExploration,
Label: "Submerged Stones", PosX: 15, PosY: 4}, Label: "Submerged Stones", PosX: 15, PosY: 4},

View File

@@ -93,6 +93,8 @@ func TestFeywildCrossingGraph_FirstCHALock(t *testing.T) {
// TestFeywildCrossingGraph_TrapAnchor verifies D1-d added the missing // TestFeywildCrossingGraph_TrapAnchor verifies D1-d added the missing
// Trap node. Original G8f graph had elite/secret but no trap. // 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) { func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) {
g := zoneFeywildCrossingGraph() g := zoneFeywildCrossingGraph()
var trapCount int var trapCount int
@@ -101,8 +103,30 @@ func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) {
trapCount++ trapCount++
} }
} }
if trapCount != 1 { if trapCount != 2 {
t.Errorf("trap nodes = %d, want 1", trapCount) 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")
} }
} }

View File

@@ -51,6 +51,16 @@ package plugin
// //
// Trap anchor (Collapsed Arch) is new — the original G8i graph had no // Trap anchor (Collapsed Arch) is new — the original G8i graph had no
// Trap node. Placed in the R1 preamble so every walk hits it. // Trap node. Placed in the R1 preamble so every walk hits it.
//
// D10 anchor variety (in-place kind swaps, no length change):
// - Silenced Chamber (R3 illithid arm) becomes a TRAP — the second
// trap, on a fork branch so the illithid route reads riskier than
// the drow route.
// - Drow Gate Garrison (R2 arm tail, at the R2→R4 boundary) becomes a
// region-guardian ELITE, giving the drow arm two elites (Captain +
// Garrison) and the multi-region transition a teeth-y interrupt.
// The deep_chasm spur stays anchor-light (harvest, no second trap/elite)
// so the CON-gated climb keeps its "denser loot, less fighting" identity.
func zoneUnderdarkGraph() ZoneGraph { func zoneUnderdarkGraph() ZoneGraph {
r1 := "underdark_surface_tunnels" r1 := "underdark_surface_tunnels"
@@ -111,15 +121,15 @@ func zoneUnderdarkGraph() ZoneGraph {
Label: "Drow Descent", PosX: 17, PosY: 0}, Label: "Drow Descent", PosX: 17, PosY: 0},
{NodeID: "underdark.drow_passage", Kind: NodeKindExploration, RegionID: r2, {NodeID: "underdark.drow_passage", Kind: NodeKindExploration, RegionID: r2,
Label: "Lower Passage", PosX: 18, PosY: 0}, Label: "Lower Passage", PosX: 18, PosY: 0},
{NodeID: "underdark.drow_gate", Kind: NodeKindExploration, RegionID: r2, {NodeID: "underdark.drow_gate", Kind: NodeKindElite, RegionID: r2,
Label: "Drow Gate", PosX: 19, PosY: 0}, Label: "Drow Gate Garrison", PosX: 19, PosY: 0},
// R3 illithid_warren arm. // R3 illithid_warren arm.
{NodeID: "underdark.psionic_corridor", Kind: NodeKindExploration, RegionID: r3, {NodeID: "underdark.psionic_corridor", Kind: NodeKindExploration, RegionID: r3,
Label: "Psionic Corridor", PosX: 8, PosY: 2}, Label: "Psionic Corridor", PosX: 8, PosY: 2},
{NodeID: "underdark.whispering_hall", Kind: NodeKindExploration, RegionID: r3, {NodeID: "underdark.whispering_hall", Kind: NodeKindExploration, RegionID: r3,
Label: "Whispering Hall", PosX: 9, PosY: 2}, Label: "Whispering Hall", PosX: 9, PosY: 2},
{NodeID: "underdark.silenced_chamber", Kind: NodeKindExploration, RegionID: r3, {NodeID: "underdark.silenced_chamber", Kind: NodeKindTrap, RegionID: r3,
Label: "Silenced Chamber", PosX: 10, PosY: 2}, Label: "Silenced Chamber", PosX: 10, PosY: 2},
{NodeID: "underdark.mind_tank_room", Kind: NodeKindExploration, RegionID: r3, {NodeID: "underdark.mind_tank_room", Kind: NodeKindExploration, RegionID: r3,
Label: "Brine Tanks", PosX: 11, PosY: 2}, Label: "Brine Tanks", PosX: 11, PosY: 2},

View File

@@ -106,8 +106,8 @@ func TestUnderdarkGraph_AllArmsReachBoss(t *testing.T) {
} }
} }
// TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing Trap // TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing R1 Trap
// node. Original G8i graph had elite/boss/harvest but no trap. // (collapsed_arch) and D10 added the illithid-arm Trap (silenced_chamber).
func TestUnderdarkGraph_TrapAnchor(t *testing.T) { func TestUnderdarkGraph_TrapAnchor(t *testing.T) {
g := zoneUnderdarkGraph() g := zoneUnderdarkGraph()
var trapCount int var trapCount int
@@ -116,7 +116,30 @@ func TestUnderdarkGraph_TrapAnchor(t *testing.T) {
trapCount++ trapCount++
} }
} }
if trapCount != 1 { if trapCount != 2 {
t.Errorf("trap nodes = %d, want 1", trapCount) 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")
} }
} }