Files
gogobee/internal/plugin/dnd_zone_test.go
prosolis d9541f07f1 adventure: Tier 6 postgame "Mythic" dungeons (P1–P7 calibration)
Five post-game dungeons above the T5 ceiling, gated on both T5 bosses beaten
+ level 18. Opt-in endgame: deadly solo, clearable by a party with Pete + pets.

- P1 gating: postgameUnlocked (T5 clears + level floor), zonesForLevel excludes
  T6 unconditionally; wired into startZoneRun, !zone/!expedition, party accept,
  boredom picker, and the list dividers.
- P2 bestiary: 15 elites + 5 signature bosses (Layer-1 stat blocks).
- P3 zone defs + 4-region registries; ZoneLootEntry.BossOnly.
- P4 five zone graphs on a shared builder (44–52 rooms, no soft-lock; Ossuary
  secret Verse nodes).
- P5 loot: BossOnly enforced; signature items are real registry magic items;
  five Thom pity recipes off the per-zone crafting anchors.
- P6 narration/flavor (5 files), T6 achievements, Pete stays zone-parametric.
- P7 (in progress): sim can now reach gated T6 (SimRunner.SeedPostgameUnlock +
  IsPostgameZone). First calibration pass on millenia — hardened ossuary +
  drowned_star, softened first_hoard + unplace; last_meridian in band.

Fix: party members were refused from every T6 zone because expeditionCmdAccept
ran the level gate (which excludes T6) before the postgame check — the intended
party endgame was unreachable. Route T6 through postgameUnlocked. Regression
tests added.
2026-07-15 23:17:07 -07:00

179 lines
5.1 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 T6 post-game target of 4452.
for _, z := range allZones() {
if z.MinRooms < 5 || z.MaxRooms > 52 || z.MinRooms > z.MaxRooms {
t.Errorf("zone %s rooms %d-%d outside design (5-52, 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},
ZoneTierMythic: {18, 20}, // post-game: gated by unlock, not level band
}
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])
}
}
}