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

@@ -121,7 +121,7 @@ type ActorStatuses struct {
// fight, set when its turn deadline lapses once (see partyTurnDeadline).
// Without the latch an away member taxes the party the full deadline every
// single round; with it, they cost one wait and then resolve instantly. Any
// combat command from that member clears it.
// combat command from that member clears it — they typed, so they are back.
//
// Like the Buff* deltas it is session-layer state with no combatState
// counterpart, so snapshotActor carries it over from the prior snapshot
@@ -129,6 +129,24 @@ type ActorStatuses struct {
// fight has no turn deadline, only the 1h session reaper.
Autopilot bool `json:"autopilot,omitempty"`
// EngineDriven marks a seat that has NO human behind it and never will: a
// hired companion today, an NPC ally or a Pete-led expedition tomorrow. It is
// the answer to "who owns this turn", and it is deliberately NOT the same
// question as Autopilot.
//
// Autopilot means "a human is away"; it is provisional, and a keystroke ends
// it. EngineDriven means "there is nobody to come back", and nothing clears it.
// Collapsing the two is a bug with teeth: the expedition autopilot drives a
// party by dispatching each seat's turn as a command from that seat, so an
// engine seat's own auto-played move arrived back at beginCombatTurn looking
// exactly like a player returning to the keyboard, and cleared the very latch
// that was moving it. The seat then stood in the fight doing nothing for the
// rest of the fight — while the enemy it had inflated by 15% killed the party.
//
// So the property lives on the seat, not on an identity check, and no command
// path can unset it.
EngineDriven bool `json:"engine_driven,omitempty"`
// Debuffs the enemy has stacked onto this character specifically.
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
@@ -256,7 +274,8 @@ func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
st.ArcaneWardHP = playerMods.ArcaneWardHP
st.HealChargesLeft = playerMods.HealItemCharges
st.ArmedAbility = seat.ArmedAbility
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
st.EngineDriven = seat.EngineDriven
return st.EngineDriven || st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
st.ArmedAbility != ""
}
@@ -387,16 +406,30 @@ func (s *CombatSession) seatOf(userID id.UserID) (int, bool) {
// turn silently rather than blocking the round on a corpse.
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker
// by a lapsed turn deadline.
// seatIsEngineDriven reports whether a seat has no human behind it at all — a
// hired companion, an NPC ally. Permanent for the life of the fight; no command
// clears it. Contrast seatIsAutopiloted, which is a human who stepped away.
func (s *CombatSession) seatIsEngineDriven(seat int) bool {
return s.actorStatusesForSeat(seat).EngineDriven
}
// seatIsAutopiloted reports whether the engine, rather than a person, decides
// this seat's action — because its human is away (a lapsed turn deadline) or
// because it never had one.
//
// Every driver in the codebase reads this, which is exactly why the two reasons
// share one accessor: the picker does not care WHY nobody is typing. What differs
// is who is allowed to take the wheel back, and that question is asked in
// beginCombatTurn against seatIsEngineDriven — never here.
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
return s.actorStatusesForSeat(seat).Autopilot
st := s.actorStatusesForSeat(seat)
return st.Autopilot || st.EngineDriven
}
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
// waiting on its player: it is down (forfeits silently) or latched onto
// autopilot. driveCombatRound keeps stepping while this holds, so a round only
// comes to rest on a live human's turn.
// waiting on its player: it is down (forfeits silently), latched onto autopilot,
// or has no human to wait for. driveCombatRound keeps stepping while this holds,
// so a round only comes to rest on a live human's turn.
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
}
@@ -553,6 +586,12 @@ type CombatSeatSetup struct {
// if they armed nothing. It is persisted onto the seat's statuses so every
// later rebuild can re-apply the ability without re-spending it.
ArmedAbility string
// EngineDriven seats this combatant with no human behind it — the hired
// companion today. It resolves from the opening round rather than after the
// 3-minute away-player deadline (nobody is coming, so waiting out a deadline
// would idle the fight and then announce him to the party as absent), and no
// command can hand the wheel back to a player who does not exist.
EngineDriven bool
}
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).