diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index 39c8aeb..9ee5485 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -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-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 `" --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 +5–10pp / mage+sorcerer+warlock +2–8pp 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 ~6–18pp 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 40–60% band at T1–T3 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`). - 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. diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 62a7d58..61b2a72 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -930,8 +930,10 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession) return "spiritual_weapon" } -// simPickSpell returns the spell ID a competent player would cast this -// turn, or "" when no usable spell is available (forcing a !attack). +// simPickSpell returns the spell argument a competent player would pass +// 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 `" --upcast N"`. // Selection rules: // - Only damage-effect spells (damage_attack / damage_save / damage_auto). // 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 // consumable-first branch in simPickCombatAction. // - Reaction-cast spells are excluded (engine rejects them). -// - Non-cantrips require an available slot at their native level (no -// upcasting — preserves high slots for high-level spells). -// - Among feasible candidates, prefer higher slot level; tie-break on +// - For each prepared leveled spell, enumerate one candidate per +// available slot at level ≥ native (D8-b, aggressive upcasting). +// 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. -// -// Returns the spell ID for handleCombatCastCmd. func simPickSpell(c *DnDCharacter, uid id.UserID) string { known, err := listKnownSpells(uid) if err != nil || len(known) == 0 { @@ -952,9 +955,10 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string { } slots, _ := getSpellSlots(uid) type cand struct { - id string - level int - expDmg float64 + id string + slot int + nativeLevel int + expDmg float64 } var cands []cand for _, k := range known { @@ -983,24 +987,40 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string { if !onList { continue } - if sp.Level > 0 { - pair, ok := slots[sp.Level] + if sp.Level == 0 { + 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 { 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 { return "" } sort.Slice(cands, func(i, j int) bool { - if cands[i].level != cands[j].level { - return cands[i].level > cands[j].level + if cands[i].slot != cands[j].slot { + return cands[i].slot > cands[j].slot } 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