mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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
255 lines
8.2 KiB
Go
255 lines
8.2 KiB
Go
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")
|
|
}
|
|
// 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
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|