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

@@ -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