Co-op audit fixes: lock scheduler, idempotent payouts, deterministic ties

Audit pass addressed concurrency races, crash-recovery double-pay risks, and
non-determinism in tie tallying.

CRITICAL — concurrency
- coopResolveFloor now acquires advUserLock for every party member (sorted by
  UserID) before mutating state, so concurrent !coop fund / vote / giftvote
  commands serialize against the scheduler.

CRITICAL — crash idempotency
- Add coop_dungeon_runs.last_resolved_day and coop_dungeon_members.member_payout
  columns (with migration entries) for crash-resume tracking.
- Skip resolution only if last_resolved_day >= day AND status is terminal;
  otherwise re-enter and finish via idempotent operations.
- On resume detection (event already has outcome), reuse the saved roll
  instead of re-rolling — prevents different outcomes on retry.
- claimCoopMemberPayout / claimCoopBetPayout: atomic UPDATE...WHERE col IS NULL
  pattern. Each member/bet can only be paid once; retries are silent no-ops.
- createCoopEvent now INSERT OR IGNORE — idempotent on retry.
- last_resolved_day is set as the FINAL write per floor.

HIGH — bugs
- Lazy-create floor event in resolver if missing — covers crash window
  between lockCoopRun and the original createCoopEvent.
- coopTallyVote sorts tied winners alphabetically before fallback (was
  non-deterministic via Go map iteration).
- Removed dead day-1 wipe combat-action refund block — refund check was
  always false because midnight reset clears state before resolution.

LOW
- Log slog.Error on malformed JSON in parseCoopVotes / parseCoopFundingMap
  instead of silently dropping data.

Tests
- Tally test extended to assert determinism across 50 runs (catches map-iteration
  leaks).
- Added coverage for new tie-with-leader-vote-outside-winners case.
- Idempotency-guard sanity check on LastResolvedDay vs CurrentDay.

Race detector: clean. Full suite: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-26 09:01:29 -07:00
parent 8ad31a0009
commit e8a3b8b35d
5 changed files with 279 additions and 149 deletions

View File

@@ -255,6 +255,21 @@ func setCoopBetPayout(runID int, userID id.UserID, payout int) error {
return err
}
// claimCoopBetPayout atomically claims a bet for payout: returns true only if
// this call wins the race (the row had payout IS NULL and we set it). Used to
// prevent double-credit on retry after a mid-resolution crash.
func claimCoopBetPayout(runID int, userID id.UserID, payout int) (bool, error) {
d := db.Get()
res, err := d.Exec(`UPDATE coop_dungeon_bets SET payout = ?
WHERE run_id = ? AND player_id = ? AND payout IS NULL`,
payout, runID, string(userID))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func loadMostRecentBettableCoopRun() (*CoopRun, error) {
runs, err := queryCoopRuns(`status IN ('open','active') ORDER BY id DESC LIMIT 1`)
if err != nil || len(runs) == 0 {
@@ -278,15 +293,22 @@ func (p *AdventurePlugin) coopResolveBets(run *CoopRun, won bool) string {
}
winners, total, rake, payouts := coopParimutuelPayouts(bets, winningPosition)
// Idempotent: claim each bet's payout slot (UPDATE...WHERE payout IS NULL)
// before crediting. If the claim fails, this bet was already paid by a
// prior resolution attempt — skip the credit. Prevents double-pay on
// crash-restart.
for _, b := range winners {
payout := payouts[b.PlayerID]
claimed, err := claimCoopBetPayout(run.ID, b.PlayerID, payout)
if err != nil || !claimed {
continue
}
if payout > 0 {
p.euro.Credit(b.PlayerID, float64(payout), "coop_bet_payout")
}
_ = setCoopBetPayout(run.ID, b.PlayerID, payout)
}
for _, b := range bets {
if b.Position != winningPosition {
if b.Position != winningPosition && !b.Payout.Valid {
_ = setCoopBetPayout(run.ID, b.PlayerID, 0)
}
}