diff --git a/internal/plugin/expedition_balance.go b/internal/plugin/expedition_balance.go new file mode 100644 index 0000000..0f8f1df --- /dev/null +++ b/internal/plugin/expedition_balance.go @@ -0,0 +1,541 @@ +package plugin + +import ( + "math/rand/v2" + "sort" +) + +// Measurement harness for the expedition-difficulty pass +// (gogobee_expedition_difficulty.md). Sibling to dnd_class_balance.go — +// same Monte-Carlo philosophy, different target metric. Class balance +// answers "is my class viable?"; this harness answers "is the dungeon +// fair?". Same combat engine, orthogonal knobs. +// +// Naming: the file lives under expedition_balance.go (no dnd_ prefix) +// per feedback_avoid_dnd_naming — new infrastructure files shouldn't +// pick up the trademark-adjacent prefix. +// +// ── Clock-injection seam (the Phase 0 critical de-risk) ──────────────────── +// +// The live expedition runs on two wall-clock-driven goroutines: +// +// expeditionBriefingTicker — 06:00 UTC, calls deliverBriefing +// expeditionRecapTicker — 21:00 UTC, calls deliverRecap +// expedition_ambient.go — every 3h, calls applyAmbientTick +// +// All three are unsuitable for a measurement harness: they're tied to +// wall time, they're 1-per-process singletons, and crucially their +// "advance" routines are heavily DB-coupled (deliverBriefing alone +// touches a half-dozen update statements). +// +// The decision Phase 0 makes here: **the harness does not run the +// tickers and does not inject a virtual time.Now**. Instead, advance +// is reimplemented as `advanceExpeditionOneDay`, a pure function that +// calls the *math-pure helpers* directly (applyDailyBurn, +// dailyThreatDrift, resolveCombatInterrupt, resolveWanderingCheck, +// SimulateCombat) on an in-memory *Expedition. No DB, no goroutines, +// no clock. +// +// What this gives up: any logic that lives only inside deliverBriefing +// (e.g. the temporal pre-burn override for Sunken Temple tidal, the +// approaching-siege RegionState["siege_warning_fired"] gate, the +// milestone narration). Those are flavor + bookkeeping, not difficulty +// math, so the cost is acceptable for measurement. Phase 1+ revisits +// any that turn out to affect outcomes. +// +// What this gives back: a self-contained replay that runs in <1ms per +// simulated day and produces reproducible numbers under a seedable +// RNG. The two combat helpers it leans on (SimulateCombat, +// surpriseRoundNick) already participate in the class-balance +// harness, so the contract is well-trodden. +// +// ── Phase 0 simplifying constraints ─────────────────────────────────────── +// +// Per the plan doc, Phase 0's bar is "one cell runs to a sensible +// number." Specifically held off until Phase 1+: +// +// - Boss completion path. Phase 0 uses survive-N-days as the +// completion proxy. Boss assembly + per-zone room walks land in +// Phase 1 alongside the full matrix. +// - Per-region zones (multi-region E4 zones). Phase 0 treats every +// zone as single-region; the registry's first region is implicit. +// - Loot drops, XP accrual, kill-tag persistence. Pure economy; not +// a difficulty knob in the regression sense. +// - Pardon proc / Sovereign reprieve. Default OFF in Phase 0 so the +// baseline numbers aren't blurred by end-game safety nets. +// - Babysit safe-rest. OFF per [[feedback_npc_buffs_are_secret]] — +// discovery buff, not baseline difficulty. +// - Temporal stack effects (Heat / Time Dilation). Zone-specific +// gimmicks; Phase 3 per-zone outlier pass handles them. +// +// Encounter cadence is a Phase 0 lever: `harnessHarvestRollsPerDay` +// controls how many d20+tier interrupts a player faces per day. The +// number is a placeholder calibrated against the autopilot-harvest +// path — Phase 1's matrix will compare cadences against real player +// data and fix it down to a single value. + +// ── Profile + result ────────────────────────────────────────────────────── + +// expeditionBalanceProfile is one cell of the matrix: a single +// (zone, character build) pair to measure. Race fixed to Human like +// the class harness so the racial floor is neutral across cells. +type expeditionBalanceProfile struct { + ZoneID ZoneID + Class DnDClass + Subclass DnDSubclass + Level int + // Supplies the player departed with. Use makeSupplies for the + // canonical T-appropriate 3×standard kit. + Supplies ExpeditionSupplies + // CampType is the camp the player establishes each night. + // Standard is the spike default; Phase 3 may sweep this per cell. + CampType string +} + +// expeditionTrialResult is the outcome of one simulated expedition. +// Completed and Died are mutually exclusive only when the run ended +// for a known reason — a SupplyStarvation extraction sets neither. +type expeditionTrialResult struct { + Completed bool + Died bool + StarvedOut bool + DaysElapsed int + ThreatAtEnd int + CombatEncounters int + HPRemainingPct float64 // endHP / maxHP at termination; survivors only + EndedReason string // "survived", "died_combat", "starved", "abandoned" +} + +// expeditionBalanceResult is the aggregated win-rate per cell. Mirrors +// classBalanceResult — same shape, different lens. +type expeditionBalanceResult struct { + Profile expeditionBalanceProfile + Trials int + Completions int + Deaths int + StarvedOuts int + MedianDays int + MedianThreatEnd int + AvgEncounters float64 + AvgHPRemainingPct float64 // mean across survivors +} + +// CompletionRate is the band-asserted headline metric for the +// per-tier target band (T1 80%, …, T5 45%, ±10pp). +func (r expeditionBalanceResult) CompletionRate() float64 { + if r.Trials == 0 { + return 0 + } + return float64(r.Completions) / float64(r.Trials) +} + +// DeathRate is the secondary diagnostic; useful for distinguishing +// "expedition is hard because you die" from "expedition is hard +// because you starve and forced-extract." +func (r expeditionBalanceResult) DeathRate() float64 { + if r.Trials == 0 { + return 0 + } + return float64(r.Deaths) / float64(r.Trials) +} + +// ── Phase 0 tunables ───────────────────────────────────────────────────── + +// harnessHarvestRollsPerDay is the number of combat-interrupt rolls a +// simulated player triggers per day. Placeholder: live autopilot +// harvests are interrupt-rolled per resource action, and a typical +// active day touches 3–5 nodes. Phase 1 will compare cell numbers +// against real player traces and pick a final cadence. +const harnessHarvestRollsPerDay = 4 + +// harnessMaxDays caps a Phase 0 expedition at this many days. The +// spike's completion proxy is "survived to this cap"; Phase 1 +// replaces the cap with a boss-kill completion path. +const harnessMaxDays = 14 + +// ── Expedition seed ────────────────────────────────────────────────────── + +// newHarnessExpedition builds an in-memory *Expedition for the +// profile. Skips the DB insert that startExpedition does in +// production. Mirrors the field defaults of startExpedition closely +// enough that the math-pure helpers (applyDailyBurn, +// dailyThreatDrift, resolveCombatInterrupt) see the same shape. +func newHarnessExpedition(p expeditionBalanceProfile) *Expedition { + e := &Expedition{ + ID: "harness", + UserID: "harness", + ZoneID: p.ZoneID, + Status: ExpeditionStatusActive, + CurrentDay: 1, + Supplies: p.Supplies, + ThreatEvents: []ThreatEvent{}, + RegionState: map[string]any{}, + DMMood: 50, + } + if p.CampType != "" { + e.Camp = &CampState{ + Active: true, + Type: p.CampType, + RoomIndex: 0, + } + } + return e +} + +// ── Day advance (the seam) ─────────────────────────────────────────────── + +// advanceExpeditionOneDay simulates one (day, night) pair on the +// in-memory expedition + character. Returns the trial result if the +// expedition terminated this day (death, starvation, day cap); zero +// value otherwise. Caller continues advancing while the trial is +// still in flight. +// +// The pipeline mirrors deliverBriefing → autopilot harvest → +// deliverRecap order so any path-order dependencies stay observable: +// +// 1. Morning rollover: supply burn (pure), day++ +// 2. Starvation check (forced extract if Current == 0) +// 3. Daily threat drift (pure) +// 4. Daytime: N harvest interrupt rolls; combat-rated brackets run +// SimulateCombat against a zone-roster pick +// 5. Night phase: resolveWanderingCheck; ambush/elite outcome runs +// a single fight +// 6. Day end: peg HP into the running char, check death +// +// All four "advance" functions (applyDailyBurn, dailyThreatDrift, +// resolveCombatInterrupt, resolveWanderingCheck) accept their RNG via +// the *Expedition state or an injectable rollFn; we use the latter +// where available so the seed flows through. +func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult { + zone, ok := getZone(h.exp.ZoneID) + if !ok { + // Unknown zone — abandon the trial with a flagged reason so + // the matrix log surfaces the wiring bug instead of NaN-ing. + return expeditionTrialResult{ + DaysElapsed: h.exp.CurrentDay, + EndedReason: "unknown_zone", + } + } + + // 1. Morning rollover — supply burn + day++. + harsh := h.exp.ThreatLevel > 60 + newSupplies, _ := applyDailyBurn(h.exp.Supplies, harsh, h.exp.SiegeMode) + h.exp.Supplies = newSupplies + h.exp.CurrentDay++ + + // 2. Starvation forced-extract — matches deliverBriefing's §4.3. + if supplyDepletion(h.exp.Supplies) == SupplyStarvation { + return h.terminate("starved", false, false, true) + } + + // 3. Daily threat drift — math is in dailyThreatDrift (pure); + // applyDailyThreatDrift's DB write is bypassed but the in-memory + // mutation is mirrored here. + if !h.exp.BossDefeated { + delta, _ := dailyThreatDrift(h.exp.DMMood) + h.exp.ThreatLevel += delta + if h.exp.ThreatLevel < 0 { + h.exp.ThreatLevel = 0 + } + if h.exp.ThreatLevel >= 100 { + h.exp.ThreatLevel = 100 + h.exp.SiegeMode = true + } + } + + // 4. Daytime combat-interrupt rolls. + for i := 0; i < harnessHarvestRollsPerDay; i++ { + kind, _ := resolveCombatInterrupt( + h.exp.ThreatLevel, int(zone.Tier), h.char.Class, h.exp.ZoneID, h.rng.d20, + ) + switch kind { + case InterruptNone: + continue + case InterruptNoise: + // §4.2: noise bumps threat +2, no fight. + h.exp.ThreatLevel += 2 + if h.exp.ThreatLevel > 100 { + h.exp.ThreatLevel = 100 + } + continue + case InterruptStandard, InterruptElite, InterruptPatrol: + res := h.runHarnessFight(zone, kind == InterruptElite) + h.encounters++ + if !res.PlayerWon { + return h.terminate("died_combat", false, true, false) + } + h.char.HPCurrent = res.PlayerEndHP + } + } + + // 5. Night phase — wandering check. resolveWanderingCheck reads + // e.Camp/e.ThreatLevel and uses an injectable rollFn. + nc := resolveWanderingCheck(h.exp, h.char.Class, h.rng.d20) + if nc.ThreatBumped { + h.exp.ThreatLevel += 2 + if h.exp.ThreatLevel > 100 { + h.exp.ThreatLevel = 100 + } + } + switch nc.Outcome { + case NightOutcomeStandard, NightOutcomeElite, NightOutcomeAmbush: + // Live path defers these to !advance; the harness resolves + // them inline so the night threat actually pressures the run. + res := h.runHarnessFight(zone, + nc.Outcome == NightOutcomeElite || nc.Outcome == NightOutcomeAmbush) + h.encounters++ + if !res.PlayerWon { + return h.terminate("died_combat", false, true, false) + } + h.char.HPCurrent = res.PlayerEndHP + } + + // 6. Long rest: camped + survived → HP back to full. Production + // puts this in processOvernightCamp, called by the next morning's + // deliverBriefing; the harness folds it into the end-of-day + // because we don't pend night-check encounters the way live + // !advance does. Severe-rationing blocks the rest (§4.3). + if h.exp.Camp != nil && h.exp.Camp.Active && + supplyAllowsLongRest(supplyDepletion(h.exp.Supplies)) { + h.char.HPCurrent = h.char.HPMax + } + + // 7. Day-cap completion proxy. Phase 1 replaces this with a real + // boss-kill or extract decision. + if h.exp.CurrentDay >= harnessMaxDays { + return h.terminate("survived", true, false, false) + } + return expeditionTrialResult{} +} + +// terminate stamps the final trial result with shared bookkeeping +// (days elapsed, threat at end, encounter count, HP%). +func (h *expeditionHarness) terminate(reason string, completed, died, starved bool) expeditionTrialResult { + pct := 0.0 + if h.char.HPMax > 0 { + pct = float64(h.char.HPCurrent) / float64(h.char.HPMax) + if pct < 0 { + pct = 0 + } + } + return expeditionTrialResult{ + Completed: completed, + Died: died, + StarvedOut: starved, + DaysElapsed: h.exp.CurrentDay, + ThreatAtEnd: h.exp.ThreatLevel, + CombatEncounters: h.encounters, + HPRemainingPct: pct, + EndedReason: reason, + } +} + +// ── Combat fold ────────────────────────────────────────────────────────── + +// runHarnessFight picks an enemy from the zone roster (using a tag- +// agnostic RNG-driven pick — the live picker is deterministic on +// (run, room), which the harness doesn't model), folds in the +// surprise-round HP nick, and runs SimulateCombat with the player +// rebuilt fresh from the class-balance loadout. HP carries over via +// h.char.HPCurrent. +func (h *expeditionHarness) runHarnessFight(zone ZoneDefinition, elite bool) CombatResult { + monster, ok := pickHarnessZoneEnemy(zone, elite, h.rng) + if !ok { + // Empty roster — should never happen for live zones, but if + // it does the trial keeps running with a no-op "fight." + return CombatResult{PlayerWon: true, PlayerEndHP: h.char.HPCurrent, PlayerStartHP: h.char.HPMax} + } + + // Surprise-round nick — same shape as runHarvestInterrupt's + // pre-combat HP shave. Cap at HP-1 so the nick alone can't KO. + nick := surpriseRoundNick(monster, int(zone.Tier)) + if nick >= h.char.HPCurrent { + nick = h.char.HPCurrent - 1 + if nick < 0 { + nick = 0 + } + } + h.char.HPCurrent -= nick + + player := buildHarnessPlayer(h.char) + // Wounded entry: carry HP from prior fights via StartHP so MaxHP + // stays the ceiling (heals respect it). combat_engine.go:348 + // reads StartHP iff 0 < StartHP < MaxHP, which is exactly our + // case for any post-first-fight chain. + if h.char.HPCurrent < h.char.HPMax { + player.Stats.StartHP = h.char.HPCurrent + } + enemy := buildHarnessZoneEnemy(monster, int(zone.Tier)) + return simulateCombatWithRNG(player, enemy, dungeonCombatPhases, h.rng.r) +} + +// pickHarnessZoneEnemy is the harness's RNG-driven analogue of +// pickZoneEnemy. Live picker uses fnv-hashed (run, room) for +// determinism across re-reads; the harness doesn't model rooms and +// gets its determinism from the seeded *harnessRNG instead. +func pickHarnessZoneEnemy(zone ZoneDefinition, isElite bool, rng *harnessRNG) (DnDMonsterTemplate, bool) { + pool := make([]ZoneEnemy, 0, len(zone.Enemies)) + if isElite { + for _, e := range zone.Enemies { + if e.IsElite { + pool = append(pool, e) + } + } + } + if len(pool) == 0 { + // Live picker's fallback: drop the elite filter rather than + // fail. Keeps zone rosters viable when IsElite isn't set. + for _, e := range zone.Enemies { + if !isElite && e.IsElite { + continue + } + pool = append(pool, e) + } + } + if len(pool) == 0 { + return DnDMonsterTemplate{}, false + } + total := 0 + for _, e := range pool { + w := e.SpawnWeight + if w <= 0 { + w = 5 + } + total += w + } + roll := rng.intN(total) + cum := 0 + for _, e := range pool { + w := e.SpawnWeight + if w <= 0 { + w = 5 + } + cum += w + if roll < cum { + tmpl, ok := dndBestiary[e.BestiaryID] + return tmpl, ok + } + } + return DnDMonsterTemplate{}, false +} + +// buildHarnessZoneEnemy is the harness analogue of the enemy half of +// buildZoneCombatants — applies the tier floor for AC/AttackBonus +// (only-raise-to-floor, never double-scale bosses). Skips the DM-mood +// tilt because Phase 0 keeps mood pinned at neutral 50, where the +// tilt is a no-op. +func buildHarnessZoneEnemy(monster DnDMonsterTemplate, tier int) Combatant { + stats, mods := monster.toCombatStats() + if tier > 1 { + if floor := dndDungeonACBase + tier; stats.AC < floor { + stats.AC = floor + } + if floor := dndDungeonAtkBase + tier; stats.AttackBonus < floor { + stats.AttackBonus = floor + } + } + return Combatant{ + Name: monster.Name, + Stats: stats, + Mods: mods, + Ability: monster.Ability, + } +} + +// ── Per-trial runner ───────────────────────────────────────────────────── + +// expeditionHarness threads per-trial mutable state (RNG, expedition, +// character HP carryover, encounter count) through the day loop. +type expeditionHarness struct { + exp *Expedition + char *DnDCharacter + rng *harnessRNG + encounters int +} + +// harnessRNG is a thin wrapper around math/rand/v2 so the d20 helper +// can be passed to resolveCombatInterrupt / resolveWanderingCheck via +// their rollFn parameters, while the same source also drives the +// roster pick. Seed flows from the trial loop. +type harnessRNG struct { + r *rand.Rand +} + +func newHarnessRNG(seed uint64) *harnessRNG { + return &harnessRNG{r: rand.New(rand.NewPCG(seed, seed^0x9E3779B97F4A7C15))} +} + +func (h *harnessRNG) d20() int { return h.r.IntN(20) + 1 } + +func (h *harnessRNG) intN(n int) int { + if n <= 0 { + return 0 + } + return h.r.IntN(n) +} + +// runExpeditionBalanceTrial runs one full expedition end-to-end and +// returns the outcome. Seedable so a Phase 1 matrix run is fully +// reproducible. +func runExpeditionBalanceTrial(p expeditionBalanceProfile, seed uint64) expeditionTrialResult { + exp := newHarnessExpedition(p) + char := buildHarnessCharacter(classBalanceProfile{ + Class: p.Class, + Subclass: p.Subclass, + Level: p.Level, + }) + h := &expeditionHarness{ + exp: exp, + char: char, + rng: newHarnessRNG(seed), + } + for { + res := h.advanceExpeditionOneDay() + if res.EndedReason != "" { + return res + } + } +} + +// runExpeditionBalanceCell runs N trials of one cell and aggregates. +// Mirrors runClassBalanceCell. +func runExpeditionBalanceCell(p expeditionBalanceProfile, trials int, baseSeed uint64) expeditionBalanceResult { + r := expeditionBalanceResult{Profile: p, Trials: trials} + days := make([]int, 0, trials) + threats := make([]int, 0, trials) + var hpSum float64 + var hpN int + var encSum float64 + for i := 0; i < trials; i++ { + tr := runExpeditionBalanceTrial(p, baseSeed+uint64(i)) + days = append(days, tr.DaysElapsed) + threats = append(threats, tr.ThreatAtEnd) + encSum += float64(tr.CombatEncounters) + if tr.Completed { + r.Completions++ + } + if tr.Died { + r.Deaths++ + } + if tr.StarvedOut { + r.StarvedOuts++ + } + if tr.Completed { + hpSum += tr.HPRemainingPct + hpN++ + } + } + sort.Ints(days) + sort.Ints(threats) + if len(days) > 0 { + r.MedianDays = days[len(days)/2] + r.MedianThreatEnd = threats[len(threats)/2] + } + if trials > 0 { + r.AvgEncounters = encSum / float64(trials) + } + if hpN > 0 { + r.AvgHPRemainingPct = hpSum / float64(hpN) + } + return r +} diff --git a/internal/plugin/expedition_balance_test.go b/internal/plugin/expedition_balance_test.go new file mode 100644 index 0000000..18c15c6 --- /dev/null +++ b/internal/plugin/expedition_balance_test.go @@ -0,0 +1,93 @@ +package plugin + +import ( + "testing" +) + +// TestExpeditionBalance_Phase0_Spike is the sanity check the +// expedition-difficulty plan doc's Phase 0 calls for: one cell, 100 +// trials, sensible numbers, fully deterministic from the seed. +// +// The cell — T2 zone (Crypt Valdris) × L5 Fighter — was chosen +// because it's the smallest cell that actually exercises every part +// of the seam: tier scaling raises monster AC/atk, supply burn lands +// at 1.5×, the player is built off the class-balance loadout ladder +// at the T2/+1 step, and the night phase has roster entries to draw +// from. If the seam works here, Phase 1's full-matrix expansion is +// mostly typing. +// +// Pass criteria are intentionally loose — we are not asserting a +// target band yet (Phase 2 does that), only that nothing is wired up +// so badly the result is degenerate (0%/100%/NaN/zero days). Anything +// in (0..100%) completion at this cell is a green Phase 0. +func TestExpeditionBalance_Phase0_Spike(t *testing.T) { + const trials = 100 + profile := expeditionBalanceProfile{ + ZoneID: ZoneCryptValdris, + Class: ClassFighter, + Level: 5, + Supplies: makeSupplies(ZoneTierApprentice, SupplyPurchase{ + StandardPacks: 3, + DeluxePacks: 0, + }), + CampType: CampTypeStandard, + } + + res := runExpeditionBalanceCell(profile, trials, 0xC0FFEE) + + t.Logf("cell: %s L%d %s — completions=%d/%d (%.1f%%), deaths=%d, starved=%d, "+ + "median_days=%d, median_threat=%d, avg_encounters=%.1f, avg_hp_remaining=%.1f%%", + profile.ZoneID, profile.Level, profile.Class, + res.Completions, res.Trials, res.CompletionRate()*100, + res.Deaths, res.StarvedOuts, + res.MedianDays, res.MedianThreatEnd, + res.AvgEncounters, res.AvgHPRemainingPct*100, + ) + + // Degenerate-outcome gates. These are harness-broken sentinels, + // not difficulty assertions — Phase 2 layers the real band on + // top of this same test. + if res.Trials != trials { + t.Fatalf("trial count mismatch: got %d, want %d", res.Trials, trials) + } + if res.Completions == 0 { + t.Errorf("zero completions in %d trials at T2/L5 Fighter — the spike cell should not be unwinnable", trials) + } + if res.Completions == trials { + t.Errorf("100%% completions in %d trials at T2/L5 Fighter — interrupt rolls / night checks not pressuring the run", trials) + } + if res.MedianDays == 0 { + t.Fatalf("median days == 0; day loop never advanced") + } + if res.MedianDays > harnessMaxDays { + t.Fatalf("median days %d > cap %d; termination wiring broken", res.MedianDays, harnessMaxDays) + } +} + +// TestExpeditionBalance_Phase0_SeedSpread confirms the RNG seam is +// actually wired — different seeds produce different trial outcomes +// across a small sample. Full byte-for-byte reproducibility under +// the same seed is *not* asserted at Phase 0: a couple of production +// helpers we lean on (surpriseRoundNick, pickWanderingMonster) draw +// from the package-global math/rand/v2, same as the class-balance +// harness. Phase 1 lifts those to seeded variants if matrix +// reproducibility becomes a real requirement; until then, "seeds +// differentiate" is the contract we can honestly hold. +func TestExpeditionBalance_Phase0_SeedSpread(t *testing.T) { + profile := expeditionBalanceProfile{ + ZoneID: ZoneCryptValdris, + Class: ClassFighter, + Level: 5, + Supplies: makeSupplies(ZoneTierApprentice, SupplyPurchase{ + StandardPacks: 3, + }), + CampType: CampTypeStandard, + } + const seedA uint64 = 0xDEADBEEF + const seedB uint64 = 0xFEEDFACE + a := runExpeditionBalanceTrial(profile, seedA) + b := runExpeditionBalanceTrial(profile, seedB) + if 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) + } +}