From 2fdb2804775cb05646df9f35300574c79d96b72e Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 27 May 2026 22:00:02 -0700 Subject: [PATCH] J3 D8-a: caster picker data fixes + Spiritual Weapon pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bard L1 += thunderwave, L2 += heat_metal; cleric L1 += inflict_wounds; shatter overlay Classes broadened (defensive — mergeClassList already unioned). New simPickSpiritualWeapon runs before simPickSpell so a cleric with an L2 slot opens fights with the BuffSelf-tagged spell that the regular picker (damage-effects only) was skipping; existing spiritWeaponStrike per-round mace path lights up. Measured L10 n=100/zone: cleric 21.0 → 23.2% (+2.2pp), bard within noise. T3+ wall unchanged — picker upcasting + concentration-damage modeling (D8-b/c) are the bigger levers, queued in plan §8. --- gogobee_long_expedition_plan.md | 29 ++++++++++++++++++- internal/plugin/dnd_spells.go | 6 ++-- internal/plugin/dnd_spells_data.go | 2 +- internal/plugin/expedition_sim.go | 45 ++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index 46ec216..39c8aeb 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -145,7 +145,34 @@ Each successful background walk now logs a `walk` entry (`appendExpeditionLog(.. **D7-d (shipped 2026-05-27):** class corpus re-run + D5-d retune decision. First fixed a sim regression `expedition_sim.go` introduced by D5-b — bare `expedition start ` now returns the loadout prompt instead of outfitting, so the sim was halting before persisting any expedition; harness now passes the `heavy` preset. Corpus: `sim_results/d7d_corpus.jsonl` (n=100 × 10 classes × 5 zones × L10 = 5000 runs, `heavy` preset, `-cap 300`). Analysis: `sim_results/d7d_analyze.py`. Writeup: `sim_results/d7d_findings.md`. **Key reads:** (i) leaderboard mirrors [[project_j2_sim_artifact]] — martials 78–82%, casters 21–42%, ~40pp cluster gap unchanged by long-expedition mechanics; (ii) bard/cleric trailers **not** relieved by autopilot camp pacing — cleric actually regressed at L10 (21% vs J2b L12 39%) on spell-pool richness, J3-territory; (iii) T3 hits target 4-day duration (median 3), T4 hits 5–6 days (median 5), T5 hits 5 of 7 (only 21 clears); (iv) **D5-d retune: no change.** `phase5BDailyBurnRatePct=50` + per-tier `DailyBurn` stay as-is — heavy-preset cleared runs end with 78–98% SU remaining; supply economy is not a binding constraint and players lose to HP zero, not starvation. New memory entry: [[project_d7d_baseline]]. -**Remaining work (D7-e+):** none scheduled; long-expedition mechanics are at v1. Future tuning rides on J3 caster picker widening (out of scope for the long-expedition track). +**Remaining work (D7-e+):** none scheduled; long-expedition mechanics are at v1. Future tuning rides on the J3 caster sim-picker work below (split out of the long-expedition track). + +## 8. J3 caster-picker rework (next session) + +**Why split out:** D7-d corpus confirmed the martial/caster gap is class-roster + sim-picker driven, not autopilot/burn/pacing driven. Long-expedition mechanics are settled; the trailer fix lives in the sim picker, not the expedition shell. + +**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. + +- 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. +- Risk: an upcasted L1 might out-score a native-L2 in the picker. That's actually probably correct (the picker is supposed to find max damage). But also enables warlock/ranger regressions — gate via `simMartialFirstClass` like the cast branch already does. +- Validation: re-run the L10 d7d-style corpus (`/tmp/exp-sim -matrix -classes <10 classes> -levels 10 -zones <5 zones> -runs 100 -cap 300`). Compare against `sim_results/d7d_corpus.jsonl`. Expected: bard +5–10pp, mage/sorcerer/warlock +2–8pp. Martials unchanged (gated). Cleric small bump (already has high-level damage spells available). + +**D8-c (TODO next session): concentration-damage modeling.** `heat_metal`, `spirit_guardians`, `spike_growth`, `call_lightning`, `flaming_sphere`, `cloud_of_daggers`, etc. are scored once by `spellExpectedDamage` as if they were a single-shot, but their value is N rounds of repetition while concentration holds. The picker therefore under-ranks them — bard's `heat_metal` (2d8 concentration) loses to `shatter` (3d8 one-shot) on every L2 pick, even though heat_metal in real play deals 2d8 × ~4 rounds = 32 vs shatter's 13.5. + +- Touch: `spellExpectedDamage` in `internal/plugin/dnd_spell_combat.go` (or wherever it lives — verify). Add a concentration multiplier: if `sp.Concentration && sp.Effect ∈ {DamageAuto, DamageSave}` and damage dice present, multiply expDmg by an "expected concentration rounds" factor. Conservative start: 3 rounds (short of a real fight-length estimate, but big enough to flip the picker). +- Risk: this also boosts druid (call_lightning, flaming_sphere) and cleric (spirit_guardians). That's desired — those are exactly the under-played identity spells. Don't multiplier-boost EffectDamageAttack (single-target attack-roll spells aren't typically concentration; `hex`-style cases are handled by mods, not the picker). +- Subtle gotcha: casting a new concentration spell drops the old one. Picker is stateless (one decision per turn) so it can't easily know "I'm already concentrating on X." Acceptable v1: let it overwrite — sim noise will eat the rare double-cast. Stateful version: read `sess.Statuses` for an active concentration marker; defer. +- Validation: same corpus + diff against D8-b. + +**Sequence:** D8-b first (bigger lever, no model assumption), measure, then D8-c (smaller lever, more assumption-laden). Both together should close ~10–15pp of the martial/caster gap if the model is right. + +**Open questions for D8:** +1. Should D8-b upcast aggressively (every available slot considered) or conservatively (only the next slot above native)? Aggressive is closer to optimal play; conservative is safer if the engine's `+1 die per slot` heuristic over- or under-scales for specific spells. +2. D8-c concentration-rounds factor: 3 fixed, or scale with tier (longer fights at T4/T5)? Fixed is simpler; tier-scaled is more correct. +3. Cleric T3+ cliff (0% manor onward) likely needs more than picker fixes — could be (a) HP scaling (cleric loses to enemy damage), (b) AC scaling (gets hit too often), (c) heal-consumable threshold too low. Worth a separate diagnostic pass after D8-b/c land. ## 4. Cross-cutting risks diff --git a/internal/plugin/dnd_spells.go b/internal/plugin/dnd_spells.go index ec5c387..dfb3ec9 100644 --- a/internal/plugin/dnd_spells.go +++ b/internal/plugin/dnd_spells.go @@ -819,7 +819,7 @@ func defaultKnownSpells(class DnDClass, level int) []string { case ClassCleric: out := []string{"sacred_flame", "guidance", "mending"} if level >= 1 { - out = append(out, "cure_wounds", "healing_word", "bless", "guiding_bolt", "shield_of_faith") + out = append(out, "cure_wounds", "healing_word", "bless", "guiding_bolt", "inflict_wounds", "shield_of_faith") } if maxSlot >= 2 { out = append(out, "spiritual_weapon", "lesser_restoration", "aid") @@ -878,10 +878,10 @@ func defaultKnownSpells(class DnDClass, level int) []string { case ClassBard: out := []string{"vicious_mockery", "minor_illusion", "message"} if level >= 1 { - out = append(out, "cure_wounds", "healing_word", "heroism", "hideous_laughter", "faerie_fire") + out = append(out, "cure_wounds", "healing_word", "heroism", "hideous_laughter", "faerie_fire", "thunderwave") } if maxSlot >= 2 { - out = append(out, "hold_person", "shatter", "invisibility", "lesser_restoration") + out = append(out, "hold_person", "shatter", "heat_metal", "invisibility", "lesser_restoration") } if maxSlot >= 3 { out = append(out, "hypnotic_pattern", "dispel_magic", "fear") diff --git a/internal/plugin/dnd_spells_data.go b/internal/plugin/dnd_spells_data.go index 3c7e787..2b252c6 100644 --- a/internal/plugin/dnd_spells_data.go +++ b/internal/plugin/dnd_spells_data.go @@ -190,7 +190,7 @@ func buildSpellList() []SpellDefinition { Description: "A foe locks rigid mid-step. Anything you hit them with next lands devastatingly well.", Upcast: "+1 target per slot above 2nd"}, {ID: "shatter", Name: "Shatter", Level: 2, School: "evocation", - Classes: mage, Effect: EffectDamageSave, CastTime: CastAction, + Classes: []DnDClass{ClassMage, ClassBard, ClassSorcerer}, Effect: EffectDamageSave, CastTime: CastAction, SaveStat: "CON", DamageDice: "3d8", DamageType: "thunder", AOE: true, Description: "An invisible chord rings, then snaps — everything brittle nearby cracks at once.", Upcast: "louder per slot above 2nd"}, diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 5372e4a..62a7d58 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -862,6 +862,15 @@ func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kin } } if isSpellcaster(c) && !simMartialFirstClass(c.Class) { + // Cleric: Spiritual Weapon is a BuffSelf that fires a spectral + // bonus-action attack each round via SpiritWeaponProc/Dmg mods — + // simPickSpell skips BuffSelf entries by design, so a cleric + // otherwise never spends an L2 slot on it. Force the pick once + // per fight (BuffSpiritProc==0) so the picker doesn't pretend + // it's not a damage option. + if id := simPickSpiritualWeapon(c, uid, sess); id != "" { + return "cast", id + } if id := simPickSpell(c, uid); id != "" { return "cast", id } @@ -885,6 +894,42 @@ func simMartialFirstClass(class DnDClass) bool { return false } +// simPickSpiritualWeapon returns "spiritual_weapon" when a cleric should +// open the fight with it: the buff is not already active on the session, +// the spell is prepared, and an L2 slot is available. Returns "" otherwise. +// The buff path in combat_cmd.go folds into BuffSpiritProc/Dmg via +// applyBuffDelta, which the turn engine fires each round via +// spiritWeaponStrike — so one cast is worth more than a single L2 damage +// spell across a multi-round fight. +func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession) string { + if c == nil || c.Class != ClassCleric || sess == nil { + return "" + } + if sess.Statuses.BuffSpiritProc > 0 { + return "" + } + known, err := listKnownSpells(uid) + if err != nil { + return "" + } + prepared := false + for _, k := range known { + if k.SpellID == "spiritual_weapon" && k.Prepared { + prepared = true + break + } + } + if !prepared { + return "" + } + slots, _ := getSpellSlots(uid) + pair, ok := slots[2] + if !ok || pair[0]-pair[1] <= 0 { + return "" + } + 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). // Selection rules: