mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user