mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user