Files
gogobee/internal/plugin/zone_combat_party.go
prosolis 1f211564d9 Review fixes: party close-out, member soft-lock, supply race, elite loot, season crown
Five bugs found reviewing n1-restoration end to end.

beginCombatTurn settles any phase the engine owes before reading whose turn it
is. That settle can end the fight — and the old code then answered "you're not
in a fight" and returned. The terminal status was already persisted, so nothing
ever paid the party out: no XP, no loot, no death recorded, no run teardown. The
reaper cannot recover it either, because listExpiredCombatSessions filters on
status='active'. Close the fight out there, the way the !fight start path and
the reaper already do.

A party member was permanently soft-locked when their leader extracted and never
resumed. seatedExpeditionFor (the guard) spans 'extracting'; expeditionForMember
(what !expedition leave resolved through) saw only 'active'. So the member was
refused any new adventure by the guard and told "No active expedition" by the
command the guard points them at, with nothing sweeping stale rows and only the
leader able to clear one. Resolve the exit through the same lookup as the gate.

updateSupplies overwrites supplies_json wholesale, and expeditionCmdAccept folded
a member's packs onto a snapshot read before the coin debit, unlocked. Handlers
run one goroutine per event, so two invitees accepting genuinely interleave and
one member's packs vanish. advUserLock cannot help — it is keyed by sender, so
racing members take different mutexes. Add advExpeditionLock and re-read the pool
under it. Closes accept-vs-accept; the six other updateSupplies callers still
race and are written up separately.

runHarvestInterrupt picked an elite enemy and elite narration off a local `elite`
flag, then passed a hardcoded false as isElite to closeOutZoneWin. dropZoneLoot
gates masterwork on isBoss||isElite, so beating an elite interrupt skipped the
masterwork roll and took standard treasure weight — while the same elite fought
via !zone paid out correctly.

arenaSeasonRollover marked its job complete even when recordArenaSeasonTitle
failed, and JobCompleted short-circuits every later run for that quarter, so a
transient SQLite BUSY lost the crown forever. Defer completion on failure; the
insert is ON CONFLICT DO NOTHING against PRIMARY KEY (season, kind) and a past
season's data is frozen, so the retry is safe.

Also: drop dead partySurvivors, collapse the zoneCombatRoster alias into
fightRoster, route partyCasualtyLine through joinNames, fold four copies of the
expedition column projection into expeditionSelectCols, stop replyDM sending a
blank DM, and correct two doc comments describing a path that no longer exists.

Deliberately not fixed, with reasons, in gogobee_code_review_followups.md — most
notably that both turn-based close-outs skip grantCombatAchievements and
persistDnDPostCombatSubclass, which the auto-resolve paths run.
2026-07-10 07:20:14 -07:00

233 lines
8.1 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"time"
"maunium.net/go/mautrix/id"
)
// runZoneCombatRoster auto-resolves one zone encounter for a whole roster.
//
// It is the seam P6e opens: before it, only elite and boss doorways seated a
// party (through the turn engine), and every other fight in a zone — exploration
// rooms, patrol encounters, harvest interrupts — resolved as the leader alone.
// On a 38-room expedition that meant a party of three fought together twice and
// the leader soloed the other ~35, which is why P7 measured zero member deaths
// and zero TPKs across 240 seats: the members were never in the fight.
//
// Roster order is the seating order, and roster[0] is the leader. Callers get
// back the seats that actually sat down, aligned index-for-index with
// res.Seats, so the character-scoped close-out (loot, death) can fan out and the
// run-scoped one (threat, kill record) can stay with the leader.
//
// A member who is down sits the encounter out. A leader who is down is a
// caller-side error — the run is theirs and the walk should not have reached
// here.
//
// Effects applied here are exactly the character-scoped ones runZoneCombat
// always applied, now per seat: HP, XP, achievements, the subclass persist, the
// heal items actually burned, and Misty's condition repair. Loot, threat, kill
// records and death remain the caller's, because only the caller knows the room.
func (p *AdventurePlugin) runZoneCombatRoster(
roster []id.UserID,
monster DnDMonsterTemplate,
tier int,
phases []CombatPhase,
dmMood int,
) (PartyCombatResult, []id.UserID, error) {
if len(roster) == 0 {
return PartyCombatResult{}, nil, fmt.Errorf("runZoneCombatRoster: empty roster")
}
if phases == nil {
phases = dungeonCombatPhases
}
type seatBuild struct {
uid id.UserID
dndChar *DnDCharacter
advChar *AdventureCharacter
equip map[EquipmentSlot]*AdvEquipment
mods CombatModifiers
}
var (
players []Combatant
builds []seatBuild
seated []id.UserID
enemy Combatant
)
for i, uid := range roster {
leader := i == 0
// The leader's failures are the encounter's failures; a member's only
// cost them their seat.
bail := func(err error) (PartyCombatResult, []id.UserID, error) {
return PartyCombatResult{}, nil, err
}
advChar, err := loadAdvCharacter(uid)
if err != nil || advChar == nil {
if leader {
return bail(fmt.Errorf("load adv character: %w", err))
}
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
continue
}
equip, err := loadAdvEquipment(uid)
if err != nil {
if leader {
return bail(fmt.Errorf("load equipment: %w", err))
}
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
continue
}
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
if err != nil {
if leader {
return bail(err)
}
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
continue
}
if leader {
enemy = e
} else if hp, _ := dndHPSnapshot(uid); hp <= 0 {
// A downed member sits it out. They own no close-out claim.
continue
}
// Pre-combat one-shots that the turn-based path does NOT share: a queued
// spell and the panic-heal consumable trigger. Both mutate this seat's
// Combatant once, before the fight runs. The queued spell can also debuff
// the shared enemy, so every seat's cast lands on the one stat block.
applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods)
players = append(players, player)
builds = append(builds, seatBuild{
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
})
seated = append(seated, uid)
}
res := simulateParty(players, enemy, phases)
dumpCombatEventsIfDebug(
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
res.Seats[0])
for i, b := range builds {
seatRes := res.Seats[i]
// Remove the actual heal items consumed during combat (one inventory item
// per heal_item event this seat fired). Cheapest-tier first.
consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes))
// Misty condition repair (post-combat, same 20% chance as the arena and
// encounter paths in combat_bridge.go). Gourmet food keeps her gear in
// shape on long expeditions, not just in the arena.
if b.advChar.MistyBuffExpires != nil && time.Now().UTC().Before(*b.advChar.MistyBuffExpires) {
if rand.Float64() < 0.20 {
npcRepairMostDamaged(b.uid, b.equip, 5)
}
}
p.grantCombatAchievements(b.uid, seatRes)
persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP)
if err := persistDnDPostCombatSubclass(b.dndChar, b.mods.BerserkerRage, seatRes, b.mods); err != nil {
slog.Error("dnd: post-combat subclass persist (zone)", "user", b.uid, "err", err)
}
if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(b.uid, xp); err != nil {
slog.Error("dnd: grantDnDXP zone", "user", b.uid, "err", err)
}
}
}
return res, seated, nil
}
// zoneCombatRoster is the roster a zone encounter seats for this walker: their
// expedition's party if they lead one, otherwise just them. Downed members are
// filtered by runZoneCombatRoster, not here — this answers "who is on the
// expedition", not "who can stand up".
//
// It must not touch getActiveZoneRun: that lookup carries the §4.3 idle reap,
// and the callers are mid-walk on the run it would reap. fightRoster observes
// the same rule, for the same reason.
func zoneCombatRoster(walker id.UserID) []id.UserID { return fightRoster(walker) }
// closeOutZoneWin hands loot to every seat still standing and the hospital to
// every seat that isn't. It returns the leader's own loot line — the only one
// that belongs in the leader's room narration — and who went down.
//
// The one asymmetry is deliberate. A *solo* player can win at 0 HP: a retaliate
// aura kills the swinger on the same blow that drops the enemy, and
// resolvePlayerAttack returns before enemyDown is consumed. Today that player is
// not marked dead, and P6e is not the place to change it — it would move the
// sim's outcome labels. A *party* marks its downed seats dead on a win, which is
// exactly what the turn-engine close-out has always done (finishPartyWin).
func (p *AdventurePlugin) closeOutZoneWin(
res PartyCombatResult,
seated []id.UserID,
zone ZoneDefinition,
monster DnDMonsterTemplate,
isBoss, elite bool,
deathSource string,
) (leaderDrop string, downed []id.UserID) {
party := len(seated) > 1
for i, uid := range seated {
if res.Seats[i].PlayerEndHP > 0 {
drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite)
if i == 0 {
leaderDrop = drop
}
continue
}
// Down when the enemy fell. No loot — they never saw it drop.
downed = append(downed, uid)
if party {
markAdventureDead(uid, deathSource, zone.Display)
}
}
return leaderDrop, downed
}
// closeOutZoneLoss marks every seat the fight actually killed. A loss is not the
// same as a death: the engine's timeout is a retreat ("ran out the clock"), and
// it leaves HP where it was. So death is read per seat off HP, never off the
// fight's terminal status — which for one seat is exactly the old
// `if !result.TimedOut` rule, since a solo player at 0 HP has already ended the
// fight.
func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) {
for i, uid := range seated {
if res.Seats[i].PlayerEndHP <= 0 {
markAdventureDead(uid, deathSource, zone.Display)
killed = append(killed, uid)
}
}
return killed
}
// partyCasualtyLine names the members a fight put on the floor, for the leader's
// narration. Empty for a solo walker, and empty when everyone walked away.
func partyCasualtyLine(downed []id.UserID) string {
if len(downed) == 0 {
return ""
}
names := make([]string, 0, len(downed))
for _, uid := range downed {
name, _ := loadDisplayName(uid)
if name == "" {
name = uid.Localpart()
}
names = append(names, "**"+name+"**")
}
return "💀 " + joinNames(names) + " went down."
}