mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 D&D Phase 11 D3a: Tier 2 zones + bestiary
Forest of Shadows + Sunken Temple of Dar'eth registered. New bestiary entries (dire wolf, bandit captain, owlbear, corrupted dryad, displacer beast, green hag, kuo-toa + whip, water elemental, merrow, aboleth thrall, Hollow King boss, Dreaming Aboleth boss) per dungeon zones doc §5. L1 tier-gate test expectation bumped from 2→4 zones now that T2 is visible to T1 players. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -198,6 +198,126 @@ var _ = func() bool {
|
|||||||
return true
|
return true
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// ---- Tier 2 zone roster (Phase 11 D3a) ---------------------------------------
|
||||||
|
//
|
||||||
|
// Forest of Shadows + Sunken Temple of Dar'eth enemy entries. Stat blocks
|
||||||
|
// per gogobee_dungeon_zones.md §5. Same Attack-as-average-damage convention
|
||||||
|
// as Tier 1: Attack ≈ avg of primary attack profile, AttackBonus = d20
|
||||||
|
// to-hit modifier. Elites flagged via the zone roster, not here.
|
||||||
|
|
||||||
|
var _ = func() bool {
|
||||||
|
tier2 := map[string]DnDMonsterTemplate{
|
||||||
|
"dire_wolf": {
|
||||||
|
ID: "dire_wolf", Name: "Dire Wolf",
|
||||||
|
CR: 1, HP: 37, AC: 14, Attack: 10, AttackBonus: 5, Speed: 16,
|
||||||
|
BlockRate: 0.05,
|
||||||
|
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"},
|
||||||
|
XPValue: 200,
|
||||||
|
Notes: "Knock Prone on hit (STR DC 13); +1d6 if ally adjacent.",
|
||||||
|
},
|
||||||
|
"bandit_captain": {
|
||||||
|
ID: "bandit_captain", Name: "Bandit Captain",
|
||||||
|
CR: 2, HP: 65, AC: 15, Attack: 14, AttackBonus: 5, Speed: 12,
|
||||||
|
BlockRate: 0.20,
|
||||||
|
Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"},
|
||||||
|
XPValue: 450,
|
||||||
|
Notes: "Multiattack 3x; reaction Parry adds +3 AC vs one attack.",
|
||||||
|
},
|
||||||
|
"owlbear": {
|
||||||
|
ID: "owlbear", Name: "Owlbear",
|
||||||
|
CR: 3, HP: 59, AC: 13, Attack: 16, AttackBonus: 7, Speed: 12,
|
||||||
|
BlockRate: 0.05,
|
||||||
|
Ability: &MonsterAbility{Name: "Grapple", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||||
|
XPValue: 700,
|
||||||
|
Notes: "Beak + Claw multiattack. Claw hit triggers grapple attempt.",
|
||||||
|
},
|
||||||
|
"dryad_corrupted": {
|
||||||
|
ID: "dryad_corrupted", Name: "Corrupted Dryad",
|
||||||
|
CR: 2, HP: 22, AC: 11, Attack: 11, AttackBonus: 4, Speed: 12,
|
||||||
|
BlockRate: 0.0,
|
||||||
|
Ability: &MonsterAbility{Name: "Fey Charm", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||||
|
XPValue: 450,
|
||||||
|
Notes: "Barkskin self-buff (+2 AC); WIS DC 14 vs charm or waste a turn.",
|
||||||
|
},
|
||||||
|
"displacer_beast": {
|
||||||
|
ID: "displacer_beast", Name: "Displacer Beast",
|
||||||
|
CR: 3, HP: 85, AC: 13, Attack: 14, AttackBonus: 6, Speed: 14,
|
||||||
|
BlockRate: 0.10,
|
||||||
|
Ability: &MonsterAbility{Name: "Displacement", Phase: "opening", ProcChance: 0.50, Effect: "evade"},
|
||||||
|
XPValue: 700,
|
||||||
|
Notes: "First hit on player each round has disadvantage; Avoidance trait.",
|
||||||
|
},
|
||||||
|
"green_hag": {
|
||||||
|
ID: "green_hag", Name: "Green Hag",
|
||||||
|
CR: 3, HP: 82, AC: 17, Attack: 15, AttackBonus: 5, Speed: 12,
|
||||||
|
BlockRate: 0.15,
|
||||||
|
Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"},
|
||||||
|
XPValue: 700,
|
||||||
|
Notes: "Elite. Illusory Appearance; Weakness curse on hit.",
|
||||||
|
},
|
||||||
|
"kuo_toa": {
|
||||||
|
ID: "kuo_toa", Name: "Kuo-toa",
|
||||||
|
CR: 0.25, HP: 18, AC: 13, Attack: 5, AttackBonus: 3, Speed: 10,
|
||||||
|
BlockRate: 0.10,
|
||||||
|
Ability: &MonsterAbility{Name: "Sticky Shield", Phase: "any", ProcChance: 0.25, Effect: "block"},
|
||||||
|
XPValue: 30,
|
||||||
|
Notes: "Amphibious. Slippery (auto-escape grapple).",
|
||||||
|
},
|
||||||
|
"kuo_toa_whip": {
|
||||||
|
ID: "kuo_toa_whip", Name: "Kuo-toa Whip",
|
||||||
|
CR: 1, HP: 65, AC: 11, Attack: 11, AttackBonus: 3, Speed: 10,
|
||||||
|
BlockRate: 0.05,
|
||||||
|
Ability: &MonsterAbility{Name: "Divine Eminence", Phase: "decisive", ProcChance: 0.50, Effect: "bonus_damage"},
|
||||||
|
XPValue: 200,
|
||||||
|
Notes: "Pincer Staff grapple on hit; +2d6 psychic on Eminence proc.",
|
||||||
|
},
|
||||||
|
"water_elemental": {
|
||||||
|
ID: "water_elemental", Name: "Water Elemental",
|
||||||
|
CR: 5, HP: 114, AC: 14, Attack: 26, AttackBonus: 7, Speed: 12,
|
||||||
|
BlockRate: 0.10,
|
||||||
|
Ability: &MonsterAbility{Name: "Whelm", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||||
|
XPValue: 1800,
|
||||||
|
Notes: "Elite. Whelm grapples + drowning (CON DC 13). Freeze reaction.",
|
||||||
|
},
|
||||||
|
"merrow": {
|
||||||
|
ID: "merrow", Name: "Merrow",
|
||||||
|
CR: 2, HP: 45, AC: 13, Attack: 13, AttackBonus: 5, Speed: 12,
|
||||||
|
BlockRate: 0.05,
|
||||||
|
Ability: &MonsterAbility{Name: "Harpoon Pull", Phase: "opening", ProcChance: 0.40, Effect: "stun"},
|
||||||
|
XPValue: 450,
|
||||||
|
Notes: "Amphibious. Harpoon yanks target 15 ft; multiattack.",
|
||||||
|
},
|
||||||
|
"aboleth_thrall": {
|
||||||
|
ID: "aboleth_thrall", Name: "Aboleth Thrall",
|
||||||
|
CR: 3, HP: 60, AC: 12, Attack: 14, AttackBonus: 5, Speed: 11,
|
||||||
|
BlockRate: 0.0,
|
||||||
|
Ability: &MonsterAbility{Name: "Psychic Bond", Phase: "any", ProcChance: 0.35, Effect: "reveal_action"},
|
||||||
|
XPValue: 700,
|
||||||
|
Notes: "WIS DC 14 or telegraph next action. Mucus Cloud aura.",
|
||||||
|
},
|
||||||
|
"boss_hollow_king": {
|
||||||
|
ID: "boss_hollow_king", Name: "The Hollow King",
|
||||||
|
CR: 6, HP: 142, AC: 15, Attack: 30, AttackBonus: 7, Speed: 13,
|
||||||
|
BlockRate: 0.15,
|
||||||
|
Ability: &MonsterAbility{Name: "Root Surge", Phase: "any", ProcChance: 0.45, Effect: "stun"},
|
||||||
|
XPValue: 2300,
|
||||||
|
Notes: "Forest of Shadows boss. Phase 2 below 40% HP — summons 2 Dire Wolves.",
|
||||||
|
},
|
||||||
|
"boss_dreaming_aboleth": {
|
||||||
|
ID: "boss_dreaming_aboleth", Name: "The Dreaming Aboleth",
|
||||||
|
CR: 10, HP: 135, AC: 17, Attack: 36, AttackBonus: 9, Speed: 10,
|
||||||
|
BlockRate: 0.10,
|
||||||
|
Ability: &MonsterAbility{Name: "Enslave", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||||
|
XPValue: 5900,
|
||||||
|
Notes: "Sunken Temple boss. Tentacle multiattack; legendary actions; psychic drain.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for id, m := range tier2 {
|
||||||
|
dndBestiary[id] = m
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}()
|
||||||
|
|
||||||
// dndBestiaryByCR returns templates whose CR is at or below the given cap.
|
// dndBestiaryByCR returns templates whose CR is at or below the given cap.
|
||||||
// Useful for procedurally selecting a monster appropriate to player level.
|
// Useful for procedurally selecting a monster appropriate to player level.
|
||||||
func dndBestiaryByCR(maxCR float32) []DnDMonsterTemplate {
|
func dndBestiaryByCR(maxCR float32) []DnDMonsterTemplate {
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ func registerZone(z ZoneDefinition) {
|
|||||||
func init() {
|
func init() {
|
||||||
registerZone(zoneGoblinWarrens())
|
registerZone(zoneGoblinWarrens())
|
||||||
registerZone(zoneCryptValdris())
|
registerZone(zoneCryptValdris())
|
||||||
|
registerZone(zoneForestShadows())
|
||||||
|
registerZone(zoneSunkenTemple())
|
||||||
}
|
}
|
||||||
|
|
||||||
// getZone returns the definition by ID and ok=false if unknown.
|
// getZone returns the definition by ID and ok=false if unknown.
|
||||||
@@ -253,3 +255,94 @@ func zoneCryptValdris() ZoneDefinition {
|
|||||||
FlavorFile: "zone_crypt_valdris_flavor.go",
|
FlavorFile: "zone_crypt_valdris_flavor.go",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Tier 2 zone factories (Phase 11 D3a) -----------------------------------
|
||||||
|
|
||||||
|
func zoneForestShadows() ZoneDefinition {
|
||||||
|
return ZoneDefinition{
|
||||||
|
ID: ZoneForestShadows,
|
||||||
|
Display: "Forest of Shadows",
|
||||||
|
Tier: ZoneTierApprentice,
|
||||||
|
LevelMin: 3,
|
||||||
|
LevelMax: 5,
|
||||||
|
Faction: "Beasts, Fey-corrupted creatures, Bandits",
|
||||||
|
Atmosphere: "Ancient forest, twisted paths, eerie silence, bioluminescent fungi, things in the canopy.",
|
||||||
|
Hook: "The forest was beautiful once. Travelers still say so, usually right before they stop saying anything at all. The trees lean in when you're not looking. TwinBee has noted this is not a metaphor.",
|
||||||
|
MinRooms: 6,
|
||||||
|
MaxRooms: 8,
|
||||||
|
Enemies: []ZoneEnemy{
|
||||||
|
{BestiaryID: "dire_wolf", SpawnWeight: 6},
|
||||||
|
{BestiaryID: "bandit_captain", SpawnWeight: 4},
|
||||||
|
{BestiaryID: "owlbear", SpawnWeight: 4},
|
||||||
|
{BestiaryID: "dryad_corrupted", SpawnWeight: 3},
|
||||||
|
{BestiaryID: "displacer_beast", SpawnWeight: 3},
|
||||||
|
{BestiaryID: "green_hag", SpawnWeight: 1, IsElite: true},
|
||||||
|
},
|
||||||
|
Boss: ZoneBoss{
|
||||||
|
BestiaryID: "boss_hollow_king",
|
||||||
|
Name: "The Hollow King",
|
||||||
|
CR: 6,
|
||||||
|
HP: 142,
|
||||||
|
AC: 15,
|
||||||
|
PhaseTwoAt: 0.40,
|
||||||
|
Description: "What was once a forest guardian, now a vessel for something older and angrier. The antlers are real. The eyes are not.",
|
||||||
|
Abilities: []string{
|
||||||
|
"Corrupting Aura: melee-range targets WIS DC 14 each turn or lose bonus action",
|
||||||
|
"Root Surge (recharge 5–6): Restrain (STR DC 15) + 2d8 bludgeoning",
|
||||||
|
"Devour Light: extinguishes magical light for 2 turns; player AC -2",
|
||||||
|
"Phase 2 (<40% HP): summons 2 Dire Wolves; gains Reckless Attack",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Loot: []ZoneLootEntry{
|
||||||
|
{ItemID: "the_hollow_crown", DropChance: 0.06, Note: "+2 WIS; Fey Sight (advantage on Perception in nature)"},
|
||||||
|
{ItemID: "arm_hide_+2", DropChance: 0.10},
|
||||||
|
{ItemID: "wpn_longbow_+1", DropChance: 0.12},
|
||||||
|
{ItemID: "forest_essence", UniqueAlways: true, Note: "crafting material x1–3"},
|
||||||
|
},
|
||||||
|
FlavorFile: "zone_forest_shadows_flavor.go",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoneSunkenTemple() ZoneDefinition {
|
||||||
|
return ZoneDefinition{
|
||||||
|
ID: ZoneSunkenTemple,
|
||||||
|
Display: "Sunken Temple of Dar'eth",
|
||||||
|
Tier: ZoneTierApprentice,
|
||||||
|
LevelMin: 3,
|
||||||
|
LevelMax: 5,
|
||||||
|
Faction: "Kuo-toa, Water Elementals, Aboleth-touched",
|
||||||
|
Atmosphere: "Flooded stone chambers, barnacled pillars, salt smell, alien glyphs, things that swim in the dark water.",
|
||||||
|
Hook: "The tide went out thirty years ago and never fully came back. The temple stayed wet anyway. Something down there keeps it that way. TwinBee suggests waterproofing your spellbook.",
|
||||||
|
MinRooms: 6,
|
||||||
|
MaxRooms: 8,
|
||||||
|
Enemies: []ZoneEnemy{
|
||||||
|
{BestiaryID: "kuo_toa", SpawnWeight: 7},
|
||||||
|
{BestiaryID: "kuo_toa_whip", SpawnWeight: 4},
|
||||||
|
{BestiaryID: "merrow", SpawnWeight: 4},
|
||||||
|
{BestiaryID: "aboleth_thrall", SpawnWeight: 3},
|
||||||
|
{BestiaryID: "water_elemental", SpawnWeight: 1, IsElite: true},
|
||||||
|
},
|
||||||
|
Boss: ZoneBoss{
|
||||||
|
BestiaryID: "boss_dreaming_aboleth",
|
||||||
|
Name: "The Dreaming Aboleth",
|
||||||
|
CR: 10,
|
||||||
|
HP: 135,
|
||||||
|
AC: 17,
|
||||||
|
PhaseTwoAt: 0,
|
||||||
|
Description: "Ancient. Patient. It has been waiting in this temple since before the city above was built. It has had time to plan.",
|
||||||
|
Abilities: []string{
|
||||||
|
"Tentacle Multiattack: 3 hits; on-hit Diseased (no magical healing 24h until cured)",
|
||||||
|
"Enslave (recharge 6): WIS DC 14 or Charmed; player skips turn, drifts toward Aboleth",
|
||||||
|
"Mucus Cloud: melee attackers CON DC 14 or skin→membrane (6d6 acid if not submerged)",
|
||||||
|
"Legendary Actions (3/round): Detect / Tail Swipe (2 LA) / Psychic Drain (3 LA, max-HP cut)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Loot: []ZoneLootEntry{
|
||||||
|
{ItemID: "aboleths_eye", DropChance: 0.05, Note: "+2 INT; Telepathy (passive lore checks auto-succeed)"},
|
||||||
|
{ItemID: "arm_breastplate_+1", DropChance: 0.10},
|
||||||
|
{ItemID: "refined_aboleth_mucus", UniqueAlways: true, Note: "rare crafting material x1"},
|
||||||
|
{ItemID: "coins_5d10x8", DropChance: 1.0, Note: "5d10 × 8 coins"},
|
||||||
|
},
|
||||||
|
FlavorFile: "zone_sunken_temple_flavor.go",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ func TestZoneRegistry_Tier1Present(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestZoneRegistry_Tier2Present(t *testing.T) {
|
||||||
|
if _, ok := getZone(ZoneForestShadows); !ok {
|
||||||
|
t.Fatal("Forest of Shadows not registered")
|
||||||
|
}
|
||||||
|
if _, ok := getZone(ZoneSunkenTemple); !ok {
|
||||||
|
t.Fatal("Sunken Temple not registered")
|
||||||
|
}
|
||||||
|
t2 := zonesByTier(ZoneTierApprentice)
|
||||||
|
if len(t2) != 2 {
|
||||||
|
t.Fatalf("expected 2 Tier 2 zones, got %d", len(t2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestZoneRegistry_BestiaryRefsResolve(t *testing.T) {
|
func TestZoneRegistry_BestiaryRefsResolve(t *testing.T) {
|
||||||
for _, z := range allZones() {
|
for _, z := range allZones() {
|
||||||
for _, e := range z.Enemies {
|
for _, e := range z.Enemies {
|
||||||
@@ -46,15 +59,15 @@ func TestZoneRegistry_BossCRMatchesZoneTier(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestZonesForLevel_TierGate(t *testing.T) {
|
func TestZonesForLevel_TierGate(t *testing.T) {
|
||||||
// L1 player (tier 1): can reach tier 1+2=3. With only T1 zones
|
// L1 player (tier 1): max tier visible = 1+2 = 3. With T1+T2 registered
|
||||||
// registered in D1a, returns both.
|
// (D1a + D3a), L1 sees all four — Tier 3 zones aren't in the registry yet.
|
||||||
got := zonesForLevel(1)
|
got := zonesForLevel(1)
|
||||||
if len(got) != 2 {
|
if len(got) != 4 {
|
||||||
t.Fatalf("L1 should see 2 T1 zones, got %d", len(got))
|
t.Fatalf("L1 should see 4 zones (2 T1 + 2 T2), got %d", len(got))
|
||||||
}
|
}
|
||||||
// L20 player should also see all zones (no upper cutoff).
|
// L20 player should also see all zones (no upper cutoff).
|
||||||
got = zonesForLevel(20)
|
got = zonesForLevel(20)
|
||||||
if len(got) < 2 {
|
if len(got) < 4 {
|
||||||
t.Fatalf("L20 should see all zones, got %d", len(got))
|
t.Fatalf("L20 should see all zones, got %d", len(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user