Companion: he carries his wounds, rations his slots, and heals himself

Three defects, all the same mistake, all found by sweep and not by tests: the
companion has no database row for a thing to persist onto, so the thing "arrives
fresh next time" — which for a resource means infinite.

1. His spell slots refilled every fight. The ledger went on his combat SEAT, and
   a seat is per-session. A human rations one pool across a 30-room run and gets
   it back at camp; rationing it IS the caster's game. Now on
   expedition_party.companion_slots_used, refreshed at camp. (Worth ~0pp alone —
   a run holds only ~2 real fights, so the pool never binds. I predicted this was
   the whole answer. It was not.)

2. His BODY refilled every fight. buildFightSeats seated him at Stats.MaxHP and
   the close-out skipped him — "he arrives fresh next time", said the comment.
   That is an infinite body: he soaked a share of every fight's incoming and then
   reset, while the humans beside him bled all the way to camp. THIS was the
   carry. Now expedition_party.companion_hp; healed at camp; a dropped companion
   returns on 1 HP rather than as a corpse, because there is no companion-death
   rule and inventing one inside a bug fix would be a second feature.

3. No autopiloted caster had ever healed ITSELF. simPickAllyHeal skipped
   `i == seat` and bailed on !IsParty(), so a solo cleric carried cure_wounds for
   a whole run and never once cast it. Now simPickHeal: heal whoever is worst off,
   which is sometimes you.

Measured, 640 runs/arm, like-for-like (the leaders whose role-fill gives Pete a
Cleric, against a human Cleric follower of the leader's own level):

  solo                    69.0%
  + a human cleric        77.6%   (+8.6pp)
  + Pete                  66.1%   (-2.9pp)

The reference arm is the point. Against SOLO even a mace-only Pete looked like a
carry — but parties are designed to be safer, so solo is the wrong yardstick.
Against a human peer the real bug appeared: a gearless, level-penalized hireling
was out-clearing a fully-geared human cleric of the leader's own level by 15pp,
because he was the only combatant in the game who healed to full between fights.

With the free lunches gone he is honest, and honestly a net negative — which is
exactly the plan's §2 diagnosis, unmasked: a below-median seat cannot pay for its
own enemy scaling (+15% boss HP and 2.4 enemy actions a round instead of 1).
§2(a) is next, and the sweep now argues FOR it; before this commit it would have
made things worse.

Self-heal moved solo 66.1% -> 66.2%, so the balance corpus is undisturbed and no
re-baseline is owed. It is also NOT the answer to §6 — casters reach for a healing
consumable first and the sim stocks them, so a human rarely falls through to the
spell. Pete carries no consumables, so it is his only heal.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
This commit is contained in:
prosolis
2026-07-11 14:56:19 -07:00
parent 01c2cb2f0b
commit 27b9de5936
10 changed files with 377 additions and 73 deletions

View File

@@ -410,6 +410,32 @@ func runMigrations(d *sql.DB) error {
// where isCompanionSeat holds — a player's row never consults them. // where isCompanionSeat holds — a player's row never consults them.
`ALTER TABLE expedition_party ADD COLUMN companion_class TEXT NOT NULL DEFAULT ''`, `ALTER TABLE expedition_party ADD COLUMN companion_class TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE expedition_party ADD COLUMN companion_level INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE expedition_party ADD COLUMN companion_level INTEGER NOT NULL DEFAULT 0`,
// The companion's spell-slot ledger, "used" per slot level, as a compact
// CSV of six ints (index 0 unused; cantrips cost nothing).
//
// It has to live on the *expedition*, next to the class and level it is a
// pool for. A human caster's slots are dnd_spell_slots rows that persist
// across every fight of the run and only come back at camp, so rationing a
// pool across a 30-room day IS the caster's game. The first cut of this
// parked the companion's ledger on his combat seat instead — and a seat is
// per-session, so he walked into every single fight with a full pool. The
// sim caught it: a level-penalized, gearless hireling out-cleared a human
// cleric of the leader's own level by 15pp.
`ALTER TABLE expedition_party ADD COLUMN companion_slots_used TEXT NOT NULL DEFAULT ''`,
// The companion's body, carried across the run. -1 means "unset" — he is
// at full, which is what a fresh hire is.
//
// He used to re-seat at full max HP for *every* fight, because he has no
// dnd_character row for his HP to persist onto and the close-out skipped
// him ("he arrives fresh next time"). That is an infinite body: a player
// bleeds across a 30-room run and only heals at camp, while the hireling
// soaked half the incoming damage and reset. Measured, it is most of why a
// gearless, level-penalized hireling out-cleared a human cleric of the
// leader's own level — his party fled 5 runs out of 640 where the human
// party fled 56.
`ALTER TABLE expedition_party ADD COLUMN companion_hp INTEGER NOT NULL DEFAULT -1`,
} }
for _, stmt := range columnMigrations { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@@ -412,6 +413,153 @@ func companionLoadout(expeditionID string) (DnDClass, int) {
return DnDClass(class), level return DnDClass(class), level
} }
// ── his spell slots, which live on the expedition ────────────────────────────
//
// A human caster's slots are dnd_spell_slots rows: one pool, spent across every
// fight of the run, refilled only at camp. Rationing it is the caster's game. The
// companion has no rows, so his pool lives on his roster row — the same row his
// class and level live on, and with the same lifetime.
//
// It must NOT live on his combat seat. A seat is per-session and every fight opens
// a new one, so a seat-scoped pool refills itself between fights: an infinite
// caster. That is not a theory — the first cut did exactly that, and the sim
// measured a gearless, level-penalized hireling out-clearing a human cleric of the
// leader's own level by 15pp.
// companionSlotsCSV encodes/decodes the ledger. CSV of six ints rather than JSON
// because it is six ints.
func companionSlotsDecode(s string) [6]int {
var out [6]int
for i, f := range strings.Split(s, ",") {
if i >= len(out) {
break
}
n, err := strconv.Atoi(strings.TrimSpace(f))
if err != nil || n < 0 {
continue
}
out[i] = n
}
return out
}
func companionSlotsEncode(used [6]int) string {
parts := make([]string, len(used))
for i, n := range used {
parts[i] = strconv.Itoa(n)
}
return strings.Join(parts, ",")
}
// companionSlotsForRun reads the ledger for the companion on the expedition that
// owns runID. A run with no companion (or no expedition) reads as an empty pool,
// which is the correct answer: nobody spent anything.
func companionSlotsForRun(runID string) [6]int {
var raw string
err := db.Get().QueryRow(`
SELECT p.companion_slots_used
FROM expedition_party p
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
WHERE e.run_id = ? AND p.user_id = ?`,
runID, string(companionUserID())).Scan(&raw)
if err != nil {
return [6]int{}
}
return companionSlotsDecode(raw)
}
// setCompanionSlotsForRun writes it back.
func setCompanionSlotsForRun(runID string, used [6]int) error {
_, err := db.Get().Exec(`
UPDATE expedition_party
SET companion_slots_used = ?
WHERE user_id = ?
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
companionSlotsEncode(used), string(companionUserID()), runID)
return err
}
// refreshCompanionSlots empties the ledger — his half of the camp rest that calls
// refreshSpellSlots for every human. Keyed by expedition, because camp is.
func refreshCompanionSlots(expeditionID string) error {
_, err := db.Get().Exec(`
UPDATE expedition_party SET companion_slots_used = ''
WHERE expedition_id = ? AND user_id = ?`,
expeditionID, string(companionUserID()))
return err
}
// ── his body, which is also carried across the run ───────────────────────────
//
// companionUnsetHP is "no wound recorded" — a fresh hire, or a companion who has
// just broken camp. Seating reads it as full.
const companionUnsetHP = -1
// companionHPFor reads the HP he carries into his next fight, or companionUnsetHP
// when he is unhurt. A run with no companion reads unset, which is harmless: there
// is nobody to seat.
func companionHPFor(expeditionID string) int {
hp := companionUnsetHP
err := db.Get().QueryRow(`
SELECT companion_hp FROM expedition_party
WHERE expedition_id = ? AND user_id = ?`,
expeditionID, string(companionUserID())).Scan(&hp)
if err != nil {
return companionUnsetHP
}
return hp
}
// companionSeatHP is what he actually sits down with: his carried wound, clamped
// into [1, maxHP].
//
// The floor of 1 is deliberate. He can be dropped *inside* a fight — the engine
// counts him out like any other seat — but he does not stay dead between them,
// because there is no companion-death mechanic and inventing one here would be a
// second feature smuggled into a bug fix. Coming back on 1 HP is a real penalty
// (one hit and he is down again) without pretending to be a corpse rule.
func companionSeatHP(expeditionID string, maxHP int) int {
hp := companionHPFor(expeditionID)
if hp == companionUnsetHP || hp > maxHP {
return maxHP
}
if hp < 1 {
return 1
}
return hp
}
// setCompanionHP records the HP he walked out of a fight with.
func setCompanionHP(expeditionID string, hp int) error {
_, err := db.Get().Exec(`
UPDATE expedition_party SET companion_hp = ?
WHERE expedition_id = ? AND user_id = ?`,
hp, expeditionID, string(companionUserID()))
return err
}
// setCompanionHPForRun is setCompanionHP for the turn-based close-out, which
// holds a run id rather than an expedition id.
func setCompanionHPForRun(runID string, hp int) error {
_, err := db.Get().Exec(`
UPDATE expedition_party
SET companion_hp = ?
WHERE user_id = ?
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
hp, string(companionUserID()), runID)
return err
}
// refreshCompanionHP is his half of the camp heal: back to full, like every human
// at a standard rest.
func refreshCompanionHP(expeditionID string) error {
_, err := db.Get().Exec(`
UPDATE expedition_party SET companion_hp = ?
WHERE expedition_id = ? AND user_id = ?`,
companionUnsetHP, expeditionID, string(companionUserID()))
return err
}
// companionLoadoutForRun is companionLoadout keyed by the zone run instead of // companionLoadoutForRun is companionLoadout keyed by the zone run instead of
// the expedition. A CombatSession carries a RunID, not an expedition id, and the // the expedition. A CombatSession carries a RunID, not an expedition id, and the
// per-turn rebuild is the hottest caller — so the join lives here rather than // per-turn rebuild is the hottest caller — so the join lives here rather than

View File

@@ -55,12 +55,16 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
// so does the bookkeeping that outlives the fight — a Berserker who raged and // so does the bookkeeping that outlives the fight — a Berserker who raged and
// lost is still exhausted. Both fan out; neither is the owner's alone. // lost is still exhausted. Both fan out; neither is the owner's alone.
for seat := range sess.RosterSize() { for seat := range sess.RosterSize() {
// The companion has no sheet to persist HP onto and no bookkeeping that // The companion has no sheet, so none of the sheet-keyed bookkeeping below
// outlives the fight — he arrives fresh next time. Skipping him here is // applies to him — and postCombatBookkeepingForSeat logs at ERROR for a
// not just tidiness: postCombatBookkeepingForSeat logs at ERROR when a // seat with no sheet, which would file one for every party fight he is ever
// seat has no sheet, so leaving him in would file an error for every // hired for. But his HP is not bookkeeping: it is the fight's result. It
// party fight he is ever hired for. // lands on his roster row, because that is the only row he has.
//
// He used to be skipped outright, and "he arrives fresh next time" was the
// stated intent. It is a free lunch — see companionSeatHP.
if isCompanionUser(sess.seatUserID(seat)) { if isCompanionUser(sess.seatUserID(seat)) {
_ = setCompanionHPForRun(sess.RunID, sess.seatHP(seat))
continue continue
} }
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat)) persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))

View File

@@ -71,11 +71,18 @@ func (p *AdventurePlugin) buildFightSeats(
// discover that would stall the fight and then announce him to the party as // discover that would stall the fight and then announce him to the party as
// an absent player. // an absent player.
if isCompanionSeat(uid) { if isCompanionSeat(uid) {
class, level := companionLoadout(companionExpeditionFor(roster[0])) expID := companionExpeditionFor(roster[0])
class, level := companionLoadout(expID)
player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood) player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood)
// He carries his wounds between fights, like everyone else. Seating him at
// full max HP — which is what this did — hands the party an infinite body:
// he soaks a share of every fight's incoming and then resets, while the
// humans beside him bleed all the way to camp.
seats = append(seats, CombatSeatSetup{ seats = append(seats, CombatSeatSetup{
UserID: uid, HP: player.Stats.MaxHP, HPMax: player.Stats.MaxHP, UserID: uid,
Mods: player.Mods, C: &player, EngineDriven: true, HP: companionSeatHP(expID, player.Stats.MaxHP),
HPMax: player.Stats.MaxHP,
Mods: player.Mods, C: &player, EngineDriven: true,
}) })
continue continue
} }

View File

@@ -86,15 +86,17 @@ func companionKnownSpells(class DnDClass, level int) []knownSpellRow {
} }
// companionSlotPool is his slot table (total, used) — the class/level progression // companionSlotPool is his slot table (total, used) — the class/level progression
// every caster shares, less what he has already spent this fight. // every caster shares, less what he has already spent. `used` is the expedition's
func companionSlotPool(class DnDClass, level int, st ActorStatuses) map[int][2]int { // ledger, NOT a per-fight one: he rations one pool across the run and gets it back
// at camp, exactly as a human caster does.
func companionSlotPool(class DnDClass, level int, used [6]int) map[int][2]int {
out := map[int][2]int{} out := map[int][2]int{}
for lvl, total := range slotsForClassLevel(class, level) { for lvl, total := range slotsForClassLevel(class, level) {
used := 0 spent := 0
if lvl >= 0 && lvl < len(st.SlotsUsed) { if lvl >= 0 && lvl < len(used) {
used = min(st.SlotsUsed[lvl], total) spent = min(used[lvl], total)
} }
out[lvl] = [2]int{total, used} out[lvl] = [2]int{total, spent}
} }
return out return out
} }
@@ -110,7 +112,7 @@ func seatKnownSpells(sess *CombatSession, seat int, uid id.UserID) ([]knownSpell
// seatSpellSlots is getSpellSlots for a seat. // seatSpellSlots is getSpellSlots for a seat.
func seatSpellSlots(sess *CombatSession, seat int, uid id.UserID) (map[int][2]int, error) { func seatSpellSlots(sess *CombatSession, seat int, uid id.UserID) (map[int][2]int, error) {
if class, level, ok := seatCompanionLoadout(sess, uid); ok { if class, level, ok := seatCompanionLoadout(sess, uid); ok {
return companionSlotPool(class, level, sess.actorStatusesForSeat(seat)), nil return companionSlotPool(class, level, companionSlotsForRun(sess.RunID)), nil
} }
return getSpellSlots(uid) return getSpellSlots(uid)
} }
@@ -133,24 +135,27 @@ func seatKnowsSpell(sess *CombatSession, seat int, uid id.UserID, spellID string
} }
// consumeSeatSlot is consumeSpellSlot for a seat. The companion's spend lands on // 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 // the expedition's ledger — one pool for the whole run, refilled at camp — so he
// commit and a mid-fight restart — and so two players who have each hired him are // rations his slots the way a human caster has to. Keying it by expedition also
// not sharing one slot pool, which a store keyed on his (single, shared) user id // keeps two parties who have each hired him from sharing a pool, which anything
// would have given them. // keyed on his (single, shared) user id would have done.
func consumeSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) (bool, error) { func consumeSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) (bool, error) {
class, level, ok := seatCompanionLoadout(sess, uid) class, level, ok := seatCompanionLoadout(sess, uid)
if !ok { if !ok {
return consumeSpellSlot(uid, slotLevel) return consumeSpellSlot(uid, slotLevel)
} }
if slotLevel < 1 || slotLevel >= len(ActorStatuses{}.SlotsUsed) { used := companionSlotsForRun(sess.RunID)
if slotLevel < 1 || slotLevel >= len(used) {
return false, nil return false, nil
} }
st := sess.actorStatusesPtr(seat) pair, exists := companionSlotPool(class, level, used)[slotLevel]
pair, exists := companionSlotPool(class, level, *st)[slotLevel]
if !exists || pair[0]-pair[1] <= 0 { if !exists || pair[0]-pair[1] <= 0 {
return false, nil return false, nil
} }
st.SlotsUsed[slotLevel]++ used[slotLevel]++
if err := setCompanionSlotsForRun(sess.RunID, used); err != nil {
return false, err
}
return true, nil return true, nil
} }
@@ -160,10 +165,10 @@ func refundSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int)
if _, _, ok := seatCompanionLoadout(sess, uid); !ok { if _, _, ok := seatCompanionLoadout(sess, uid); !ok {
return refundSpellSlot(uid, slotLevel) return refundSpellSlot(uid, slotLevel)
} }
st := sess.actorStatusesPtr(seat) used := companionSlotsForRun(sess.RunID)
if slotLevel < 1 || slotLevel >= len(st.SlotsUsed) || st.SlotsUsed[slotLevel] <= 0 { if slotLevel < 1 || slotLevel >= len(used) || used[slotLevel] <= 0 {
return nil return nil
} }
st.SlotsUsed[slotLevel]-- used[slotLevel]--
return nil return setCompanionSlotsForRun(sess.RunID, used)
} }

View File

@@ -116,14 +116,10 @@ func TestCompanionSpells_SpendsHisSeatNotTheDatabase(t *testing.T) {
t.Fatalf("effect = %+v, want an ally heal", action.Effect) t.Fatalf("effect = %+v, want an ally heal", action.Effect)
} }
// The slot came off his seat's ledger. // The slot came off the expedition's ledger — which is where it has to live, so
if used := ct.sess.actorStatusesPtr(1).SlotsUsed[1]; used != 1 { // that it is still spent in the NEXT fight of the same run.
t.Errorf("companion spent %d level-1 slots on his seat, want 1", used) if used := companionSlotsForRun(ct.sess.RunID); used[1] != 1 {
} t.Errorf("companion spent %v level-1 slots on the run's ledger, want 1", used[1])
// ...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"} { for _, table := range []string{"dnd_character", "dnd_known_spells", "dnd_spell_slots", "player_meta"} {
@@ -148,7 +144,7 @@ func TestCompanionSpells_RunsOutOfSlots(t *testing.T) {
runID := hireForFight(t, "exp-dry", leader, ClassCleric, 6) runID := hireForFight(t, "exp-dry", leader, ClassCleric, 6)
ct := petePartyFight(t, p, runID, leader, 20) ct := petePartyFight(t, p, runID, leader, 20)
total := companionSlotPool(ClassCleric, 6, ActorStatuses{})[1][0] total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
if total <= 0 { if total <= 0 {
t.Fatal("a level-6 cleric has no level-1 slots — the slot table did not resolve") t.Fatal("a level-6 cleric has no level-1 slots — the slot table did not resolve")
} }
@@ -170,6 +166,95 @@ func TestCompanionSpells_RunsOutOfSlots(t *testing.T) {
} }
} }
// His pool does NOT refill between fights, and it DOES come back at camp.
//
// This is the one the sweep caught and the unit tests did not. The first cut of
// the spellbook parked his ledger on his combat seat — and a seat is per-session,
// so every fight opened a fresh one and he walked in with full slots. A human
// cleric rations a single pool across the whole run; rationing it IS the caster's
// game. Handed an infinite pool, a gearless level-penalized hireling out-cleared a
// same-level human cleric by 15pp in the sim.
func TestCompanionSpells_PoolIsRationedAcrossTheRun(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
leader := id.UserID("@lead:example.org")
runID := hireForFight(t, "exp-ration", leader, ClassCleric, 6)
// Fight one: spend every level-1 slot he owns.
first := petePartyFight(t, p, runID, leader, 20)
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
for range total {
if ok, err := consumeSeatSlot(first.sess, 1, companionUserID(), 1); err != nil || !ok {
t.Fatalf("consume: %v (%v)", ok, err)
}
}
// Fight two, same run — a NEW combat session, which is exactly what used to
// hand him a fresh pool.
if err := markCombatSessionExpired(first.sess.SessionID); err != nil {
t.Fatal(err)
}
second := petePartyFight(t, p, runID, leader, 20)
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); ok {
t.Error("the companion's spell slots refilled between fights — he is an infinite caster")
}
// ...and camp gives them back, the same way it does for every human.
if err := refreshCompanionSlots("exp-ration"); err != nil {
t.Fatal(err)
}
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); !ok {
t.Error("camp did not restore the companion's slots — his pool only ever goes down")
}
}
// He carries his wounds between fights, and camp patches him up.
//
// He used to re-seat at full max HP for every single fight — "he arrives fresh
// next time" was the close-out's stated intent — which is an infinite body. A
// player bleeds across a 30-room run and only heals at camp; the hireling soaked
// his share of every fight and then reset. In the sim his party fled 5 runs out of
// 640 where the same party with a *human* cleric fled 56.
func TestCompanion_CarriesWoundsBetweenFights(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
seedExpedition(t, "exp-hp", leader, "active")
seatLeaderFixture(t, "exp-hp")
if err := hireCompanion("exp-hp", ClassCleric, 6); err != nil {
t.Fatal(err)
}
// A fresh hire is at full.
if got := companionSeatHP("exp-hp", 100); got != 100 {
t.Errorf("a fresh hire seats at %d/100 HP, want full", got)
}
// He walks out of a fight on 30 HP; he walks into the next one on 30 HP.
if err := setCompanionHP("exp-hp", 30); err != nil {
t.Fatal(err)
}
if got := companionSeatHP("exp-hp", 100); got != 30 {
t.Errorf("he re-seated at %d/100 HP after ending a fight on 30 — the wound did not carry", got)
}
// Dropped in a fight, he comes back on his feet but barely — not as a corpse
// (there is no companion-death rule) and not at full.
if err := setCompanionHP("exp-hp", 0); err != nil {
t.Fatal(err)
}
if got := companionSeatHP("exp-hp", 100); got != 1 {
t.Errorf("after being dropped he seats at %d/100 HP, want 1", got)
}
// Camp puts him right, exactly as it does every human.
if err := refreshCompanionHP("exp-hp"); err != nil {
t.Fatal(err)
}
if got := companionSeatHP("exp-hp", 100); got != 100 {
t.Errorf("camp left him on %d/100 HP — his body only ever goes down", got)
}
}
// A hired martial is still a martial: the spellbook is the class's, not a blanket // 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. // grant, so hiring a Fighter does not quietly buy a caster.
func TestCompanionSpells_MartialHasNoSpellbook(t *testing.T) { func TestCompanionSpells_MartialHasNoSpellbook(t *testing.T) {

View File

@@ -147,21 +147,6 @@ type ActorStatuses struct {
// path can unset it. // path can unset it.
EngineDriven bool `json:"engine_driven,omitempty"` 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. // Debuffs the enemy has stacked onto this character specifically.
PlayerAtkDrain int `json:"player_atk_drain,omitempty"` PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
PlayerACDebuff int `json:"player_ac_debuff,omitempty"` PlayerACDebuff int `json:"player_ac_debuff,omitempty"`

View File

@@ -325,6 +325,13 @@ func applyCampRest(e *Expedition, kind string) string {
if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase { if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
_ = refreshAllResources(uid) _ = refreshAllResources(uid)
_ = refreshSpellSlots(uid) _ = refreshSpellSlots(uid)
// The companion sleeps at the same fire. His slots and his wounds are not
// dnd_spell_slots / dnd_character rows — he has none — so nothing above
// reaches him, and without these two his pool and his body would only ever
// go down. Camp is where a caster gets their slots back and a body gets
// patched up, and he is at the camp.
_ = refreshCompanionSlots(e.ID)
_ = refreshCompanionHP(e.ID)
_ = ReplenishHarvestNodes(e) _ = ReplenishHarvestNodes(e)
} }

View File

@@ -1237,7 +1237,13 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
// player does, and until the engine could target another seat it was not an // player does, and until the engine could target another seat it was not an
// option the picker even had. It runs before the damage picks: a party member // option the picker even had. It runs before the damage picks: a party member
// about to die is a more urgent use of a slot than another Fireball. // about to die is a more urgent use of a slot than another Fireball.
if id, target := simPickAllyHeal(c, uid, sess, seat); id != "" { // A healer heals whoever is worst off — the friend bleeding out beside them, or
// themselves. An empty target is a self-cast, and `!cast <spell>` with no
// @mention is exactly how a player writes that.
if id, target := simPickHeal(c, uid, sess, seat); id != "" {
if target == "" {
return "cast", id
}
return "cast", id + " @" + target return "cast", id + " @" + target
} }
@@ -1352,15 +1358,27 @@ func simPickSpiritualWeapon(c *DnDCharacter, sess *CombatSession, seat int, uid
// the party's damage output is what ends the fight. // the party's damage output is what ends the fight.
const simHealAllyThresholdPct = 45 const simHealAllyThresholdPct = 45
// simPickAllyHeal returns the heal spell and target name a competent healer would // simPickHeal returns the heal spell a competent healer would cast this turn, and
// use on the worst-hurt *other* seat, or ("", "") if nobody needs it, the caster // the seat to put it on — which may be the healer's own. An empty spell id means
// cannot heal, or the fight is solo. // nobody needs healing, or this character cannot heal.
// //
// This is the picker half of §1. Before the engine could target another seat there // Returning target "" means "cast it on yourself": the caller drops the @mention,
// was nothing for it to choose, so an autopiloted or engine-driven healer simply // and the engine's ordinary self-heal path takes it.
// swung a weapon while their friends died beside them. //
func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) { // It considers the caster's own seat, and it runs for a solo fight, and BOTH of
if sess == nil || !sess.IsParty() || c == nil || !isSpellcaster(c) { // those are recent. Until now the rule skipped `i == seat` and bailed on
// `!sess.IsParty()`, which together meant something nobody had said out loud: **no
// autopiloted caster in this game had ever cast a heal on themselves.** A solo
// cleric carried cure_wounds for a whole 30-room run and used it exactly never,
// while dying with a full pool of level-1 slots. That is a strong candidate for
// why the class corpus has cleric at 4656% against fighter's 82%
// ([[project_d8prereq_baseline]], §6 of the combat-engine plan) — the picker was
// not playing the class.
//
// The healer heals whoever is worst off. Usually that is the friend bleeding out
// next to them. Sometimes it is them.
func simPickHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) {
if sess == nil || c == nil || !isSpellcaster(c) {
return "", "" return "", ""
} }
@@ -1368,9 +1386,6 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
// and a heal aimed at a corpse is a lost turn. // and a heal aimed at a corpse is a lost turn.
worst, worstPct := -1, 101 worst, worstPct := -1, 101
for i := range sess.RosterSize() { for i := range sess.RosterSize() {
if i == seat {
continue
}
hp, hpMax := sess.seatHP(i), sess.seatHPMax(i) hp, hpMax := sess.seatHP(i), sess.seatHPMax(i)
if hp <= 0 || hpMax <= 0 { if hp <= 0 || hpMax <= 0 {
continue continue
@@ -1382,12 +1397,28 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
if worst < 0 || worstPct >= simHealAllyThresholdPct { if worst < 0 || worstPct >= simHealAllyThresholdPct {
return "", "" return "", ""
} }
// Healing yourself is not an ally-target at all — it is the plain self-heal the
// engine has always had. Hand the caller no target and let it say so.
if worst == seat {
return simPickHealSpell(c, uid, sess, seat), ""
}
// The cheapest heal that is actually castable — burning a 5th-level slot to best := simPickHealSpell(c, uid, sess, seat)
// top somebody up is not what a competent player does. if best == "" {
return "", ""
}
// Target by the seat's own Matrix localpart: splitCastTarget resolves against
// the seats in this fight, so this round-trips without a room lookup.
return best, id.UserID(sess.seatUserID(worst)).Localpart()
}
// simPickHealSpell is the cheapest heal this caster can actually cast right now,
// or "". Burning a 5th-level slot to top somebody up is not what a competent
// player does, so it takes the lowest castable slot.
func simPickHealSpell(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) string {
known, err := seatKnownSpells(sess, seat, uid) known, err := seatKnownSpells(sess, seat, uid)
if err != nil { if err != nil {
return "", "" return ""
} }
slots, _ := seatSpellSlots(sess, seat, uid) slots, _ := seatSpellSlots(sess, seat, uid)
best, bestLevel := "", 99 best, bestLevel := "", 99
@@ -1416,12 +1447,7 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
best, bestLevel = sp.ID, sp.Level best, bestLevel = sp.ID, sp.Level
} }
} }
if best == "" { return best
return "", ""
}
// Target by the seat's own Matrix localpart: splitCastTarget resolves against
// the seats in this fight, so this round-trips without a room lookup.
return best, id.UserID(sess.seatUserID(worst)).Localpart()
} }
func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string { func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string {

View File

@@ -79,8 +79,14 @@ func (p *AdventurePlugin) runZoneCombatRoster(
// than loaded, and his seatBuild is flagged so the close-out loop below // than loaded, and his seatBuild is flagged so the close-out loop below
// gives him no XP, no loot, and no post-combat persistence. // gives him no XP, no loot, and no post-combat persistence.
if isCompanionSeat(uid) { if isCompanionSeat(uid) {
class, level := companionLoadout(companionExpeditionFor(roster[0])) expID := companionExpeditionFor(roster[0])
class, level := companionLoadout(expID)
player, e, _ := p.companionCombatant(class, level, monster, tier, dmMood) player, e, _ := p.companionCombatant(class, level, monster, tier, dmMood)
// The wounds he carries into this fight. His synthetic sheet is always
// written at full HP, so applyDnDHPScaling left StartHP at the
// "enter at MaxHP" sentinel and he healed himself for free between every
// room of the run. See companionSeatHP.
player.Stats.StartHP = companionSeatHP(expID, player.Stats.MaxHP)
if leader { if leader {
enemy = e enemy = e
} }
@@ -162,7 +168,12 @@ func (p *AdventurePlugin) runZoneCombatRoster(
// deduct from, no sheet to persist to, no XP to earn. Every call below // deduct from, no sheet to persist to, no XP to earn. Every call below
// would either write rows for a bot or log an error about the rows it // would either write rows for a bot or log an error about the rows it
// hasn't got. // hasn't got.
//
// His HP is the exception, and it is not bookkeeping — it is the fight's
// result. Without it he re-enters the next room at full while the humans
// beside him carry every wound to camp.
if b.companion { if b.companion {
_ = setCompanionHP(companionExpeditionFor(roster[0]), seatRes.PlayerEndHP)
continue continue
} }