mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
§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
185 lines
5.9 KiB
Go
185 lines
5.9 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")
|
|
}
|
|
// 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)
|
|
}
|
|
}
|