mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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>
437 lines
12 KiB
Go
437 lines
12 KiB
Go
package plugin
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
type CoopRun struct {
|
|
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 {
|
|
RunID int
|
|
UserID id.UserID
|
|
TurnOrder int
|
|
TotalContributed int
|
|
IsLiability bool
|
|
DailyFunding map[int]CoopFundingTier // day → tier
|
|
}
|
|
|
|
func createCoopRun(tier int, leader id.UserID, totalDays, baseDifficulty int) (*CoopRun, error) {
|
|
d := db.Get()
|
|
res, err := d.Exec(`INSERT INTO coop_dungeon_runs (tier, leader_id, total_days, base_difficulty)
|
|
VALUES (?, ?, ?, ?)`, tier, string(leader), totalDays, baseDifficulty)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id64, _ := res.LastInsertId()
|
|
return loadCoopRun(int(id64))
|
|
}
|
|
|
|
func loadCoopRun(runID int) (*CoopRun, error) {
|
|
d := db.Get()
|
|
r := &CoopRun{}
|
|
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, 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.LastResolvedDay,
|
|
&r.CreatedAt, &lockedAt, &completedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.LeaderID = id.UserID(leader)
|
|
if lockedAt.Valid {
|
|
t := lockedAt.Time
|
|
r.LockedAt = &t
|
|
}
|
|
if completedAt.Valid {
|
|
t := completedAt.Time
|
|
r.CompletedAt = &t
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func loadOpenCoopRuns() ([]*CoopRun, error) {
|
|
return queryCoopRuns(`status = 'open' ORDER BY created_at DESC`)
|
|
}
|
|
|
|
func loadActiveCoopRuns() ([]*CoopRun, error) {
|
|
return queryCoopRuns(`status = 'active' ORDER BY id`)
|
|
}
|
|
|
|
func loadMostRecentOpenCoopRun() (*CoopRun, error) {
|
|
runs, err := queryCoopRuns(`status = 'open' ORDER BY created_at DESC LIMIT 1`)
|
|
if err != nil || len(runs) == 0 {
|
|
return nil, err
|
|
}
|
|
return runs[0], nil
|
|
}
|
|
|
|
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, last_resolved_day, created_at, locked_at, completed_at
|
|
FROM coop_dungeon_runs WHERE ` + whereClause)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*CoopRun
|
|
for rows.Next() {
|
|
r := &CoopRun{}
|
|
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.LastResolvedDay,
|
|
&r.CreatedAt, &lockedAt, &completedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
r.LeaderID = id.UserID(leader)
|
|
if lockedAt.Valid {
|
|
t := lockedAt.Time
|
|
r.LockedAt = &t
|
|
}
|
|
if completedAt.Valid {
|
|
t := completedAt.Time
|
|
r.CompletedAt = &t
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func loadCoopRunForUser(userID id.UserID) (*CoopRun, error) {
|
|
d := db.Get()
|
|
var runID int
|
|
err := d.QueryRow(`SELECT m.run_id FROM coop_dungeon_members m
|
|
JOIN coop_dungeon_runs r ON r.id = m.run_id
|
|
WHERE m.user_id = ? AND r.status IN ('open','active')
|
|
ORDER BY r.id DESC LIMIT 1`, string(userID)).Scan(&runID)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return loadCoopRun(runID)
|
|
}
|
|
|
|
func setCoopRunStatus(runID int, status string) error {
|
|
d := db.Get()
|
|
_, err := d.Exec(`UPDATE coop_dungeon_runs SET status = ? WHERE id = ?`, status, runID)
|
|
return err
|
|
}
|
|
|
|
func lockCoopRun(runID int) error {
|
|
d := db.Get()
|
|
_, err := d.Exec(`UPDATE coop_dungeon_runs
|
|
SET status = 'active', locked_at = CURRENT_TIMESTAMP, current_day = 1
|
|
WHERE id = ? AND status = 'open'`, runID)
|
|
return err
|
|
}
|
|
|
|
func advanceCoopDay(runID, newDay int) error {
|
|
d := db.Get()
|
|
_, err := d.Exec(`UPDATE coop_dungeon_runs SET current_day = ? WHERE id = ?`, newDay, runID)
|
|
return err
|
|
}
|
|
|
|
func completeCoopRun(runID int, status string, reward int) error {
|
|
d := db.Get()
|
|
_, err := d.Exec(`UPDATE coop_dungeon_runs
|
|
SET status = ?, reward_total = ?, completed_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?`, status, reward, runID)
|
|
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)
|
|
return err
|
|
}
|
|
|
|
// ── Members ─────────────────────────────────────────────────────────────────
|
|
|
|
func addCoopMember(runID int, userID id.UserID, turnOrder int, liability bool) error {
|
|
d := db.Get()
|
|
libVal := 0
|
|
if liability {
|
|
libVal = 1
|
|
}
|
|
_, err := d.Exec(`INSERT INTO coop_dungeon_members
|
|
(run_id, user_id, turn_order, is_liability, daily_funding) VALUES (?, ?, ?, ?, '{}')`,
|
|
runID, string(userID), turnOrder, libVal)
|
|
return err
|
|
}
|
|
|
|
func loadCoopMembers(runID int) ([]CoopMember, error) {
|
|
d := db.Get()
|
|
rows, err := d.Query(`SELECT run_id, user_id, turn_order, total_contributed, is_liability, daily_funding
|
|
FROM coop_dungeon_members WHERE run_id = ? ORDER BY turn_order`, runID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []CoopMember
|
|
for rows.Next() {
|
|
m := CoopMember{}
|
|
var userID, fundingJSON string
|
|
var liability int
|
|
if err := rows.Scan(&m.RunID, &userID, &m.TurnOrder, &m.TotalContributed, &liability, &fundingJSON); err != nil {
|
|
return nil, err
|
|
}
|
|
m.UserID = id.UserID(userID)
|
|
m.IsLiability = liability != 0
|
|
m.DailyFunding = parseCoopFundingMap(fundingJSON)
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func loadCoopMember(runID int, userID id.UserID) (*CoopMember, error) {
|
|
d := db.Get()
|
|
m := &CoopMember{}
|
|
var uid, fundingJSON string
|
|
var liability int
|
|
err := d.QueryRow(`SELECT run_id, user_id, turn_order, total_contributed, is_liability, daily_funding
|
|
FROM coop_dungeon_members WHERE run_id = ? AND user_id = ?`, runID, string(userID)).Scan(
|
|
&m.RunID, &uid, &m.TurnOrder, &m.TotalContributed, &liability, &fundingJSON)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.UserID = id.UserID(uid)
|
|
m.IsLiability = liability != 0
|
|
m.DailyFunding = parseCoopFundingMap(fundingJSON)
|
|
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))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = d.Exec(`UPDATE coop_dungeon_members
|
|
SET daily_funding = ?, total_contributed = ?
|
|
WHERE run_id = ? AND user_id = ?`,
|
|
string(jsonBytes), m.TotalContributed, m.RunID, string(m.UserID))
|
|
return err
|
|
}
|
|
|
|
func parseCoopFundingMap(s string) map[int]CoopFundingTier {
|
|
out := map[int]CoopFundingTier{}
|
|
if s == "" || s == "{}" {
|
|
return out
|
|
}
|
|
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 {
|
|
day, err := strconv.Atoi(k)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out[day] = CoopFundingTier(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func serializeCoopFundingMap(m map[int]CoopFundingTier) map[string]string {
|
|
out := make(map[string]string, len(m))
|
|
for k, v := range m {
|
|
out[fmt.Sprintf("%d", k)] = string(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── Floor Events ────────────────────────────────────────────────────────────
|
|
|
|
type CoopEvent struct {
|
|
RunID int
|
|
Day int
|
|
Category CoopEventCategory
|
|
EventIndex int
|
|
Recommended string
|
|
Votes map[id.UserID]string // userID → option label
|
|
WinningVote string // "" until resolved
|
|
Outcome string // "" until resolved; "success" or "failure"
|
|
ModifierApplied int
|
|
PostEventID id.EventID
|
|
}
|
|
|
|
func createCoopEvent(runID, day int, cat CoopEventCategory, idx int, recommended string) error {
|
|
d := db.Get()
|
|
// 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
|
|
}
|
|
|
|
func loadCoopEvent(runID, day int) (*CoopEvent, error) {
|
|
d := db.Get()
|
|
e := &CoopEvent{}
|
|
var cat, votesJSON string
|
|
var winning, outcome, postID sql.NullString
|
|
err := d.QueryRow(`SELECT run_id, day, category, event_index, recommended,
|
|
votes, winning_vote, outcome, modifier_applied, post_event_id
|
|
FROM coop_dungeon_events WHERE run_id = ? AND day = ?`, runID, day).Scan(
|
|
&e.RunID, &e.Day, &cat, &e.EventIndex, &e.Recommended,
|
|
&votesJSON, &winning, &outcome, &e.ModifierApplied, &postID)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e.Category = CoopEventCategory(cat)
|
|
e.Votes = parseCoopVotes(votesJSON)
|
|
if winning.Valid {
|
|
e.WinningVote = winning.String
|
|
}
|
|
if outcome.Valid {
|
|
e.Outcome = outcome.String
|
|
}
|
|
if postID.Valid {
|
|
e.PostEventID = id.EventID(postID.String)
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
func saveCoopEventVotes(e *CoopEvent) error {
|
|
d := db.Get()
|
|
jsonBytes, err := json.Marshal(serializeCoopVotes(e.Votes))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = d.Exec(`UPDATE coop_dungeon_events SET votes = ?
|
|
WHERE run_id = ? AND day = ?`, string(jsonBytes), e.RunID, e.Day)
|
|
return err
|
|
}
|
|
|
|
func saveCoopEventPostID(runID, day int, eventID id.EventID) error {
|
|
d := db.Get()
|
|
_, err := d.Exec(`UPDATE coop_dungeon_events SET post_event_id = ?
|
|
WHERE run_id = ? AND day = ?`, string(eventID), runID, day)
|
|
return err
|
|
}
|
|
|
|
func resolveCoopEvent(runID, day int, winning, outcome string, modifier int) error {
|
|
d := db.Get()
|
|
_, err := d.Exec(`UPDATE coop_dungeon_events
|
|
SET winning_vote = ?, outcome = ?, modifier_applied = ?
|
|
WHERE run_id = ? AND day = ?`, winning, outcome, modifier, runID, day)
|
|
return err
|
|
}
|
|
|
|
// CoopTwinBeeHelpfulness returns a score in [0,1] where 1 means TwinBee's
|
|
// recommendations have been winning + succeeding (or losing + failing). Used
|
|
// by phase 3 betting odds.
|
|
func coopTwinBeeHelpfulness(window int) float64 {
|
|
d := db.Get()
|
|
rows, err := d.Query(`SELECT recommended, winning_vote, outcome
|
|
FROM coop_dungeon_events
|
|
WHERE outcome IS NOT NULL
|
|
ORDER BY rowid DESC LIMIT ?`, window)
|
|
if err != nil {
|
|
return 0.5
|
|
}
|
|
defer rows.Close()
|
|
hits, total := 0, 0
|
|
for rows.Next() {
|
|
var rec, win, out string
|
|
if err := rows.Scan(&rec, &win, &out); err != nil {
|
|
continue
|
|
}
|
|
total++
|
|
if rec == win && out == "success" {
|
|
hits++
|
|
} else if rec != win && out == "failure" {
|
|
hits++
|
|
}
|
|
}
|
|
if total == 0 {
|
|
return 0.5
|
|
}
|
|
return float64(hits) / float64(total)
|
|
}
|
|
|
|
func parseCoopVotes(s string) map[id.UserID]string {
|
|
out := map[id.UserID]string{}
|
|
if s == "" || s == "{}" {
|
|
return out
|
|
}
|
|
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 {
|
|
out[id.UserID(k)] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func serializeCoopVotes(m map[id.UserID]string) map[string]string {
|
|
out := make(map[string]string, len(m))
|
|
for k, v := range m {
|
|
out[string(k)] = v
|
|
}
|
|
return out
|
|
}
|