mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 D&D Phase 11 D1a: zone registry + Tier 1 zones
Zone Infrastructure SUB-A: ZoneDefinition / ZoneTier / ZoneEnemy / ZoneBoss / ZoneLootEntry types and dndZoneRegistry. Tier 1 zones populated per gogobee_dungeon_zones.md §5: Goblin Warrens (T1, L1-3, boss Grol the Unbroken CR3) and Crypt of Valdris (T1, L1-3, boss Valdris the Unburied CR5, phase 2 at 50% HP). Helpers: getZone, zonesForLevel (2-tier-above ceiling), zonesByTier, allZones. Bestiary additions for Tier 1 enemy rosters: goblin_sneak, goblin_archer, hobgoblin_grunt, worg, goblin_shaman, hobgoblin_warchief, zombie, shadow, specter, wight, flameskull, plus the two zone bosses. Zombie AC bumped 8->10 to satisfy engine TestBestiaryAllWellFormed minimum. 7 new tests covering registry presence, bestiary-ref resolution, boss CR floor, level-tier gating, loot-chance bounds, room-count sanity, elite flag presence, and tier->level-range alignment. Full plugin suite green. Also marks SUB3 done in gogobee_subclass_system.md (all 15 subclasses through L15 shipped across SUB3a-d). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -249,10 +249,10 @@ Each subclass selection triggers a TwinBee narration line and a one-line flavor
|
|||||||
- [x] Berserker Rage mechanic and Exhaustion tracking
|
- [x] Berserker Rage mechanic and Exhaustion tracking
|
||||||
|
|
||||||
### Phase SUB3 — Advanced Abilities
|
### Phase SUB3 — Advanced Abilities
|
||||||
- [ ] Level 10 and 15 abilities per subclass
|
- [x] Level 10 and 15 abilities per subclass (all 15 subclasses)
|
||||||
- [ ] Battle Master maneuver dice system
|
- [x] Battle Master maneuver dice system (SUB2a-ii)
|
||||||
- [ ] Arcane Trickster spellcasting integration
|
- [x] Arcane Trickster spellcasting integration (SUB2-AT)
|
||||||
- [ ] Beast Master full combat pet participation
|
- [x] Beast Master full combat pet participation (SUB3d Superior Bond)
|
||||||
|
|
||||||
---
|
---
|
||||||
*End of Subclass System.*
|
*End of Subclass System.*
|
||||||
|
|||||||
@@ -78,6 +78,126 @@ var dndBestiary = map[string]DnDMonsterTemplate{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Tier 1 zone roster (Phase 11 D1a) ---------------------------------------
|
||||||
|
//
|
||||||
|
// Goblin Warrens + Crypt of Valdris enemy entries. Stat blocks per
|
||||||
|
// gogobee_dungeon_zones.md §5. AttackBonus and Attack are derived from
|
||||||
|
// each creature's primary attack profile in 5e (rounded for the engine's
|
||||||
|
// integer "Attack" stat — engine treats this as average damage).
|
||||||
|
|
||||||
|
var _ = func() bool {
|
||||||
|
tier1 := map[string]DnDMonsterTemplate{
|
||||||
|
"goblin_sneak": {
|
||||||
|
ID: "goblin_sneak", Name: "Goblin Sneak",
|
||||||
|
CR: 0.25, HP: 7, AC: 13, Attack: 6, AttackBonus: 4, Speed: 14,
|
||||||
|
BlockRate: 0.05,
|
||||||
|
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.25, Effect: "advantage"},
|
||||||
|
XPValue: 30,
|
||||||
|
Notes: "Nimble Escape; +1d4 if ally adjacent.",
|
||||||
|
},
|
||||||
|
"goblin_archer": {
|
||||||
|
ID: "goblin_archer", Name: "Goblin Archer",
|
||||||
|
CR: 0.25, HP: 7, AC: 13, Attack: 5, AttackBonus: 4, Speed: 14,
|
||||||
|
BlockRate: 0.0,
|
||||||
|
Ability: &MonsterAbility{Name: "Scurry", Phase: "any", ProcChance: 0.30, Effect: "evade"},
|
||||||
|
XPValue: 30,
|
||||||
|
Notes: "Ranged only. Disengages after attack.",
|
||||||
|
},
|
||||||
|
"hobgoblin_grunt": {
|
||||||
|
ID: "hobgoblin_grunt", Name: "Hobgoblin Grunt",
|
||||||
|
CR: 0.5, HP: 11, AC: 18, Attack: 9, AttackBonus: 3, Speed: 11,
|
||||||
|
BlockRate: 0.10,
|
||||||
|
Ability: &MonsterAbility{Name: "Martial Advantage", Phase: "any", ProcChance: 0.30, Effect: "bonus_damage"},
|
||||||
|
XPValue: 100,
|
||||||
|
Notes: "Pack-tactics variant. Formation bonus when grouped.",
|
||||||
|
},
|
||||||
|
"worg": {
|
||||||
|
ID: "worg", Name: "Worg",
|
||||||
|
CR: 0.5, HP: 26, AC: 13, Attack: 10, AttackBonus: 5, Speed: 17,
|
||||||
|
BlockRate: 0.05,
|
||||||
|
Ability: &MonsterAbility{Name: "Knock Prone", Phase: "any", ProcChance: 0.25, Effect: "stun"},
|
||||||
|
XPValue: 100,
|
||||||
|
Notes: "Carries goblin riders. STR DC 13 to avoid prone on hit.",
|
||||||
|
},
|
||||||
|
"goblin_shaman": {
|
||||||
|
ID: "goblin_shaman", Name: "Goblin Shaman",
|
||||||
|
CR: 1, HP: 22, AC: 11, Attack: 12, AttackBonus: 4, Speed: 12,
|
||||||
|
BlockRate: 0.0,
|
||||||
|
Ability: &MonsterAbility{Name: "Burning Hands", Phase: "opening", ProcChance: 0.50, Effect: "aoe_fire"},
|
||||||
|
XPValue: 200,
|
||||||
|
Notes: "Burning Hands 2d6 fire DEX DC 11; Healing Word on allies.",
|
||||||
|
},
|
||||||
|
"hobgoblin_warchief": {
|
||||||
|
ID: "hobgoblin_warchief", Name: "Hobgoblin Warchief",
|
||||||
|
CR: 2, HP: 52, AC: 18, Attack: 18, AttackBonus: 5, Speed: 11,
|
||||||
|
BlockRate: 0.15,
|
||||||
|
Ability: &MonsterAbility{Name: "Leadership Aura", Phase: "any", ProcChance: 0.40, Effect: "ally_buff"},
|
||||||
|
XPValue: 450,
|
||||||
|
Notes: "Elite. +1d4 to ally attacks; multiattack.",
|
||||||
|
},
|
||||||
|
"zombie": {
|
||||||
|
ID: "zombie", Name: "Zombie",
|
||||||
|
CR: 0.25, HP: 22, AC: 10, Attack: 5, AttackBonus: 3, Speed: 6,
|
||||||
|
BlockRate: 0.0,
|
||||||
|
Ability: &MonsterAbility{Name: "Undead Fortitude", Phase: "decisive", ProcChance: 0.50, Effect: "survive_at_1"},
|
||||||
|
XPValue: 50,
|
||||||
|
Notes: "CON DC 5 + damage dealt or survive at 1 HP. AC bumped 8→10 to satisfy engine min.",
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
ID: "shadow", Name: "Shadow",
|
||||||
|
CR: 0.5, HP: 16, AC: 12, Attack: 9, AttackBonus: 4, Speed: 12,
|
||||||
|
BlockRate: 0.0,
|
||||||
|
Ability: &MonsterAbility{Name: "Strength Drain", Phase: "any", ProcChance: 0.35, Effect: "stat_drain"},
|
||||||
|
XPValue: 100,
|
||||||
|
Notes: "Hit reduces STR by 1d4 until long rest. Resist non-magical physical.",
|
||||||
|
},
|
||||||
|
"specter": {
|
||||||
|
ID: "specter", Name: "Specter",
|
||||||
|
CR: 1, HP: 22, AC: 12, Attack: 10, AttackBonus: 4, Speed: 14,
|
||||||
|
BlockRate: 0.0,
|
||||||
|
Ability: &MonsterAbility{Name: "Life Drain", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"},
|
||||||
|
XPValue: 200,
|
||||||
|
Notes: "Incorporeal: non-magical weapons deal half damage.",
|
||||||
|
},
|
||||||
|
"wight": {
|
||||||
|
ID: "wight", Name: "Wight",
|
||||||
|
CR: 3, HP: 45, AC: 14, Attack: 14, AttackBonus: 4, Speed: 12,
|
||||||
|
BlockRate: 0.10,
|
||||||
|
Ability: &MonsterAbility{Name: "Life Drain", Phase: "any", ProcChance: 0.45, Effect: "lifesteal"},
|
||||||
|
XPValue: 700,
|
||||||
|
Notes: "Elite. Raises slain humanoids as zombies.",
|
||||||
|
},
|
||||||
|
"flameskull": {
|
||||||
|
ID: "flameskull", Name: "Flameskull",
|
||||||
|
CR: 4, HP: 40, AC: 13, Attack: 18, AttackBonus: 5, Speed: 15,
|
||||||
|
BlockRate: 0.05,
|
||||||
|
Ability: &MonsterAbility{Name: "Fireball", Phase: "decisive", ProcChance: 0.55, Effect: "aoe_fire"},
|
||||||
|
XPValue: 1100,
|
||||||
|
Notes: "Elite. Fireball 8d6 DEX DC 13. Rejuvenation in 1h unless holy water used.",
|
||||||
|
},
|
||||||
|
"boss_grol_unbroken": {
|
||||||
|
ID: "boss_grol_unbroken", Name: "Grol the Unbroken",
|
||||||
|
CR: 3, HP: 65, AC: 17, Attack: 22, AttackBonus: 5, Speed: 12,
|
||||||
|
BlockRate: 0.15,
|
||||||
|
Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 1.0, Effect: "bonus_damage"},
|
||||||
|
XPValue: 700,
|
||||||
|
Notes: "Goblin Warrens boss. Bugbear war-chief.",
|
||||||
|
},
|
||||||
|
"boss_valdris_unburied": {
|
||||||
|
ID: "boss_valdris_unburied", Name: "Valdris the Unburied",
|
||||||
|
CR: 5, HP: 97, AC: 17, Attack: 28, AttackBonus: 7, Speed: 12,
|
||||||
|
BlockRate: 0.10,
|
||||||
|
Ability: &MonsterAbility{Name: "Corrupting Touch", Phase: "any", ProcChance: 0.50, Effect: "max_hp_drain"},
|
||||||
|
XPValue: 1800,
|
||||||
|
Notes: "Crypt of Valdris boss. Lich-aspirant; phase 2 below 50% HP.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for id, m := range tier1 {
|
||||||
|
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 {
|
||||||
|
|||||||
255
internal/plugin/dnd_zone.go
Normal file
255
internal/plugin/dnd_zone.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// Phase 11 D1a — zone registry. Implements `gogobee_dungeon_zones.md` §5.
|
||||||
|
//
|
||||||
|
// D1a (this file) ships zone *definitions* only — names, tiers, level
|
||||||
|
// ranges, enemy rosters by bestiary ID, boss stat block, and loot stubs.
|
||||||
|
// State machine (DungeonRun), commands (!zone enter/advance/etc), and
|
||||||
|
// TwinBee GM mood land in subsequent D1 sub-phases (D1b–D1d).
|
||||||
|
//
|
||||||
|
// Tier 1 ships first (Goblin Warrens, Crypt of Valdris). Tiers 2–5 are
|
||||||
|
// added in D2/D3/D4/D5. Loot table item IDs reference equipment/treasure
|
||||||
|
// IDs that may not yet exist in the equipment registry; that wiring
|
||||||
|
// happens at drop time, not at zone-definition time, so unknown IDs are
|
||||||
|
// not an error here — they're the contract that future content fulfils.
|
||||||
|
|
||||||
|
type ZoneID string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ZoneGoblinWarrens ZoneID = "goblin_warrens"
|
||||||
|
ZoneCryptValdris ZoneID = "crypt_valdris"
|
||||||
|
ZoneForestShadows ZoneID = "forest_shadows"
|
||||||
|
ZoneSunkenTemple ZoneID = "sunken_temple"
|
||||||
|
ZoneManorBlackspire ZoneID = "manor_blackspire"
|
||||||
|
ZoneUnderforge ZoneID = "underforge"
|
||||||
|
ZoneUnderdark ZoneID = "underdark"
|
||||||
|
ZoneFeywildCrossing ZoneID = "feywild_crossing"
|
||||||
|
ZoneDragonsLair ZoneID = "dragons_lair"
|
||||||
|
ZoneAbyssPortal ZoneID = "abyss_portal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ZoneTier — 1..5. Player level ranges per design doc §2.
|
||||||
|
type ZoneTier int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ZoneTierBeginner ZoneTier = 1
|
||||||
|
ZoneTierApprentice ZoneTier = 2
|
||||||
|
ZoneTierJourneyman ZoneTier = 3
|
||||||
|
ZoneTierVeteran ZoneTier = 4
|
||||||
|
ZoneTierLegendary ZoneTier = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// ZoneEnemy is a roster entry. BestiaryID must resolve via dndBestiary
|
||||||
|
// at spawn time (D1c+). SpawnWeight biases procedural room population —
|
||||||
|
// higher = appears more often in exploration rooms.
|
||||||
|
type ZoneEnemy struct {
|
||||||
|
BestiaryID string
|
||||||
|
SpawnWeight int // 1..10; default 5
|
||||||
|
IsElite bool // appears only in Elite rooms
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoneBoss — single boss for the zone. Multi-phase encounters expose
|
||||||
|
// PhaseTwoAt as a fraction of MaxHP (0.5 = phase 2 starts at 50% HP).
|
||||||
|
// Abilities is descriptive for now (D1a is data-only); D5+ wires real
|
||||||
|
// boss-ability hooks into the combat engine.
|
||||||
|
type ZoneBoss struct {
|
||||||
|
BestiaryID string // canonical ID; bestiary entry added with the zone
|
||||||
|
Name string // display name for the boss intro line
|
||||||
|
CR float32 // for tier validation only
|
||||||
|
HP int
|
||||||
|
AC int
|
||||||
|
PhaseTwoAt float64 // fraction of MaxHP triggering phase 2; 0 = no phase 2
|
||||||
|
Abilities []string
|
||||||
|
Description string // one-line lore for boss-entry narration
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoneLootEntry — drop chance + item id. UniqueAlways marks story drops
|
||||||
|
// that always appear (e.g. quest items, phylactery shards).
|
||||||
|
type ZoneLootEntry struct {
|
||||||
|
ItemID string
|
||||||
|
DropChance float64 // 0..1; ignored if UniqueAlways
|
||||||
|
UniqueAlways bool // always drops; used for quest items
|
||||||
|
Note string // free-text descriptor (unique-item flavor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoneDefinition — full zone descriptor. Room count is the design-doc's
|
||||||
|
// "6–8 rooms per run", scaled by tier.
|
||||||
|
type ZoneDefinition struct {
|
||||||
|
ID ZoneID
|
||||||
|
Display string
|
||||||
|
Tier ZoneTier
|
||||||
|
LevelMin int
|
||||||
|
LevelMax int
|
||||||
|
Faction string
|
||||||
|
Atmosphere string // one-line biome description
|
||||||
|
Hook string // entry-room narration seed (TwinBee voice, italic)
|
||||||
|
MinRooms int
|
||||||
|
MaxRooms int
|
||||||
|
Enemies []ZoneEnemy
|
||||||
|
Boss ZoneBoss
|
||||||
|
Loot []ZoneLootEntry
|
||||||
|
FlavorFile string // expected flavor file name (D6 deliverable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dndZoneRegistry — keyed by ZoneID. Tier 1 lands in D1a; remaining
|
||||||
|
// tiers join in D2/D3/D4/D5. zoneOrder preserves design-doc ordering.
|
||||||
|
var dndZoneRegistry = map[ZoneID]ZoneDefinition{}
|
||||||
|
var zoneOrder = []ZoneID{}
|
||||||
|
|
||||||
|
func registerZone(z ZoneDefinition) {
|
||||||
|
if _, dup := dndZoneRegistry[z.ID]; dup {
|
||||||
|
// Programmer error: duplicate definition. Panic at init makes
|
||||||
|
// this surface immediately on test start rather than in prod.
|
||||||
|
panic("duplicate zone registration: " + string(z.ID))
|
||||||
|
}
|
||||||
|
dndZoneRegistry[z.ID] = z
|
||||||
|
zoneOrder = append(zoneOrder, z.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerZone(zoneGoblinWarrens())
|
||||||
|
registerZone(zoneCryptValdris())
|
||||||
|
}
|
||||||
|
|
||||||
|
// getZone returns the definition by ID and ok=false if unknown.
|
||||||
|
func getZone(id ZoneID) (ZoneDefinition, bool) {
|
||||||
|
z, ok := dndZoneRegistry[id]
|
||||||
|
return z, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// zonesForLevel returns all zones a player at dndLevel may enter.
|
||||||
|
// Per design doc §2: a player cannot enter a zone more than 2 tiers
|
||||||
|
// above their current level. We approximate "current tier" as
|
||||||
|
// floor((level-1)/3)+1 so L1–3=T1, L4–6≈T2, etc., capping at T5.
|
||||||
|
// Result is sorted by tier ascending then declared order.
|
||||||
|
func zonesForLevel(dndLevel int) []ZoneDefinition {
|
||||||
|
playerTier := (dndLevel-1)/3 + 1
|
||||||
|
if playerTier < 1 {
|
||||||
|
playerTier = 1
|
||||||
|
}
|
||||||
|
maxTier := playerTier + 2
|
||||||
|
var out []ZoneDefinition
|
||||||
|
for _, id := range zoneOrder {
|
||||||
|
z := dndZoneRegistry[id]
|
||||||
|
if int(z.Tier) <= maxTier {
|
||||||
|
out = append(out, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
return out[i].Tier < out[j].Tier
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// zonesByTier — zones at exactly the given tier, in declared order.
|
||||||
|
func zonesByTier(t ZoneTier) []ZoneDefinition {
|
||||||
|
var out []ZoneDefinition
|
||||||
|
for _, id := range zoneOrder {
|
||||||
|
if z := dndZoneRegistry[id]; z.Tier == t {
|
||||||
|
out = append(out, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// allZones — every registered zone in declared (design-doc) order.
|
||||||
|
func allZones() []ZoneDefinition {
|
||||||
|
out := make([]ZoneDefinition, 0, len(zoneOrder))
|
||||||
|
for _, id := range zoneOrder {
|
||||||
|
out = append(out, dndZoneRegistry[id])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tier 1 zone factories ---------------------------------------------------
|
||||||
|
|
||||||
|
func zoneGoblinWarrens() ZoneDefinition {
|
||||||
|
return ZoneDefinition{
|
||||||
|
ID: ZoneGoblinWarrens,
|
||||||
|
Display: "Goblin Warrens",
|
||||||
|
Tier: ZoneTierBeginner,
|
||||||
|
LevelMin: 1,
|
||||||
|
LevelMax: 3,
|
||||||
|
Faction: "Goblins, Hobgoblins",
|
||||||
|
Atmosphere: "Low ceilings, torchlight, crude traps, cackling in the dark.",
|
||||||
|
Hook: "A network of fetid tunnels burrowed beneath the Merchant's Road. The smell arrives before the sounds — smoke, rot, and something worse. TwinBee advises keeping one hand on your blade.",
|
||||||
|
MinRooms: 6,
|
||||||
|
MaxRooms: 7,
|
||||||
|
Enemies: []ZoneEnemy{
|
||||||
|
{BestiaryID: "goblin_sneak", SpawnWeight: 7},
|
||||||
|
{BestiaryID: "goblin_archer", SpawnWeight: 6},
|
||||||
|
{BestiaryID: "hobgoblin_grunt", SpawnWeight: 5},
|
||||||
|
{BestiaryID: "worg", SpawnWeight: 3},
|
||||||
|
{BestiaryID: "goblin_shaman", SpawnWeight: 2},
|
||||||
|
{BestiaryID: "hobgoblin_warchief", SpawnWeight: 1, IsElite: true},
|
||||||
|
},
|
||||||
|
Boss: ZoneBoss{
|
||||||
|
BestiaryID: "boss_grol_unbroken",
|
||||||
|
Name: "Grol the Unbroken",
|
||||||
|
CR: 3,
|
||||||
|
HP: 65,
|
||||||
|
AC: 17,
|
||||||
|
PhaseTwoAt: 0,
|
||||||
|
Description: "A Bugbear war-chief who has united three goblin clans under his banner. Wears a belt of troll teeth and smells like he earned them.",
|
||||||
|
Abilities: []string{
|
||||||
|
"Surprise Attack: +2d6 damage if player has not acted this combat",
|
||||||
|
"Heart of Hruggek: crits deal max damage (no roll)",
|
||||||
|
"Terrifying Roar (1/combat): allies +2 to hit for 2 turns; player WIS DC 13 or Frightened",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Loot: []ZoneLootEntry{
|
||||||
|
{ItemID: "wpn_handaxe_+1", DropChance: 0.15},
|
||||||
|
{ItemID: "arm_studded_leather_+1", DropChance: 0.10},
|
||||||
|
{ItemID: "grols_belt", DropChance: 0.05, Note: "+2 STR while equipped"},
|
||||||
|
{ItemID: "coins_2d10x5", DropChance: 1.0, Note: "2d10 × 5 coins"},
|
||||||
|
},
|
||||||
|
FlavorFile: "zone_goblin_warrens_flavor.go",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoneCryptValdris() ZoneDefinition {
|
||||||
|
return ZoneDefinition{
|
||||||
|
ID: ZoneCryptValdris,
|
||||||
|
Display: "The Crypt of Valdris",
|
||||||
|
Tier: ZoneTierBeginner,
|
||||||
|
LevelMin: 1,
|
||||||
|
LevelMax: 3,
|
||||||
|
Faction: "Undead",
|
||||||
|
Atmosphere: "Stone corridors, dripping water, candles that shouldn't still be burning.",
|
||||||
|
Hook: "The iron gate hangs open — someone left in a hurry. Carved into the stone above: \"HERE LIES VALDRIS. DO NOT.\" The rest has been chiseled away. TwinBee declines to speculate.",
|
||||||
|
MinRooms: 6,
|
||||||
|
MaxRooms: 7,
|
||||||
|
Enemies: []ZoneEnemy{
|
||||||
|
{BestiaryID: "skeleton", SpawnWeight: 7},
|
||||||
|
{BestiaryID: "zombie", SpawnWeight: 6},
|
||||||
|
{BestiaryID: "shadow", SpawnWeight: 4},
|
||||||
|
{BestiaryID: "specter", SpawnWeight: 3},
|
||||||
|
{BestiaryID: "wight", SpawnWeight: 1, IsElite: true},
|
||||||
|
{BestiaryID: "flameskull", SpawnWeight: 1, IsElite: true},
|
||||||
|
},
|
||||||
|
Boss: ZoneBoss{
|
||||||
|
BestiaryID: "boss_valdris_unburied",
|
||||||
|
Name: "Valdris the Unburied",
|
||||||
|
CR: 5,
|
||||||
|
HP: 97,
|
||||||
|
AC: 17,
|
||||||
|
PhaseTwoAt: 0.50,
|
||||||
|
Description: "A lich-aspirant who got most of the way there. Cunning enough to be dangerous, failed enough to be bitter.",
|
||||||
|
Abilities: []string{
|
||||||
|
"Corrupting Touch: +4d6 necrotic; target max HP reduced by damage dealt (long rest restores)",
|
||||||
|
"Legendary Resistance (2/combat): auto-succeed one failed save",
|
||||||
|
"Call of the Grave (recharge 5–6): summons 1d4 skeletons from room bones",
|
||||||
|
"Phase 2 (<50% HP): Fly speed; spells deal +1d6 necrotic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Loot: []ZoneLootEntry{
|
||||||
|
{ItemID: "valdris_phylactery_shard", UniqueAlways: true, Note: "quest item"},
|
||||||
|
{ItemID: "arm_cloak_of_protection", DropChance: 0.12},
|
||||||
|
{ItemID: "wpn_mace_of_smiting", DropChance: 0.08},
|
||||||
|
{ItemID: "coins_3d10x4", DropChance: 1.0, Note: "3d10 × 4 coins"},
|
||||||
|
},
|
||||||
|
FlavorFile: "zone_crypt_valdris_flavor.go",
|
||||||
|
}
|
||||||
|
}
|
||||||
122
internal/plugin/dnd_zone_test.go
Normal file
122
internal/plugin/dnd_zone_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Phase 11 D1a — zone registry tests. Validates that:
|
||||||
|
// 1. Tier 1 zones are registered.
|
||||||
|
// 2. All enemy/boss BestiaryID references resolve.
|
||||||
|
// 3. Level gating (zonesForLevel) respects the 2-tier-above ceiling.
|
||||||
|
// 4. Loot tables either have explicit drop chances in [0,1] or
|
||||||
|
// UniqueAlways set.
|
||||||
|
|
||||||
|
func TestZoneRegistry_Tier1Present(t *testing.T) {
|
||||||
|
if _, ok := getZone(ZoneGoblinWarrens); !ok {
|
||||||
|
t.Fatal("Goblin Warrens not registered")
|
||||||
|
}
|
||||||
|
if _, ok := getZone(ZoneCryptValdris); !ok {
|
||||||
|
t.Fatal("Crypt of Valdris not registered")
|
||||||
|
}
|
||||||
|
t1 := zonesByTier(ZoneTierBeginner)
|
||||||
|
if len(t1) != 2 {
|
||||||
|
t.Fatalf("expected 2 Tier 1 zones, got %d", len(t1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneRegistry_BestiaryRefsResolve(t *testing.T) {
|
||||||
|
for _, z := range allZones() {
|
||||||
|
for _, e := range z.Enemies {
|
||||||
|
if _, ok := dndBestiary[e.BestiaryID]; !ok {
|
||||||
|
t.Errorf("zone %s enemy %s missing from bestiary", z.ID, e.BestiaryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := dndBestiary[z.Boss.BestiaryID]; !ok {
|
||||||
|
t.Errorf("zone %s boss %s missing from bestiary", z.ID, z.Boss.BestiaryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneRegistry_BossCRMatchesZoneTier(t *testing.T) {
|
||||||
|
// Sanity: boss CR should be at least at the high end of tier
|
||||||
|
// CR range. Tier 1 = CR 1/8–1 enemies, but boss is CR 3–5.
|
||||||
|
for _, z := range allZones() {
|
||||||
|
if z.Boss.CR < 1 {
|
||||||
|
t.Errorf("zone %s boss CR %.1f too low", z.ID, z.Boss.CR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZonesForLevel_TierGate(t *testing.T) {
|
||||||
|
// L1 player (tier 1): can reach tier 1+2=3. With only T1 zones
|
||||||
|
// registered in D1a, returns both.
|
||||||
|
got := zonesForLevel(1)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("L1 should see 2 T1 zones, got %d", len(got))
|
||||||
|
}
|
||||||
|
// L20 player should also see all zones (no upper cutoff).
|
||||||
|
got = zonesForLevel(20)
|
||||||
|
if len(got) < 2 {
|
||||||
|
t.Fatalf("L20 should see all zones, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneRegistry_LootDropChances(t *testing.T) {
|
||||||
|
for _, z := range allZones() {
|
||||||
|
for _, l := range z.Loot {
|
||||||
|
if l.UniqueAlways {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if l.DropChance <= 0 || l.DropChance > 1 {
|
||||||
|
t.Errorf("zone %s loot %s drop chance %.2f out of range",
|
||||||
|
z.ID, l.ItemID, l.DropChance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneRegistry_RoomCountSane(t *testing.T) {
|
||||||
|
for _, z := range allZones() {
|
||||||
|
if z.MinRooms < 5 || z.MaxRooms > 10 || z.MinRooms > z.MaxRooms {
|
||||||
|
t.Errorf("zone %s rooms %d-%d outside design (5-10, min<=max)",
|
||||||
|
z.ID, z.MinRooms, z.MaxRooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneRegistry_ElitesFlagged(t *testing.T) {
|
||||||
|
// Each Tier 1 zone roster should contain at least one elite (per
|
||||||
|
// design doc each zone lists ELITE entries).
|
||||||
|
for _, z := range allZones() {
|
||||||
|
anyElite := false
|
||||||
|
for _, e := range z.Enemies {
|
||||||
|
if e.IsElite {
|
||||||
|
anyElite = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !anyElite {
|
||||||
|
t.Errorf("zone %s has no elite enemy", z.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneRegistry_LevelRangeMatchesTier(t *testing.T) {
|
||||||
|
// Tier→expected level range per design doc §2.
|
||||||
|
expected := map[ZoneTier][2]int{
|
||||||
|
ZoneTierBeginner: {1, 3},
|
||||||
|
ZoneTierApprentice: {3, 5},
|
||||||
|
ZoneTierJourneyman: {5, 8},
|
||||||
|
ZoneTierVeteran: {8, 12},
|
||||||
|
ZoneTierLegendary: {12, 20},
|
||||||
|
}
|
||||||
|
for _, z := range allZones() {
|
||||||
|
want, ok := expected[z.Tier]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("zone %s has unknown tier %d", z.ID, z.Tier)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if z.LevelMin < want[0] || z.LevelMax > want[1] {
|
||||||
|
t.Errorf("zone %s level %d-%d outside tier %d range %d-%d",
|
||||||
|
z.ID, z.LevelMin, z.LevelMax, z.Tier, want[0], want[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user