J3 D8-b: sim picker upcasting + discovery picker is dead since D3

simPickSpell now enumerates one candidate per available slot >= native
for every prepared damage spell, scored via spellExpectedDamage with the
existing "+1 die per slot" scaling. Cantrips contribute a single slot-0
candidate. Tie-break: highest slot first, then highest expDmg. Picker
returns "<id> --upcast N" when the winning slot exceeds native so
parseCombatCast upcasts in the engine. Build + plugin tests green.

Then: measured zero impact. d8b_corpus.jsonl (same 10x5x100 matrix as
d7d) lands every class within +/-1.5pp of d7d baseline.

Root cause discovered post-corpus: simPickSpell is dead code in the
current sim path. Commit 68ed8e7 ("Long expeditions D3: compact
autopilot auto-resolves boss rooms") added a !compact gate at
dnd_expedition_cmd.go:810 so compact autopilot inline-resolves boss/
elite rooms via resolveCombatRoom -> runZoneCombat -> SimulateCombat
instead of returning stopBoss/stopElite for autoResolveCombat to handle.
The sim uses compact=true (expedition_sim.go:433), so autoResolveCombat
- the only caller of simPickCombatAction/simPickSpell - never fires.
Verified empirically: a stderr-traced simPickSpell produced zero hits
across full bard/cleric L10 runs.

This rewrites the picker-era retrospective. J2's baseline_j2a_v2_all10
(bard 40%, cleric 39%) was measured before D3 with the picker live.
d7d's L10 baseline (bard 34%, cleric 21%) is post-D3 with the picker
disconnected - that 6-18pp drop is the post-D3 caster regression, not
"long-expedition mechanics didn't help casters" as d7d framed it.
D8-a's "+2.2pp cleric" was also noise (1sigma ~ 1.8pp on n=500).

D8-b code kept in tree (correct, currently inert, turns on as soon as
the wire reconnects). Plan rewritten in section 8: D8-b and D8-c are
deferred behind D8-prereq, which splits compact so the sim opts out of
compact-inline boss/elite combat without affecting prod rendering.

Files:
- internal/plugin/expedition_sim.go: simPickSpell upcast enumeration
- gogobee_long_expedition_plan.md: D8-b implemented-but-inert,
  D8-prereq added as next step
- sim_results/d8b_corpus.jsonl: 5000-row corpus retained for the
  picker-dead baseline
This commit is contained in:
prosolis
2026-05-27 22:27:04 -07:00
parent 2fdb280477
commit ad2a8258ba
2 changed files with 50 additions and 17 deletions

View File

@@ -153,7 +153,20 @@ Each successful background walk now logs a `walk` entry (`appendExpeditionLog(..
**D8-a (shipped 2026-05-27, this session):** quick data-layer cleanup. Bard L1 prepared list gained `thunderwave`; L2 gained `heat_metal`. Cleric L1 gained `inflict_wounds`. `shatter` overlay Classes broadened to mage/bard/sorcerer (overlay was already unioning via `mergeClassList`, so this is defensive). New `simPickSpiritualWeapon` runs before `simPickSpell` in `simPickCombatAction`: cleric with an L2 slot + spiritual_weapon prepared + `sess.Statuses.BuffSpiritProc == 0` opens the fight with it, picking up the existing `spiritWeaponStrike` per-round bonus-action attack. **Measured impact (L10, n=100/zone, heavy preset):** cleric 21.0 → 23.2% (+2.2pp), bard 34.2 → 32.8% (within noise). T3+ wall unchanged — the data fixes are correct but the picker math is what's binding. Files touched: `internal/plugin/dnd_spells_data.go:192`, `internal/plugin/dnd_spells.go:822,881,884`, `internal/plugin/expedition_sim.go:simPickCombatAction + new simPickSpiritualWeapon`. All green. **D8-a (shipped 2026-05-27, this session):** quick data-layer cleanup. Bard L1 prepared list gained `thunderwave`; L2 gained `heat_metal`. Cleric L1 gained `inflict_wounds`. `shatter` overlay Classes broadened to mage/bard/sorcerer (overlay was already unioning via `mergeClassList`, so this is defensive). New `simPickSpiritualWeapon` runs before `simPickSpell` in `simPickCombatAction`: cleric with an L2 slot + spiritual_weapon prepared + `sess.Statuses.BuffSpiritProc == 0` opens the fight with it, picking up the existing `spiritWeaponStrike` per-round bonus-action attack. **Measured impact (L10, n=100/zone, heavy preset):** cleric 21.0 → 23.2% (+2.2pp), bard 34.2 → 32.8% (within noise). T3+ wall unchanged — the data fixes are correct but the picker math is what's binding. Files touched: `internal/plugin/dnd_spells_data.go:192`, `internal/plugin/dnd_spells.go:822,881,884`, `internal/plugin/expedition_sim.go:simPickCombatAction + new simPickSpiritualWeapon`. All green.
**D8-b (TODO next session): sim picker upcasting.** At L10 a bard has 3×L1 + 3×L2 + 3×L3 + 3×L4 + 2×L5 = 14 slots, but no damage spell at L3/L4/L5 native level — the picker leaves those slots un-spent because it doesn't upcast. Wizard/sorcerer hit the same wall above their highest native-level damage spell. **D8-b (implemented 2026-05-27, **inert** — see D8-prereq below): sim picker upcasting.** `simPickSpell` (`internal/plugin/expedition_sim.go:933`) now enumerates one candidate per available slot ≥ native for every prepared damage spell, scored via `spellExpectedDamage(sp, slotLevel, c.Level)`. Cantrips contribute a single slot-0 candidate. Tie-break: highest slot first, then highest expDmg. When the winning slot exceeds the spell's native level, the picker returns `"<id> --upcast N"` so `parseCombatCast` upcasts in the engine. Build + tests green.
**Measured impact: nothing.** d8b corpus (`sim_results/d8b_corpus.jsonl`, same matrix as d7d) lands every class within ±1.5pp of the d7d baseline — bard 1.0, cleric 0.4, mage 1.2, sorcerer +1.4, warlock 0.8, druid +0.6, martials ±0.2. Pure noise band. Expected bard +510pp / mage+sorcerer+warlock +28pp never materialized.
**Root cause (discovered post-corpus, this session):** `simPickSpell` is **dead code** in the current sim path. Commit `68ed8e7` ("Long expeditions D3: compact autopilot auto-resolves boss rooms") added a `!compact` gate at `dnd_expedition_cmd.go:810` so compact autopilot inline-resolves boss/elite rooms via `resolveCombatRoom``runZoneCombat``SimulateCombat` instead of returning `stopBoss/stopElite` for `autoResolveCombat` to handle. The sim uses `compact=true` (`expedition_sim.go:433`), so `autoResolveCombat` — the only caller of `simPickCombatAction`/`simPickSpell` — never fires. Verified empirically: a stderr-traced `simPickSpell` produced zero hits across full bard/cleric L10 runs.
This rewrites the picker-era retrospective. **J2's `baseline_j2a_v2_all10.jsonl` (bard 40%, cleric 39%) was measured before D3, with the picker live.** d7d's L10 baseline (bard 34%, cleric 21%) was measured *after* D3, with the picker disconnected — that ~618pp drop is the post-D3 caster regression, not "long-expedition mechanics didn't help casters" as the d7d writeup framed it. D8-a's "+2.2pp cleric" was also noise (1σ ≈ 1.8pp on n=500).
**D8-prereq (TODO next session, **blocks D8-b/c**): re-wire the sim to the turn-based path.** Option 1 from the discovery write-up: add a sim-only flag (e.g. `SimRunner.turnBasedBossFights = true`, or a third compact mode) so `runAutopilotWalk` returns `stopBoss`/`stopElite` for the sim even though it's running compact — production stays compact-inline. Then `autoResolveCombat` fires for sim boss/elite rooms, `simPickSpell` (with D8-b upcasting) drives those fights, and we can finally measure the picker's real lift.
- Touch: probably a third arg to `runAutopilotWalk` or an `s.P.simMode` flag the dnd_expedition_cmd reads alongside compact. Be careful — the inline-resolve path also runs harvest, loot, threat; if we surface stopBoss/stopElite earlier, those bookkeeping steps still need to happen at the right moment. Probably simplest: split `compact` into `compactRendering` (keep) and `compactCombatInline` (sim turns this off).
- Validation: re-run the d7d/d8b matrix. Acceptance: casters move materially (bard/mage/sorcerer/warlock into the 4060% band at T1T3 like the J2 baseline). If they don't, D8-b's expDmg model is wrong and we need a deeper rework, not just rewiring.
**D8-b/c are deferred** until the prereq lands. D8-b code stays in-tree (it's correct, just inert today, and will turn on as soon as the wire reconnects).
- Touch: `simPickSpell` in `internal/plugin/expedition_sim.go` (~line 903). For each prepared damage spell, instead of one candidate at `sp.Level`, enumerate candidates at every available slot level ≥ `sp.Level` where a slot is open. Use `spellExpectedDamage(sp, slotLevel, c.Level)` for the upcast scaling — that helper already exists and handles the "+1 die per slot above native" pattern in `rollSpellDamageDice` (`internal/plugin/dnd_spell_combat.go:387-418`). - Touch: `simPickSpell` in `internal/plugin/expedition_sim.go` (~line 903). For each prepared damage spell, instead of one candidate at `sp.Level`, enumerate candidates at every available slot level ≥ `sp.Level` where a slot is open. Use `spellExpectedDamage(sp, slotLevel, c.Level)` for the upcast scaling — that helper already exists and handles the "+1 die per slot above native" pattern in `rollSpellDamageDice` (`internal/plugin/dnd_spell_combat.go:387-418`).
- Tie-break still: highest slot first (preserve high-slot supremacy), then highest expDmg. But now a bard at L10 with shatter can spend its L5 slot on shatter@L5 = 6d8 ≈ 27 instead of leaving it un-spent. - Tie-break still: highest slot first (preserve high-slot supremacy), then highest expDmg. But now a bard at L10 with shatter can spend its L5 slot on shatter@L5 = 6d8 ≈ 27 instead of leaving it un-spent.

View File

@@ -930,8 +930,10 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession)
return "spiritual_weapon" return "spiritual_weapon"
} }
// simPickSpell returns the spell ID a competent player would cast this // simPickSpell returns the spell argument a competent player would pass
// turn, or "" when no usable spell is available (forcing a !attack). // to !cast this turn, or "" when no usable spell is available (forcing a
// !attack). The return value is fed straight to handleCombatCastCmd, so
// upcast picks come back as `"<spell_id> --upcast N"`.
// Selection rules: // Selection rules:
// - Only damage-effect spells (damage_attack / damage_save / damage_auto). // - Only damage-effect spells (damage_attack / damage_save / damage_auto).
// Control/buff/heal are out (J2c sweep showed control scoring at // Control/buff/heal are out (J2c sweep showed control scoring at
@@ -939,12 +941,13 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession)
// no headroom worth the complexity). Healing is handled by the // no headroom worth the complexity). Healing is handled by the
// consumable-first branch in simPickCombatAction. // consumable-first branch in simPickCombatAction.
// - Reaction-cast spells are excluded (engine rejects them). // - Reaction-cast spells are excluded (engine rejects them).
// - Non-cantrips require an available slot at their native level (no // - For each prepared leveled spell, enumerate one candidate per
// upcasting — preserves high slots for high-level spells). // available slot at level ≥ native (D8-b, aggressive upcasting).
// - Among feasible candidates, prefer higher slot level; tie-break on // spellExpectedDamage handles the +1-die-per-slot-above-native
// scaling. Cantrips contribute one slot-0 candidate.
// - Among feasible candidates, prefer higher slot level (preserves
// high-slot supremacy and burns the big slots first); tie-break on
// expected damage from the dice string. // expected damage from the dice string.
//
// Returns the spell ID for handleCombatCastCmd.
func simPickSpell(c *DnDCharacter, uid id.UserID) string { func simPickSpell(c *DnDCharacter, uid id.UserID) string {
known, err := listKnownSpells(uid) known, err := listKnownSpells(uid)
if err != nil || len(known) == 0 { if err != nil || len(known) == 0 {
@@ -952,9 +955,10 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
} }
slots, _ := getSpellSlots(uid) slots, _ := getSpellSlots(uid)
type cand struct { type cand struct {
id string id string
level int slot int
expDmg float64 nativeLevel int
expDmg float64
} }
var cands []cand var cands []cand
for _, k := range known { for _, k := range known {
@@ -983,24 +987,40 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
if !onList { if !onList {
continue continue
} }
if sp.Level > 0 { if sp.Level == 0 {
pair, ok := slots[sp.Level] cands = append(cands, cand{id: sp.ID, slot: 0, nativeLevel: 0, expDmg: spellExpectedDamage(sp, 0, c.Level)})
continue
}
// simMaxSlot mirrors parseCombatCast's slot-level cap; anything
// above it would be rejected by the cast handler anyway.
const simMaxSlot = 5
for sl := sp.Level; sl <= simMaxSlot; sl++ {
pair, ok := slots[sl]
if !ok || pair[0]-pair[1] <= 0 { if !ok || pair[0]-pair[1] <= 0 {
continue continue
} }
cands = append(cands, cand{
id: sp.ID,
slot: sl,
nativeLevel: sp.Level,
expDmg: spellExpectedDamage(sp, sl, c.Level),
})
} }
cands = append(cands, cand{id: sp.ID, level: sp.Level, expDmg: spellExpectedDamage(sp, sp.Level, c.Level)})
} }
if len(cands) == 0 { if len(cands) == 0 {
return "" return ""
} }
sort.Slice(cands, func(i, j int) bool { sort.Slice(cands, func(i, j int) bool {
if cands[i].level != cands[j].level { if cands[i].slot != cands[j].slot {
return cands[i].level > cands[j].level return cands[i].slot > cands[j].slot
} }
return cands[i].expDmg > cands[j].expDmg return cands[i].expDmg > cands[j].expDmg
}) })
return cands[0].id best := cands[0]
if best.slot > best.nativeLevel && best.nativeLevel > 0 {
return fmt.Sprintf("%s --upcast %d", best.id, best.slot)
}
return best.id
} }
// applyAutoCamp drives the production camp scheduler under the sim's // applyAutoCamp drives the production camp scheduler under the sim's