Files
gogobee/gogobee_expedition_difficulty.md
prosolis 159daf8fc8 Phase 2b (lever): wounded-entrant surprise-nick clamp
The post-2a tier-lethality trace showed the remaining 0% completion
was driven by chained surprise-round nicks on already-wounded fighters,
not by the elites themselves. Pattern from the trace:

  fight day=5 Hobgoblin Warchief: hp_pre=24 → hp_post=14  WON
  fight day=5 Goblin Archer:      nick=6, hp_pre=8 → hp_post=0  LOST

The Warchief left the fighter at HP 14; the Goblin Archer's surprise
nick (6 HP) dropped them to 8 before combat resolved — and a standard
goblin then finished a fighter who should have survived. Same shape at
T2 (Dire Wolf nick 4 on hp=3), T3 (Fire Elemental nick 4 on hp=5),
T4-T5. The nick was acting as a hidden cascade multiplier, pre-empting
the combat engine on wounded entries.

clampSurpriseNick caps the nick at max(1, hpCurrent/5) when the
fighter enters wounded (HPCurrent < HPMax); at full HP the raw nick
stands. The existing 'nick < HPCurrent' KO-guard is preserved as a
backstop. /5 is the wounded-fighter lethality knob; tighter (/10) is
gentler, looser (/3) re-opens the cascade.

Live caller (runHarvestInterrupt) and harness (runHarnessFight) both
route through the new helper so the sim measures the same lever the
live caller applies.

Matrix delta is mild (encs +0.1-0.2 per cell, completion% still 0%)
but the tier-lethality trace stretches substantively: T1 trial 0 ran
5→8 encs / 5→7 days, T3 trial 1 saw a fighter survive multiple
chained interrupts at low HP that pre-2b would have ended on nick
alone. The remaining deaths are now legible as elite-one-shot fights
on fresh entries (Warchief, Green Hag, Roper, Young Red Dragon) —
that's the Phase 2c roster-gate signal.

Push-back on the original lever-order: the Phase 2a recap put roster
gate first, but the trace fingerprint named the wounded-entry nick
as the dominant cause-of-death in 4 of 5 tier traces. Doing nick-cap
first keeps Phase 2c's diagnostic clean and avoids re-tuning rosters
after another lever changes the shape under us.

Pre-existing failures unrelated to this change:
- TestAdv2Scenario_ZoneRunGoblinWarrens (advance regression, prior)
- TestMageSpellbookLineInRender (render assertion, prior)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:27:29 -07:00

8.9 KiB
Raw Blame History

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% 7090%
T2 72% 6282%
T3 65% 5575%
T4 55% 4565%
T5 45% 3555%

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 T1T5; 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