mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Every spell lookup in the engine is keyed on a Matrix user id and answered by a dnd_* table. The companion has rows in none of them, deliberately — a sheet on disk is what would turn him into a real character everywhere. So the auto-picker's first statement, LoadDnDCharacter(uid), came back nil and returned "attack", every turn, for the whole fight. A hired Cleric swung a mace while the party died. Role-fill hands a lone martial a Cleric, so that was the common case of the feature. Adds a seat-scoped spellbook: seatKnownSpells / seatSpellSlots / seatKnowsSpell / consumeSeatSlot / refundSeatSlot. A human seat delegates to the DB functions verbatim — same queries, same order — so solo combat and the balance corpus are untouched (both goldens byte-identical). A companion seat is answered from his synthetic sheet and a slot ledger on his seat's persisted statuses. The seat is the correct home and not merely the available one: every expedition hires the same @pete, so a store keyed on his user id would have two parties sharing one pool of slots. He gets the same default kit a real character of his class and level gets. The below-median stays where it was — the level penalty, the never-Masterwork gear, the absent subclass and magic items. A bespoke weaker spell list would be a second nerf hidden in a different file. castActionForSeat was also a live hazard: it loaded the caster through ensureCharForDnDCmd, whose auto-migration branch, handed a user with no sheet, builds one at level 1 and *saves* it. Pointed at the companion that silently makes him a player. He now takes a branch that never reaches it, and a test counts rows in dnd_character / dnd_known_spells / dnd_spell_slots / player_meta to keep it that way. Measured, 640 runs/arm (10 classes x L10,L12 x 4 zones): solo 66.1% + Pete, mace-only (HEAD) 83.4% (+17.3pp) + Pete, casting 95.9% (+29.8pp) The fix does what it should. It also lands on top of an unpaid §2(a): the mace-only arm shows Pete was ALREADY a carry, taking the trailing band from 6.8% to 63.6% without casting a thing. The tell is the cleric leader, who role-fills a *Fighter* Pete — a seat this commit cannot touch — and still goes 26.6% -> 98.4%. That is enemy scaling undercharging for a seat, not spells. §2(a) is next, and is not optional. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
170 lines
7.0 KiB
Go
170 lines
7.0 KiB
Go
package plugin
|
|
|
|
import (
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// Seat-scoped spellbook.
|
|
//
|
|
// Every spell lookup in the engine — known list, prepared flag, slot pool, slot
|
|
// spend — is keyed on a Matrix user id and answered by a `dnd_*` table. That was
|
|
// fine while every combatant was a player. The hired companion is not: he has no
|
|
// row in dnd_character, dnd_known_spells or dnd_spell_slots, deliberately, because
|
|
// a sheet on disk for him is the thing that would turn him into a real character
|
|
// everywhere (player_meta, the leaderboard, !stats). His sheet is synthesized per
|
|
// fight and thrown away.
|
|
//
|
|
// So every one of those lookups returned "nothing" for him, and the picker's very
|
|
// first line — `c, _ := LoadDnDCharacter(uid); if c == nil { return "attack" }` —
|
|
// sent him to swing a mace, every turn, forever. A hired Cleric could not heal.
|
|
// Role-fill hands a lone fighter a Cleric, so that was the *common* case.
|
|
//
|
|
// These are the seat-scoped forms of those lookups. A human seat delegates to the
|
|
// DB functions verbatim — same queries, same order, so solo combat and the balance
|
|
// corpus are untouched. A companion seat is answered from his in-memory sheet and
|
|
// a slot ledger on his seat's persisted statuses.
|
|
//
|
|
// Nothing here may write a row for the companion. That invariant is what
|
|
// TestCompanion_SheetIsBelowMedianAndNeverPersisted pins, and the auto-migration
|
|
// inside ensureCharForDnDCmd would violate it silently: handed a user with no
|
|
// sheet, it *builds one at level 1 and saves it*. Hence seatCastSheet.
|
|
|
|
// seatCompanionLoadout returns the class and level a companion seat fights as,
|
|
// and whether the seat is the companion at all.
|
|
func seatCompanionLoadout(sess *CombatSession, uid id.UserID) (DnDClass, int, bool) {
|
|
if sess == nil || !isCompanionSeat(uid) {
|
|
return "", 0, false
|
|
}
|
|
class, level := companionLoadoutForRun(sess.RunID)
|
|
return class, level, true
|
|
}
|
|
|
|
// seatCastSheet resolves the character behind a seat for the !cast path.
|
|
//
|
|
// A human goes through ensureCharForDnDCmd, exactly as before — including its
|
|
// auto-migration for a player who predates Adv 2.0. The companion must NOT: that
|
|
// migration would mint and persist a level-1 dnd_character row for him, quietly
|
|
// making him a player and throwing away the class and level he was hired at. He
|
|
// gets his synthetic sheet instead, built from the same class-priority pipeline a
|
|
// real character of his level uses.
|
|
func (p *AdventurePlugin) seatCastSheet(sess *CombatSession, uid id.UserID) (*DnDCharacter, error) {
|
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
|
return companionSheet(class, level), nil
|
|
}
|
|
advChar, _ := loadAdvCharacter(uid)
|
|
return p.ensureCharForDnDCmd(uid, advChar)
|
|
}
|
|
|
|
// seatPickSheet is seatCastSheet for the auto-picker, which reads a sheet to
|
|
// decide a turn and must never create one. Humans get LoadDnDCharacter, which is
|
|
// what the picker has always called; a miss returns nil and the caller swings.
|
|
func seatPickSheet(sess *CombatSession, uid id.UserID) *DnDCharacter {
|
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
|
return companionSheet(class, level)
|
|
}
|
|
c, _ := LoadDnDCharacter(uid)
|
|
return c
|
|
}
|
|
|
|
// companionKnownSpells is the companion's spell list: the same default kit
|
|
// ensureSpellsForCharacter grants a real character of that class and level, every
|
|
// entry prepared (which is also what that function does — preparation is SP4 and
|
|
// until it lands every granted spell is auto-prepared so !cast works).
|
|
//
|
|
// He gets the player kit on purpose. His below-median comes from the level penalty,
|
|
// the never-Masterwork gear and the absent subclass/magic items — the layers a
|
|
// player accumulates. Handing him a bespoke, weaker spell list would be a second
|
|
// nerf hidden in a different file, and it would drift away from the tuned list the
|
|
// moment anyone touched one and not the other.
|
|
func companionKnownSpells(class DnDClass, level int) []knownSpellRow {
|
|
ids := defaultKnownSpells(class, level)
|
|
out := make([]knownSpellRow, 0, len(ids))
|
|
for _, sid := range ids {
|
|
out = append(out, knownSpellRow{SpellID: sid, Source: "companion", Prepared: true})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// companionSlotPool is his slot table (total, used) — the class/level progression
|
|
// every caster shares, less what he has already spent this fight.
|
|
func companionSlotPool(class DnDClass, level int, st ActorStatuses) map[int][2]int {
|
|
out := map[int][2]int{}
|
|
for lvl, total := range slotsForClassLevel(class, level) {
|
|
used := 0
|
|
if lvl >= 0 && lvl < len(st.SlotsUsed) {
|
|
used = min(st.SlotsUsed[lvl], total)
|
|
}
|
|
out[lvl] = [2]int{total, used}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// seatKnownSpells is listKnownSpells for a seat.
|
|
func seatKnownSpells(sess *CombatSession, seat int, uid id.UserID) ([]knownSpellRow, error) {
|
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
|
return companionKnownSpells(class, level), nil
|
|
}
|
|
return listKnownSpells(uid)
|
|
}
|
|
|
|
// seatSpellSlots is getSpellSlots for a seat.
|
|
func seatSpellSlots(sess *CombatSession, seat int, uid id.UserID) (map[int][2]int, error) {
|
|
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
|
return companionSlotPool(class, level, sess.actorStatusesForSeat(seat)), nil
|
|
}
|
|
return getSpellSlots(uid)
|
|
}
|
|
|
|
// seatKnowsSpell is playerKnowsSpell for a seat: (known, prepared, err).
|
|
func seatKnowsSpell(sess *CombatSession, seat int, uid id.UserID, spellID string) (bool, bool, error) {
|
|
if _, _, ok := seatCompanionLoadout(sess, uid); ok {
|
|
known, err := seatKnownSpells(sess, seat, uid)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
for _, k := range known {
|
|
if k.SpellID == spellID {
|
|
return true, k.Prepared, nil
|
|
}
|
|
}
|
|
return false, false, nil
|
|
}
|
|
return playerKnowsSpell(uid, spellID)
|
|
}
|
|
|
|
// consumeSeatSlot is consumeSpellSlot for a seat. The companion's spend lands on
|
|
// his seat's persisted statuses rather than a table, so it survives the round
|
|
// commit and a mid-fight restart — and so two players who have each hired him are
|
|
// not sharing one slot pool, which a store keyed on his (single, shared) user id
|
|
// would have given them.
|
|
func consumeSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) (bool, error) {
|
|
class, level, ok := seatCompanionLoadout(sess, uid)
|
|
if !ok {
|
|
return consumeSpellSlot(uid, slotLevel)
|
|
}
|
|
if slotLevel < 1 || slotLevel >= len(ActorStatuses{}.SlotsUsed) {
|
|
return false, nil
|
|
}
|
|
st := sess.actorStatusesPtr(seat)
|
|
pair, exists := companionSlotPool(class, level, *st)[slotLevel]
|
|
if !exists || pair[0]-pair[1] <= 0 {
|
|
return false, nil
|
|
}
|
|
st.SlotsUsed[slotLevel]++
|
|
return true, nil
|
|
}
|
|
|
|
// refundSeatSlot is refundSpellSlot for a seat: the rollback half of the above,
|
|
// called when the round the slot was spent on failed to resolve.
|
|
func refundSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) error {
|
|
if _, _, ok := seatCompanionLoadout(sess, uid); !ok {
|
|
return refundSpellSlot(uid, slotLevel)
|
|
}
|
|
st := sess.actorStatusesPtr(seat)
|
|
if slotLevel < 1 || slotLevel >= len(st.SlotsUsed) || st.SlotsUsed[slotLevel] <= 0 {
|
|
return nil
|
|
}
|
|
st.SlotsUsed[slotLevel]--
|
|
return nil
|
|
}
|