Combat engine §6: a caster casts in the rooms, not just at the boss

§6 was filed as "clerics are weak in solo — lift the trailer". It is not a balance
problem and a tuning dial would have buried it.

Re-baselined on HEAD: cleric still last, 46.4% vs fighter 81.4%. But cleric *dies*
less than mage, sorcerer or warlock. Its entire deficit is FLEEING — 167 of 500 runs
end with the player alive and the expedition over, where fighter and ranger flee
zero. Instrumenting the stopEnded sites: every one of those runs ends in an ORDINARY
room fight. Not a patrol, not an elite, not the boss.

An ordinary room is runZoneCombatRoster → SimulateCombat on an 8-round clock: one
breath, no turn engine, no action picker. The only spell that could ever land there
was a PendingCast the player queued BY HAND before walking in. On autopilot nobody
queues one — so for every room of every expedition, a caster fought with a weapon and
nothing else. A cleric has no Extra Attack, a mace, and its whole kit unusable: it
grinds the 8 rounds out, and a wounded one starts losing fights a fighter never
loses. One room loss ends the whole expedition.

The tell is that dnd_class_balance.go — the harness the class corpus was tuned on —
ALWAYS hands a caster their best damage spell before it simulates. The numbers that
say "cleric is a weak class" modelled a cleric who casts. The live room modelled one
who does not. The corpus and the game disagreed about what a cleric is.

Same defect as the rest of this plan: the action model is narrower than the kit. §1
(the picker never healed), §3 (the companion never acted), Pete (LoadDnDCharacter →
nil → "attack"), and now every caster in every room.

The spell is additive pre-damage, exactly as a hand-queued PendingCast has always
been, so this stays comparable to the corpus instead of being a new mechanic.

It is slot-aware and NOT free. pickBestDamageSpell reads slotsForClassLevel — the
theoretical class table — so reusing it would have cast an unlimited leveled spell
every room: the same "no row to persist onto, so it arrives fresh" free lunch that
gave the companion an infinite body. The new picker reads the seat's real remaining
slots and spends one.

It never upcasts. The big slots are what the elite and the boss are for and the turn
engine spends them there; a picker that nukes a goblin with a 5th-level slot leaves
the caster swinging a stick at the thing that matters.

Both goldens byte-identical — they pin the engine, and this changes its inputs at the
zone layer. Martials provably untouched.

UNMEASURED: the verification sweep is still in flight. Read cleric's fled count, and
watch bard/druid for overshoot — they get the same buff and were not the problem.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
This commit is contained in:
prosolis
2026-07-11 16:30:41 -07:00
parent c9128fb0d6
commit bae83271fe
3 changed files with 318 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
package plugin
import (
"maunium.net/go/mautrix/id"
)
// §6 — a caster casts in an auto-resolved fight.
//
// An ordinary room fight is runZoneCombatRoster → SimulateCombat on an 8-round
// clock: one breath, no turn engine, no action picker. The only spell that has
// ever landed there is a PendingCast the player queued BY HAND with `!cast`
// before walking in. Under autopilot nobody queues one — so for every room of
// every expedition, a caster fought with a weapon and nothing else.
//
// A cleric is the extreme case: no Extra Attack, a mace, and its whole kit
// unusable. Measured on the room path at L10, a wounded cleric loses 8% of
// ORDINARY room fights (a fighter loses 0.0%), and a room loss ends the entire
// expedition — "the fight drags on, X outlasts you, you retreat". That is why
// cleric fled 167 of 500 runs in the corpus while fighter, ranger and paladin
// fled none, and it is the whole of the "cleric is a weak class" gap.
//
// The tell: dnd_class_balance.go — the harness the class corpus was tuned on —
// ALWAYS hands a caster their best damage spell (pickBestDamageSpell +
// applyHarnessSpellCast) before it simulates. So the numbers we balanced against
// modelled a caster who casts, and the live room modelled one who does not. This
// closes that gap: the character fights with the kit it actually owns.
//
// It is NOT a new mechanic. A folded-in spell is additive pre-damage, exactly as
// a hand-queued PendingCast has always been, which is what keeps this comparable
// to the corpus rather than a fresh invention.
// autoCastForAutoResolve picks the caster's best damage spell, spends the slot,
// and folds the damage into the Combatant pair. Reports whether it cast.
//
// It reads the seat's ACTUAL remaining slots, not the class slot table.
// pickBestDamageSpell (the harness one) reads slotsForClassLevel — the
// theoretical pool — which is right for a one-fight harness and would be an
// infinite spell here: the same "no row to persist onto, so it arrives fresh"
// bug that gave the companion an unlimited body and an unlimited slot pool.
// A leveled spell cast here costs a real slot and stays spent until a rest.
func (p *AdventurePlugin) autoCastForAutoResolve(
uid id.UserID,
c *DnDCharacter,
playerStats *CombatStats,
playerMods *CombatModifiers,
enemyStats *CombatStats,
) bool {
// A hand-queued spell wins: the player already chose, applyPendingCast owns
// it, and casting a second one would be two spells in one action.
if c == nil || c.PendingCast != "" || !isSpellcaster(c) {
return false
}
spell, slot, ok := pickAutoResolveSpell(uid, c)
if !ok {
return false
}
// Leveled spells cost a slot. Spend it BEFORE the damage lands, and bail if
// the debit fails — a spell that could not be paid for must not be cast.
if slot > 0 {
spent, err := consumeSpellSlot(uid, slot)
if err != nil || !spent {
return false
}
}
applyHarnessSpellCast(c, spell, slot, playerStats, playerMods, enemyStats)
return true
}
// pickAutoResolveSpell is the best damage spell this caster can actually cast
// right now: known, prepared, on their list, and backed by a slot they still
// hold.
//
// It casts at the spell's NATIVE level and never upcasts. simPickSpell upcasts
// aggressively — correct there, because it runs in the turn engine at an elite
// or a boss, which is what the big slots are for. A trash room is not, and a
// picker that nukes a goblin with a 5th-level slot would leave the caster
// swinging a stick at the thing that actually matters.
func pickAutoResolveSpell(uid id.UserID, c *DnDCharacter) (SpellDefinition, int, bool) {
known, err := listKnownSpells(uid)
if err != nil || len(known) == 0 {
return SpellDefinition{}, 0, false
}
slots, err := getSpellSlots(uid)
if err != nil {
return SpellDefinition{}, 0, false
}
var best SpellDefinition
bestSlot := 0
bestScore := -1.0
for _, k := range known {
if !k.Prepared {
continue
}
sp, ok := lookupSpell(k.SpellID)
if !ok {
continue
}
switch sp.Effect {
case EffectDamageAttack, EffectDamageSave, EffectDamageAuto:
default:
continue
}
// The auto-resolve engine has no reaction window, same as everywhere else.
if sp.CastTime == CastReaction {
continue
}
onList := false
for _, cl := range sp.Classes {
if cl == c.Class {
onList = true
break
}
}
if !onList {
continue
}
// A cantrip is free; a leveled spell needs a slot still in hand.
if sp.Level > 0 {
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
continue
}
}
if score := spellExpectedDamage(sp, sp.Level, c.Level); score > bestScore {
best, bestSlot, bestScore = sp, sp.Level, score
}
}
return best, bestSlot, bestScore >= 0
}

View File

@@ -0,0 +1,184 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// §6 — the caster casts in an auto-resolved room fight.
//
// Before this, the ONLY spell that could land in an ordinary room was one the
// player hand-queued with `!cast` before walking in. On autopilot nobody queues
// one, so a cleric fought every room of every expedition with a mace, lost room
// fights a fighter never loses, and a room loss ends the whole expedition.
func autocastCleric(t *testing.T, tag string) (id.UserID, *DnDCharacter) {
t.Helper()
p := &AdventurePlugin{}
s := &SimRunner{P: p}
uid := id.UserID("@autocast-" + tag + ":example.org")
c, err := s.BuildCharacter(uid, ClassCleric, 10)
if err != nil {
t.Fatal(err)
}
return uid, c
}
func usedSlots(t *testing.T, uid id.UserID) int {
t.Helper()
slots, err := getSpellSlots(uid)
if err != nil {
t.Fatal(err)
}
total := 0
for _, pair := range slots {
total += pair[1]
}
return total
}
func TestAutoCast_CasterCastsAndPaysForIt(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
uid, c := autocastCleric(t, "pays")
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 100, AC: 14}
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
t.Fatal("a level-10 cleric with a full slot pool cast nothing")
}
// The spell has to actually land as damage — otherwise we spent a slot on air.
if mods.SpellPreDamage <= 0 && enemy.MaxHP >= 100 {
t.Fatalf("cast produced no damage: preDamage=%d enemyHP=%d", mods.SpellPreDamage, enemy.MaxHP)
}
// And it has to be PAID for. pickBestDamageSpell reads the theoretical class
// slot table; if we had reused it here the spell would be infinite — the same
// free-lunch bug that gave the companion an unlimited body.
if used := usedSlots(t, uid); used != 1 {
t.Fatalf("slots used = %d, want exactly 1 — the spell must cost a real slot", used)
}
}
// The pool is finite: cast far more times than the caster has slots, and the
// ledger must converge rather than run forever. This is the free-lunch guard —
// reusing the harness's pickBestDamageSpell (which reads the theoretical class
// slot table, not the row) would cast an unlimited leveled spell every room.
//
// It converges BELOW the full pool, and that is correct: a cleric owns no damage
// spell at slot 2 or 4 (guiding_bolt and inflict_wounds are L1, spirit_guardians
// L3, flame_strike L5), and a trash room never upcasts. Those slots are not
// wasted — the turn engine upcasts them at the elite and the boss, which is what
// they are for.
func TestAutoCast_SlotPoolIsFiniteAndConverges(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
uid, c := autocastCleric(t, "finite")
before, err := getSpellSlots(uid)
if err != nil {
t.Fatal(err)
}
pool := 0
for _, pair := range before {
pool += pair[0]
}
if pool == 0 {
t.Fatal("test cleric has no slots at all")
}
cast := func(n int) {
for i := 0; i < n; i++ {
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 1000, AC: 14}
p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy)
}
}
cast(pool + 10)
drained := usedSlots(t, uid)
if drained == 0 {
t.Fatal("the cleric never spent a slot — it is not really casting")
}
if drained > pool {
t.Fatalf("slots used = %d > pool %d — slots are being conjured", drained, pool)
}
// Dry means dry: another twenty rooms must not find another leveled slot.
cast(20)
if after := usedSlots(t, uid); after != drained {
t.Fatalf("slots used went %d → %d after the pool was dry — a leveled spell is being cast for free",
drained, after)
}
// And a dry caster still throws a cantrip rather than reverting to a stick.
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 1000, AC: 14}
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
t.Fatal("an out-of-slots caster cast nothing at all; a cantrip is free")
}
}
// A hand-queued spell wins. applyPendingCast already owns that cast; auto-casting
// on top of it would fire two spells in one action.
func TestAutoCast_HandQueuedSpellWins(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
uid, c := autocastCleric(t, "queued")
c.PendingCast = encodePendingCast(PendingCast{SpellID: "guiding_bolt", SlotLevel: 1})
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 100, AC: 14}
if p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
t.Fatal("auto-cast fired on top of a hand-queued spell — that is two spells in one action")
}
if used := usedSlots(t, uid); used != 0 {
t.Fatalf("slots used = %d; the auto-cast must not spend anything when the player already queued", used)
}
}
// A martial has no spellbook and must be left exactly as it was — the balance
// corpus rests on the martials not moving.
func TestAutoCast_MartialIsUntouched(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
s := &SimRunner{P: p}
uid := id.UserID("@autocast-fighter:example.org")
c, err := s.BuildCharacter(uid, ClassFighter, 10)
if err != nil {
t.Fatal(err)
}
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 100, AC: 14}
if p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
t.Fatal("a fighter cast a spell")
}
if mods.SpellPreDamage != 0 {
t.Fatalf("fighter picked up %d spell pre-damage", mods.SpellPreDamage)
}
}
// Never upcast in a trash room: the big slots are what the elite and the boss
// are for, and the turn engine spends them there.
func TestAutoCast_DoesNotBurnTheBigSlots(t *testing.T) {
setupEmptyTestDB(t)
uid, c := autocastCleric(t, "native")
spell, slot, ok := pickAutoResolveSpell(uid, c)
if !ok {
t.Fatal("cleric picked nothing")
}
if slot != spell.Level {
t.Fatalf("%s cast at slot %d, native level %d — an ordinary room must not upcast",
spell.ID, slot, spell.Level)
}
}

View File

@@ -152,6 +152,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
// Combatant once, before the fight runs. The queued spell can also debuff // Combatant once, before the fight runs. The queued spell can also debuff
// the shared enemy, so every seat's cast lands on the one stat block. // the shared enemy, so every seat's cast lands on the one stat block.
applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats) applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
// §6 — and if they queued nothing, they still cast. This engine has no
// action picker, so before this a caster on autopilot swung a weapon for
// the whole fight and their spellbook may as well not have existed. The
// slot is really spent; see autoCastForAutoResolve.
p.autoCastForAutoResolve(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods) setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods)
players = append(players, player) players = append(players, player)