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))
}
}