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
130 lines
4.7 KiB
Go
130 lines
4.7 KiB
Go
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
|
|
}
|