From 61b7fff9e2bd8d8333e99e6e0125bdd65caa9381 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 13:20:16 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 11 D4a: Tier 3 zones + bestiary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Haunted Manor of Blackspire + The Underforge registered. New bestiary entries (poltergeist, banshee, wraith, vampire spawn, revenant, Aldric Blackspire boss; magmin, azer, salamander, fire elemental, helmed horror, Emberlord Thyrak boss) per dungeon zones doc §5. Manor reuses Tier 1 shadow; Underforge reuses Tier 1 flameskull. Manor Blackspire RoomEntry pool already lives in twinbee_gm_flavor.go and is wired in dnd_zone_narration.go from a prior phase, so no new flavor file is needed for that zone in D4a. L1 tier-gate test bumped 4→6 now that T3 is visible to T1 players. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/dnd_bestiary.go | 112 +++++++++++++++++++++++++++++++ internal/plugin/dnd_zone.go | 97 ++++++++++++++++++++++++++ internal/plugin/dnd_zone_test.go | 23 +++++-- 3 files changed, 227 insertions(+), 5 deletions(-) diff --git a/internal/plugin/dnd_bestiary.go b/internal/plugin/dnd_bestiary.go index ca68af8..91fb9f8 100644 --- a/internal/plugin/dnd_bestiary.go +++ b/internal/plugin/dnd_bestiary.go @@ -318,6 +318,118 @@ var _ = func() bool { return true }() +// ---- Tier 3 zone roster (Phase 11 D4a) --------------------------------------- +// +// Haunted Manor of Blackspire + The Underforge enemy entries. Stat blocks +// per gogobee_dungeon_zones.md §5. Same Attack-as-average-damage convention +// as prior tiers; AttackBonus = d20 to-hit modifier; elites flagged via +// the zone roster, not here. Shadow + Flameskull reuse Tier 1 entries. + +var _ = func() bool { + tier3 := map[string]DnDMonsterTemplate{ + "poltergeist": { + ID: "poltergeist", Name: "Poltergeist", + CR: 2, HP: 22, AC: 12, Attack: 10, AttackBonus: 4, Speed: 12, + BlockRate: 0.05, + Ability: &MonsterAbility{Name: "Telekinetic Thrust", Phase: "any", ProcChance: 0.40, Effect: "stun"}, + XPValue: 450, + Notes: "Invisible: melee attackers have disadvantage. Forceful Slam (bludgeoning).", + }, + "banshee": { + ID: "banshee", Name: "Banshee", + CR: 4, HP: 58, AC: 12, Attack: 14, AttackBonus: 4, Speed: 12, + BlockRate: 0.05, + Ability: &MonsterAbility{Name: "Wail", Phase: "decisive", ProcChance: 0.20, Effect: "execute"}, + XPValue: 1100, + Notes: "Wail (1/combat): WIS DC 13 or drop to 0 HP. Corrupting Touch; undead nature.", + }, + "wraith": { + ID: "wraith", Name: "Wraith", + CR: 5, HP: 67, AC: 13, Attack: 21, AttackBonus: 6, Speed: 16, + BlockRate: 0.10, + Ability: &MonsterAbility{Name: "Life Drain", Phase: "any", ProcChance: 0.40, Effect: "max_hp_drain"}, + XPValue: 1800, + Notes: "Incorporeal movement; sunlight sensitivity; resist non-magical physical.", + }, + "vampire_spawn": { + ID: "vampire_spawn", Name: "Vampire Spawn", + CR: 5, HP: 82, AC: 15, Attack: 16, AttackBonus: 6, Speed: 12, + BlockRate: 0.15, + Ability: &MonsterAbility{Name: "Bite (Life Drain)", Phase: "any", ProcChance: 0.40, Effect: "self_heal"}, + XPValue: 1800, + Notes: "Misty Step; Charm Gaze (WIS DC 13). Bite heals vampire for damage dealt.", + }, + "revenant": { + ID: "revenant", Name: "Revenant", + CR: 5, HP: 136, AC: 13, Attack: 25, AttackBonus: 7, Speed: 12, + BlockRate: 0.10, + Ability: &MonsterAbility{Name: "Vengeful Regeneration", Phase: "any", ProcChance: 0.50, Effect: "regenerate"}, + XPValue: 1800, + Notes: "Elite. +10 HP/turn regen; cannot die permanently until goal fulfilled; STR DC 20 grapple.", + }, + "boss_aldric_blackspire": { + ID: "boss_aldric_blackspire", Name: "Lord Aldric Blackspire", + CR: 13, HP: 144, AC: 16, Attack: 38, AttackBonus: 9, Speed: 14, + BlockRate: 0.20, + Ability: &MonsterAbility{Name: "Charm", Phase: "any", ProcChance: 0.40, Effect: "stun"}, + XPValue: 10000, + Notes: "Manor Blackspire boss. Mist Form retreat; Children of the Night summon; Legendary Resistance 3/combat.", + }, + "magmin": { + ID: "magmin", Name: "Magmin", + CR: 0.5, HP: 9, AC: 14, Attack: 7, AttackBonus: 3, Speed: 10, + BlockRate: 0.05, + Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1.00, Effect: "death_aoe"}, + XPValue: 100, + Notes: "Death Burst: 2d6 fire in 10 ft on death. Ignite flammable objects on touch.", + }, + "azer": { + ID: "azer", Name: "Azer", + CR: 2, HP: 39, AC: 17, Attack: 11, AttackBonus: 5, Speed: 12, + BlockRate: 0.20, + Ability: &MonsterAbility{Name: "Fire Aura", Phase: "any", ProcChance: 0.50, Effect: "retaliate"}, + XPValue: 450, + Notes: "1d10 fire to melee attackers; immune to fire; weapons deal heated bonus damage.", + }, + "salamander": { + ID: "salamander", Name: "Salamander", + CR: 5, HP: 90, AC: 15, Attack: 22, AttackBonus: 7, Speed: 12, + BlockRate: 0.10, + Ability: &MonsterAbility{Name: "Constrict", Phase: "any", ProcChance: 0.40, Effect: "stun"}, + XPValue: 1800, + Notes: "Constrict grapple + 2d6 fire/turn; spear multiattack; fire aura.", + }, + "fire_elemental": { + ID: "fire_elemental", Name: "Fire Elemental", + CR: 5, HP: 102, AC: 13, Attack: 20, AttackBonus: 6, Speed: 14, + BlockRate: 0.05, + Ability: &MonsterAbility{Name: "Fire Form", Phase: "any", ProcChance: 0.50, Effect: "retaliate"}, + XPValue: 1800, + Notes: "1d10 contact fire damage; immune to fire; vulnerable to water/cold.", + }, + "helmed_horror": { + ID: "helmed_horror", Name: "Helmed Horror", + CR: 4, HP: 60, AC: 20, Attack: 16, AttackBonus: 6, Speed: 12, + BlockRate: 0.25, + Ability: &MonsterAbility{Name: "Spell Immunity", Phase: "any", ProcChance: 1.00, Effect: "spell_resist"}, + XPValue: 1100, + Notes: "Elite. Immune to 3 specific spells (rolled per instance); multiattack; magic resistance.", + }, + "boss_emberlord_thyrak": { + ID: "boss_emberlord_thyrak", Name: "Emberlord Thyrak", + CR: 9, HP: 178, AC: 18, Attack: 34, AttackBonus: 8, Speed: 11, + BlockRate: 0.20, + Ability: &MonsterAbility{Name: "Forge Breath", Phase: "any", ProcChance: 0.30, Effect: "aoe"}, + XPValue: 5000, + Notes: "Underforge boss. Phase 2 below 50% HP — spawns 2 Fire Elementals; breath recharge 4–6.", + }, + } + for id, m := range tier3 { + dndBestiary[id] = m + } + return true +}() + // dndBestiaryByCR returns templates whose CR is at or below the given cap. // Useful for procedurally selecting a monster appropriate to player level. func dndBestiaryByCR(maxCR float32) []DnDMonsterTemplate { diff --git a/internal/plugin/dnd_zone.go b/internal/plugin/dnd_zone.go index 9e33039..464317e 100644 --- a/internal/plugin/dnd_zone.go +++ b/internal/plugin/dnd_zone.go @@ -113,6 +113,8 @@ func init() { registerZone(zoneCryptValdris()) registerZone(zoneForestShadows()) registerZone(zoneSunkenTemple()) + registerZone(zoneManorBlackspire()) + registerZone(zoneUnderforge()) } // getZone returns the definition by ID and ok=false if unknown. @@ -346,3 +348,98 @@ func zoneSunkenTemple() ZoneDefinition { FlavorFile: "zone_sunken_temple_flavor.go", } } + +// ---- Tier 3 zone factories (Phase 11 D4a) ----------------------------------- + +func zoneManorBlackspire() ZoneDefinition { + return ZoneDefinition{ + ID: ZoneManorBlackspire, + Display: "Haunted Manor of Blackspire", + Tier: ZoneTierJourneyman, + LevelMin: 5, + LevelMax: 8, + Faction: "Undead, Shadows, Vampiric", + Atmosphere: "Victorian decay, impossible architecture, portraits whose eyes follow movement, cold spots, locked rooms that weren't locked before.", + Hook: "The manor has been for sale for eleven years. Every buyer has either left immediately or not left at all. The real estate listing describes it as 'full of character.' TwinBee finds this accurate.", + MinRooms: 7, + MaxRooms: 9, + Enemies: []ZoneEnemy{ + {BestiaryID: "shadow", SpawnWeight: 6}, + {BestiaryID: "poltergeist", SpawnWeight: 5}, + {BestiaryID: "banshee", SpawnWeight: 3}, + {BestiaryID: "wraith", SpawnWeight: 3}, + {BestiaryID: "vampire_spawn", SpawnWeight: 3}, + {BestiaryID: "revenant", SpawnWeight: 1, IsElite: true}, + }, + Boss: ZoneBoss{ + BestiaryID: "boss_aldric_blackspire", + Name: "Lord Aldric Blackspire", + CR: 13, + HP: 144, + AC: 16, + PhaseTwoAt: 0, + Description: "He has watched seven generations of his bloodline die in this house. He considers himself the only survivor. He may be right.", + Abilities: []string{ + "Multiattack: Unarmed Strike + Bite (life drain) every turn", + "Charm: WIS DC 17 or Charmed for 24h (broken by damage)", + "Children of the Night (1/combat): summons 2d6 Bat Swarms or 3d6 Rats", + "Mist Form: at 0 HP retreats to coffin; must destroy coffin in 30 turns or fully regenerates", + "Legendary Resistance (3/combat); Phase 2 (Mist destroyed): all attacks have advantage; AoE Charm", + }, + }, + Loot: []ZoneLootEntry{ + {ItemID: "blackspire_signet_ring", DropChance: 0.06, Note: "+2 CHA; undead NPCs treat you as neutral unless attacked"}, + {ItemID: "wpn_sword_of_wounding", DropChance: 0.10}, + {ItemID: "arm_armor_of_resistance_necrotic", DropChance: 0.08}, + {ItemID: "blackspire_deed", UniqueAlways: true, Note: "story/quest item"}, + {ItemID: "coins_8d10x10", DropChance: 1.0, Note: "8d10 × 10 coins"}, + }, + FlavorFile: "zone_manor_blackspire_flavor.go", + } +} + +func zoneUnderforge() ZoneDefinition { + return ZoneDefinition{ + ID: ZoneUnderforge, + Display: "The Underforge", + Tier: ZoneTierJourneyman, + LevelMin: 5, + LevelMax: 8, + Faction: "Fire Elementals, Constructs, Salamanders, Azers", + Atmosphere: "Volcanic caverns, rivers of cooling lava, ancient dwarven stonework, the constant bass note of something very large moving below.", + Hook: "The dwarven forge-city of Kharak Dûn was not abandoned. It was sealed from the outside. TwinBee does not have information on what they were sealing in.", + MinRooms: 7, + MaxRooms: 9, + Enemies: []ZoneEnemy{ + {BestiaryID: "magmin", SpawnWeight: 6}, + {BestiaryID: "azer", SpawnWeight: 5}, + {BestiaryID: "flameskull", SpawnWeight: 4}, + {BestiaryID: "salamander", SpawnWeight: 3}, + {BestiaryID: "fire_elemental", SpawnWeight: 3}, + {BestiaryID: "helmed_horror", SpawnWeight: 1, IsElite: true}, + }, + Boss: ZoneBoss{ + BestiaryID: "boss_emberlord_thyrak", + Name: "Emberlord Thyrak", + CR: 9, + HP: 178, + AC: 18, + PhaseTwoAt: 0.50, + Description: "The forge-golem that ran Kharak Dûn's furnaces for three centuries. When the dwarves left, it kept working. It has been improving the designs.", + Abilities: []string{ + "Molten Strike: +4d6 fire on hit; target armor -1 AC until repaired (long rest)", + "Forge Breath (recharge 5–6): 30-ft cone, 10d6 fire, DEX DC 16 half", + "Living Forge: heals 15 HP/turn while standing on lava/forge tiles", + "Construct Resilience: immune poison/psychic/charm/exhaustion; resist non-magical physical", + "Phase 2 (<50% HP): Forge Breath recharge 4–6; spawns 2 Fire Elementals", + }, + }, + Loot: []ZoneLootEntry{ + {ItemID: "thyraks_core", UniqueAlways: true, Note: "crafting material for legendary weapons"}, + {ItemID: "arm_adamantine_armor", DropChance: 0.12}, + {ItemID: "wpn_flame_tongue", DropChance: 0.10}, + {ItemID: "azer_ingots", UniqueAlways: true, Note: "1d6 ingots; high-value Thom Krooke sale"}, + }, + FlavorFile: "zone_underforge_flavor.go", + } +} diff --git a/internal/plugin/dnd_zone_test.go b/internal/plugin/dnd_zone_test.go index 7fbb81e..72bf597 100644 --- a/internal/plugin/dnd_zone_test.go +++ b/internal/plugin/dnd_zone_test.go @@ -35,6 +35,19 @@ func TestZoneRegistry_Tier2Present(t *testing.T) { } } +func TestZoneRegistry_Tier3Present(t *testing.T) { + if _, ok := getZone(ZoneManorBlackspire); !ok { + t.Fatal("Haunted Manor of Blackspire not registered") + } + if _, ok := getZone(ZoneUnderforge); !ok { + t.Fatal("The Underforge not registered") + } + t3 := zonesByTier(ZoneTierJourneyman) + if len(t3) != 2 { + t.Fatalf("expected 2 Tier 3 zones, got %d", len(t3)) + } +} + func TestZoneRegistry_BestiaryRefsResolve(t *testing.T) { for _, z := range allZones() { for _, e := range z.Enemies { @@ -59,15 +72,15 @@ func TestZoneRegistry_BossCRMatchesZoneTier(t *testing.T) { } func TestZonesForLevel_TierGate(t *testing.T) { - // L1 player (tier 1): max tier visible = 1+2 = 3. With T1+T2 registered - // (D1a + D3a), L1 sees all four — Tier 3 zones aren't in the registry yet. + // L1 player (tier 1): max tier visible = 1+2 = 3. With T1+T2+T3 + // registered (D1a + D3a + D4a), L1 sees all six. got := zonesForLevel(1) - if len(got) != 4 { - t.Fatalf("L1 should see 4 zones (2 T1 + 2 T2), got %d", len(got)) + if len(got) != 6 { + t.Fatalf("L1 should see 6 zones (2 T1 + 2 T2 + 2 T3), got %d", len(got)) } // L20 player should also see all zones (no upper cutoff). got = zonesForLevel(20) - if len(got) < 4 { + if len(got) < 6 { t.Fatalf("L20 should see all zones, got %d", len(got)) } }