J3 D8-a: caster picker data fixes + Spiritual Weapon pick

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.
This commit is contained in:
prosolis
2026-05-27 22:00:02 -07:00
parent 4576c75722
commit 2fdb280477
4 changed files with 77 additions and 5 deletions

View File

@@ -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")

View File

@@ -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"},

View File

@@ -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: