Files
gogobee/internal/plugin/dnd_zone_test.go
prosolis 4999368031 Long expeditions D1-a: graph-driven TotalRooms; goblin_warrens 7→16
Length is now sourced from the zone graph's longest entry→boss path,
not the dice roll, so "Room X/Y" matches what the player walks. The
dice fallback stays for graphless zones.

Goblin Warrens grows to the T1 12–14 band (13-node traversal): adds the
missing Trap anchor and deepens both fork branches + the post-merge
approach. Pattern reference for the remaining zones in D1-b…d.
2026-05-27 17:24:36 -07:00

178 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_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_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_Tier4Present(t *testing.T) {
if _, ok := getZone(ZoneUnderdark); !ok {
t.Fatal("The Underdark not registered")
}
if _, ok := getZone(ZoneFeywildCrossing); !ok {
t.Fatal("Feywild Crossing not registered")
}
t4 := zonesByTier(ZoneTierVeteran)
if len(t4) != 2 {
t.Fatalf("expected 2 Tier 4 zones, got %d", len(t4))
}
}
func TestZoneRegistry_Tier5Present(t *testing.T) {
if _, ok := getZone(ZoneDragonsLair); !ok {
t.Fatal("Dragon's Lair not registered")
}
if _, ok := getZone(ZoneAbyssPortal); !ok {
t.Fatal("The Abyss Portal not registered")
}
t5 := zonesByTier(ZoneTierLegendary)
if len(t5) != 2 {
t.Fatalf("expected 2 Tier 5 zones, got %d", len(t5))
}
}
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/81 enemies, but boss is CR 35.
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): max tier visible = 1+2 = 3. With T1+T2+T3
// registered (D1a + D3a + D4a), L1 sees all six.
got := zonesForLevel(1)
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) < 6 {
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) {
// Long-expedition plan §2 widens the bands per tier: T1 1214 up to
// T5 ~3644. The guard floor stays at 5 (no zone should ever drop
// below that) and the ceiling tracks the T5 target.
for _, z := range allZones() {
if z.MinRooms < 5 || z.MaxRooms > 44 || z.MinRooms > z.MaxRooms {
t.Errorf("zone %s rooms %d-%d outside design (5-44, 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])
}
}
}