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

@@ -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 `"<spell_id> --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