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

@@ -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
// 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.
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
}
@@ -1352,15 +1358,27 @@ func simPickSpiritualWeapon(c *DnDCharacter, sess *CombatSession, seat int, uid
// the party's damage output is what ends the fight.
const simHealAllyThresholdPct = 45
// simPickAllyHeal returns the heal spell and target name a competent healer would
// use on the worst-hurt *other* seat, or ("", "") if nobody needs it, the caster
// cannot heal, or the fight is solo.
// simPickHeal returns the heal spell a competent healer would cast this turn, and
// the seat to put it on — which may be the healer's own. An empty spell id means
// nobody needs healing, or this character cannot heal.
//
// This is the picker half of §1. Before the engine could target another seat there
// was nothing for it to choose, so an autopiloted or engine-driven healer simply
// swung a weapon while their friends died beside them.
func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) {
if sess == nil || !sess.IsParty() || c == nil || !isSpellcaster(c) {
// Returning target "" means "cast it on yourself": the caller drops the @mention,
// and the engine's ordinary self-heal path takes it.
//
// It considers the caster's own seat, and it runs for a solo fight, and BOTH of
// 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 "", ""
}
@@ -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.
worst, worstPct := -1, 101
for i := range sess.RosterSize() {
if i == seat {
continue
}
hp, hpMax := sess.seatHP(i), sess.seatHPMax(i)
if hp <= 0 || hpMax <= 0 {
continue
@@ -1382,12 +1397,28 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
if worst < 0 || worstPct >= simHealAllyThresholdPct {
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
// top somebody up is not what a competent player does.
best := simPickHealSpell(c, uid, sess, seat)
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)
if err != nil {
return "", ""
return ""
}
slots, _ := seatSpellSlots(sess, seat, uid)
best, bestLevel := "", 99
@@ -1416,12 +1447,7 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
best, bestLevel = sp.ID, sp.Level
}
}
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()
return best
}
func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string {