From 01c2cb2f0b22b8e14a4303a5375903dc4a3e3b87 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:44:47 -0700 Subject: [PATCH] =?UTF-8?q?Combat=20engine=20=C2=A71:=20the=20hired=20comp?= =?UTF-8?q?anion=20can=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/plugin/combat_cmd.go | 35 ++-- internal/plugin/combat_seat_spells.go | 169 ++++++++++++++++++ internal/plugin/combat_seat_spells_test.go | 192 +++++++++++++++++++++ internal/plugin/combat_session.go | 15 ++ internal/plugin/expedition_sim.go | 32 ++-- 5 files changed, 421 insertions(+), 22 deletions(-) create mode 100644 internal/plugin/combat_seat_spells.go create mode 100644 internal/plugin/combat_seat_spells_test.go diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index fd5afdf..817b8bc 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -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 ` 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)) } } diff --git a/internal/plugin/combat_seat_spells.go b/internal/plugin/combat_seat_spells.go new file mode 100644 index 0000000..3ae815b --- /dev/null +++ b/internal/plugin/combat_seat_spells.go @@ -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 +} diff --git a/internal/plugin/combat_seat_spells_test.go b/internal/plugin/combat_seat_spells_test.go new file mode 100644 index 0000000..9d4ea09 --- /dev/null +++ b/internal/plugin/combat_seat_spells_test.go @@ -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) + } +} diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 49b13b3..c93c973 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -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 (1–5; + // 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"` diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 6e1d4a3..e448316 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -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