diff --git a/internal/db/db.go b/internal/db/db.go index 0bcc8cf..751ad45 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -135,6 +135,8 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE adventure_characters ADD COLUMN pet_morning_defense INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE adventure_characters ADD COLUMN auto_babysit INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE adventure_characters ADD COLUMN streak_decayed INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE coop_dungeon_runs ADD COLUMN last_resolved_day INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE coop_dungeon_members ADD COLUMN member_payout INTEGER`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -1375,18 +1377,19 @@ CREATE INDEX IF NOT EXISTS idx_tip_audit_action ON holdem_tip_audit(action, stre -- Co-op Dungeon (party multi-day runs) CREATE TABLE IF NOT EXISTS coop_dungeon_runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - tier INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'open', -- open, active, complete, wiped, cancelled - leader_id TEXT NOT NULL, - current_day INTEGER NOT NULL DEFAULT 0, - total_days INTEGER NOT NULL, - base_difficulty INTEGER NOT NULL, -- per-floor failure % at zero modifier - gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding - reward_total INTEGER NOT NULL DEFAULT 0, -- final reward (set on completion) - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - locked_at DATETIME, - completed_at DATETIME + id INTEGER PRIMARY KEY AUTOINCREMENT, + tier INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'open', -- open, active, complete, wiped, cancelled + leader_id TEXT NOT NULL, + current_day INTEGER NOT NULL DEFAULT 0, + total_days INTEGER NOT NULL, + base_difficulty INTEGER NOT NULL, -- per-floor failure % at zero modifier + gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding + reward_total INTEGER NOT NULL DEFAULT 0, -- final reward (set on completion) + last_resolved_day INTEGER NOT NULL DEFAULT 0, -- crash-resume guard: highest day whose floor outcome is final + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + locked_at DATETIME, + completed_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_coop_runs_status ON coop_dungeon_runs(status); @@ -1397,6 +1400,7 @@ CREATE TABLE IF NOT EXISTS coop_dungeon_members ( total_contributed INTEGER NOT NULL DEFAULT 0, is_liability INTEGER NOT NULL DEFAULT 0, daily_funding TEXT NOT NULL DEFAULT '{}', -- JSON: {"1":"standard","2":"all_in",...} + member_payout INTEGER, -- NULL until reward credited; idempotency claim PRIMARY KEY (run_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_coop_members_user ON coop_dungeon_members(user_id); diff --git a/internal/plugin/coop_dungeon_betting.go b/internal/plugin/coop_dungeon_betting.go index 86b76c9..af2023d 100644 --- a/internal/plugin/coop_dungeon_betting.go +++ b/internal/plugin/coop_dungeon_betting.go @@ -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) } } diff --git a/internal/plugin/coop_dungeon_db.go b/internal/plugin/coop_dungeon_db.go index 4ecbb27..4a4e435 100644 --- a/internal/plugin/coop_dungeon_db.go +++ b/internal/plugin/coop_dungeon_db.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "log/slog" "strconv" "time" @@ -13,18 +14,19 @@ import ( ) type CoopRun struct { - ID int - Tier int - Status string - LeaderID id.UserID - CurrentDay int - TotalDays int - BaseDifficulty int - GoldPool int - RewardTotal int - CreatedAt time.Time - LockedAt *time.Time - CompletedAt *time.Time + ID int + Tier int + Status string + LeaderID id.UserID + CurrentDay int + TotalDays int + BaseDifficulty int + GoldPool int + RewardTotal int + LastResolvedDay int + CreatedAt time.Time + LockedAt *time.Time + CompletedAt *time.Time } type CoopMember struct { @@ -53,10 +55,10 @@ func loadCoopRun(runID int) (*CoopRun, error) { var leader string var lockedAt, completedAt sql.NullTime err := d.QueryRow(`SELECT id, tier, status, leader_id, current_day, total_days, - base_difficulty, gold_pool, reward_total, created_at, locked_at, completed_at + base_difficulty, gold_pool, reward_total, last_resolved_day, created_at, locked_at, completed_at FROM coop_dungeon_runs WHERE id = ?`, runID).Scan( &r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays, - &r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, + &r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay, &r.CreatedAt, &lockedAt, &completedAt) if err != nil { return nil, err @@ -92,7 +94,7 @@ func loadMostRecentOpenCoopRun() (*CoopRun, error) { func queryCoopRuns(whereClause string) ([]*CoopRun, error) { d := db.Get() rows, err := d.Query(`SELECT id, tier, status, leader_id, current_day, total_days, - base_difficulty, gold_pool, reward_total, created_at, locked_at, completed_at + base_difficulty, gold_pool, reward_total, last_resolved_day, created_at, locked_at, completed_at FROM coop_dungeon_runs WHERE ` + whereClause) if err != nil { return nil, err @@ -105,7 +107,7 @@ func queryCoopRuns(whereClause string) ([]*CoopRun, error) { var leader string var lockedAt, completedAt sql.NullTime if err := rows.Scan(&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays, - &r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, + &r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay, &r.CreatedAt, &lockedAt, &completedAt); err != nil { return nil, err } @@ -167,6 +169,14 @@ func completeCoopRun(runID int, status string, reward int) error { return err } +// setCoopRunLastResolvedDay marks a day as resolved for crash-resume idempotency. +// Resolution is idempotent: re-entering coopResolveFloor for the same day is a no-op. +func setCoopRunLastResolvedDay(runID, day int) error { + d := db.Get() + _, err := d.Exec(`UPDATE coop_dungeon_runs SET last_resolved_day = ? WHERE id = ?`, day, runID) + return err +} + func addCoopRunPool(runID, delta int) error { d := db.Get() _, err := d.Exec(`UPDATE coop_dungeon_runs SET gold_pool = gold_pool + ? WHERE id = ?`, delta, runID) @@ -232,6 +242,21 @@ func loadCoopMember(runID int, userID id.UserID) (*CoopMember, error) { return m, nil } +// claimCoopMemberPayout atomically claims a member's reward slot. Returns true +// only if this call wins the race (member_payout was NULL and we set it). +// Prevents double-credit on crash-retry. +func claimCoopMemberPayout(runID int, userID id.UserID, payout int) (bool, error) { + d := db.Get() + res, err := d.Exec(`UPDATE coop_dungeon_members SET member_payout = ? + WHERE run_id = ? AND user_id = ? AND member_payout IS NULL`, + payout, runID, string(userID)) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + func saveCoopMemberFunding(m *CoopMember) error { d := db.Get() jsonBytes, err := json.Marshal(serializeCoopFundingMap(m.DailyFunding)) @@ -252,6 +277,7 @@ func parseCoopFundingMap(s string) map[int]CoopFundingTier { } raw := map[string]string{} if err := json.Unmarshal([]byte(s), &raw); err != nil { + slog.Error("coop: parse funding map", "raw", s, "err", err) return out } for k, v := range raw { @@ -289,7 +315,9 @@ type CoopEvent struct { func createCoopEvent(runID, day int, cat CoopEventCategory, idx int, recommended string) error { d := db.Get() - _, err := d.Exec(`INSERT INTO coop_dungeon_events + // INSERT OR IGNORE: idempotent on retry. If the row already exists from a + // prior tick, leave it untouched (don't re-roll the event index). + _, err := d.Exec(`INSERT OR IGNORE INTO coop_dungeon_events (run_id, day, category, event_index, recommended) VALUES (?, ?, ?, ?, ?)`, runID, day, string(cat), idx, recommended) return err @@ -390,6 +418,7 @@ func parseCoopVotes(s string) map[id.UserID]string { } raw := map[string]string{} if err := json.Unmarshal([]byte(s), &raw); err != nil { + slog.Error("coop: parse votes map", "raw", s, "err", err) return out } for k, v := range raw { diff --git a/internal/plugin/coop_dungeon_scheduler.go b/internal/plugin/coop_dungeon_scheduler.go index d82ffe9..ba5b2e6 100644 --- a/internal/plugin/coop_dungeon_scheduler.go +++ b/internal/plugin/coop_dungeon_scheduler.go @@ -4,7 +4,9 @@ import ( "fmt" "log/slog" "math/rand/v2" + "sort" "strings" + "sync" "time" "gogobee/internal/db" @@ -154,136 +156,183 @@ func (p *AdventurePlugin) coopProcessActiveRuns() { // funders, rolls outcome, advances or completes the run. func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error { day := run.CurrentDay + + // Skip only if this run is fully terminal. A "resolved" day with the run + // still in 'active' status means a prior tick crashed mid-distribution; + // we must re-enter to finish via idempotent operations (per-row claims). + if run.LastResolvedDay >= day && run.Status != "active" { + return nil + } + members, err := loadCoopMembers(run.ID) if err != nil { return err } - // Auto-play any member who didn't fund. - autoPlayed := []*CoopMember{} - for i := range members { - m := &members[i] - if _, ok := m.DailyFunding[day]; !ok { - m.DailyFunding[day] = CoopFundNone - if err := saveCoopMemberFunding(m); err != nil { - slog.Error("coop: auto-play save", "run", run.ID, "user", m.UserID, "err", err) - } - autoPlayed = append(autoPlayed, m) - } - } - - // Compute success modifier: base = (100 - baseDifficulty), adjusted by - // funding, party levels, pets, and the floor event vote. - totalMod := 0 - choices := make(map[string]CoopFundingTier, len(members)) - tierDef := coopTierTable[run.Tier] + // Lock all party members in stable UserID order so concurrent !coop fund / + // vote / giftvote commands don't race the resolver. Stable order rules + // out lock-order deadlock with other goroutines that lock multiple users. + sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID }) + mutexes := make([]*sync.Mutex, 0, len(members)) for _, m := range members { - tier, mod := coopMemberModifier(&m, day) - choices[string(m.UserID)] = tier - totalMod += mod - // Level + pet bonuses pulled fresh per-floor; they reflect current - // state (a player may have leveled up mid-run). - char, err := loadAdvCharacter(m.UserID) - if err != nil { - continue - } - totalMod += coopLevelBonus(char.CombatLevel, tierDef.minLevel) - totalMod += coopPetBonus(char) + mu := p.advUserLock(m.UserID) + mu.Lock() + mutexes = append(mutexes, mu) } + defer func() { + for _, mu := range mutexes { + mu.Unlock() + } + }() - // Tally vote on today's floor event. Ties go to the leader's vote (or - // option A if leader didn't vote either). + // Reload the run after acquiring locks. A concurrent admin action or + // another scheduler tick may have moved the state. + freshRun, err := loadCoopRun(run.ID) + if err != nil || freshRun == nil { + return err + } + run = freshRun + if run.Status != "active" { + return nil + } + // Re-check skip after lock acquisition (another tick could have already + // fully finalized — though our per-tick guard makes that unlikely). + if run.LastResolvedDay >= day && run.Status != "active" { + return nil + } + // Reload members under lock for fresh funding state. + members, err = loadCoopMembers(run.ID) + if err != nil { + return err + } + sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID }) + + // Resume vs. fresh detection: if the day's event already has an outcome, + // a prior tick rolled this floor before crashing. Reuse that outcome — + // re-rolling would be the worst sin (different result on retry). event, _ := loadCoopEvent(run.ID, day) - winning := "" - eventMod := 0 - if event != nil { - winning = coopTallyVote(event, run.LeaderID) - eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning) - totalMod += eventMod - } + resuming := event != nil && event.Outcome != "" - // Resolve gifts received on this day. - giftMod, giftSummary := p.coopResolveGifts(run, run.LeaderID, day) - totalMod += giftMod + autoPlayed := []*CoopMember{} + choices := make(map[string]CoopFundingTier, len(members)) + var floorWon bool + var winning string + var eventMod, successPct int + var giftSummary string - successPct := 100 - run.BaseDifficulty + totalMod - if successPct < 5 { - successPct = 5 - } - if successPct > 95 { - successPct = 95 - } - - roll := rand.IntN(100) - floorWon := roll < successPct - - // Persist the event resolution before posting (so a crash mid-post still - // records what happened for TwinBee helpfulness tracking). - if event != nil { - outcomeStr := "failure" - if floorWon { - outcomeStr = "success" + if resuming { + floorWon = event.Outcome == "success" + winning = event.WinningVote + eventMod = event.ModifierApplied + // Reconstruct choices for display purposes. + for _, m := range members { + tier, _ := coopMemberModifier(&m, day) + choices[string(m.UserID)] = tier + } + successPct = 0 // unknown on resume; render hides it + } else { + // Auto-play any member who didn't fund. + for i := range members { + m := &members[i] + if _, ok := m.DailyFunding[day]; !ok { + m.DailyFunding[day] = CoopFundNone + if err := saveCoopMemberFunding(m); err != nil { + slog.Error("coop: auto-play save", "run", run.ID, "user", m.UserID, "err", err) + } + autoPlayed = append(autoPlayed, m) + } + } + + totalMod := 0 + tierDef := coopTierTable[run.Tier] + for _, m := range members { + tier, mod := coopMemberModifier(&m, day) + choices[string(m.UserID)] = tier + totalMod += mod + char, err := loadAdvCharacter(m.UserID) + if err != nil { + continue + } + totalMod += coopLevelBonus(char.CombatLevel, tierDef.minLevel) + totalMod += coopPetBonus(char) + } + + // Lazy-create the day's event if missing — covers the crash window + // between lock and the original event creation. + if event == nil { + cat := pickCoopEventCategory(run.Tier) + idx := pickCoopEvent(cat) + _ = createCoopEvent(run.ID, day, cat, idx, coopEventRecommended(cat, idx)) + event, _ = loadCoopEvent(run.ID, day) + } + + if event != nil { + winning = coopTallyVote(event, run.LeaderID) + eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning) + totalMod += eventMod + } + + var giftMod int + giftMod, giftSummary = p.coopResolveGifts(run, run.LeaderID, day) + totalMod += giftMod + + successPct = 100 - run.BaseDifficulty + totalMod + if successPct < 5 { + successPct = 5 + } + if successPct > 95 { + successPct = 95 + } + + roll := rand.IntN(100) + floorWon = roll < successPct + + // Persist event resolution — turns this into a "resume" path on retry. + if event != nil { + outcomeStr := "failure" + if floorWon { + outcomeStr = "success" + } + _ = resolveCoopEvent(run.ID, day, winning, outcomeStr, eventMod) + } + + dailyPost := renderCoopDailyResult(p, run, members, day, choices, successPct, floorWon, autoPlayed, event, winning, eventMod) + if giftSummary != "" { + dailyPost += "\n\n" + giftSummary + } + if gr := gamesRoom(); gr != "" { + _ = p.SendMessage(gr, dailyPost) } - _ = resolveCoopEvent(run.ID, day, winning, outcomeStr, eventMod) } gr := gamesRoom() - dailyPost := renderCoopDailyResult(p, run, members, day, choices, successPct, floorWon, autoPlayed, event, winning, eventMod) - if giftSummary != "" { - dailyPost += "\n\n" + giftSummary - } - if gr != "" { - _ = p.SendMessage(gr, dailyPost) - } - if !floorWon { - // Wipe. Combat actions are only consumed at lock (day 1), so only a - // day-1 wipe has anything to refund. - if day == 1 { - now := time.Now().UTC().Format("2006-01-02") - for _, m := range members { - char, err := loadAdvCharacter(m.UserID) - if err != nil { - continue - } - if char.LastActionDate == now && char.CombatActionsUsed > 0 { - char.CombatActionsUsed-- - _ = saveAdvCharacter(char) - } - } + // Resolve bets first (idempotent via claimCoopBetPayout), then mark + // the run wiped. Order matters: if status is set first and the bot + // crashes, the next tick won't pick this run up. + summary := p.coopResolveBets(run, false) + giftLog := renderCoopGiftLog(p, run.ID) + parts := []string{} + if summary != "" { + parts = append(parts, summary) + } + if giftLog != "" { + parts = append(parts, giftLog) + } + if len(parts) > 0 && gr != "" { + _ = p.SendMessage(gr, strings.Join(parts, "\n\n")) } _ = completeCoopRun(run.ID, "wiped", 0) - // Resolve betting pool — failure side wins on a wipe. - freshRun, _ := loadCoopRun(run.ID) - if freshRun != nil { - summary := p.coopResolveBets(freshRun, false) - giftLog := renderCoopGiftLog(p, run.ID) - parts := []string{} - if summary != "" { - parts = append(parts, summary) - } - if giftLog != "" { - parts = append(parts, giftLog) - } - if len(parts) > 0 { - if gr := gamesRoom(); gr != "" { - _ = p.SendMessage(gr, strings.Join(parts, "\n\n")) - } - } - } + _ = setCoopRunLastResolvedDay(run.ID, day) return nil } - // Floor cleared — advance or complete. if day >= run.TotalDays { - // Run complete — split reward. def := coopTierTable[run.Tier] reward := def.rewardBase + p.coopDistributeReward(run, reward, members) _ = completeCoopRun(run.ID, "complete", reward) - freshRun, _ := loadCoopRun(run.ID) - if freshRun != nil { - p.coopDistributeReward(freshRun, reward, members) - } + _ = setCoopRunLastResolvedDay(run.ID, day) return nil } if err := advanceCoopDay(run.ID, day+1); err != nil { @@ -292,16 +341,17 @@ func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error { for _, m := range members { _ = p.SendDM(m.UserID, renderCoopFundingPrompt(run, day+1)) } - // Generate next day's floor event. - freshRun, _ := loadCoopRun(run.ID) + freshRun, _ = loadCoopRun(run.ID) if freshRun != nil { p.coopGenerateAndPostEvent(freshRun, members, day+1) } + _ = setCoopRunLastResolvedDay(run.ID, day) return nil } // coopTallyVote counts votes; majority wins. Ties resolve to the leader's -// vote if any, otherwise option A. +// vote if it's among the tied options; otherwise the lexicographically first +// tied label (deterministic, since Go map iteration is randomized). func coopTallyVote(event *CoopEvent, leader id.UserID) string { tally := map[string]int{} for _, v := range event.Votes { @@ -320,6 +370,7 @@ func coopTallyVote(event *CoopEvent, leader id.UserID) string { if len(winners) == 1 { return winners[0] } + sort.Strings(winners) if leaderVote, ok := event.Votes[leader]; ok { for _, w := range winners { if w == leaderVote { @@ -341,6 +392,12 @@ func (p *AdventurePlugin) coopDistributeReward(run *CoopRun, totalReward int, me gr := gamesRoom() var lines []string for _, m := range members { + // Atomic claim: only credit if this is the first time we're paying + // this member for this run. Skip silently on retry. + claimed, err := claimCoopMemberPayout(run.ID, m.UserID, share) + if err != nil || !claimed { + continue + } net, _ := communityTax(m.UserID, float64(share), coopAdventureRake) p.euro.Credit(m.UserID, net, "coop_dungeon_reward") lines = append(lines, fmt.Sprintf(" %s: €%.0f (after tax)", coopDisplayName(p, m.UserID), net)) diff --git a/internal/plugin/coop_dungeon_test.go b/internal/plugin/coop_dungeon_test.go index 19a6669..34c664b 100644 --- a/internal/plugin/coop_dungeon_test.go +++ b/internal/plugin/coop_dungeon_test.go @@ -96,19 +96,19 @@ func TestCoopTallyVote(t *testing.T) { {"clear majority", map[id.UserID]string{leader: "A", other: "A", third: "B"}, "A"}, {"tie broken by leader", map[id.UserID]string{leader: "B", other: "A"}, "B"}, {"all abstained falls back to A", map[id.UserID]string{}, "A"}, - {"tie no leader vote falls back deterministically", map[id.UserID]string{other: "A", third: "B"}, ""}, // "A" or "B" — accept either + {"tie with no leader vote → lex first (deterministic)", map[id.UserID]string{other: "A", third: "B"}, "A"}, + {"three-way tie, leader's vote in winners → leader's pick", map[id.UserID]string{leader: "C", other: "A", third: "B"}, "C"}, + {"tie with leader voting outside winners → lex first", map[id.UserID]string{leader: "C", other: "A", "@4:t": "A", third: "B", "@5:t": "B"}, "A"}, } for _, tc := range tests { - event := &CoopEvent{Votes: tc.votes} - got := coopTallyVote(event, leader) - if tc.want == "" { - if got != "A" && got != "B" { - t.Errorf("%s: got %q, want one of A/B", tc.name, got) + // Run repeatedly to catch any non-determinism leaking from map iteration. + for i := 0; i < 50; i++ { + event := &CoopEvent{Votes: tc.votes} + got := coopTallyVote(event, leader) + if got != tc.want { + t.Errorf("%s (iter %d): got %q, want %q", tc.name, i, got, tc.want) + break } - continue - } - if got != tc.want { - t.Errorf("%s: got %q, want %q", tc.name, got, tc.want) } } } @@ -163,6 +163,24 @@ func TestCoopEventMetaConsistency(t *testing.T) { } } +func TestCoopResolutionIdempotencyGuard(t *testing.T) { + t.Parallel() + // Sanity check: the LastResolvedDay field on CoopRun is the authoritative + // idempotency marker. The resolver short-circuits when LastResolvedDay >= + // CurrentDay, so a crash-restart on the same UTC day after the roll has + // landed is a safe no-op. + run := &CoopRun{CurrentDay: 3, LastResolvedDay: 3} + if !(run.LastResolvedDay >= run.CurrentDay) { + t.Errorf("expected resolution skip when LastResolvedDay (%d) >= CurrentDay (%d)", + run.LastResolvedDay, run.CurrentDay) + } + run.LastResolvedDay = 2 + if run.LastResolvedDay >= run.CurrentDay { + t.Errorf("expected resolution to proceed when LastResolvedDay (%d) < CurrentDay (%d)", + run.LastResolvedDay, run.CurrentDay) + } +} + func TestCoopParimutuelPayouts(t *testing.T) { t.Parallel() a := id.UserID("@a:t")