Phase 1: expedition-difficulty full matrix baseline

New permanent test TestExpeditionBalance_Phase1_FullMatrix — every
registered zone × its tier-centerline level, 200 trials/cell, Fighter
fixed (class parity is the class-balance pass's job).

Centerlines per plan doc §Method (median of design-doc level range):
T1=L3, T2=L5, T3=L8, T4=L11, T5=L15.

Gates split: wiring pathologies (zero-day loop, days > cap) are fatal;
"0% at T1 / 100% at T5" land as WARN log lines, not t.Errorf. Phase 1's
deliverable is a logged baseline, not a tuning gate — Phase 2 promotes
those to band assertions once the global levers move.

Baseline numbers (seed 0xE0FFEE1, 200 trials, current Phase 0 harness):

  all 10 cells: 0.0% completion, 100% death, median day 2,
  avg encounters 2.0-3.6, threat at end 5-7.

The uniform 0% says the placeholder harnessHarvestRollsPerDay=4 plus
the tier-floored AC/atk is overwhelming the fighter on day 1 across
all tiers, well before threat drift matters. Per-tier spread is 0.0pp
because every zone is floored — Phase 2 has plenty of headroom and a
clear first lever (encounter cadence).

Plan doc: gogobee_expedition_difficulty.md §Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-15 09:09:47 -07:00
parent 53d1608490
commit 881cbfca2f

View File

@@ -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
}