Files
gogobee/gogobee_expedition_difficulty.md
prosolis 25ea23f9d9 Phase 5C (rosters): T3 polish + feywild T4-spread fix
Phase 5-B's exit named two follow-ups: T3 below band (manor 39 /
underforge 47 vs 55-75) and the T4 spread (feywild 59 vs underdark
88, 30pp asymmetry). Phase 5-C re-runs the Phase 4-A trace on the
four affected zones under the shipped Phase 5-B cell and applies
roster knobs only (IsElite + SpawnWeight; no bestiary stat-block
touches).

TestExpeditionBalance_Phase5C_OutlierDiagnostic names the killer
per zone:

  manor_blackspire (53%): Vampire Spawn (72% win, 42 kills) +
    Revenant (16% win, 31 kills at SW=1, can't go lower). First
    attempt trimmed VS SW 3 -> 2 and backfired by concentrating
    elite share on Revenant (kills jumped to 48); reverted.
  underforge (49.5%): Fire Elemental (57 kills, 61hp/win) +
    Salamander (33 kills) carry the lethality; Helmed Horror only
    100%-win elite but SW=1, no dilution effect.
  feywild_crossing (54%): only 2 elites in the pool; Fomorian
    (50% win, SW=1) still pulls 25% of elite picks and owns 65
    kills.
  underdark (86%): Drow at SW=7 fills nearly half of standard
    rolls at 100% win / 1.1hp loss -- free-HP filler.

Roster changes:

  manor    Banshee promoted to elite SW=2 (was standard SW=3,
           99.6% win). Soft 4th elite slot dilutes Revenant share
           ~14% -> ~11%. Standards collapse to Shadow+Poltergeist
           (>=99% win).

  underforge  Helmed Horror SW 1 -> 3. Three-way elite pool drops
              Fire Elemental's share from ~44% to ~33%.

  feywild  Green Hag promoted to elite SW=2 (was standard SW=4).
           Adds soft 3rd elite + removes a 16hp/win standard.
           Standards become Redcap+Will-o-Wisp+Quickling.

  underdark  Drow SW 7 -> 5 (light trim per the user's "lift
             trailers, don't nerf leaders" stance). Standards
             shift toward Hook Horror / Drow Mage.

Phase 1 matrix after Phase 5-C (200 trials, Fighter @ centerline):

  T1  88.5%  spread  1.0   (in band 70-90)
  T2  74.5%  spread 15.0   (in band 62-82)
  T3  56.7%  spread  0.5   (in band 55-75)  +13.5pp
  T4  77.0%  spread 13.0   (over band 45-65, matches 5-B target;
                            spread halved from 25.5)
  T5  58.0%  spread 40.0   (abyss_portal 38% is the residual)

T3 mean lifted +13.5pp; both zones inside band with 0.5pp residual
spread. T4 spread halved (25.5 -> 13.0pp) by lifting feywild
+11.5pp and trimming underdark -4.5pp. T1/T2/T5 untouched.

No test debt -- no production tests pin SpawnWeight or IsElite on
the changed entries (TestMonsterKillTags_GatesKnownMonsters checks
vampire_spawn tags only, unaffected). -short suite green.

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

679 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 2 lever sweep (shipped) — negative result
Before adding Phase 2c, the two surfaced knobs (`retreatThreatBump`,
`clampSurpriseNick` divisor) got swept across a full 3×4 grid
(bump ∈ {2, 5, 10} × divisor ∈ {3, 5, 8, 12}) at 200 trials/cell
across every zone in the matrix. Wired via
`expeditionBalanceProfile.{RetreatThreatBumpOverride,
SurpriseNickDivisorOverride}` threaded into `expeditionHarness`;
the live caller (`runHarvestInterrupt`) is untouched.
Sweep file: `TestExpeditionBalance_Phase2_LeverSweep` in
`expedition_balance_test.go`. `-short` skips.
**Outcome:** every one of the 24,000 trial-cells reports 0.0%
completion / ~100% death. The two knobs are inert on the headline
metric — even the gentlest combo (b=2, d=12) cannot lift any tier
off the floor. Median days-to-end stays at 23 across the grid
with median 2.67.3 encounters before death.
**Read:** confirms the post-2b tier-lethality trace — deaths are
fresh-entry elite one-shots (Warchief @ HP19/20, Hag @ HP41, Roper,
Young Red Dragon), not chained-interrupt cascades. The two levers
target the cascade path that's now mostly resolved; they have no
remaining death-fraction to convert. Phase 2c (roster dilution) is
justified rather than premature.
### Phase 2c — roster gate (shipped)
Promoted one mid-tier alt to `IsElite: true` in every zone whose elite
pool was a single SpawnWeight=1 boss (`internal/plugin/dnd_zone.go`).
The boss stays in the pool; weighted dilution means the InterruptElite
bracket no longer auto-picks the over-tier boss. `pickZoneEnemy` also
excludes the promoted entry from the standard pool, lowering standard
encounter difficulty by removing one mid-difficulty option from
non-elite brackets (acceptable: each zone still has 3-4 standard
entries left). Crypt-Valdris was already dual-elite (wight + flameskull)
and was left alone.
| zone | promoted alt | boss (untouched) |
|--------------------|-----------------------|--------------------|
| goblin_warrens | worg (SW=3) | hobgoblin_warchief |
| forest_shadows | owlbear (SW=4) | green_hag |
| sunken_temple | aboleth_thrall (SW=3) | water_elemental |
| manor_blackspire | vampire_spawn (SW=3) | revenant |
| underforge | salamander (SW=3) + fire_elemental (SW=3) | helmed_horror |
| underdark | drow_elite_warrior (SW=4) | roper |
| feywild_crossing | night_hag (SW=3) | fomorian |
| dragons_lair | dragonborn_cultist (SW=3) | young_red_dragon |
| abyss_portal | hezrou (SW=4) | marilith |
**Phase 1 matrix delta (200 trials/cell, Fighter, tier centerline):**
goblin_warrens 0% → **3.0%** completion (first non-zero T1 reading
since Phase 0); sunken_temple 0% → 0.5%; dragons_lair death% **100% →
60%** with 40% now starve (fighter survives the elite-spawn path long
enough to run out of supplies — different failure mode); underdark
death% 100% → 90% (10% starve); feywild_crossing 100% → 92% death
(8% starve). T3 manor_blackspire / underforge still 100% death — the
remaining death pressure there is the post-2b chained-standard path,
not the elite roll.
Tier-lethality trace cross-check (`Phase2_TierLethality`) shows the
new alt-elites actually spawning and being winnable: underdark trial
1 now opens with `Drow Elite Warrior` (won), reaches the Roper at
turn 6, retreats, then dies on a Hook Horror nick — a substantively
longer arc than the pre-2c fresh-entry Roper one-shot. Dragons_lair
trials similarly show `Dragonborn Cultist` as a winnable elite stop
before the dragon roll lands.
Stat-block tuning of the bosses themselves is off-limits per the
Phase 3 constraint (bestiary is shared across systems).
### Phase 3 — 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.
#### Phase 3-A — elite-bracket + drift sweep (shipped)
Wired two harness overrides
(`EliteInterruptThresholdOverride`, `ThreatDriftBaseOverride`) and
swept a 3×3 grid (elite ∈ {17, 19, 23} × drift ∈ {1, 3, 5}) over
the Phase 1 matrix. Test: `TestExpeditionBalance_Phase3_GlobalLeverSweep`.
**Strong partial positive.** The elite-bracket threshold is the
dominant lever for T1T3; threat drift is a small modulator on top.
| (elite, drift) | T1 mean | T2 mean | T3 mean | T4 mean | T5 mean |
|----------------|--------:|--------:|--------:|--------:|--------:|
| 17, 1 (more elites) | 0.5% | 0.0% | 0.0% | 0.0% | 0.0% |
| 19, 3 (live) | 3.0% | 0.2% | 0.0% | 0.0% | 0.0% |
| 23, 1 (rare elites) | **24.0%** | **7.2%** | **1.8%** | 0.0% | 0.0% |
| 23, 3 | 16.5% | 4.0% | 2.0% | 0.0% | 0.0% |
| 23, 5 | 11.8% | 3.2% | 0.2% | 0.0% | 0.0% |
Best cell at e=23/d=1: goblin_warrens 40.5%, sunken_temple 14.5%.
Still well below the T1 70-90% / T2 62-82% target bands — the lever
moves the needle in the right direction but cannot land any tier
on-band alone.
**T4/T5 fingerprint changed but didn't lift.** At e=23 the dragons_lair
death-fraction drops from ~60% → 24% but starvation climbs to 75% —
the fighter now survives elites long enough to run out of supplies.
T4 (underdark/feywild) likewise sees death drop and starvation rise.
Indicates a second lever is needed for the higher tiers: either
standard-fight survivability (surprise-nick floor / standard roster
density) or supply margin.
**Next:** Phase 3-B sweeps surprise-round nick and supply-burn knobs
on top of e=23/d=1 to push T1-T3 toward target band and lift T4-T5
off the floor.
#### Phase 3-B — nick-floor + supply-burn sweep (shipped)
Wired two more harness overrides (`SurpriseNickFloorOverride`,
`SupplyBurnRatePctOverride`) and swept a 3×3 grid (floor ∈ {0, 1, tier
(=live)} × burn% ∈ {50, 75, 100 (=live)}) on top of the Phase 3-A best
cell (e=23, d=1). Test: `TestExpeditionBalance_Phase3B_NickSupplySweep`.
**Strong partial positive for T5 supply margin; nick-floor lever inert.**
Tier-mean completion (Fighter, centerline level, 10 zones × 200 trials):
| (floor, burn%) | T1 mean | T2 | T3 | T4 | T5 |
|----------------|--------:|----:|----:|-----:|-----:|
| 0, 50 | 20.8% | 5.0%| 3.8%| 7.5% | 29.5%|
| 0, 75 | 26.5% | 6.0%| 2.8%| 9.0% | 0.0% |
| 0, 100 | 25.8% | 5.0%| 3.0%| 0.0% | 0.0% |
| 1, 50 | 25.5% | 6.5%| 1.8%| 8.5% | 27.8%|
| 1, 75 | 28.5% | 6.2%| 4.0%|11.5% | 0.0% |
| 1, 100 | 26.8% | 7.5%| 2.5%| 0.0% | 0.0% |
| tier, 50 | 26.8% | 6.2%| 2.5%| 7.8% | 27.5%|
| tier, 75 (live floor) | **29.5%** | **7.8%** | 3.0% | **12.8%** | 0.0% |
| tier,100 (live)| 28.5% | 6.8%| 3.0%| 0.0% | 0.0% |
Three readings:
1. **Supply burn is the T5 unlock.** dragons_lair completes ~55% at
burn=50 (versus 0% at burn=75/100) — the fighter survives elites
but starves en route. burn=75 isn't enough margin; burn=50
converts the starvation-out into a completion.
2. **Supply burn is the T4 modulator.** underdark/feywild step from
0% (burn=100) → 12% (burn=75) → 8% (burn=50). The burn=75 peak is
the sweet spot; halving further leaves the fighter alive *into*
more elite fights, where it dies in combat instead.
3. **Nick-floor lever is inert.** Across all burn levels, dropping
the surprise-round floor from `tier``1``0` moves T1-T3
means by at most ~3pp. The wounded-clamp (Phase 2b,
`clampSurpriseNick`) already eats the chip-damage budget on
repeat entries; the raw floor only matters on the first fresh-HP
swing, which the engine recovers from. **Recommendation: discard
this lever from the live-tuning candidate list.**
**T2-T3 wall persists.** Even at the best (floor=tier, burn=75) cell:
T2 reads 7.8% (target 62-82%) and T3 reads 3.0% (target 54-74%).
forest_shadows T2, manor_blackspire T3, and abyss_portal T5 sit at
~0% across every combo — these are zone-specific outliers, not
addressable by any global lever. **Phase 4 (per-zone outlier pass)
is the next move.**
**Next:** Phase 4 walks the matrix zone-by-zone. The shipped Phase 3
sweep already names the worst offenders (forest_shadows T2,
manor_blackspire T3, abyss_portal T5, crypt_valdris T1); each needs a
diagnostic trace (boss stat-block, roster mix, supply DC) to pick the
right per-zone tool from `Phase 4`'s list. Optional: revisit shipping
the burn=75 global if Phase 4 outlier fixes leave T4 still stuck.
### Phase 4 — per-zone outlier pass
After globals settle, the matrix names 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.
#### Phase 4-A — outlier diagnostic (shipped)
Added a structured per-fight trace hook (`traceFightStruct` +
`harnessFightTrace`) alongside the existing string `traceFight`;
both fire from the same `runHarnessFight` site so the human log and
the structured aggregate stay in sync. Test:
`TestExpeditionBalance_Phase4A_OutlierDiagnostic`. For each of the
four outlier zones it pairs against the healthy sibling at the same
tier (goblin_warrens, sunken_temple, underforge, dragons_lair),
running 200 trials each on the Phase 3-B best cell (e=23, d=1,
burn=50) and reporting per-monster appearances / win-rate / avg HP
loss / kill attribution + day-of-end histogram.
Findings — each outlier had one or two miscategorised entries that
Phase 2c's single-boss-elite dilution sweep had skipped:
- **crypt_valdris**: dual-killer elite pool (Wight 99k, Flameskull
68k); Phase 2c had skipped this zone as "already dual-elite," but
both elites are over-tier for T1.
- **forest_shadows**: standard pool too lethal — Displacer Beast
(38% win, 53k) + Bandit Captain (57% win, 48k).
- **manor_blackspire**: Wraith on standard slot (45hp loss per win,
85k).
- **abyss_portal**: Nalfeshnee mis-classified standard (CR-13, 2.8%
win, 86k) + Vrock standard at 43hp loss per win.
#### Phase 4-B — roster re-classification (shipped)
Per-zone roster tweaks based on Phase 4-A's attribution. No monster
stat-block touches.
- crypt_valdris: dropped flameskull (lives in underforge T3);
promoted specter to IsElite SW=3 as the soft dilutor.
- forest_shadows: promoted Displacer Beast + Bandit Captain to
IsElite; Owlbear SW 4→2 so the 4-elite pool dilutes evenly.
- manor_blackspire: promoted Wraith to IsElite (joins
Spawn+Revenant).
- abyss_portal: promoted Nalfeshnee + Vrock to IsElite (collapses
standard pool to Quasit alone, ≥99% win).
Outlier-vs-sibling completion (Phase 3-B best cell, 200 trials):
| Zone | Before | After | Sibling |
|------|-------:|------:|--------:|
| crypt_valdris T1 | 8.5% | **46.0%** | 45.0% |
| forest_shadows T2 | 0.0% | **7.5%** | 13.0% |
| manor_blackspire T3| 0.5% | **14.5%** | 3.0% |
| abyss_portal T5 | 0.0% | **25.0%** | 57.5% |
Phase 3-B tier mean (b=50, f=tier) before → after:
T1 26.8% → 44.0% (+17pp), T2 6.2% → 10.0% (+4),
T3 2.5% → 8.5% (+6), T4 unchanged, T5 27.5% → 41.0% (+14).
**Exit (partial):** the four off-band outliers are no longer
outliers — every per-zone cell is within the spread of its tier
siblings. The Phase 4 plan's stricter exit ("all per-zone cells
within target band") is **not** met: T2/T3/T5 sibling pairs still
sit below band (T2 7-13% vs 62-82%; T3 3-14% vs 54-74%; T5 25-57%
vs 36-56%). That gap is tier-wide, not zone-specific — both zones at
each problem tier underperform together — so no further per-zone
tool will close it. Phase 5 (or a follow-up tier-wide pass) needs to
take over.
**Next:** Phase 5. Candidate moves are tier-wide: revisit player
gear-tier mapping at T2/T3 (the centerline level vs gear ladder, see
`phase1TierCenterline`), reconsider the elite-bracket threshold
per-tier instead of global, or ship `burn=75` as the new live global
since Phase 4-B already converted T4 to playable at b=50.
### Phase 5 — tier-wide pass (T2/T3/T5 under-band)
Phase 4-B closed the per-zone outliers but left T2/T3/T5 sibling pairs
below the target band as a *tier-wide* gap. The plan doc's three
candidate moves (gear-tier centerline remap, per-tier elite threshold,
ship `burn=75`) needed disambiguation before any of them got pulled —
hence a measure-first sub-phase.
#### Phase 5-A — tier-wide sensitivity sweep (shipped)
`TestExpeditionBalance_Phase5A_TierWideSensitivity` runs the 6 under-
band zones (T2: forest_shadows + sunken_temple; T3:
manor_blackspire + underforge; T5: abyss_portal + dragons_lair) through
three one-axis sweeps with the other two levers held at Phase 3-B best
(e=23, d=1, burn=50): player level ±2 around tier centerline, elite
threshold ∈ {18, 23, 28}, supply burn pct ∈ {40, 50, 60}. 200
trials/cell × 6 zones × 9 cells = 10.8k trials; runs in <1s.
Numbers (200 trials/cell, comp%):
```
T2 (band 62-82%)
L forest=0→6.5→19 sunken=0→13.5→34.5 STRONG slope
E forest=0.5→9→8 sunken=0→14.5→13 saturated @23; 18 collapses
B forest=3.5→7→6.5 sunken=14→15.5→11 inert
T3 (band 54-74%)
L manor=3→10.5→13.5 under=0→4→8.5 clear slope
E manor=0→9.5→9.5 under=0→3→4 saturated @23; 18 collapses
B manor=13→9.5→9.5 under=2.5→4→2.5 inert
T5 (band 36-56%)
L abyss=21.5→28.5→32 dragons=60→60.5→53 gentle; dragons in-band
E abyss=1→28.5→31 dragons=10→60.5→57.5 saturated @23; 18 collapses
B abyss=27.5→28.5→0 dragons=59→60.5→0 burn=60 STARVE CLIFF
```
**Reading:**
- **Player level is the dominant lever at T2/T3.** Every +2 levels
lifts completion meaningfully; the elite gate and supply burn are
inert or actively harmful around the Phase 3-B baseline.
- **Elite threshold is already at its sweet spot at e=23.** Going to
e=18 floods elites and collapses every tier; e=28 is flat. The per-
tier-threshold candidate from Phase 4 close-out is killed: there's
no per-tier value to be found in the explored range.
- **`burn=75` globally is killed.** burn=60 alone produces 36% / 61%
starve at T5; burn=75 would be catastrophic. The Phase 3-B negative
result holds tier-wide. burn must stay ≤50.
- **T5 dragons_lair is already in-band** at the baseline (60.5%).
The T5 tier-wide gap is really an abyss_portal gap (28.5%); the
Phase 4-B re-class lifted it from outlier to "low-but-not-isolated,"
but it still sits below band by itself.
- **The level-bump path has a catch.** L7 at T2 (still inside the
L3-7 design range and gearTier=2) only reaches ~27% mean — still
well below the 62-82% band. Closing T2 to band likely requires a
*cross-gearTier-boundary* centerline (T2 → L9 = gearTier 3) and/or
a player-side combat-math adjustment, not just a within-bracket
centerline shift. That's a design call, not a knob twist.
**Next:** Phase 5-B is a design choice between three concrete moves
that the measurement now backs:
1. **Cross-bracket centerline bump** for T2 (→ L9) and T3 (→ L13),
plus T5 abyss-only level tweak. Lifts player gear bracket into the
next tier; closes the band gap directly but is a player-side power
move that affects the live game (chars at the centerline level
keep their existing gear; centerline only governs the harness's
measurement cell). The change is *in the harness*, but it admits
the live difficulty target was wrong for these tiers — Phase 5-C
would then carry it back to the live gear-acquisition curve.
2. **Player combat-math retune** at T2/T3 (HP scaling, proficiency
curve, attack-bonus floor) — bigger lift, touches the class-balance
harness too, deserves its own pass.
3. **Lower the band target** for T2/T3/T5 to ~30-50% mean and accept
that completion is meant to be hard. This is the "do nothing"
option dressed up as a design decision; surfaces the question of
whether the original 70% T2 target was ever the right number.
Recommend option (1) for Phase 5-B since the harness already measures
it cleanly and the data points at it; option (3) is the fallback if
the cross-bracket numbers still don't close the band.
#### Phase 5-B — player power floor + Phase-3 winners shipped
The Phase 5-A read named player level / gear-tier centerline as the
dominant lever at T2/T3 but flagged that even max-of-range within the
gear bracket missed the band. Three design options were on the table:
cross-bracket centerline bump, **player combat-math retune**, or
lower the band target. The user picked combat-math retune ("fairly
breezy with some death — not a ton") — option (2). On reflection
this was the only one of the three that actually moves *live*
difficulty (cross-bracket centerline only moves the harness number;
lowering the band is the "do-nothing" fallback).
The shipped lift, in five small pieces:
1. **`computeMaxHP` ×1.5** (`dnd.go: phase5BHPMult`). The HP curve is
uniformly scaled so every class/level lifts together — preserves
the class-balance harness's in-tier spread assertion. Migration
`bootstrapPhase5BHPRefresh` walks `dnd_character` once at startup
to refresh existing rows; idempotent via `db.JobCompleted`.
2. **Combat-stat player floor** (`dnd_combat.go:
applyPhase5BPlayerFloor`): +3 AC, +3 AttackBonus, +3
weapon.MagicBonus (damage). Applied at the END of
`applyDnDEquipmentLayer` (so the AC override from
`computeArmorAC` doesn't stomp it) AND inside `buildHarnessPlayer`
so live and harness measurement agree.
3. **Elite bracket 19 → 23** (`dnd_expedition_combat.go:
resolveCombatInterrupt`). Case order: Elite (≥23) before Patrol
(≥22) so a 23+ total prefers the single dangerous fight over the
patrol-flavored standard pick. Elite is now effectively a
*high-threat* event reachable only via the +1-per-20-threat-above-40
mod — base d20+tier+ranger maxes at ~21. Phase 4-B's elite-pool
monsters still appear; just less often.
4. **Threat drift base 3 → 1** (`dnd_expedition_threat.go:
dailyThreatDrift`). Slows the threat clock so players have the
days they need to extract before threat tips zones into the new
23+ elite band.
5. **Daily supply burn × 0.5** (`dnd_expedition_supplies.go:
phase5BDailyBurnRatePct`). `applyDailyBurn`'s default now passes
50; `applyDailyBurnP` keeps its rate-parameterized form for the
harness. Also applied in the temporal-override branch
(`dnd_expedition_cycle.go`) so tidal / unraveling days scale by
the same 0.5× — otherwise those days would be disproportionately
harsh against the new baseline.
The harness reflects all five (`expedition_balance.go` reads
`phase5BDailyBurnRatePct` as the default-burn fallback when the
override knob is zero). Phase 1 matrix on the shipped baseline,
Fighter @ tier-centerline, 200 trials:
```
T1 87.8% spread 1.5 (crypt 89, goblin 87) ⬆ band 70-90 — IN BAND
T2 74.0% spread 12 (forest 80, sunken 68) ⬆ band 62-82 — IN BAND
T3 43.2% spread 8.5 (manor 39, underforge 47) ⬇ band 55-75 — UNDER
T4 71.8% spread 25.5 (feywild 59, underdark 88) ⬆ band 45-65 — OVER (breezy)
T5 ~57% (dragons 78, abyss 38) ⬆ band 35-55 — borderline
```
Four of five tier means land at or above band-center, matching the
"fairly breezy with some death" target. **T3 remains the hump** —
both manor and underforge sit ~40-50% completion, killing 50-60% of
the time. Phase 4-B already promoted T3's worst killers (Wraith) to
elite, so the standard-pool deaths are now the irreducible part.
T4's wide spread (feywild 59 vs underdark 88) means feywild still
has a roster-side asymmetry that warrants a Phase 4-B-style pass.
**Test debt cleaned:** 13 unit tests in `combat_stats_test.go`,
`dnd_test.go`, `dnd_xp_test.go`, `dnd_equipment_profiles_test.go`,
`dnd_expedition_supplies_test.go`, `dnd_expedition_cycle_test.go`,
`dnd_expedition_extract_test.go`, `dnd_expedition_region_cmd_test.go`,
`dnd_expedition_combat_test.go`, `dnd_expedition_threat_test.go`,
`dnd_expedition_temporal_test.go`, `expedition_balance_test.go` were
pinning the pre-Phase-5-B numbers; updated to the shipped values
with comments noting the cause.
**Exit:** Phase 5-B closed-out the user's primary target ("make it
breezy"). T1/T2/T4/T5 mean in or above band; T3 remains the design
hump and is fine for "some death" tier shape. Remaining work
(Phase 5-C+) is T3 roster polish + feywild T4-spread fix — separate
session.
#### Phase 5-C — T3 polish + T4 spread fix (rosters only)
Phase 5-B's exit named two follow-ups: T3 below band (manor 39 /
underforge 47 vs 55-75), and the T4 spread (feywild 59 vs underdark
88 = 30pp asymmetry). Phase 5-C re-runs the Phase 4-A trace on the
four affected zones at the shipped Phase 5-B cell
(`TestExpeditionBalance_Phase5C_OutlierDiagnostic`) and identifies
which monsters own the lethality budget per zone, then applies the
Phase 4-B toolset (IsElite toggle + SpawnWeight rebalance only — no
bestiary stat-block touches).
Per-zone reads from the diagnostic (200 trials, L9/L13 Fighter,
e=23 d=1 burn=50):
manor_blackspire (53% comp): standards all ≥99% win. Elite pool
is the lethality source — Vampire Spawn (72% win, 42 kills) and
Revenant (16% win, 31 kills at SW=1) split the kill budget.
Revenant can't drop below SW=1, and trimming Vampire Spawn
(first attempt: SW 3 → 2) backfired by shifting elite share onto
Revenant — kills jumped to 48 and completion fell to 44.5%.
underforge (49.5% comp): Fire Elemental (57 kills, 61hp/win) +
Salamander (33 kills) own elite share. Helmed Horror was the
only 100%-win elite but sat at SW=1, providing almost no
dilution.
feywild_crossing (54% comp): Fomorian alone (65 kills, 50% win,
SW=1) owns the elite kill budget — only 2 elites in the pool
(Night Hag SW=3 + Fomorian SW=1), so Fomorian still gets 25% of
elite picks despite the low weight.
underdark (86% comp): Drow at SW=7 (1801/4164 standard rolls,
100% win / 1.1hp loss) is a free-HP filler dominating the
standard pool.
Roster changes shipped (`dnd_zone.go`):
manor_blackspire — Banshee promoted to elite at SW=2 (was SW=3
standard, 99.6% win, 11hp loss): adds a soft 4th elite slot,
dropping Revenant's elite share ~14% → 11% without changing
its presence. Standard floor collapses to Shadow + Poltergeist,
both ≥99% win at ≤5hp loss.
underforge — Helmed Horror SW 1 → 3 (98%+ win, 15hp loss): three
roughly-equal elite slots (Salamander 3 + Fire Elemental 3 +
Helmed Horror 3), reducing Fire Elemental's elite share from
~44% to ~33%.
feywild_crossing — Green Hag promoted to elite at SW=2 (was SW=4
standard, 98% win, 16hp loss): adds a softer 3rd elite slot,
diluting Fomorian's share, AND pulls a moderate-attrition fight
out of the standard pool. Standard floor becomes Redcap +
Will-o-Wisp + Quickling (all ≥99% win, ≤11hp loss).
underdark (light trim per user's "lift trailers, don't nerf
leaders" stance) — Drow SW 7 → 5: standards shift toward Hook
Horror (17.8hp/win) and Drow Mage (16.4hp/win), still safe but
with real attrition.
Phase 1 matrix after Phase 5-C (200 trials, Fighter @ centerline):
```
T1 88.5% spread 1.0 (crypt 89, goblin 88) ⬆ band 70-90 — IN BAND
T2 74.5% spread 15.0 (forest 82, sunken 67) ⬆ band 62-82 — IN BAND
T3 56.7% spread 0.5 (manor 57, underforge 56.5) ⬆ band 55-75 — IN BAND ⬆ +13.5pp
T4 77.0% spread 13.0 (feywild 70.5, underdark 83.5) ⬆ band 45-65 — OVER (matches 5-B target)
T5 58.0% spread 40.0 (abyss 38, dragons 78) ⬆ band 35-55 — borderline (abyss the residual)
```
T3 mean lifted +13.5pp; both zones land inside the band with a
0.5pp residual spread. T4 spread halved (25.5 → 13.0pp) by lifting
feywild +11.5pp and trimming underdark -4.5pp, matching the user's
chosen "lift mostly, trim lightly" direction. T1/T2/T5 untouched.
**Test debt:** none — no production tests pinned the changed
SpawnWeight or IsElite values. `TestMonsterKillTags_GatesKnownMonsters`
checks `vampire_spawn` tags but is unaffected by weight.
**Exit:** T1-T4 all in or above band; T5's abyss_portal (38%) is
the only remaining sub-band cell and is a tier-asymmetry residual,
not a per-zone outlier. Phase 5-D could revisit abyss specifically,
but it's optional — the user's "breezy with some death" target is
hit across the live tier ladder.
### Phase 6 — 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