diff --git a/internal/plugin/expedition_balance_test.go b/internal/plugin/expedition_balance_test.go index 18c15c6..cbd35cf 100644 --- a/internal/plugin/expedition_balance_test.go +++ b/internal/plugin/expedition_balance_test.go @@ -1,6 +1,9 @@ package plugin import ( + "fmt" + "math" + "sort" "testing" ) @@ -91,3 +94,176 @@ func TestExpeditionBalance_Phase0_SeedSpread(t *testing.T) { t.Fatalf("two distinct seeds produced byte-identical trials — RNG seam may not be wired:\n a = %+v\n b = %+v", a, b) } } + +// phase1TierCenterline maps each tier to its centerline player level — +// the median of the design-doc player-level range for the tier: +// +// T1 (L1-4) → 3 +// T2 (L3-7) → 5 +// T3 (L5-10) → 8 +// T4 (L7-15) → 11 +// T5 (L10-20) → 15 +// +// One level per tier keeps the matrix at zones × 1, not zones × range. +// Phase 4 may widen this if a tier band looks level-sensitive. +var phase1TierCenterline = map[ZoneTier]int{ + ZoneTierBeginner: 3, + ZoneTierApprentice: 5, + ZoneTierJourneyman: 8, + ZoneTierVeteran: 11, + ZoneTierLegendary: 15, +} + +// TestExpeditionBalance_Phase1_FullMatrix is the Phase 1 baseline- +// measurement test: every registered zone × its tier centerline level, +// 200 trials/cell, class fixed to Fighter so the cell-to-cell delta +// isolates zone difficulty (class parity is the class-balance pass's +// job, not this one). +// +// Per the plan doc, Phase 1's gate is *pathology-only*: no zone at T1 +// reads 0% completion, no zone at T5 reads 100% completion, no NaN / +// zero-day cells. The real target-band assertion lands in Phase 2 after +// the global levers have been tuned to centerline. Until then the +// matrix's job is to *log numbers we can read*, so the per-cell line +// and per-tier mean+spread go through t.Log for the commit diff. +func TestExpeditionBalance_Phase1_FullMatrix(t *testing.T) { + if testing.Short() { + t.Skip("phase 1 matrix is heavy; -short skips it") + } + + const trialsPerCell = 200 + const baseSeed uint64 = 0xE0FFEE1 + + // Walk the zone registry in declared order (matches zoneOrder so + // the log table is stable across runs / commits). + type cellRow struct { + zone ZoneDefinition + result expeditionBalanceResult + } + rows := make([]cellRow, 0, len(zoneOrder)) + for i, id := range zoneOrder { + zone, ok := getZone(id) + if !ok { + t.Fatalf("zoneOrder[%d]=%q not in registry", i, id) + } + level, ok := phase1TierCenterline[zone.Tier] + if !ok { + t.Fatalf("zone %q has tier %d with no phase1 centerline mapping", id, zone.Tier) + } + profile := expeditionBalanceProfile{ + ZoneID: id, + Class: ClassFighter, + Level: level, + Supplies: makeSupplies(zone.Tier, SupplyPurchase{StandardPacks: 3}), + CampType: CampTypeStandard, + } + // Per-cell seed offset keeps cells independent — same seed + // across cells would correlate their RNG streams. + res := runExpeditionBalanceCell(profile, trialsPerCell, baseSeed+uint64(i)*1_000_003) + rows = append(rows, cellRow{zone: zone, result: res}) + } + + // Per-cell log. The format is grep-able: leading "CELL" tag, fixed + // columns. Future me will diff this between commits to see what + // Phase 2's lever moves actually did. + t.Logf("phase1 matrix — %d zones × %d trials, Fighter @ tier-centerline level", len(rows), trialsPerCell) + t.Logf("CELL %-18s %-3s %-3s %-12s %-12s %-13s %-11s %-13s %-9s %-12s", + "zone", "tier", "lvl", "comp%", "death%", "starve%", "med_days", "med_threat", "encs", "hp_left%") + for _, row := range rows { + r := row.result + t.Logf("CELL %-18s T%d L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% med_days=%2d med_threat=%3d encs=%4.1f hp_left=%5.1f%%", + row.zone.ID, row.zone.Tier, r.Profile.Level, + r.CompletionRate()*100, + r.DeathRate()*100, + float64(r.StarvedOuts)/float64(r.Trials)*100, + r.MedianDays, r.MedianThreatEnd, + r.AvgEncounters, r.AvgHPRemainingPct*100, + ) + } + + // Per-tier diagnostic: mean completion% across the zones in the + // tier, and the spread (max − min). Mirrors the class-balance + // per-(level,tier) spread diagnostic. A tight spread means the + // global lever pass alone can drag the tier onto target; a wide + // spread is a signal that Phase 3 per-zone outlier work is + // unavoidable. + tierRows := map[ZoneTier][]cellRow{} + for _, row := range rows { + tierRows[row.zone.Tier] = append(tierRows[row.zone.Tier], row) + } + tiers := []ZoneTier{ + ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman, + ZoneTierVeteran, ZoneTierLegendary, + } + for _, tier := range tiers { + group := tierRows[tier] + if len(group) == 0 { + continue + } + var sum, lo, hi float64 + lo = math.Inf(1) + hi = math.Inf(-1) + zoneNames := make([]string, 0, len(group)) + for _, row := range group { + c := row.result.CompletionRate() * 100 + sum += c + if c < lo { + lo = c + } + if c > hi { + hi = c + } + zoneNames = append(zoneNames, fmt.Sprintf("%s=%.1f%%", row.zone.ID, c)) + } + sort.Strings(zoneNames) + mean := sum / float64(len(group)) + t.Logf("TIER T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp [%s]", + tier, len(group), mean, hi-lo, joinZones(zoneNames)) + } + + // Gates split into two buckets: + // + // *Wiring* pathologies (zero-day loop, days > cap) — fatal. These + // would mean the harness itself is broken and the matrix numbers + // above are noise. + // + // *Difficulty* sentinels (0% at T1, 100% at T5) — logged as WARN, + // not fatal. Phase 1's contract is "log a baseline"; the band + // assertion is Phase 2's job once the global levers have been + // tuned to land within ±10pp of target. Promoting these to + // t.Errorf here would turn Phase 1 into a tuning gate before + // Phase 2 has a chance to move the levers, which the plan doc + // explicitly defers. + for _, row := range rows { + r := row.result + if r.MedianDays == 0 { + t.Errorf("%s: median days == 0; day loop never advanced", row.zone.ID) + } + if r.MedianDays > harnessMaxDays { + t.Errorf("%s: median days %d > cap %d; termination wiring broken", + row.zone.ID, r.MedianDays, harnessMaxDays) + } + if row.zone.Tier == ZoneTierBeginner && r.Completions == 0 { + t.Logf("WARN %s (T1): 0%% completions in %d trials — Phase 2 should lift this", + row.zone.ID, r.Trials) + } + if row.zone.Tier == ZoneTierLegendary && r.Completions == r.Trials { + t.Logf("WARN %s (T5): 100%% completions in %d trials — Phase 2 should pressure this", + row.zone.ID, r.Trials) + } + } +} + +// joinZones is a tiny helper kept local to the test file so the +// per-tier log line reads in one logical chunk without pulling in +// strings.Join's import for production code. +func joinZones(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += ", " + } + out += p + } + return out +}