mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +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
152 lines
5.9 KiB
Go
152 lines
5.9 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
|
|
}
|
|
|
|
// 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, 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
|
|
// 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, 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
|
|
}
|
|
}
|
|
if score := spellExpectedDamage(sp, sp.Level, c.Level); score > bestScore {
|
|
best, bestSlot, bestScore = sp, sp.Level, score
|
|
}
|
|
}
|
|
return best, bestSlot, bestScore >= 0
|
|
}
|