3 Commits

Author SHA1 Message Date
prosolis
667f87f9d0 Fix SQL errors for D&D-only players: seed player_meta on setup confirm
!setup confirm wrote only dnd_character, never the canonical player_meta
seed row. A player who built a character via !setup and played purely in
the D&D/expedition system (which writes inventory directly, bypassing
ensureCharacter) ended up with no player_meta — so every legacy-layer
command's loadAdvCharacter returned 'sql: no rows in result set'.

dndSetupConfirm now routes through ensureCharacter (which seeds
player_meta + tier-0 equipment on first contact) instead of a bare
loadAdvCharacter.

Also fix !wotd force: it was deleting from a non-existent
job_completed/job_key table (silent no-op that left forced re-posts
blocked by the stale dedup row). Now clears the real daily_prefetch
rows and checks the error.
2026-06-03 17:23:53 -07:00
prosolis
1b8d13e0dd J3 D11: T5 difficulty lift (leaders-define-band) + empty-EnemyID combat guard
Boss-only tuning at the L15/L16 mid-range (D8-f #2 had tested T5 at the L12
floor where everything walls). The two T5 zones needed opposite treatment:

- dragons_lair (infernax): impossible wall, leaders 0-2% -> HP 546->405,
  AC 22->20, frightful-presence stun 0.80->0.40, multiattack 49->42.
- abyss_portal (belaxath): leader faceroll 88-92% -> HP 262->300, AC 19->20,
  multiattack 40->41.

Final n=50: leaders 61% at L15 (both zones), 72-79% at L16 -- same
leaders-define-band shape as T4; casters ~0% (J3 caster-track gap, not
monster-tunable). Bosses are the sole binding lever (every run decided at the
boss; standard/elite pools already survivable).

Also harden handleFightCmd: a malformed bestiary entry with an empty ID was
silently persisting an enemy-less combat session that spun to autoDriveCombat's
200-round cap. Now treated as a bestiary miss (fail loud). Writeup:
sim_results/t5_findings.md (gitignored).
2026-05-28 21:55:34 -07:00
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
13 changed files with 216 additions and 29 deletions

View File

@@ -88,14 +88,14 @@ var srdProfiles = map[string]SRDProfile{
{Name: "Thorned Lash", AttackBonus: 9, Damage: 12},
{Name: "Thorned Lash", AttackBonus: 9, Damage: 11},
}},
"boss_infernax": {Attacks: []SRDAttack{ // Attack 38 → ~49
{Name: "Bite", AttackBonus: 11, Damage: 19},
{Name: "Claw", AttackBonus: 11, Damage: 15},
{Name: "Claw", AttackBonus: 11, Damage: 15},
"boss_infernax": {Attacks: []SRDAttack{ // D11 T5 lift: 49 → ~42 (nerf; was an impossible wall at L15-16)
{Name: "Bite", AttackBonus: 11, Damage: 16},
{Name: "Claw", AttackBonus: 11, Damage: 13},
{Name: "Claw", AttackBonus: 11, Damage: 13},
}},
"boss_belaxath": {Attacks: []SRDAttack{ // Attack 31 → ~40
"boss_belaxath": {Attacks: []SRDAttack{ // D11 T5 lift: 40 → ~41 (buff; was a leader faceroll)
{Name: "Longsword", AttackBonus: 11, Damage: 24},
{Name: "Whip", AttackBonus: 11, Damage: 16},
{Name: "Whip", AttackBonus: 11, Damage: 17},
}},
// ── Multiattack elites ───────────────────────────────────────────────

View File

@@ -80,7 +80,12 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
} else {
monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true)
}
if !ok {
if !ok || monster.ID == "" {
// monster.ID == "" guards a malformed bestiary entry (e.g. one whose
// ID field was dropped): startCombatSession would otherwise persist a
// session with an empty EnemyID, and the turn engine — having no enemy
// to resolve — spins inertly until autoDriveCombat's round cap. Fail
// loudly instead of stalling.
return p.replyDM(ctx, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
}

View File

@@ -602,9 +602,18 @@ var _ = func() bool {
},
"boss_infernax": {
ID: "boss_infernax", Name: "Infernax the Undying",
CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 11, Speed: 18,
// T5 lift (D11): dragons_lair was an impossible wall — at L15-16
// (mid-range) leaders cleared 0-2%, 100% TPK at the boss (it has
// 546 HP / AC22 / 49-dmg multiattack / near-guaranteed stun, a
// CR24 block fought ~9 levels under its CR). Nerfed to land
// martial leaders in the 60-75% band: HP 546→405, AC 22→20,
// frightful-presence stun 0.80→0.40, multiattack 49→42 (srd).
// (Pass 1's 360/AC20/36 over-nerfed → 98% leaders; Pass 2's
// 460/AC21/42 over-corrected → ~40%; this Pass 3 405/AC20/42
// lands leaders in band.)
CR: 24, HP: 405, AC: 20, Attack: 38, AttackBonus: 11, Speed: 18,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.80, Effect: "stun"},
Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.40, Effect: "stun"},
XPValue: 62000,
Notes: "Dragon's Lair boss. Ancient Red Dragon. Legendary Actions; Lair Actions; phase 2 below 50% HP — fire ignores resistance.",
FireAttacker: true,
@@ -652,7 +661,15 @@ var _ = func() bool {
},
"boss_belaxath": {
ID: "boss_belaxath", Name: "Belaxath the Undivided",
CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 11, Speed: 14,
// T5 lift (D11): abyss_portal was a faceroll for leaders — at its
// own floor (L15) fighter/ranger cleared 88-92% (above the 60-75%
// band), killing the 262-HP boss in 6-13 rounds with HP to spare.
// Buffed to bring leaders into band: HP 262→300, AC 19→20,
// multiattack 40→41 (srd). Elites (nalfeshnee/marilith) already
// wall the trailers, so this only moves the leaders. (Pass 1's
// 360/AC20/45 over-buffed → 19-40% leaders; this is the Pass 2
// correction back down toward band.)
CR: 19, HP: 300, AC: 20, Attack: 31, AttackBonus: 11, Speed: 14,
BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Lightning Discharge", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
XPValue: 22000,

View File

@@ -258,7 +258,13 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
// Initial D&D level seeded from existing combat_level (v1.1 §4.1).
// combat_level "freezes" thereafter — dnd_level is canonical.
advChar, _ := loadAdvCharacter(ctx.Sender)
//
// ensureCharacter (not a bare loadAdvCharacter) so the canonical
// player_meta seed row + tier-0 equipment exist for D&D-only players
// who reach !setup confirm without ever touching the legacy adventure
// path. Without this seed, loadAdvCharacter returns "sql: no rows in
// result set" on every legacy-layer command (arena, npcs, events, …).
advChar, _, _ := p.ensureCharacter(ctx.Sender)
startLevel := 1
if advChar != nil {
startLevel = dndLevelFromCombatLevel(advChar.CombatLevel)

View File

@@ -247,8 +247,17 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
if _, delErr := d.Exec(`DELETE FROM wotd_log WHERE date = ?`, today); delErr != nil {
slog.Error("wotd: force delete failed", "err", delErr)
}
// Also clear job-completed flags so PostWOTD will re-post
d.Exec(`DELETE FROM job_completed WHERE job_name = 'wotd' AND job_key LIKE ?`, today+"%")
// Also clear job-completed flags so PostWOTD will re-post. The
// per-room dedup keys are "<date>:<roomID>" in daily_prefetch, so
// match every room's flag for today. (Was targeting a non-existent
// job_completed/job_key table — a silent no-op that left forced
// re-posts blocked by the stale dedup row.)
if _, delErr := d.Exec(
`DELETE FROM daily_prefetch WHERE job_name = 'wotd' AND date LIKE ?`,
today+"%",
); delErr != nil {
slog.Error("wotd: force clear dedup flags failed", "err", delErr)
}
}
_, err := d.Exec(

View File

@@ -56,6 +56,18 @@ package plugin
// Longest entry→boss walk: 10 (R1) + 10 (R2) + 10 (R3) + 10 (R4) = 40
// nodes, inside [36, 44]. All four meaningful walks (fork1 × fork2 ×
// 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 {
r1 := "abyss_outer_rift"
@@ -96,7 +108,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
{NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, RegionID: r2,
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},
{NodeID: "abyss_portal.listening_room", Kind: NodeKindExploration, RegionID: r2,
Label: "Listening Room", PosX: 12, PosY: 3},
@@ -135,7 +147,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
// R3 buildup to fork3.
{NodeID: "abyss_portal.wardens_outer_post", Kind: NodeKindExploration, RegionID: r3,
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},
{NodeID: "abyss_portal.wardens_chapel", Kind: NodeKindExploration, RegionID: r3,
Label: "Warden's Chapel", PosX: 25, PosY: 2},
@@ -163,7 +175,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
{NodeID: "abyss_portal.claimed_path", Kind: NodeKindExploration, RegionID: r4,
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},
{NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, RegionID: r4,
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)
}
}
// 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
// nodes, inside [36, 44]. The two R2 spurs and three R4 capstones each
// 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 {
r1 := "dragons_lair_kobold_warrens"
@@ -110,7 +121,7 @@ func zoneDragonsLairGraph() ZoneGraph {
{NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration, RegionID: r2,
Label: "Treasure Vault", PosX: 17, PosY: 2,
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},
{NodeID: "dragons_lair.vault_passage", Kind: NodeKindExploration, RegionID: r2,
Label: "Vault Passage", PosX: 19, PosY: 2},
@@ -152,7 +163,7 @@ func zoneDragonsLairGraph() ZoneGraph {
Label: "Audience Hall", PosX: 31, PosY: 1},
// 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},
{NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret, RegionID: r4,
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)
}
}
// 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
// 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):
// entry → twilight_path → veiled_glade → faerie_lights →
// cursed_thicket (TRAP) → revel_road → moonshadow_bridge →
@@ -87,7 +97,7 @@ func zoneFeywildCrossingGraph() ZoneGraph {
Label: "Grove Threshold", PosX: 10, PosY: 0},
{NodeID: "feywild_crossing.starlight_path", Kind: NodeKindExploration,
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},
{NodeID: "feywild_crossing.mirror_pond", Kind: NodeKindExploration,
Label: "Mirror Pond", PosX: 13, PosY: 0},
@@ -109,7 +119,7 @@ func zoneFeywildCrossingGraph() ZoneGraph {
Label: "Fog Basin", PosX: 12, PosY: 4},
{NodeID: "feywild_crossing.moaning_reeds", Kind: NodeKindExploration,
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},
{NodeID: "feywild_crossing.submerged_stones", Kind: NodeKindExploration,
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
// 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
@@ -101,8 +103,30 @@ func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) {
trapCount++
}
}
if trapCount != 1 {
t.Errorf("trap nodes = %d, want 1", 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")
}
}

View File

@@ -51,6 +51,16 @@ package plugin
//
// Trap anchor (Collapsed Arch) is new — the original G8i graph had no
// 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 {
r1 := "underdark_surface_tunnels"
@@ -111,15 +121,15 @@ func zoneUnderdarkGraph() ZoneGraph {
Label: "Drow Descent", PosX: 17, PosY: 0},
{NodeID: "underdark.drow_passage", Kind: NodeKindExploration, RegionID: r2,
Label: "Lower Passage", PosX: 18, PosY: 0},
{NodeID: "underdark.drow_gate", Kind: NodeKindExploration, RegionID: r2,
Label: "Drow Gate", PosX: 19, PosY: 0},
{NodeID: "underdark.drow_gate", Kind: NodeKindElite, RegionID: r2,
Label: "Drow Gate Garrison", PosX: 19, PosY: 0},
// R3 illithid_warren arm.
{NodeID: "underdark.psionic_corridor", Kind: NodeKindExploration, RegionID: r3,
Label: "Psionic Corridor", PosX: 8, PosY: 2},
{NodeID: "underdark.whispering_hall", Kind: NodeKindExploration, RegionID: r3,
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},
{NodeID: "underdark.mind_tank_room", Kind: NodeKindExploration, RegionID: r3,
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
// node. Original G8i graph had elite/boss/harvest but no trap.
// 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
@@ -116,7 +116,30 @@ func TestUnderdarkGraph_TrapAnchor(t *testing.T) {
trapCount++
}
}
if trapCount != 1 {
t.Errorf("trap nodes = %d, want 1", 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")
}
}