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

@@ -526,10 +526,91 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
// the roster so the buff is live for the round's enemy turn. Before P5 that
// delta went to the session's embedded copy — seat 0 — so a party member
// buffing themselves would have buffed the leader.
// splitCastTarget peels an optional ally target off the end of a `!cast` argument
// — `cure wounds @alex`, or just `cure wounds alex` — and resolves it to a seat.
//
// It resolves against the people *in this fight* rather than the room, which is
// both cheaper (no ResolveUser round-trip, no RoomID to thread down here) and
// more correct: the only legal target of a combat heal is somebody sitting in the
// combat. Anything it does not recognise is left on the string for the spell
// parser, so `!cast cure wounds` and `!cast fireball 3` behave exactly as before.
//
// Both spellings work: the `--target @alex` flag that `!help` has advertised all
// along (and that parseCombatCast has been silently swallowing since SP2 —
// "reserved for SP3, accept and ignore"), and a plain trailing `@alex`.
//
// Returns (remainingArgs, seat, errMsg). seat is -1 when no target was named.
func splitCastTarget(ct *combatTurn, caster int, args string) (string, int, string) {
args = strings.TrimSpace(args)
if args == "" || !ct.isParty() {
return args, -1, ""
}
fields := strings.Fields(args)
// `--target <who>` anywhere in the string.
explicit, name := false, ""
for i := 0; i < len(fields); i++ {
if !strings.EqualFold(fields[i], "--target") {
continue
}
if i+1 >= len(fields) {
return args, -1, "`--target` needs a name: `!cast cure wounds --target @alex`."
}
explicit, name = true, strings.TrimPrefix(fields[i+1], "@")
fields = append(fields[:i], fields[i+2:]...)
break
}
if name == "" {
last := fields[len(fields)-1]
// A bare number is a slot level (`!cast fireball 3`), never a target.
if _, err := strconv.Atoi(last); err == nil {
return args, -1, ""
}
explicit = strings.HasPrefix(last, "@")
name = strings.TrimPrefix(last, "@")
if name == "" {
return args, -1, ""
}
fields = fields[:len(fields)-1]
}
// From here `fields` is the spell tokens with the target removed.
rest := strings.Join(fields, " ")
for i, c := range ct.players {
uid := ct.sess.seatUserID(i)
if !strings.EqualFold(c.Name, name) &&
!strings.EqualFold(uid, name) &&
!strings.EqualFold(id.UserID(uid).Localpart(), name) {
continue
}
if i == caster {
// Targeting yourself is just casting it on yourself, which is what the
// engine does by default. Drop the target and carry on.
return rest, -1, ""
}
return rest, i, ""
}
// An explicit @mention that matches nobody in the fight is a mistake worth
// naming — silently casting it on yourself would waste the slot.
if explicit {
return args, -1, fmt.Sprintf("**%s** isn't in this fight. Cast it on someone who is.", name)
}
return args, -1, ""
}
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
noop := func(bool) {}
uid := id.UserID(ct.sess.seatUserID(seat))
// §1 — a heal may name somebody else in the fight: `!cast cure wounds @alex`.
// Split the target off before the spell is parsed, so the spell parser sees
// the same string it always has.
args, targetSeat, targetErr := splitCastTarget(ct, seat, args)
if targetErr != "" {
return PlayerAction{}, noop, targetErr
}
advChar, _ := loadAdvCharacter(uid)
c, err := p.ensureCharForDnDCmd(uid, advChar)
if err != nil || c == nil {
@@ -612,6 +693,20 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip,
}
// §1 — redirect the heal onto the named ally. The roll is the same; only
// the body it lands on changes. This is the line that makes a cleric a
// cleric: until it existed, every heal in the engine was a self-heal, and
// the class whose whole job is keeping other people upright could not put
// a single hit point on a friend.
if targetSeat >= 0 && targetSeat != seat {
if out.PlayerHeal <= 0 {
return PlayerAction{}, refund, fmt.Sprintf(
"%s isn't something you can cast on someone else. Drop the target to cast it yourself.", spell.Name)
}
eff.AllyHeal, eff.AllySeat = out.PlayerHeal, targetSeat
eff.PlayerHeal = 0
eff.Label = fmt.Sprintf("%s → %s (+%d HP)", spell.Name, ct.players[targetSeat].Name, out.PlayerHeal)
}
// Concentration AOE damage spells linger: the burst lands this round
// (EnemyDamage) and the same value re-ticks every round_end after, via
// the engine's concentration aura. spiritual_weapon already covers the