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

@@ -116,14 +116,10 @@ func TestCompanionSpells_SpendsHisSeatNotTheDatabase(t *testing.T) {
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)
// The slot came off the expedition's ledger — which is where it has to live, so
// that it is still spent in the NEXT fight of the same run.
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])
}
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)
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 {
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
// grant, so hiring a Fighter does not quietly buy a caster.
func TestCompanionSpells_MartialHasNoSpellbook(t *testing.T) {