diff --git a/internal/plugin/zone_combat_autocast.go b/internal/plugin/zone_combat_autocast.go new file mode 100644 index 0000000..ff6fd5d --- /dev/null +++ b/internal/plugin/zone_combat_autocast.go @@ -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 +} diff --git a/internal/plugin/zone_combat_autocast_test.go b/internal/plugin/zone_combat_autocast_test.go new file mode 100644 index 0000000..357b136 --- /dev/null +++ b/internal/plugin/zone_combat_autocast_test.go @@ -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) + } +} diff --git a/internal/plugin/zone_combat_party.go b/internal/plugin/zone_combat_party.go index 42411f3..f787da1 100644 --- a/internal/plugin/zone_combat_party.go +++ b/internal/plugin/zone_combat_party.go @@ -152,6 +152,11 @@ func (p *AdventurePlugin) runZoneCombatRoster( // 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. 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) players = append(players, player)