diff --git a/internal/plugin/zone_combat_autocast.go b/internal/plugin/zone_combat_autocast.go index ff6fd5d..c2c9506 100644 --- a/internal/plugin/zone_combat_autocast.go +++ b/internal/plugin/zone_combat_autocast.go @@ -66,9 +66,27 @@ func (p *AdventurePlugin) autoCastForAutoResolve( return true } +// roomSlotCap is the highest slot level an ordinary room may spend. Cantrips are +// free and always available; anything above this is reserved for the elite and +// the boss. +// +// The first cut of §6 had no cap — "never upcast" was supposed to be enough. It +// was not, and the sweep said so: room deaths fell for every caster (mage 131 → +// 105) while elite and boss deaths exploded (mage 7 → 38 and 19 → 61, sorcerer +// 14 → 51 and 15 → 65). Casters were winning the trash rooms and arriving at the +// thing that matters with an empty pool. Native-level is not a reserve, because +// a mage's native-level spells ARE its boss kit; only a level cap is. +// +// 2 is where the two demands stop overlapping. Every caster owns a damage spell +// at or below it (a cleric's guiding_bolt and inflict_wounds are both 1st), so +// the rooms still get a real spell — but a fireball, a spirit_guardians, a +// flame_strike, and every slot the turn engine would upcast into stays in the +// caster's pocket until there is something worth spending it on. +const roomSlotCap = 2 + // 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. +// right now: known, prepared, on their list, at or under the room slot cap, 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 @@ -115,8 +133,12 @@ func pickAutoResolveSpell(uid id.UserID, c *DnDCharacter) (SpellDefinition, int, if !onList { continue } - // A cantrip is free; a leveled spell needs a slot still in hand. + // A cantrip is free; a leveled spell needs a slot still in hand, and the + // room only gets to reach for the small ones. if sp.Level > 0 { + if sp.Level > roomSlotCap { + continue + } if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 { continue } diff --git a/internal/plugin/zone_combat_autocast_test.go b/internal/plugin/zone_combat_autocast_test.go index 357b136..a8a4213 100644 --- a/internal/plugin/zone_combat_autocast_test.go +++ b/internal/plugin/zone_combat_autocast_test.go @@ -50,18 +50,42 @@ func TestAutoCast_CasterCastsAndPaysForIt(t *testing.T) { 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. + // 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. The slot is spent + // whether or not the spell connects, exactly as it is at a real table. if used := usedSlots(t, uid); used != 1 { t.Fatalf("slots used = %d, want exactly 1 — the spell must cost a real slot", used) } } +// The slot has to buy damage. Not on any GIVEN cast — under the room slot cap a +// cleric reaches for inflict_wounds, which is an attack-roll spell and is +// allowed to miss — but a caster who spends its pool and never scratches +// anything would be strictly worse than swinging the mace. +func TestAutoCast_SpellActuallyDealsDamage(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + uid, c := autocastCleric(t, "damage") + + landed := 0 + for i := 0; i < 40; i++ { + var mods CombatModifiers + stats := CombatStats{MaxHP: 100, AC: 14} + enemy := CombatStats{MaxHP: 1000, AC: 10} + if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) { + continue + } + if mods.SpellPreDamage > 0 || enemy.MaxHP < 1000 { + landed++ + } + } + if landed == 0 { + t.Fatal("40 casts against AC 10 and not one dealt a point of damage — " + + "the caster is spending slots on air") + } +} + // 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 @@ -182,3 +206,49 @@ func TestAutoCast_DoesNotBurnTheBigSlots(t *testing.T) { spell.ID, slot, spell.Level) } } + +// The boss kit survives the walk in. This is the regression the first cut of §6 +// shipped and the sweep caught: with no cap, a caster spent its whole pool on +// goblins and reached the boss empty — room deaths fell but boss deaths tripled +// (mage 19 → 61, sorcerer 15 → 65). "Never upcast" is not a reserve, because a +// mage's native-level spells ARE its boss kit. +// +// So: grind a full expedition's worth of rooms, and every slot above the cap +// must still be sitting there untouched when the dragon opens its eyes. +func TestAutoCast_ReservesTheBossSlots(t *testing.T) { + for _, class := range []DnDClass{ClassMage, ClassSorcerer, ClassCleric, ClassWarlock} { + t.Run(string(class), func(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + s := &SimRunner{P: p} + uid := id.UserID("@autocast-reserve-" + string(class) + ":example.org") + c, err := s.BuildCharacter(uid, class, 12) + if err != nil { + t.Fatal(err) + } + + // A long expedition: far more rooms than the caster has slots. + for i := 0; i < 60; i++ { + var mods CombatModifiers + stats := CombatStats{MaxHP: 100, AC: 14} + enemy := CombatStats{MaxHP: 1000, AC: 14} + p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) + } + + slots, err := getSpellSlots(uid) + if err != nil { + t.Fatal(err) + } + for lvl, pair := range slots { + if lvl <= roomSlotCap { + continue + } + if pair[1] != 0 { + t.Errorf("level-%d slots: %d of %d spent in ordinary rooms — "+ + "that is the boss's kit, and the caster will arrive empty", + lvl, pair[1], pair[0]) + } + } + }) + } +}