From ba2a2b5e9037626ca7317a7c6fd4ec4a51486d94 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 11:53:54 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 11 D1a: zone registry + Tier 1 zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gogobee_subclass_system.md | 8 +- internal/plugin/dnd_bestiary.go | 120 +++++++++++++++ internal/plugin/dnd_zone.go | 255 +++++++++++++++++++++++++++++++ internal/plugin/dnd_zone_test.go | 122 +++++++++++++++ 4 files changed, 501 insertions(+), 4 deletions(-) create mode 100644 internal/plugin/dnd_zone.go create mode 100644 internal/plugin/dnd_zone_test.go diff --git a/gogobee_subclass_system.md b/gogobee_subclass_system.md index 48e2272..9127b1a 100644 --- a/gogobee_subclass_system.md +++ b/gogobee_subclass_system.md @@ -249,10 +249,10 @@ Each subclass selection triggers a TwinBee narration line and a one-line flavor - [x] Berserker Rage mechanic and Exhaustion tracking ### Phase SUB3 — Advanced Abilities -- [ ] Level 10 and 15 abilities per subclass -- [ ] Battle Master maneuver dice system -- [ ] Arcane Trickster spellcasting integration -- [ ] Beast Master full combat pet participation +- [x] Level 10 and 15 abilities per subclass (all 15 subclasses) +- [x] Battle Master maneuver dice system (SUB2a-ii) +- [x] Arcane Trickster spellcasting integration (SUB2-AT) +- [x] Beast Master full combat pet participation (SUB3d Superior Bond) --- *End of Subclass System.* diff --git a/internal/plugin/dnd_bestiary.go b/internal/plugin/dnd_bestiary.go index fe73988..af1aa73 100644 --- a/internal/plugin/dnd_bestiary.go +++ b/internal/plugin/dnd_bestiary.go @@ -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. // 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 new file mode 100644 index 0000000..709865d --- /dev/null +++ b/internal/plugin/dnd_zone.go @@ -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", + } +} diff --git a/internal/plugin/dnd_zone_test.go b/internal/plugin/dnd_zone_test.go new file mode 100644 index 0000000..b0a578d --- /dev/null +++ b/internal/plugin/dnd_zone_test.go @@ -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]) + } + } +}