Combat engine §6: the rooms get the small change, the boss keeps the big slots

The first cut let a caster cast in auto-resolved rooms and capped nothing but
upcasting. The sweep said that was half a fix. Room deaths fell for every caster
— mage 131 -> 105, sorcerer 118 -> 105 — and fleeing collapsed, which is what §6
predicted. But elite and boss deaths exploded behind it: mage 7 -> 38 and 19 ->
61, sorcerer 14 -> 51 and 15 -> 65. They were winning the trash rooms and
arriving at the thing that matters with an empty pool. Net, mage and sorcerer
came out of §6 worse than they went in (-34 and -37 runs cleared).

"Never upcast" is not a reserve. A mage's native-level spells ARE its boss kit,
so casting them at native level in a goblin room still spends the dragon's
slots. Only a level cap reserves anything.

So an ordinary room may reach for a 2nd-level slot and no higher. Every caster
still owns a real spell down there — a cleric's inflict_wounds, everyone else's
scorching_ray — but fireball, spirit_guardians, flame_strike and every slot the
turn engine would upcast into stay in the caster's pocket until there is
something worth spending them on.

Claude-Session: https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
This commit is contained in:
prosolis
2026-07-12 10:01:56 -07:00
parent bae83271fe
commit 064ecb1848
2 changed files with 102 additions and 10 deletions

View File

@@ -66,9 +66,27 @@ func (p *AdventurePlugin) autoCastForAutoResolve(
return true 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 // 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 // right now: known, prepared, on their list, at or under the room slot cap, and
// hold. // backed by a slot they still hold.
// //
// It casts at the spell's NATIVE level and never upcasts. simPickSpell upcasts // 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 // 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 { if !onList {
continue 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 > 0 {
if sp.Level > roomSlotCap {
continue
}
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 { if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
continue continue
} }

View File

@@ -50,18 +50,42 @@ func TestAutoCast_CasterCastsAndPaysForIt(t *testing.T) {
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) { if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
t.Fatal("a level-10 cleric with a full slot pool cast nothing") 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. // It has to be PAID for. pickBestDamageSpell reads the theoretical class slot
if mods.SpellPreDamage <= 0 && enemy.MaxHP >= 100 { // table; if we had reused it here the spell would be infinite — the same
t.Fatalf("cast produced no damage: preDamage=%d enemyHP=%d", mods.SpellPreDamage, enemy.MaxHP) // 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.
// 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 { if used := usedSlots(t, uid); used != 1 {
t.Fatalf("slots used = %d, want exactly 1 — the spell must cost a real slot", used) 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 // 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 — // ledger must converge rather than run forever. This is the free-lunch guard —
// reusing the harness's pickBestDamageSpell (which reads the theoretical class // 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) 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])
}
}
})
}
}