mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
3 Commits
4934383a9a
...
667f87f9d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667f87f9d0 | ||
|
|
1b8d13e0dd | ||
|
|
d80b437525 |
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -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.)_")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user