mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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 `"<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 +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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user