Combat engine: pay off the N-body debt, and hire Pete

N3 widened the combat *roster* from 1 to N but never widened the action model,
the scaling model, or the test net to match. Building the hireable companion
walked into all three. Only one of the four defects found was the companion's;
the rest have been live in prod for every human party since N3.

The party golden did not exist (§5)
  Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had
  nothing. That is why a healer class that cannot heal shipped without a test
  going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl.
  weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the
  N-body path can never quietly stop being a superset of the balance corpus.
  Regenerate only on purpose: -update-party.

No action could target another seat (§1)
  Every heal in the engine was self-scoped. A party cleric could not put one hit
  point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast <spell>
  @user` and `--target @user` -- the latter has been advertised in !help and
  silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto
  picker uses it too (simPickAllyHeal), so away-players and engine-driven healers
  behave like competent ones. It will not raise the dead.

Corpses kept buffing the boss (§2b)
  enemyActionsThisRound counted len(st.actors), dead included -- so a party that
  lost a member kept paying for them, and the survivor faced a boss still swinging
  at two-player cadence, alone. A death spiral with the arrow pointing the wrong
  way. Now counts livingActors(). Party golden moved deliberately for this.

An engine-driven seat was a bool any command could clear (§3)
  autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so
  a companion's own auto-played move arrived at beginCombatTurn looking like a
  player returning to the keyboard and cleared the latch that was moving him. He
  then stood in the fight doing nothing while the boss he had inflated killed
  everyone. ActorStatuses.EngineDriven is now a persisted seat property that no
  command clears, and the driver calls driveEngineSeat instead of impersonating.

"The party" could be empty (§4)
  A solo expedition has no expedition_party rows, so asking the roster who was in
  the party answered "nobody" -- and every caller fell back to something plausible.
  That is how the companion got hired at level 1 for exactly the player the feature
  exists for. expeditionParty()/partyHumans() always include the owner.

The companion himself (!expedition hire [class] / !expedition dismiss)
  Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an
  NPC seat and must never become a player: no player_meta, no dnd_character, no
  inventory, no DM room -- mint him a player_meta row and
  ensureDnDCharacterForCombat will auto-build the news bot a real character, and
  he starts appearing in the graveyard and filing death notices about himself.
  Mail and seats are different sets: he fights, he does not get written to.

Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no
companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for
trailing players and +2.0pp for leaders. Help, never a carry.

The solo golden is byte-identical throughout: solo combat provably did not move,
and the balance corpus is intact.

Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB
and he has none by design), so a hired Cleric is still just a bad fighter.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
This commit is contained in:
prosolis
2026-07-11 12:39:01 -07:00
parent f4a4c0d30b
commit d538f91cf7
23 changed files with 3924 additions and 38 deletions

View File

@@ -34,6 +34,11 @@ func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") ==
type SimRunner struct {
P *AdventurePlugin
Euro *EuroPlugin
// Companion hires Pete into the party on Day 1: "" = none, "auto" = fill the
// missing role, or an explicit class id. He takes a combat seat and no loot,
// which is exactly the thing the sweep is here to measure — whether an extra
// below-median body lifts the trailing case without carrying it.
Companion string
}
// NewSimRunner initializes a fresh sqlite DB in dataDir and constructs
@@ -400,7 +405,42 @@ type SimCombatSummary struct {
// SetSimIncludeTrace(true) has been called, and only on the LAST
// combat per expedition (the boss room) to keep JSONL size bounded.
// Used by J2 caster-survival analysis.
Events []CombatEvent `json:",omitempty"`
Events []simTraceEvent `json:",omitempty"`
}
// simTraceEvent is CombatEvent with the seat ALWAYS emitted.
//
// CombatEvent.Seat is `omitempty` for wire-compat reasons that are correct for
// persistence and wrong for a trace: seat 0 and "no seat" serialize identically,
// so a party trace silently reads as a solo fight. That is not hypothetical — it
// is what made a hired companion who never took a turn look, in the JSON, like a
// fight that only ever had one seat, and it cost an hour of chasing the wrong bug.
// A diagnostic that cannot tell you who acted is not a diagnostic.
type simTraceEvent struct {
Round int
Seat int // always present, even when 0
Phase string
Actor string
Action string
Damage int
PlayerHP int
EnemyHP int
Roll int
RollAgainst int
Desc string `json:",omitempty"`
}
// simTraceEvents converts a TurnLog for the trace.
func simTraceEvents(events []CombatEvent) []simTraceEvent {
out := make([]simTraceEvent, len(events))
for i, e := range events {
out[i] = simTraceEvent{
Round: e.Round, Seat: e.Seat, Phase: e.Phase, Actor: e.Actor, Action: e.Action,
Damage: e.Damage, PlayerHP: e.PlayerHP, EnemyHP: e.EnemyHP,
Roll: e.Roll, RollAgainst: e.RollAgainst, Desc: e.Desc,
}
}
return out
}
// simIncludeTrace gates per-round event capture on SimCombatSummary.
@@ -516,6 +556,22 @@ func (s *SimRunner) RunPartyExpedition(uid id.UserID, members []id.UserID, zoneI
return res, fmt.Errorf("expedition vanished while seating the party")
}
}
// Hire the companion on Day 1, after the humans are seated so the role fill
// sees the party it is filling a hole in. He is not in `members` — he buys no
// packs and takes no seat in the human roster — but fightRoster reads
// expeditionSeats, so he shows up in every fight from here on.
if s.Companion != "" {
class, ok := parseCompanionClass(s.Companion)
if !ok {
class = companionRoleFill(companionPartyClasses(exp.ID))
}
if err := hireCompanion(exp.ID, class, companionPartyLevel(exp.ID)); err != nil {
res.Outcome = "halted"
res.StopCode = "companion:" + err.Error()
return res, err
}
}
roster := append([]id.UserID{uid}, members...)
res.PartySize = len(roster)
for _, m := range members {
@@ -942,7 +998,7 @@ func simCombatSummaries(uid id.UserID) []SimCombatSummary {
out = append(out, s)
}
if simIncludeTrace && len(out) > 0 {
out[len(out)-1].Events = lastEvents
out[len(out)-1].Events = simTraceEvents(lastEvents)
}
return out
}
@@ -1038,6 +1094,18 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
turn.Sender = id.UserID(cur.seatUserID(seat))
}
// An engine-driven seat is never dispatched as a chat command. There is no
// character for the handlers to load, and a command arriving from that
// seat's id reads to beginCombatTurn as a player returning to the keyboard
// — which is how the driver used to clear the very latch that was moving
// the seat. Drive it directly instead.
if cur.seatIsEngineDriven(seat) {
if err := p.driveEngineSeat(cur, seat); err != nil {
return false, fmt.Errorf("engine seat %d: %w", seat, err)
}
continue
}
kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat)
var dispatchErr error
switch kind {
@@ -1157,6 +1225,14 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
}
}
}
// §1 — a healer with a hurt friend heals the friend. This is what a competent
// 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 != "" {
return "cast", id + " @" + target
}
if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
@@ -1261,6 +1337,85 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
// - Among feasible candidates, prefer higher slot level (preserves
// high-slot supremacy and burns the big slots first); tie-break on
// expected damage from the dice string.
//
// simHealAllyThresholdPct is how hurt a friend has to be before a healer spends a
// slot on them rather than on damage. Deliberately lower than the self-heal
// threshold: a heal is a wasted turn if the target was going to live anyway, and
// 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.
//
// 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) {
return "", ""
}
// Who is worst off? Only living seats — the engine will not raise the downed,
// 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
}
if pct := hp * 100 / hpMax; pct < worstPct {
worst, worstPct = i, pct
}
}
if worst < 0 || worstPct >= simHealAllyThresholdPct {
return "", ""
}
// 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)
if err != nil {
return "", ""
}
slots, _ := getSpellSlots(uid)
best, bestLevel := "", 99
for _, k := range known {
if !k.Prepared {
continue
}
sp, ok := lookupSpell(k.SpellID)
if !ok || sp.Effect != EffectSpellHeal || sp.CastTime == CastReaction {
continue
}
onList := false
for _, cl := range sp.Classes {
if cl == c.Class {
onList = true
break
}
}
if !onList || sp.Level == 0 {
continue // heals are slot spells; a level-0 "heal" is not a thing we cast
}
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
continue
}
if sp.Level < bestLevel {
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()
}
func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string {
known, err := listKnownSpells(uid)
if err != nil || len(known) == 0 {