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
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
"testing"
|
"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)
|
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