diff --git a/gogobee_expedition_difficulty.md b/gogobee_expedition_difficulty.md new file mode 100644 index 0000000..7e9d5c1 --- /dev/null +++ b/gogobee_expedition_difficulty.md @@ -0,0 +1,221 @@ +# Expedition Difficulty Tuning Pass + +Started 2026-05-15, after the Phase D audit close-out. Sibling to the +class-balance pass (`gogobee_class_balance.md`) — same Monte-Carlo +philosophy, different target metric. + +**Class balance answers** "is my class viable?" (parity between classes +vs. the same monster tier). +**This pass answers** "is the dungeon fair?" (does a tier-appropriate +character complete a tier-N expedition at the expected rate?). + +Same combat engine (`simulateCombatWithRNG`), orthogonal knobs. + +--- + +## Method + +**Sim harness, not telemetry-first.** A new +`internal/plugin/dnd_expedition_balance.go` will run thousands of +seeded expeditions through the real autopilot / threat / encounter / +combat code and report: + +- completion % +- death % +- median days-to-extract +- median threat at extract +- supply curve (% expeditions that hit Rationing / Severe / Starvation) +- combat-encounter count per run +- coin/XP earned + +The harness produces these per (zone, player-level) cell; the +regression test asserts the per-tier completion band. + +**Tier target band** (steered by [[feedback_difficulty_target]] +"relatively easy but not too easy", and reflecting that expeditions are +multi-day commitments players plan around — losing one should sting but +not feel arbitrary): + +| Tier | Target completion % | Acceptable band | +|------|--------------------:|-----------------| +| T1 | 80% | 70–90% | +| T2 | 72% | 62–82% | +| T3 | 65% | 55–75% | +| T4 | 55% | 45–65% | +| T5 | 45% | 35–55% | + +Bands are ±10pp around the centerline. Sim is asserted against the band, +not the centerline (no overfit). Player-level for each cell = "tier +appropriate" per the class-balance kit ladder: T1 → L1-4, T2 → L3-7, +T3 → L5-10, T4 → L7-15, T5 → L10-20 (median level per tier). + +**Out of scope:** + +- Per-encounter combat parity (covered by `gogobee_class_balance.md`). +- Arena difficulty (separate system, separate balance). +- Live telemetry tables — deferred; sim is the primary truth source for + this pass. We can add counters later if the sim diverges from + observed live numbers. + +--- + +## Phases + +Each phase ≈ 1 session, ships green + committed. + +### Phase 0 — sim harness spike + +Build the minimum harness: + +- New file `internal/plugin/dnd_expedition_balance.go` exposing + `simulateExpeditionWithRNG(profile, seed)` → result struct. +- **Critical de-risk: replay the autopilot deterministically.** The + ticker is wall-clock driven (`dnd_expedition_cycle.go:38,56` at + 06:00 / 21:00 UTC; `expedition_ambient.go:22` every 3h). Harness + must inject a virtual clock or call the per-phase advance functions + directly (`deliverBriefing`, `deliverRecap`, ambient nudge, harvest + autopilot) in tight loop, bypassing the goroutine ticker. **Settle + the clock-injection seam in Phase 0** — if this is messy, the whole + pass collapses. +- DB-touching layers stubbed/in-memory (same trick as class-balance + Phase 0). +- Sanity test: one cell — e.g. T2 zone at L5 Fighter — runs to + completion or death deterministically, returns sensible numbers, + 100 trials in <10s. + +**Exit:** `TestExpeditionBalance_Phase0_Spike` green; commit. + +### Phase 1 — full matrix measurement + +- Build the cell list: every zone × tier-appropriate-level. Per the + exploration map, expeditions span zones tagged T1–T5; we measure + every zone at its centerline level. +- 200 trials/cell (same density as class-balance Phase 1). +- Matrix log: per-cell completion%, death%, median days, median + extract-threat, supply-curve histogram. +- Per-tier mean + spread diagnostic across zones (analogue of the + class-balance per-(level,tier) spread diagnostic). +- `TestClassBalance_Phase1_FullMatrix`-style permanent test, but only + gating on harness-broken pathologies for now (0% completion anywhere + at T1, 100% completion at T5). + +**Exit:** matrix numbers logged; commit with "Phase 1 baseline" note. + +### Phase 2b — wounded-entrant nick clamp (shipped) + +`clampSurpriseNick(rawNick, hpCurrent, hpMax)` in +`dnd_expedition_combat.go`: at full HP the raw nick stands; when +wounded the nick is capped at `max(1, hpCurrent/5)` so a single nick +on a low-HP fighter can't shave more than ~20% of remaining HP. The +existing "nick can't KO" guard is preserved. + +Motivation came from the post-2a tier-lethality trace +(`TestExpeditionBalance_Phase2_TierLethality`): every tier had a +fingerprint where an over-tier elite left the fighter at 25-50% HP + +retreat, then the next standard's surprise nick (3-7 HP, often +exceeding the fighter's headroom) pre-empted the combat engine +entirely. Capping the wounded-entrant nick separates that cascade +from genuine elite-one-shot deaths so Phase 2c can tune the elites +with a clean read. + +Matrix delta vs Phase 2a baseline is small (median encs +0.1-0.2 +per cell) — completion% still 0% across every tier — but the +tier-lethality trace stretched substantively: T1 trial 0 went 5 → 8 +encs / 5 → 7 days; T3 trial 1 ran 18 → 10 encs but visibly survived +multiple chained interrupts at low HP that pre-2b would have ended +on the nick alone. The remaining deaths are now legible as elite +one-shot fights on fresh entries (Hobgoblin Warchief, Green Hag, +Roper, Young Red Dragon) — that's the Phase 2c roster signal. + +**Tuning surface:** the `/5` divisor in `clampSurpriseNick` is the +wounded-fighter lethality knob. + +### Phase 2c — roster gate (next) + +Promote softer mid-tier monsters to `IsElite: true` in zones whose +elite pool is a single tier-disproportionate boss (warchief / hag / +roper / dragon are all SpawnWeight=1 alone in their elite pool, so +an InterruptElite bracket roll = 100% pick of the over-tier boss). +Diluting the elite pool with one or two softer alternates at higher +SpawnWeight lets the fighter sometimes face a winnable elite, +without removing the over-tier boss as a possible spawn. + +Stat-block tuning of the bosses themselves is off-limits per the +Phase 3 constraint (bestiary is shared across systems). + +### Phase 2 — global lever tuning + +Tune the knobs that affect every zone uniformly: + +- **Daily threat drift** (`dnd_expedition_threat.go:87` — base +3/day). +- **Threat band thresholds** (Quiet/Stirring/Alert/Hostile/Siege — + `dnd_expedition_threat.go:70-82`). +- **Supply burn multipliers** (`dnd_expedition_supplies.go:43-56` — + T1×1 → T5×3). +- **Surprise-round damage floor** (`dnd_expedition_combat.go:184`). +- **Encounter bracket thresholds** (`dnd_expedition_combat.go:39-44` + None/Noise/Standard/Elite/Patrol). +- **Camp mods** (`dnd_expedition_night.go:85-92`). + +Goal: every tier centerline lands within ±5pp of target. Bands stay +±10pp. + +**Exit:** test asserts the ±10pp band per tier; commit. + +### Phase 3 — per-zone outlier pass + +After globals settle, the matrix will name outlier zones — tiers +where one zone reads dramatically off-band while sibling zones are +fine. Per-zone tools: + +- `SpawnWeight` / `IsElite` flags in zone rosters (`dnd_zone.go:100`). +- Boss stat tuning in zone definitions. +- Resource DCs (affects harvest interrupt roll). +- Per-zone loot table density (downstream economy impact — flag if + significant). + +Constraint: do not touch monster stat blocks (those belong to the +bestiary, shared across systems). Tune the roster + zone-level knobs, +not the monsters themselves. + +**Exit:** all per-zone cells within band; commit. + +### Phase 4 — optional MAD / second-order + +If post-Phase-3 the bands hold but feel wrong subjectively +(e.g. "median days too short", "no expedition ever hits Siege"), add a +secondary assertion on median-days-to-extract or extract-threat. Phase +3 may also surface a request to expose a difficulty knob to operators +(env var or admin command); scope that here if it comes up. + +--- + +## Open questions + +- Should the babysit perk be on or off in the sim? Pick one and + document; both as separate cells doubles matrix size for diminishing + insight. Default proposed: **off**, since it's a discovery-mechanic + buff ([[feedback_npc_buffs_are_secret]]) — players who find it earn + extra margin, not the baseline. +- Pardon proc (33% @ chat 20+): include in sim, but at a fixed + chat-level assumption (chat 15 = no pardon, since most fresh runs + haven't hit the threshold). Decide in Phase 0. +- Sovereign reprieve: skip in sim — it's gated on full set equipped + and is a one-shot per cooldown. End-game-only; not part of the + baseline difficulty. +- Multi-region zones (E4): does the sim need to walk all regions in + one expedition, or pick one region per run? Probably full walk for + realism. Settle in Phase 0. +- DM mood: starts at 50; drifts via combat outcomes. Let it drift + naturally in the sim, do not pin. + +--- + +## Deliverables checklist + +- [ ] `internal/plugin/dnd_expedition_balance.go` (sim harness) +- [ ] `TestExpeditionBalance_Phase0_Spike` +- [ ] `TestExpeditionBalance_Phase1_FullMatrix` (becomes the permanent + regression gate after Phase 2 tightens it) +- [ ] Tuning commits per phase +- [ ] One-line update to `MEMORY.md` + project memory pointer diff --git a/internal/plugin/dnd_expedition_combat.go b/internal/plugin/dnd_expedition_combat.go index 831969f..6738d2a 100644 --- a/internal/plugin/dnd_expedition_combat.go +++ b/internal/plugin/dnd_expedition_combat.go @@ -121,16 +121,14 @@ func (p *AdventurePlugin) runHarvestInterrupt( } // Surprise round (§4.1): a single free enemy swing nicks HP. We cap at - // HP-1 so the nick alone can't KO; real combat resolves below. + // HP-1 so the nick alone can't KO; real combat resolves below. The + // wounded-entrant clamp (clampSurpriseNick) softens the nick further + // when the fighter is already below full HP — see that helper for + // the death-spiral motivation. preDmg := surpriseRoundNick(monster, int(zone.Tier)) dndChar, _ := LoadDnDCharacter(userID) if dndChar != nil && preDmg > 0 { - if preDmg >= dndChar.HPCurrent { - preDmg = dndChar.HPCurrent - 1 - if preDmg < 0 { - preDmg = 0 - } - } + preDmg = clampSurpriseNick(preDmg, dndChar.HPCurrent, dndChar.HPMax) dndChar.HPCurrent -= preDmg _ = SaveDnDCharacter(dndChar) } @@ -216,6 +214,49 @@ func surpriseRoundNick(m DnDMonsterTemplate, tier int) int { return dmg } +// clampSurpriseNick scales the surprise-round nick down for fighters who +// enter combat already wounded. The raw nick is fine on a fresh entry +// (full HP), but chained interrupts create a death-spiral: an over-tier +// elite drops the fighter to ~25% HP and a retreat, then the next +// standard fight's surprise nick — landing on already-low HP — pre-empts +// the combat engine entirely. The Phase 2 tier-lethality trace +// (TestExpeditionBalance_Phase2_TierLethality) showed this cascade was +// the cause of death in 4 of 5 tier traces post-2a, not the elites +// themselves. +// +// Policy: at full HP, raw nick stands. When wounded (HPCurrent < HPMax), +// cap the nick at max(1, HPCurrent/5) so a wounded fighter loses at most +// ~20% of remaining HP to the free swing — they enter the fight bruised +// but with margin to fight back. The existing KO-guard (nick < HP) is +// preserved as a hard backstop. +// +// Tuning surface: the /5 divisor is the wounded-fighter lethality knob. +// Tighter (e.g. /10) is gentler; looser (/3) re-opens the cascade. See +// gogobee_expedition_difficulty.md Phase 2b. +func clampSurpriseNick(rawNick, hpCurrent, hpMax int) int { + if rawNick <= 0 || hpCurrent <= 0 { + return 0 + } + nick := rawNick + if hpCurrent < hpMax { + cap := hpCurrent / 5 + if cap < 1 { + cap = 1 + } + if nick > cap { + nick = cap + } + } + // KO-guard: surprise alone never finishes the fighter. + if nick >= hpCurrent { + nick = hpCurrent - 1 + } + if nick < 0 { + nick = 0 + } + return nick +} + // ── Kill-log writer ───────────────────────────────────────────────────────── // monsterKillTags maps a bestiary ID to the kill-log tags it satisfies. diff --git a/internal/plugin/expedition_balance.go b/internal/plugin/expedition_balance.go index 1073d60..e981fe6 100644 --- a/internal/plugin/expedition_balance.go +++ b/internal/plugin/expedition_balance.go @@ -372,14 +372,11 @@ func (h *expeditionHarness) runHarnessFight(zone ZoneDefinition, elite bool) Com } // 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 - } - } + // pre-combat HP shave, including the wounded-entrant clamp + // (clampSurpriseNick) that breaks the chained-interrupt death + // spiral. Mirror live exactly so the harness measures the same + // lever the live caller applies. + nick := clampSurpriseNick(surpriseRoundNick(monster, int(zone.Tier)), h.char.HPCurrent, h.char.HPMax) h.char.HPCurrent -= nick player := buildHarnessPlayer(h.char)