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.
This commit is contained in:
prosolis
2026-07-10 07:20:14 -07:00
parent 3369d7d8fe
commit 1f211564d9
14 changed files with 151 additions and 71 deletions

View File

@@ -178,8 +178,22 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
pooled := addSupplyPurchase(exp.Supplies, suppliesPurchase)
if err := updateSupplies(exp.ID, pooled); err != nil {
// updateSupplies overwrites supplies_json wholesale, so the pool has to be
// re-read under the expedition's own lock: `exp` was fetched before the coin
// debit, and a second invitee accepting — or the leader's day-burn tick —
// may have rewritten the row since. Folding onto that stale snapshot would
// silently drop their packs or resurrect spent SU.
expMu := p.advExpeditionLock(exp.ID)
expMu.Lock()
fresh, err := getExpedition(exp.ID)
if err != nil || fresh == nil {
expMu.Unlock()
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool.")
}
pooled := addSupplyPurchase(fresh.Supplies, suppliesPurchase)
err = updateSupplies(exp.ID, pooled)
expMu.Unlock()
if err != nil {
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
}
@@ -261,6 +275,20 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
// expeditionCmdLeave walks a member out. The leader cannot leave — their row is
// the expedition — so they are pointed at `!extract`, which ends it for all.
func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error {
// Resolve the seat the way the guards that trap them do. seatedExpeditionFor
// spans `extracting`, which activeExpeditionFor does not: a leader who
// extracts and never resumes would otherwise leave their members seated —
// refused a new adventure by the guard, and told "no active expedition" by
// the very command the guard points them at. The exit has to see every state
// the gate sees. It already excludes leaders, so they fall through below.
seated, err := seatedExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if seated != nil {
return p.leaveSeatedParty(ctx, seated)
}
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
@@ -272,6 +300,12 @@ func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error {
return p.SendDM(ctx.Sender,
"You're leading this one — `!extract` ends it for everyone, or `!expedition abandon` to walk away from it.")
}
return p.leaveSeatedParty(ctx, exp)
}
// leaveSeatedParty unseats a member and tells both ends. Shared by the two ways
// a member's seat resolves: the `extracting` limbo and the plain active party.
func (p *AdventurePlugin) leaveSeatedParty(ctx MessageContext, exp *Expedition) error {
if err := leaveParty(exp.ID, ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't leave: "+err.Error())
}