Co-op audit fixes: lock scheduler, idempotent payouts, deterministic ties

Audit pass addressed concurrency races, crash-recovery double-pay risks, and
non-determinism in tie tallying.

CRITICAL — concurrency
- coopResolveFloor now acquires advUserLock for every party member (sorted by
  UserID) before mutating state, so concurrent !coop fund / vote / giftvote
  commands serialize against the scheduler.

CRITICAL — crash idempotency
- Add coop_dungeon_runs.last_resolved_day and coop_dungeon_members.member_payout
  columns (with migration entries) for crash-resume tracking.
- Skip resolution only if last_resolved_day >= day AND status is terminal;
  otherwise re-enter and finish via idempotent operations.
- On resume detection (event already has outcome), reuse the saved roll
  instead of re-rolling — prevents different outcomes on retry.
- claimCoopMemberPayout / claimCoopBetPayout: atomic UPDATE...WHERE col IS NULL
  pattern. Each member/bet can only be paid once; retries are silent no-ops.
- createCoopEvent now INSERT OR IGNORE — idempotent on retry.
- last_resolved_day is set as the FINAL write per floor.

HIGH — bugs
- Lazy-create floor event in resolver if missing — covers crash window
  between lockCoopRun and the original createCoopEvent.
- coopTallyVote sorts tied winners alphabetically before fallback (was
  non-deterministic via Go map iteration).
- Removed dead day-1 wipe combat-action refund block — refund check was
  always false because midnight reset clears state before resolution.

LOW
- Log slog.Error on malformed JSON in parseCoopVotes / parseCoopFundingMap
  instead of silently dropping data.

Tests
- Tally test extended to assert determinism across 50 runs (catches map-iteration
  leaks).
- Added coverage for new tie-with-leader-vote-outside-winners case.
- Idempotency-guard sanity check on LastResolvedDay vs CurrentDay.

Race detector: clean. Full suite: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-26 09:01:29 -07:00
parent 8ad31a0009
commit e8a3b8b35d
5 changed files with 279 additions and 149 deletions

View File

@@ -135,6 +135,8 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE adventure_characters ADD COLUMN pet_morning_defense INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN auto_babysit INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN streak_decayed INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE coop_dungeon_runs ADD COLUMN last_resolved_day INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE coop_dungeon_members ADD COLUMN member_payout INTEGER`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -1375,18 +1377,19 @@ CREATE INDEX IF NOT EXISTS idx_tip_audit_action ON holdem_tip_audit(action, stre
-- Co-op Dungeon (party multi-day runs)
CREATE TABLE IF NOT EXISTS coop_dungeon_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tier INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open', -- open, active, complete, wiped, cancelled
leader_id TEXT NOT NULL,
current_day INTEGER NOT NULL DEFAULT 0,
total_days INTEGER NOT NULL,
base_difficulty INTEGER NOT NULL, -- per-floor failure % at zero modifier
gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding
reward_total INTEGER NOT NULL DEFAULT 0, -- final reward (set on completion)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
locked_at DATETIME,
completed_at DATETIME
id INTEGER PRIMARY KEY AUTOINCREMENT,
tier INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open', -- open, active, complete, wiped, cancelled
leader_id TEXT NOT NULL,
current_day INTEGER NOT NULL DEFAULT 0,
total_days INTEGER NOT NULL,
base_difficulty INTEGER NOT NULL, -- per-floor failure % at zero modifier
gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding
reward_total INTEGER NOT NULL DEFAULT 0, -- final reward (set on completion)
last_resolved_day INTEGER NOT NULL DEFAULT 0, -- crash-resume guard: highest day whose floor outcome is final
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
locked_at DATETIME,
completed_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_coop_runs_status ON coop_dungeon_runs(status);
@@ -1397,6 +1400,7 @@ CREATE TABLE IF NOT EXISTS coop_dungeon_members (
total_contributed INTEGER NOT NULL DEFAULT 0,
is_liability INTEGER NOT NULL DEFAULT 0,
daily_funding TEXT NOT NULL DEFAULT '{}', -- JSON: {"1":"standard","2":"all_in",...}
member_payout INTEGER, -- NULL until reward credited; idempotency claim
PRIMARY KEY (run_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_coop_members_user ON coop_dungeon_members(user_id);