Combat engine §1: the hired companion can cast

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
This commit is contained in:
prosolis
2026-07-11 13:44:47 -07:00
parent d538f91cf7
commit 01c2cb2f0b
5 changed files with 421 additions and 22 deletions

View File

@@ -425,7 +425,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
// returns the spell plus the resolved slot level. errMsg is non-empty and
// player-facing on any validation failure. It performs NO resource spend —
// the caller debits the slot only once the round is about to resolve.
func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
//
// It takes the seat, not just the user, because "do you know this spell" is a
// question about a combatant and only *usually* a question about a database row:
// the hired companion has no rows and answers it from his synthetic sheet.
func parseCombatCast(sess *CombatSession, seat int, userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
tokens := strings.Fields(args)
upcast := 0
var spellTokens []string
@@ -473,7 +477,7 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin
return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx)
}
}
known, prepared, err := playerKnowsSpell(userID, spell.ID)
known, prepared, err := seatKnowsSpell(sess, seat, userID, spell.ID)
if err != nil {
return SpellDefinition{}, 0, "Couldn't check your spell list."
}
@@ -611,8 +615,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
return PlayerAction{}, noop, targetErr
}
advChar, _ := loadAdvCharacter(uid)
c, err := p.ensureCharForDnDCmd(uid, advChar)
c, err := p.seatCastSheet(ct.sess, uid)
if err != nil || c == nil {
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
}
@@ -621,7 +624,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
}
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
spell, slotLevel, errMsg := parseCombatCast(ct.sess, seat, uid, c, strings.TrimSpace(args))
if errMsg != "" {
return PlayerAction{}, noop, errMsg
}
@@ -632,7 +635,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
refund := func(ok bool) {
if !ok && spell.Level > 0 {
_ = refundSpellSlot(uid, slotLevel)
_ = refundSeatSlot(ct.sess, seat, uid, slotLevel)
}
}
@@ -649,7 +652,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
return PlayerAction{}, noop, fmt.Sprintf(
"%s has no effect the turn-based engine can apply yet.", spell.Name)
}
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
return PlayerAction{}, noop, msg
}
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
@@ -671,7 +674,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
return PlayerAction{}, noop, fmt.Sprintf(
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)
}
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
return PlayerAction{}, noop, msg
}
// Park the Necromancy kill-heal stash on the casting seat. The
@@ -735,18 +738,30 @@ func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error {
// success the caller owns the slot and must refundSpellSlot if the round itself
// errors. Material components (rare in a fight) are not refunded if the slot
// debit then fails — matching the auto-resolve cast path.
func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string {
func (p *AdventurePlugin) chargeSpellCost(sess *CombatSession, seat int, userID id.UserID, spell SpellDefinition, slotLevel int) string {
_, _, companion := seatCompanionLoadout(sess, userID)
// The companion carries no purse — he has no wallet to debit and no inventory
// to stock one from, so a component cost is not a price he can pay but a spell
// he cannot cast. Refusing here (rather than letting the debit fail on an empty
// account) keeps that an explicit rule instead of an accident of his balance.
if spell.MaterialCost > 0 {
if companion {
return fmt.Sprintf("%s needs a component %s doesn't carry.", spell.Name, companionDisplayName)
}
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
}
}
if spell.Level > 0 {
ok, serr := consumeSpellSlot(userID, slotLevel)
ok, serr := consumeSeatSlot(sess, seat, userID, slotLevel)
if serr != nil {
return "Couldn't consume slot: " + serr.Error()
}
if !ok {
if companion {
return fmt.Sprintf("%s is out of level-%d energy.", companionDisplayName, slotLevel)
}
return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID))
}
}

View File

@@ -0,0 +1,169 @@
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
}

View File

@@ -0,0 +1,192 @@
package plugin
import (
"strings"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// The hired companion casts.
//
// He could not, and the reason was one line: every spell lookup in the engine is
// keyed on a user id and answered by a dnd_* table, and he has no rows in any of
// them — deliberately, because a sheet on disk is what would turn him into a real
// character everywhere. So the picker's first statement (`LoadDnDCharacter(uid)`)
// came back nil and returned "attack", every turn, for the whole fight.
//
// Role-fill hands a lone martial a Cleric. So the common case of the feature was a
// healer who could not heal, in a party engine that had only just learned to let
// anyone heal anyone (§1). These pin both halves: that he picks the heal, and that
// picking it leaves no trace of him in the database.
// hireForFight seeds an expedition with the companion hired into it, and returns
// the run id his loadout is resolved against.
func hireForFight(t *testing.T, expID string, owner id.UserID, class DnDClass, level int) string {
t.Helper()
seedExpedition(t, expID, owner, "active")
seatLeaderFixture(t, expID)
if err := hireCompanion(expID, class, level); err != nil {
t.Fatalf("hireCompanion: %v", err)
}
return "run-" + expID
}
// petePartyFight seats a hurt leader and the companion, and returns the turn.
func petePartyFight(t *testing.T, p *AdventurePlugin, runID string, leader id.UserID, leaderHP int) *combatTurn {
t.Helper()
monster := dndBestiary["goblin"]
lead, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0)
lead.Name = "lead"
pete, _, _ := p.companionCombatant(ClassCleric, 6, monster, 2, 0)
pete.Name = companionDisplayName
enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}}
sess, err := p.startPartyCombatSession(runID, "enc", "goblin", &enemy, []CombatSeatSetup{
{UserID: leader, HP: leaderHP, HPMax: 100, Mods: lead.Mods, C: &lead},
{UserID: companionUserID(), HP: 60, HPMax: 60, Mods: pete.Mods, C: &pete, EngineDriven: true},
})
if err != nil {
t.Fatal(err)
}
return &combatTurn{
sess: sess, players: []*Combatant{&lead, &pete}, enemy: &enemy,
seat: 1, uid: companionUserID(),
}
}
// The headline: a hired Cleric, watching the leader bleed out, casts a heal on him.
// Before the seat-scoped spellbook this returned ("attack", "") — he swung a mace
// at the boss while the man who paid for him died.
func TestCompanionSpells_HiredClericHealsTheLeader(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
leader := id.UserID("@lead:example.org")
runID := hireForFight(t, "exp-heal", leader, ClassCleric, 6)
ct := petePartyFight(t, p, runID, leader, 20) // leader at 20/100 — well under the 45% bar
kind, arg := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1)
if kind != "cast" {
t.Fatalf("the hired cleric picked %q %q with the leader at 20%% HP — he must heal", kind, arg)
}
if !strings.Contains(arg, "@lead") {
t.Fatalf("cast arg = %q, want the heal aimed at the leader's seat", arg)
}
// And it lands on the leader, not on himself.
action, settle, msg := p.castActionForSeat(ct, 1, arg)
if msg != "" {
t.Fatalf("castActionForSeat refused the companion's own pick: %s", msg)
}
settle(true)
eff := action.Effect
if eff == nil || eff.AllyHeal <= 0 {
t.Fatalf("effect = %+v, want an ally heal", eff)
}
if eff.AllySeat != 0 {
t.Errorf("heal landed on seat %d, want seat 0 (the leader)", eff.AllySeat)
}
if eff.PlayerHeal != 0 {
t.Errorf("the heal also healed the caster (%d HP) — it was redirected, not copied", eff.PlayerHeal)
}
}
// His slots are spent on his seat, and he leaves no rows behind.
//
// This is the invariant with teeth. castActionForSeat used to load the caster via
// 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: a dnd_character row, a player_meta seed, a spellbook, and a
// level-1 chassis in place of the level he was hired at.
func TestCompanionSpells_SpendsHisSeatNotTheDatabase(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
leader := id.UserID("@lead:example.org")
runID := hireForFight(t, "exp-rows", leader, ClassCleric, 6)
ct := petePartyFight(t, p, runID, leader, 20)
action, settle, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead")
if msg != "" {
t.Fatalf("the hired cleric could not cast cure wounds: %s", msg)
}
settle(true)
if action.Effect == nil || action.Effect.AllyHeal <= 0 {
t.Fatalf("effect = %+v, want an ally heal", action.Effect)
}
// The slot came off his seat's ledger.
if used := ct.sess.actorStatusesPtr(1).SlotsUsed[1]; used != 1 {
t.Errorf("companion spent %d level-1 slots on his seat, want 1", used)
}
// ...and not off the leader's, which is the bug the seat-scoping exists to
// prevent: one shared @pete id across every expedition that hires him.
if used := ct.sess.actorStatusesPtr(0).SlotsUsed[1]; used != 0 {
t.Errorf("the companion's cast debited the LEADER's seat (%d slots)", used)
}
for _, table := range []string{"dnd_character", "dnd_known_spells", "dnd_spell_slots", "player_meta"} {
var n int
if err := db.Get().QueryRow(
`SELECT COUNT(*) FROM `+table+` WHERE user_id = ?`, string(companionUserID()),
).Scan(&n); err != nil {
t.Fatalf("count %s: %v", table, err)
}
if n != 0 {
t.Errorf("casting wrote %d %s row(s) for the companion — he is not a player", n, table)
}
}
}
// He runs dry like anybody else, and then he swings. A companion with an infinite
// spell pool is the "carry" the whole design says he must never be.
func TestCompanionSpells_RunsOutOfSlots(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
leader := id.UserID("@lead:example.org")
runID := hireForFight(t, "exp-dry", leader, ClassCleric, 6)
ct := petePartyFight(t, p, runID, leader, 20)
total := companionSlotPool(ClassCleric, 6, ActorStatuses{})[1][0]
if total <= 0 {
t.Fatal("a level-6 cleric has no level-1 slots — the slot table did not resolve")
}
for i := range total {
if ok, err := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil || !ok {
t.Fatalf("slot %d/%d: consume = %v (%v), want it to succeed", i+1, total, ok, err)
}
}
if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); ok {
t.Fatalf("the companion cast a %d-th level-1 spell out of a %d-slot pool", total+1, total)
}
// The refund half: a round that fails to resolve gives the slot back.
if err := refundSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil {
t.Fatal(err)
}
if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); !ok {
t.Error("a refunded slot was not castable again")
}
}
// A hired martial is still a martial: the spellbook is the class's, not a blanket
// grant, so hiring a Fighter does not quietly buy a caster.
func TestCompanionSpells_MartialHasNoSpellbook(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
leader := id.UserID("@lead:example.org")
runID := hireForFight(t, "exp-mart", leader, ClassFighter, 6)
ct := petePartyFight(t, p, runID, leader, 20)
if known, _ := seatKnownSpells(ct.sess, 1, companionUserID()); len(known) != 0 {
t.Errorf("a hired Fighter knows %d spells, want none", len(known))
}
if _, _, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead"); msg == "" {
t.Error("a hired Fighter cast cure wounds")
}
kind, _ := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1)
if kind != "attack" {
t.Errorf("a hired Fighter picked %q, want attack", kind)
}
}

View File

@@ -147,6 +147,21 @@ type ActorStatuses struct {
// path can unset it.
EngineDriven bool `json:"engine_driven,omitempty"`
// SlotsUsed is the companion's spell-slot ledger, indexed by slot level (15;
// index 0 is unused, cantrips cost nothing). Zero for every human seat.
//
// A human's slots live in dnd_spell_slots, keyed by user id. The companion has
// no rows there by design (see combat_seat_spells.go), so his spend is tracked
// on the seat. The seat is also the *correct* home rather than 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.
//
// An array and not a map: ActorStatuses is a comparable value that gets copied
// (`s := prior` in snapshotActor) and compared field-wise in the participant
// tests. A map would alias across those copies and make the struct
// uncomparable — both of which are how the next person gets hurt.
SlotsUsed [6]int `json:"slots_used,omitempty"`
// Debuffs the enemy has stacked onto this character specifically.
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`

View File

@@ -1210,8 +1210,16 @@ func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSessio
// which is seat 0. Driving a party member's turn off the leader's HP would heal
// the wrong person and re-arm the wrong aura.
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
c, _ := LoadDnDCharacter(uid)
if c == nil || sess == nil {
if sess == nil {
return "attack", ""
}
// seatPickSheet, not LoadDnDCharacter: the hired companion has no character row
// and never will, so the raw load returned nil for him and this function bailed
// to "attack" on its first line — every turn, for the whole fight. That one line
// is why a hired Cleric swung a mace while the party died. He fights off the
// same synthetic sheet the rest of his seat is built from.
c := seatPickSheet(sess, uid)
if c == nil {
return "attack", ""
}
st := sess.actorStatusesForSeat(seat)
@@ -1240,7 +1248,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
// otherwise never spends an L2 slot on it. Force the pick once
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
// it's not a damage option.
if id := simPickSpiritualWeapon(c, uid, st); id != "" {
if id := simPickSpiritualWeapon(c, sess, seat, uid, st); id != "" {
return "cast", id
}
// Once a concentration aura is up, a competent caster maintains it and
@@ -1248,7 +1256,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
// slot to re-arm the same aura — so the picker excludes concentration
// spells while one is active.
auraActive := st.ConcentrationDmg > 0
if id := simPickSpell(c, uid, auraActive); id != "" {
if id := simPickSpell(c, sess, seat, uid, auraActive); id != "" {
return "cast", id
}
}
@@ -1283,14 +1291,14 @@ func simMartialFirstClass(class DnDClass) bool {
// above 2nd, so spending a precious L5 to add a single d8 to the proc is
// not worth burning the bigger slot's damage potential elsewhere; sim
// behaves like a competent player and saves the high slot.
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) string {
func simPickSpiritualWeapon(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, st ActorStatuses) string {
if c == nil || c.Class != ClassCleric {
return ""
}
if st.BuffSpiritProc > 0 {
return ""
}
known, err := listKnownSpells(uid)
known, err := seatKnownSpells(sess, seat, uid)
if err != nil {
return ""
}
@@ -1304,7 +1312,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
if !prepared {
return ""
}
slots, _ := getSpellSlots(uid)
slots, _ := seatSpellSlots(sess, seat, uid)
const simMaxSlot = 5
for sl := 2; sl <= simMaxSlot; sl++ {
pair, ok := slots[sl]
@@ -1377,11 +1385,11 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
// The cheapest heal that is actually castable — burning a 5th-level slot to
// top somebody up is not what a competent player does.
known, err := listKnownSpells(uid)
known, err := seatKnownSpells(sess, seat, uid)
if err != nil {
return "", ""
}
slots, _ := getSpellSlots(uid)
slots, _ := seatSpellSlots(sess, seat, uid)
best, bestLevel := "", 99
for _, k := range known {
if !k.Prepared {
@@ -1416,12 +1424,12 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
return best, id.UserID(sess.seatUserID(worst)).Localpart()
}
func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string {
known, err := listKnownSpells(uid)
func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string {
known, err := seatKnownSpells(sess, seat, uid)
if err != nil || len(known) == 0 {
return ""
}
slots, _ := getSpellSlots(uid)
slots, _ := seatSpellSlots(sess, seat, uid)
type cand struct {
id string
slot int