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 {
@@ -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) -- Co-op Dungeon (party multi-day runs)
CREATE TABLE IF NOT EXISTS coop_dungeon_runs ( CREATE TABLE IF NOT EXISTS coop_dungeon_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
tier INTEGER NOT NULL, tier INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open', -- open, active, complete, wiped, cancelled status TEXT NOT NULL DEFAULT 'open', -- open, active, complete, wiped, cancelled
leader_id TEXT NOT NULL, leader_id TEXT NOT NULL,
current_day INTEGER NOT NULL DEFAULT 0, current_day INTEGER NOT NULL DEFAULT 0,
total_days INTEGER NOT NULL, total_days INTEGER NOT NULL,
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)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_resolved_day INTEGER NOT NULL DEFAULT 0, -- crash-resume guard: highest day whose floor outcome is final
locked_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME locked_at DATETIME,
completed_at DATETIME
); );
CREATE INDEX IF NOT EXISTS idx_coop_runs_status ON coop_dungeon_runs(status); 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, 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"
@@ -13,18 +14,19 @@ import (
) )
type CoopRun struct { type CoopRun struct {
ID int ID int
Tier int Tier int
Status string Status string
LeaderID id.UserID LeaderID id.UserID
CurrentDay int CurrentDay int
TotalDays int TotalDays int
BaseDifficulty int BaseDifficulty int
GoldPool int GoldPool int
RewardTotal int RewardTotal int
CreatedAt time.Time LastResolvedDay int
LockedAt *time.Time CreatedAt time.Time
CompletedAt *time.Time LockedAt *time.Time
CompletedAt *time.Time
} }
type CoopMember struct { type CoopMember struct {
@@ -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,136 +156,183 @@ 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 /
autoPlayed := []*CoopMember{} // vote / giftvote commands don't race the resolver. Stable order rules
for i := range members { // out lock-order deadlock with other goroutines that lock multiple users.
m := &members[i] sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID })
if _, ok := m.DailyFunding[day]; !ok { mutexes := make([]*sync.Mutex, 0, len(members))
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]
for _, m := range members { for _, m := range members {
tier, mod := coopMemberModifier(&m, day) mu := p.advUserLock(m.UserID)
choices[string(m.UserID)] = tier mu.Lock()
totalMod += mod mutexes = append(mutexes, mu)
// 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)
} }
defer func() {
for _, mu := range mutexes {
mu.Unlock()
}
}()
// Tally vote on today's floor event. Ties go to the leader's vote (or // Reload the run after acquiring locks. A concurrent admin action or
// option A if leader didn't vote either). // 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) event, _ := loadCoopEvent(run.ID, day)
winning := "" resuming := event != nil && event.Outcome != ""
eventMod := 0
if event != nil {
winning = coopTallyVote(event, run.LeaderID)
eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning)
totalMod += eventMod
}
// Resolve gifts received on this day. autoPlayed := []*CoopMember{}
giftMod, giftSummary := p.coopResolveGifts(run, run.LeaderID, day) choices := make(map[string]CoopFundingTier, len(members))
totalMod += giftMod var floorWon bool
var winning string
var eventMod, successPct int
var giftSummary string
successPct := 100 - run.BaseDifficulty + totalMod if resuming {
if successPct < 5 { floorWon = event.Outcome == "success"
successPct = 5 winning = event.WinningVote
} eventMod = event.ModifierApplied
if successPct > 95 { // Reconstruct choices for display purposes.
successPct = 95 for _, m := range members {
} tier, _ := coopMemberModifier(&m, day)
choices[string(m.UserID)] = tier
roll := rand.IntN(100) }
floorWon := roll < successPct successPct = 0 // unknown on resume; render hides it
} else {
// Persist the event resolution before posting (so a crash mid-post still // Auto-play any member who didn't fund.
// records what happened for TwinBee helpfulness tracking). for i := range members {
if event != nil { m := &members[i]
outcomeStr := "failure" if _, ok := m.DailyFunding[day]; !ok {
if floorWon { m.DailyFunding[day] = CoopFundNone
outcomeStr = "success" 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() 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 { 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 { giftLog := renderCoopGiftLog(p, run.ID)
char, err := loadAdvCharacter(m.UserID) parts := []string{}
if err != nil { if summary != "" {
continue parts = append(parts, summary)
} }
if char.LastActionDate == now && char.CombatActionsUsed > 0 { if giftLog != "" {
char.CombatActionsUsed-- parts = append(parts, giftLog)
_ = saveAdvCharacter(char) }
} if len(parts) > 0 && gr != "" {
} _ = p.SendMessage(gr, strings.Join(parts, "\n\n"))
} }
_ = completeCoopRun(run.ID, "wiped", 0) _ = completeCoopRun(run.ID, "wiped", 0)
// Resolve betting pool — failure side wins on a wipe. _ = setCoopRunLastResolvedDay(run.ID, day)
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"))
}
}
}
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 {
event := &CoopEvent{Votes: tc.votes} // Run repeatedly to catch any non-determinism leaking from map iteration.
got := coopTallyVote(event, leader) for i := 0; i < 50; i++ {
if tc.want == "" { event := &CoopEvent{Votes: tc.votes}
if got != "A" && got != "B" { got := coopTallyVote(event, leader)
t.Errorf("%s: got %q, want one of A/B", tc.name, got) 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) { func TestCoopParimutuelPayouts(t *testing.T) {
t.Parallel() t.Parallel()
a := id.UserID("@a:t") a := id.UserID("@a:t")