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