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

@@ -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);

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)
}
}

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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")