package storage import ( "crypto/rand" "database/sql" "encoding/base64" "errors" "fmt" "time" ) // The chip ledger and the euro/chip border. // // Chips are euros that have crossed into the casino. They are 1:1 with euros and // they are not a second wallet: every chip that exists came from a euro gogobee // debited, and every chip destroyed becomes a euro gogobee credits back. Pete // never writes a euro balance. The border is crossed only by a game_escrow row, // whose guid is the idempotency key gogobee hands to DebitIdem/CreditIdem — so a // claim whose acknowledgement is lost on the wire can be retried without the // player paying for it twice. // // The whole reason for the border is latency. gogobee has no inbound API and is // not getting one, so it polls; a bet that round-tripped through a poll loop // would take seconds to be dealt. Instead the poll loop runs twice per *session* // — buy in, cash out — and every hand in between plays against chips held here, // at full speed, with no economy call in the hot path. // MaxChipsOnTable caps how many chips a player can hold at once. A buy-in that // would push them over is refused before it ever reaches gogobee. // // This is the inflation brake. A web casino runs orders of magnitude more hands // per hour than a Matrix-paced one ever did, so whatever the house edge is, it // compounds far faster in both directions. The cap bounds the worst case for a // single sitting; the rake (see the blackjack engine) bleeds the rest back out. const MaxChipsOnTable int64 = 10_000 // EscrowStaleAfter is how long a claimed-but-unsettled escrow row waits before // the poll endpoint offers it again. gogobee can die between claiming a row and // pushing its result; without a re-offer, the player's money sits in limbo // forever. Re-claiming is safe precisely because the guid makes it idempotent. const EscrowStaleAfter = 90 * time.Second // SessionIdleAfter is when the reaper decides a player has walked away and cashes // their chips back to euros on their behalf. Chips in an abandoned session are // euros in limbo, and limbo is not a state a player's money should be in. const SessionIdleAfter = 30 * time.Minute // Escrow kinds and states. These strings cross the wire to gogobee, so they are // part of the contract — see §4 of pete_games_plan.md. const ( KindBuyIn = "buyin" KindCashOut = "cashout" EscrowRequested = "requested" // the player asked; gogobee hasn't seen it yet EscrowClaimed = "claimed" // gogobee has it and is moving the euros EscrowFunded = "funded" // buy-in landed; the chips are spendable EscrowRejected = "rejected" // buy-in refused; no chips, no euros moved EscrowSettled = "settled" // cash-out landed; chips destroyed, euros credited ) var ( ErrInsufficientChips = errors.New("games: not enough chips") ErrOverTableCap = errors.New("games: that would put more than the cap on the table") ErrBadAmount = errors.New("games: amount must be positive") ErrNoSuchEscrow = errors.New("games: no such escrow row") ) // Escrow is one crossing of the euro/chip border. type Escrow struct { GUID string `json:"guid"` MatrixUser string `json:"matrix_user"` Kind string `json:"kind"` Amount int64 `json:"amount"` State string `json:"state,omitempty"` Reason string `json:"reason,omitempty"` BalanceAfter float64 `json:"balance_after,omitempty"` CreatedAt int64 `json:"created_at,omitempty"` } // ChipStack is what a player has on the table right now. type ChipStack struct { Chips int64 // spendable // Pending is chips asked for but not yet funded — a buy-in gogobee hasn't // claimed or settled. Shown as "buying chips…", never spendable. Pending int64 EuroBalance float64 // advisory, from the last gogobee push; may be minutes stale LastPlayed int64 } // newGUID mints an escrow id. It's the idempotency key for a real money move, so // it comes from crypto/rand rather than anything a caller could collide with. func newGUID() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("games: mint guid: %w", err) } return base64.RawURLEncoding.EncodeToString(b), nil } // Chips reports a player's stack. A player who has never played has no row and // reads as an empty stack rather than an error. func Chips(user string) (ChipStack, error) { var st ChipStack var euro sql.NullFloat64 err := Get().QueryRow( `SELECT chips, euro_balance, last_played FROM game_chips WHERE matrix_user = ?`, user, ).Scan(&st.Chips, &euro, &st.LastPlayed) if err != nil && !errors.Is(err, sql.ErrNoRows) { return ChipStack{}, fmt.Errorf("games: read chips: %w", err) } st.EuroBalance = euro.Float64 if err := Get().QueryRow( `SELECT COALESCE(SUM(amount), 0) FROM game_escrow WHERE matrix_user = ? AND kind = ? AND state IN (?, ?)`, user, KindBuyIn, EscrowRequested, EscrowClaimed, ).Scan(&st.Pending); err != nil { return ChipStack{}, fmt.Errorf("games: read pending buy-ins: %w", err) } return st, nil } // RequestBuyIn opens a buy-in: the player wants `amount` euros turned into chips. // No chips exist yet — they appear only when gogobee confirms it took the euros. // The table cap is enforced here, against chips already held *plus* buy-ins still // in flight, so a player can't clear the cap by firing several at once. func RequestBuyIn(user string, amount int64) (Escrow, error) { if amount <= 0 { return Escrow{}, ErrBadAmount } st, err := Chips(user) if err != nil { return Escrow{}, err } if st.Chips+st.Pending+amount > MaxChipsOnTable { return Escrow{}, ErrOverTableCap } guid, err := newGUID() if err != nil { return Escrow{}, err } now := nowUnix() if _, err := Get().Exec( `INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, guid, user, KindBuyIn, amount, EscrowRequested, now, now, ); err != nil { return Escrow{}, fmt.Errorf("games: request buy-in: %w", err) } return Escrow{GUID: guid, MatrixUser: user, Kind: KindBuyIn, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil } // RequestCashOut opens a cash-out: chips are destroyed *now*, and the matching // euros arrive when gogobee claims the row. // // Destroying them up front is what keeps the invariant true. If the chips lingered // until gogobee confirmed, a player could bet them while the cash-out was in // flight and the same euro would exist on both sides of the border. If the credit // somehow fails, RefundCashOut puts the chips back. func RequestCashOut(user string, amount int64) (Escrow, error) { if amount <= 0 { return Escrow{}, ErrBadAmount } guid, err := newGUID() if err != nil { return Escrow{}, err } now := nowUnix() tx, err := Get().Begin() if err != nil { return Escrow{}, fmt.Errorf("games: begin cash-out: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op once committed // Conditional update: the chips leave only if they're actually there. res, err := tx.Exec( `UPDATE game_chips SET chips = chips - ?, updated_at = ? WHERE matrix_user = ? AND chips >= ?`, amount, now, user, amount, ) if err != nil { return Escrow{}, fmt.Errorf("games: debit chips: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return Escrow{}, ErrInsufficientChips } if _, err := tx.Exec( `INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, guid, user, KindCashOut, amount, EscrowRequested, now, now, ); err != nil { return Escrow{}, fmt.Errorf("games: request cash-out: %w", err) } if err := tx.Commit(); err != nil { return Escrow{}, fmt.Errorf("games: commit cash-out: %w", err) } return Escrow{GUID: guid, MatrixUser: user, Kind: KindCashOut, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil } // PendingEscrow is what gogobee's poll loop reads: everything waiting to be moved. // // It returns rows nobody has claimed, *and* rows claimed long enough ago that we // have to assume gogobee died holding them. Re-offering a claimed row is safe // because the guid is idempotent end to end: if gogobee already moved the euros, // the retry is a no-op that reports the same answer. func PendingEscrow(limit int) ([]Escrow, error) { if limit <= 0 { limit = 100 } stale := nowUnix() - int64(EscrowStaleAfter.Seconds()) rows, err := Get().Query( `SELECT guid, matrix_user, kind, amount, state, created_at FROM game_escrow WHERE state = ? OR (state = ? AND COALESCE(claimed_at, 0) < ?) ORDER BY created_at LIMIT ?`, EscrowRequested, EscrowClaimed, stale, limit, ) if err != nil { return nil, fmt.Errorf("games: pending escrow: %w", err) } defer rows.Close() var out []Escrow for rows.Next() { var e Escrow if err := rows.Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &e.CreatedAt); err != nil { return nil, fmt.Errorf("games: scan escrow: %w", err) } out = append(out, e) } return out, rows.Err() } // ClaimEscrow marks a row as taken by gogobee. Claiming is idempotent and is not // a lock: a row already claimed can be claimed again (that's how a stale re-offer // works), but a row already *finished* cannot be, which is what stops a settled // cash-out from being paid a second time. func ClaimEscrow(guid string) (Escrow, error) { now := nowUnix() res, err := Get().Exec( `UPDATE game_escrow SET state = ?, claimed_at = ?, updated_at = ? WHERE guid = ? AND state IN (?, ?)`, EscrowClaimed, now, now, guid, EscrowRequested, EscrowClaimed, ) if err != nil { return Escrow{}, fmt.Errorf("games: claim escrow: %w", err) } if n, _ := res.RowsAffected(); n == 0 { // Either it doesn't exist or it's already finished. Tell the caller which. e, err := EscrowByGUID(guid) if err != nil { return Escrow{}, err } return e, nil } return EscrowByGUID(guid) } // EscrowByGUID reads one row. func EscrowByGUID(guid string) (Escrow, error) { var e Escrow var reason sql.NullString var bal sql.NullFloat64 err := Get().QueryRow( `SELECT guid, matrix_user, kind, amount, state, reason, balance_after, created_at FROM game_escrow WHERE guid = ?`, guid, ).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &reason, &bal, &e.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return Escrow{}, ErrNoSuchEscrow } if err != nil { return Escrow{}, fmt.Errorf("games: read escrow: %w", err) } e.Reason, e.BalanceAfter = reason.String, bal.Float64 return e, nil } // SettleEscrow applies gogobee's verdict on a claimed row, and is the only place // chips are created or finally destroyed. // // buy-in, ok -> chips appear (funded) // buy-in, !ok -> nothing happens, nothing moved (rejected) // cash-out, ok -> chips stay destroyed, euros paid (settled) // cash-out, !ok -> chips come back (funded — the player never lost them) // // It is idempotent: gogobee's push queue retries, so the same verdict can arrive // more than once and only the first one may move chips. A row that has already // reached a terminal state is a no-op, not an error. func SettleEscrow(guid string, ok bool, reason string, balanceAfter float64) (Escrow, error) { now := nowUnix() tx, err := Get().Begin() if err != nil { return Escrow{}, fmt.Errorf("games: begin settle: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op once committed var e Escrow var st string if err := tx.QueryRow( `SELECT guid, matrix_user, kind, amount, state FROM game_escrow WHERE guid = ?`, guid, ).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &st); errors.Is(err, sql.ErrNoRows) { return Escrow{}, ErrNoSuchEscrow } else if err != nil { return Escrow{}, fmt.Errorf("games: settle lookup: %w", err) } // Terminal already — a retried push. Report what we decided the first time. if st == EscrowFunded || st == EscrowRejected || st == EscrowSettled { if err := tx.Commit(); err != nil { return Escrow{}, fmt.Errorf("games: commit settle: %w", err) } return EscrowByGUID(guid) } final := EscrowFunded switch { case e.Kind == KindBuyIn && ok: if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil { return Escrow{}, err } case e.Kind == KindBuyIn && !ok: final = EscrowRejected // gogobee took nothing, so we create nothing case e.Kind == KindCashOut && ok: final = EscrowSettled // the chips were destroyed when the row was opened case e.Kind == KindCashOut && !ok: // gogobee couldn't pay. The chips were already destroyed on our side, so // give them back rather than vanishing the player's money. if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil { return Escrow{}, err } } if _, err := tx.Exec( `UPDATE game_escrow SET state = ?, reason = ?, balance_after = ?, updated_at = ? WHERE guid = ?`, final, reason, balanceAfter, now, guid, ); err != nil { return Escrow{}, fmt.Errorf("games: settle update: %w", err) } // The euro balance gogobee just reported is the freshest one we'll get. // Advisory only — we display it, we never decide anything with it. if _, err := tx.Exec( `UPDATE game_chips SET euro_balance = ?, updated_at = ? WHERE matrix_user = ?`, balanceAfter, now, e.MatrixUser, ); err != nil { return Escrow{}, fmt.Errorf("games: cache euro balance: %w", err) } if err := tx.Commit(); err != nil { return Escrow{}, fmt.Errorf("games: commit settle: %w", err) } return EscrowByGUID(guid) } // addChips credits a stack inside an open transaction, creating the row if the // player has never held chips before. func addChips(tx *sql.Tx, user string, amount int64, now int64) error { if _, err := tx.Exec( `INSERT INTO game_chips (matrix_user, chips, last_played, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(matrix_user) DO UPDATE SET chips = chips + excluded.chips, updated_at = excluded.updated_at`, user, amount, now, now, ); err != nil { return fmt.Errorf("games: credit chips: %w", err) } return nil } // Stake takes chips off a player's stack to put them at risk on a hand. It is the // conditional-update kind of debit: the chips leave in the same statement that // checks they're there, so two hands opened at once can't spend the same chip. func Stake(user string, amount int64) error { if amount <= 0 { return ErrBadAmount } now := nowUnix() res, err := Get().Exec( `UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ? WHERE matrix_user = ? AND chips >= ?`, amount, now, now, user, amount, ) if err != nil { return fmt.Errorf("games: stake: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrInsufficientChips } return nil } // Award returns chips to a player when a hand settles: stake plus winnings, net // of rake, exactly as the engine computed it. A losing hand awards nothing and // should not call this. func Award(user string, amount int64) error { if amount <= 0 { return nil } now := nowUnix() if _, err := Get().Exec( `INSERT INTO game_chips (matrix_user, chips, last_played, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(matrix_user) DO UPDATE SET chips = chips + excluded.chips, last_played = excluded.last_played, updated_at = excluded.updated_at`, user, amount, now, now, ); err != nil { return fmt.Errorf("games: award chips: %w", err) } return nil } // Hand is one settled hand, as the audit log keeps it. type Hand struct { MatrixUser string Game string Bet int64 Payout int64 Rake int64 Outcome string Seed1 uint64 Seed2 uint64 } // RecordHand writes a finished hand to the audit trail. The seeds are the point: // with them, any hand in the log can be dealt again exactly as it fell, which is // how a dispute gets answered with a fact instead of an apology. func RecordHand(h Hand) error { if _, err := Get().Exec( `INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome, int64(h.Seed1), int64(h.Seed2), nowUnix(), ); err != nil { return fmt.Errorf("games: record hand: %w", err) } return nil } // IdleStacks lists players holding chips who stopped playing a while ago. The // reaper cashes these out on their behalf: chips in an abandoned session are // euros in limbo, and they should be back in the player's balance where they can // see them. func IdleStacks(idleFor time.Duration) ([]ChipStack, []string, error) { cutoff := nowUnix() - int64(idleFor.Seconds()) rows, err := Get().Query( `SELECT matrix_user, chips, last_played FROM game_chips WHERE chips > 0 AND last_played > 0 AND last_played < ?`, cutoff, ) if err != nil { return nil, nil, fmt.Errorf("games: idle stacks: %w", err) } defer rows.Close() var stacks []ChipStack var users []string for rows.Next() { var st ChipStack var user string if err := rows.Scan(&user, &st.Chips, &st.LastPlayed); err != nil { return nil, nil, fmt.Errorf("games: scan idle stack: %w", err) } stacks = append(stacks, st) users = append(users, user) } return stacks, users, rows.Err() } // ReapIdleSessions cashes out everyone who walked away, and reports how many it // sent home. Safe to run on a timer: a player who comes back simply buys in again, // and a cash-out that's already in flight can't be opened twice because the chips // are gone from the stack the moment the first one is. func ReapIdleSessions(idleFor time.Duration) (int, error) { stacks, users, err := IdleStacks(idleFor) if err != nil { return 0, err } reaped := 0 for i, user := range users { if _, err := RequestCashOut(user, stacks[i].Chips); err != nil { // One player's stack failing to reap shouldn't strand everyone else's. if !errors.Is(err, ErrInsufficientChips) { return reaped, fmt.Errorf("games: reap %s: %w", user, err) } continue } reaped++ } return reaped, nil } // Touch marks a player as active, so the reaper leaves them alone. Called on any // deliberate action at a table — not on a page load, or an open tab would keep a // walked-away player's chips hostage forever. func Touch(user string) { exec("games: touch session", `UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`, nowUnix(), nowUnix(), user) } // HouseTake is the total rake collected since a given time — the number that // answers "is this economy inflating". func HouseTake(since int64) (int64, error) { var total int64 if err := Get().QueryRow( `SELECT COALESCE(SUM(rake), 0) FROM game_hands WHERE played_at >= ?`, since, ).Scan(&total); err != nil { return 0, fmt.Errorf("games: house take: %w", err) } return total, nil }