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 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 auto_babysit INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN streak_decayed 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 { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {
@@ -1384,6 +1386,7 @@ CREATE TABLE IF NOT EXISTS coop_dungeon_runs (
base_difficulty INTEGER NOT NULL, -- per-floor failure % at zero modifier base_difficulty INTEGER NOT NULL, -- per-floor failure % at zero modifier
gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding
reward_total INTEGER NOT NULL DEFAULT 0, -- final reward (set on completion) 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, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
locked_at DATETIME, locked_at DATETIME,
completed_at DATETIME completed_at DATETIME
@@ -1397,6 +1400,7 @@ CREATE TABLE IF NOT EXISTS coop_dungeon_members (
total_contributed INTEGER NOT NULL DEFAULT 0, total_contributed INTEGER NOT NULL DEFAULT 0,
is_liability INTEGER NOT NULL DEFAULT 0, is_liability INTEGER NOT NULL DEFAULT 0,
daily_funding TEXT NOT NULL DEFAULT '{}', -- JSON: {"1":"standard","2":"all_in",...} 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) PRIMARY KEY (run_id, user_id)
); );
CREATE INDEX IF NOT EXISTS idx_coop_members_user ON coop_dungeon_members(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 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) { func loadMostRecentBettableCoopRun() (*CoopRun, error) {
runs, err := queryCoopRuns(`status IN ('open','active') ORDER BY id DESC LIMIT 1`) runs, err := queryCoopRuns(`status IN ('open','active') ORDER BY id DESC LIMIT 1`)
if err != nil || len(runs) == 0 { 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) 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 { for _, b := range winners {
payout := payouts[b.PlayerID] payout := payouts[b.PlayerID]
claimed, err := claimCoopBetPayout(run.ID, b.PlayerID, payout)
if err != nil || !claimed {
continue
}
if payout > 0 { if payout > 0 {
p.euro.Credit(b.PlayerID, float64(payout), "coop_bet_payout") p.euro.Credit(b.PlayerID, float64(payout), "coop_bet_payout")
} }
_ = setCoopBetPayout(run.ID, b.PlayerID, payout)
} }
for _, b := range bets { for _, b := range bets {
if b.Position != winningPosition { if b.Position != winningPosition && !b.Payout.Valid {
_ = setCoopBetPayout(run.ID, b.PlayerID, 0) _ = setCoopBetPayout(run.ID, b.PlayerID, 0)
} }
} }

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"strconv" "strconv"
"time" "time"
@@ -22,6 +23,7 @@ type CoopRun struct {
BaseDifficulty int BaseDifficulty int
GoldPool int GoldPool int
RewardTotal int RewardTotal int
LastResolvedDay int
CreatedAt time.Time CreatedAt time.Time
LockedAt *time.Time LockedAt *time.Time
CompletedAt *time.Time CompletedAt *time.Time
@@ -53,10 +55,10 @@ func loadCoopRun(runID int) (*CoopRun, error) {
var leader string var leader string
var lockedAt, completedAt sql.NullTime var lockedAt, completedAt sql.NullTime
err := d.QueryRow(`SELECT id, tier, status, leader_id, current_day, total_days, 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( FROM coop_dungeon_runs WHERE id = ?`, runID).Scan(
&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays, &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) &r.CreatedAt, &lockedAt, &completedAt)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -92,7 +94,7 @@ func loadMostRecentOpenCoopRun() (*CoopRun, error) {
func queryCoopRuns(whereClause string) ([]*CoopRun, error) { func queryCoopRuns(whereClause string) ([]*CoopRun, error) {
d := db.Get() d := db.Get()
rows, err := d.Query(`SELECT id, tier, status, leader_id, current_day, total_days, 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) FROM coop_dungeon_runs WHERE ` + whereClause)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -105,7 +107,7 @@ func queryCoopRuns(whereClause string) ([]*CoopRun, error) {
var leader string var leader string
var lockedAt, completedAt sql.NullTime var lockedAt, completedAt sql.NullTime
if err := rows.Scan(&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays, 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 { &r.CreatedAt, &lockedAt, &completedAt); err != nil {
return nil, err return nil, err
} }
@@ -167,6 +169,14 @@ func completeCoopRun(runID int, status string, reward int) error {
return err 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 { func addCoopRunPool(runID, delta int) error {
d := db.Get() d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_runs SET gold_pool = gold_pool + ? WHERE id = ?`, delta, runID) _, 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 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 { func saveCoopMemberFunding(m *CoopMember) error {
d := db.Get() d := db.Get()
jsonBytes, err := json.Marshal(serializeCoopFundingMap(m.DailyFunding)) jsonBytes, err := json.Marshal(serializeCoopFundingMap(m.DailyFunding))
@@ -252,6 +277,7 @@ func parseCoopFundingMap(s string) map[int]CoopFundingTier {
} }
raw := map[string]string{} raw := map[string]string{}
if err := json.Unmarshal([]byte(s), &raw); err != nil { if err := json.Unmarshal([]byte(s), &raw); err != nil {
slog.Error("coop: parse funding map", "raw", s, "err", err)
return out return out
} }
for k, v := range raw { for k, v := range raw {
@@ -289,7 +315,9 @@ type CoopEvent struct {
func createCoopEvent(runID, day int, cat CoopEventCategory, idx int, recommended string) error { func createCoopEvent(runID, day int, cat CoopEventCategory, idx int, recommended string) error {
d := db.Get() 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 (?, ?, ?, ?, ?)`, (run_id, day, category, event_index, recommended) VALUES (?, ?, ?, ?, ?)`,
runID, day, string(cat), idx, recommended) runID, day, string(cat), idx, recommended)
return err return err
@@ -390,6 +418,7 @@ func parseCoopVotes(s string) map[id.UserID]string {
} }
raw := map[string]string{} raw := map[string]string{}
if err := json.Unmarshal([]byte(s), &raw); err != nil { if err := json.Unmarshal([]byte(s), &raw); err != nil {
slog.Error("coop: parse votes map", "raw", s, "err", err)
return out return out
} }
for k, v := range raw { for k, v := range raw {

View File

@@ -4,7 +4,9 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand/v2" "math/rand/v2"
"sort"
"strings" "strings"
"sync"
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
@@ -154,13 +156,82 @@ func (p *AdventurePlugin) coopProcessActiveRuns() {
// funders, rolls outcome, advances or completes the run. // funders, rolls outcome, advances or completes the run.
func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error { func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error {
day := run.CurrentDay 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) members, err := loadCoopMembers(run.ID)
if err != nil { if err != nil {
return err return err
} }
// Auto-play any member who didn't fund. // 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 {
mu := p.advUserLock(m.UserID)
mu.Lock()
mutexes = append(mutexes, mu)
}
defer func() {
for _, mu := range mutexes {
mu.Unlock()
}
}()
// 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)
resuming := event != nil && event.Outcome != ""
autoPlayed := []*CoopMember{} autoPlayed := []*CoopMember{}
choices := make(map[string]CoopFundingTier, len(members))
var floorWon bool
var winning string
var eventMod, successPct int
var giftSummary string
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 { for i := range members {
m := &members[i] m := &members[i]
if _, ok := m.DailyFunding[day]; !ok { if _, ok := m.DailyFunding[day]; !ok {
@@ -172,17 +243,12 @@ func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error {
} }
} }
// Compute success modifier: base = (100 - baseDifficulty), adjusted by
// funding, party levels, pets, and the floor event vote.
totalMod := 0 totalMod := 0
choices := make(map[string]CoopFundingTier, len(members))
tierDef := coopTierTable[run.Tier] tierDef := coopTierTable[run.Tier]
for _, m := range members { for _, m := range members {
tier, mod := coopMemberModifier(&m, day) tier, mod := coopMemberModifier(&m, day)
choices[string(m.UserID)] = tier choices[string(m.UserID)] = tier
totalMod += mod 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) char, err := loadAdvCharacter(m.UserID)
if err != nil { if err != nil {
continue continue
@@ -191,22 +257,26 @@ func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error {
totalMod += coopPetBonus(char) totalMod += coopPetBonus(char)
} }
// Tally vote on today's floor event. Ties go to the leader's vote (or // Lazy-create the day's event if missing — covers the crash window
// option A if leader didn't vote either). // between lock and the original event creation.
event, _ := loadCoopEvent(run.ID, day) if event == nil {
winning := "" cat := pickCoopEventCategory(run.Tier)
eventMod := 0 idx := pickCoopEvent(cat)
_ = createCoopEvent(run.ID, day, cat, idx, coopEventRecommended(cat, idx))
event, _ = loadCoopEvent(run.ID, day)
}
if event != nil { if event != nil {
winning = coopTallyVote(event, run.LeaderID) winning = coopTallyVote(event, run.LeaderID)
eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning) eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning)
totalMod += eventMod totalMod += eventMod
} }
// Resolve gifts received on this day. var giftMod int
giftMod, giftSummary := p.coopResolveGifts(run, run.LeaderID, day) giftMod, giftSummary = p.coopResolveGifts(run, run.LeaderID, day)
totalMod += giftMod totalMod += giftMod
successPct := 100 - run.BaseDifficulty + totalMod successPct = 100 - run.BaseDifficulty + totalMod
if successPct < 5 { if successPct < 5 {
successPct = 5 successPct = 5
} }
@@ -215,10 +285,9 @@ func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error {
} }
roll := rand.IntN(100) roll := rand.IntN(100)
floorWon := roll < successPct floorWon = roll < successPct
// Persist the event resolution before posting (so a crash mid-post still // Persist event resolution — turns this into a "resume" path on retry.
// records what happened for TwinBee helpfulness tracking).
if event != nil { if event != nil {
outcomeStr := "failure" outcomeStr := "failure"
if floorWon { if floorWon {
@@ -227,36 +296,21 @@ func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error {
_ = resolveCoopEvent(run.ID, day, winning, outcomeStr, eventMod) _ = resolveCoopEvent(run.ID, day, winning, outcomeStr, eventMod)
} }
gr := gamesRoom()
dailyPost := renderCoopDailyResult(p, run, members, day, choices, successPct, floorWon, autoPlayed, event, winning, eventMod) dailyPost := renderCoopDailyResult(p, run, members, day, choices, successPct, floorWon, autoPlayed, event, winning, eventMod)
if giftSummary != "" { if giftSummary != "" {
dailyPost += "\n\n" + giftSummary dailyPost += "\n\n" + giftSummary
} }
if gr != "" { if gr := gamesRoom(); gr != "" {
_ = p.SendMessage(gr, dailyPost) _ = p.SendMessage(gr, dailyPost)
} }
}
gr := gamesRoom()
if !floorWon { if !floorWon {
// Wipe. Combat actions are only consumed at lock (day 1), so only a // Resolve bets first (idempotent via claimCoopBetPayout), then mark
// day-1 wipe has anything to refund. // the run wiped. Order matters: if status is set first and the bot
if day == 1 { // crashes, the next tick won't pick this run up.
now := time.Now().UTC().Format("2006-01-02") summary := p.coopResolveBets(run, false)
for _, m := range members {
char, err := loadAdvCharacter(m.UserID)
if err != nil {
continue
}
if char.LastActionDate == now && char.CombatActionsUsed > 0 {
char.CombatActionsUsed--
_ = saveAdvCharacter(char)
}
}
}
_ = 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) giftLog := renderCoopGiftLog(p, run.ID)
parts := []string{} parts := []string{}
if summary != "" { if summary != "" {
@@ -265,25 +319,20 @@ func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error {
if giftLog != "" { if giftLog != "" {
parts = append(parts, giftLog) parts = append(parts, giftLog)
} }
if len(parts) > 0 { if len(parts) > 0 && gr != "" {
if gr := gamesRoom(); gr != "" {
_ = p.SendMessage(gr, strings.Join(parts, "\n\n")) _ = p.SendMessage(gr, strings.Join(parts, "\n\n"))
} }
} _ = completeCoopRun(run.ID, "wiped", 0)
} _ = setCoopRunLastResolvedDay(run.ID, day)
return nil return nil
} }
// Floor cleared — advance or complete.
if day >= run.TotalDays { if day >= run.TotalDays {
// Run complete — split reward.
def := coopTierTable[run.Tier] def := coopTierTable[run.Tier]
reward := def.rewardBase reward := def.rewardBase
p.coopDistributeReward(run, reward, members)
_ = completeCoopRun(run.ID, "complete", reward) _ = completeCoopRun(run.ID, "complete", reward)
freshRun, _ := loadCoopRun(run.ID) _ = setCoopRunLastResolvedDay(run.ID, day)
if freshRun != nil {
p.coopDistributeReward(freshRun, reward, members)
}
return nil return nil
} }
if err := advanceCoopDay(run.ID, day+1); err != 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 { for _, m := range members {
_ = p.SendDM(m.UserID, renderCoopFundingPrompt(run, day+1)) _ = 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 { if freshRun != nil {
p.coopGenerateAndPostEvent(freshRun, members, day+1) p.coopGenerateAndPostEvent(freshRun, members, day+1)
} }
_ = setCoopRunLastResolvedDay(run.ID, day)
return nil return nil
} }
// coopTallyVote counts votes; majority wins. Ties resolve to the leader's // 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 { func coopTallyVote(event *CoopEvent, leader id.UserID) string {
tally := map[string]int{} tally := map[string]int{}
for _, v := range event.Votes { for _, v := range event.Votes {
@@ -320,6 +370,7 @@ func coopTallyVote(event *CoopEvent, leader id.UserID) string {
if len(winners) == 1 { if len(winners) == 1 {
return winners[0] return winners[0]
} }
sort.Strings(winners)
if leaderVote, ok := event.Votes[leader]; ok { if leaderVote, ok := event.Votes[leader]; ok {
for _, w := range winners { for _, w := range winners {
if w == leaderVote { if w == leaderVote {
@@ -341,6 +392,12 @@ func (p *AdventurePlugin) coopDistributeReward(run *CoopRun, totalReward int, me
gr := gamesRoom() gr := gamesRoom()
var lines []string var lines []string
for _, m := range members { 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) net, _ := communityTax(m.UserID, float64(share), coopAdventureRake)
p.euro.Credit(m.UserID, net, "coop_dungeon_reward") p.euro.Credit(m.UserID, net, "coop_dungeon_reward")
lines = append(lines, fmt.Sprintf(" %s: €%.0f (after tax)", coopDisplayName(p, m.UserID), net)) 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"}, {"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"}, {"tie broken by leader", map[id.UserID]string{leader: "B", other: "A"}, "B"},
{"all abstained falls back to A", map[id.UserID]string{}, "A"}, {"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 { for _, tc := range tests {
// Run repeatedly to catch any non-determinism leaking from map iteration.
for i := 0; i < 50; i++ {
event := &CoopEvent{Votes: tc.votes} event := &CoopEvent{Votes: tc.votes}
got := coopTallyVote(event, leader) 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)
}
continue
}
if got != tc.want { if got != tc.want {
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want) t.Errorf("%s (iter %d): got %q, want %q", tc.name, i, got, tc.want)
break
}
} }
} }
} }
@@ -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) { func TestCoopParimutuelPayouts(t *testing.T) {
t.Parallel() t.Parallel()
a := id.UserID("@a:t") a := id.UserID("@a:t")