Files
gogobee/internal/plugin/combat_party_finish.go
prosolis d538f91cf7 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
2026-07-11 12:39:01 -07:00

257 lines
9.9 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"strings"
"maunium.net/go/mautrix/id"
)
// N3/P5 — closing out a party fight.
//
// finishCombatSession (combat_cmd.go) is the solo close-out and stays exactly
// what it was. This is its sibling for a seated roster, and the split it makes
// is the one the data model already forced:
//
// - Run- and expedition-scoped effects fire ONCE, for the owner. Threat, the
// zone-kill record, the boss-defeat threat drop, the run teardown on a loss
// — every one of them resolves through getActiveExpedition(userID) or
// getActiveZoneRun(userID), and a party member owns neither row. Fanning
// them out would be a no-op for members and would triple the threat the
// owner pays for a single kill.
// - Character-scoped effects fan out. HP, XP, loot, and death belong to the
// person, not the expedition, and every seat gets their own.
//
// A member can be dead in a fight the party won: they dropped, the others
// finished the job. So death is decided per seat off HP, not off the session's
// terminal status.
// finishPartyCombatSession runs the terminal side effects for a seated roster
// and returns each seat's own player-facing close-out, indexed by seat. A solo
// session delegates to finishCombatSession untouched.
func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
sess, enemy := ct.sess, *ct.enemy
owner := id.UserID(sess.UserID)
if !ct.isParty() {
return []string{p.finishCombatSession(owner, sess, enemy)}
}
run, _ := getZoneRun(sess.RunID)
var zone ZoneDefinition
elite := true
cadence := sess.Round
if run != nil {
zone = zoneOrFallback(run.ZoneID)
elite = run.CurrentRoomType() != RoomBoss
cadence = narrationCadence(run)
}
monster := dndBestiary[sess.EnemyID]
// nat20/nat1 mood deltas from the whole fight's event log — the run's, so once.
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
// Every seat's HP lands on their sheet regardless of how the fight ended, and
// so does the bookkeeping that outlives the fight — a Berserker who raged and
// lost is still exhausted. Both fan out; neither is the owner's alone.
for seat := range sess.RosterSize() {
// The companion has no sheet to persist HP onto and no bookkeeping that
// outlives the fight — he arrives fresh next time. Skipping him here is
// not just tidiness: postCombatBookkeepingForSeat logs at ERROR when a
// seat has no sheet, so leaving him in would file an error for every
// party fight he is ever hired for.
if isCompanionUser(sess.seatUserID(seat)) {
continue
}
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
p.postCombatBookkeepingForSeat(sess, seat)
}
switch sess.Status {
case CombatStatusWon:
return p.finishPartyWin(ct, run, zone, monster, elite, cadence)
case CombatStatusLost:
return p.finishPartyLoss(ct, zone, cadence)
case CombatStatusFled:
endRunOnLoss(owner, sess.RunID, false)
return p.eachSeat(ct, fmt.Sprintf(
"🏃 The party broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
default:
return p.eachSeat(ct, "The fight is over.")
}
}
// finishPartyWin pays the party out. The kill is the expedition's, so threat and
// the zone-kill record resolve once through the owner; XP and loot are the
// character's, so every member rolls their own — loot independently, per the C1
// no-split rule, and XP in full, per accessibility over crunch.
func (p *AdventurePlugin) finishPartyWin(
ct *combatTurn, run *DungeonRun, zone ZoneDefinition, monster DnDMonsterTemplate, elite bool, cadence int,
) []string {
sess := ct.sess
owner := id.UserID(sess.UserID)
bossOnExpedition := p.applyOwnerWinEffects(owner, sess.EnemyID, elite)
tier := 1
if run != nil {
tier = int(zone.Tier)
}
shared := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence)
emoji := "✅"
if !elite {
emoji = "🏆"
}
out := make([]string, sess.RosterSize())
for seat := range sess.RosterSize() {
uid := id.UserID(sess.seatUserID(seat))
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
// The hired companion takes no cut. He earns no XP (there is no sheet to
// put it on), rolls no loot (dropZoneLoot writes real inventory rows for
// whatever id it is handed, and a bot with a magic sword is nobody's
// intent), and takes no death row. His seat renders nothing: he is not
// reading this. He was paid up front.
if isCompanionSeat(uid) {
out[seat] = ""
continue
}
// A member who went down before the killing blow still earns the kill.
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
var b strings.Builder
if shared != "" && seat == 0 {
b.WriteString(shared + "\n")
}
b.WriteString(fmt.Sprintf("%s **%s** down. The party stands.\n", emoji, ct.enemy.Name))
if hp <= 0 {
// They won it from the floor. Down is down: the hospital takes them.
markAdventureDead(uid, "zone", zone.Display)
b.WriteString("💀 You didn't see it fall — you were already down.\n")
} else {
b.WriteString(fmt.Sprintf("You finished at **%d/%d HP**.\n", hp, hpMax))
if drop := p.dropZoneLoot(uid, zone.ID, monster, !elite, elite); drop != "" {
b.WriteString(drop + "\n")
}
}
if !elite {
writeBossEpilogue(&b, zone.ID)
}
switch {
case bossOnExpedition && seat == 0:
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
case bossOnExpedition:
b.WriteString("🎉 **Zone cleared — the expedition is won.** Your leader marches the party out.")
case seat == 0:
b.WriteString(continueHint(owner))
default:
b.WriteString("Waiting on your leader to move the party on.")
}
out[seat] = b.String()
}
return out
}
// finishPartyLoss ends the run for everyone. The whole roster is down — the turn
// engine only reports Lost once anyAlive() goes false — so every member takes
// the death, and the owner's run and expedition are torn down once.
func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, cadence int) []string {
sess := ct.sess
owner := id.UserID(sess.UserID)
endRunOnLoss(owner, sess.RunID, true)
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
out := make([]string, sess.RosterSize())
for seat := range sess.RosterSize() {
uid := id.UserID(sess.seatUserID(seat))
// The companion does not die. markAdventureDead is a silent no-op for him
// today (no player_meta row to mark), but relying on that accident is how
// he ends up in the graveyard the first time someone gives him a row —
// and emitDeathNews would have the news bot file a death notice about
// itself. Say it out loud instead.
if isCompanionSeat(uid) {
out[seat] = ""
continue
}
markAdventureDead(uid, "zone", zone.Display)
var b strings.Builder
if line != "" && seat == 0 {
b.WriteString(line + "\n")
}
b.WriteString(fmt.Sprintf("💀 The party fell to **%s**. Run ended.", ct.enemy.Name))
out[seat] = b.String()
}
return out
}
// eachSeat gives every seat the same close-out block.
func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
out := make([]string, ct.sess.RosterSize())
for i := range out {
out[i] = block
}
return out
}
// ── Shared terminal effects (item D) ─────────────────────────────────────────
//
// finishCombatSession (solo, combat_cmd.go) and finishPartyWin/finishPartyLoss
// (party, above) still write their own player-facing text — the solo line says
// "You finished", the party line says "The party stands", and the death-on-win
// rule is deliberately roster-size-dependent (see item E). But the *effects*
// they fire were hand-copied lists, and item A drifted exactly there. These
// helpers are the single copy of each effect; both close-outs route through
// them so the effect lists cannot diverge again.
// applyOwnerWinEffects fires the terminal effects a win owes the run and
// expedition — the zone-kill record, room threat, and the boss-defeat threat
// drop — once, through the row's owner (a party member owns neither row).
// Returns whether the kill was a zone boss on an active expedition; both
// close-outs use it to reframe the final line as the expedition's climax.
func (p *AdventurePlugin) applyOwnerWinEffects(owner id.UserID, enemyID string, elite bool) (bossOnExpedition bool) {
recordZoneKillForUser(owner, enemyID)
applyRoomCombatThreatForUser(owner, elite)
if !elite {
// §8.1 — a zone boss defeat drops expedition threat. Silent no-op for a
// standalone zone run with no active expedition.
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID)
bossOnExpedition = true
}
}
return bossOnExpedition
}
// grantSeatWinXP pays one combatant their kill XP. A seat that finished at a
// fifth of its pool or less takes the near-death path. Full XP per person,
// solo or party, per the accessibility-over-crunch stance.
func (p *AdventurePlugin) grantSeatWinXP(uid id.UserID, hp, hpMax int, monster DnDMonsterTemplate, tier int) {
nearDeath := hpMax > 0 && hp*5 < hpMax
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(uid, xp); err != nil {
slog.Error("combat: grantDnDXP", "user", uid, "err", err)
}
}
}
// endRunOnLoss tears down the owner's run and wrapping expedition when a fight
// ends in death or flight. On a death (not a flee) it also stamps the run's
// mood event. Owner-scoped, fires once; the per-seat death mark stays with the
// caller, since it is decided per HP and gated on roster size.
func endRunOnLoss(owner id.UserID, runID string, death bool) {
if death {
if run, _ := getZoneRun(runID); run != nil {
_, _ = applyMoodEvent(runID, MoodEventPlayerDeath)
}
}
_ = abandonZoneRun(owner)
reason := "combat flee"
if death {
reason = "combat death"
}
forceExtractExpeditionForRunLoss(owner, reason)
}