Phase B foundation for the multiplayer casino: the shared-table storage layer, the SSE fan-out, and the lock that only ever pretends to be the authority. - game_tables/game_seats/game_chat, plus a nullable table_id on game_live_hands so occupancy stays one row per player — the same primary key that stops a second solo hand stops a second seat. No second uniqueness domain, no split brain, no cash-out-to-zero while sitting on a pot. - The money model the plan sketched turned out simpler than it drew: chips cross the border only at sit-down and get-up, so a hand settles by moving the pot *within* the state blob and credits nobody. That deletes the payout ledger the design called for — there is no money write to make idempotent, only a state write conditional on the version. A replayed settle affects zero rows. - CommitTable/SitDown/LeaveTable each one transaction with the state write in it; the version column is the concurrency authority and the striped in-memory lock is only an optimisation over it, because a mutex does not survive a redeploy. - The SSE hub is a dumb byte fan-out: non-blocking sends (a stalled phone must not hold the table lock and freeze the clock for the room) and never a DB touch after the first read (holding the one connection open bricks the app). - DueTables/PushDeadlines for the turn clock to come; Chat keeps the hand_no it was said during, because at a money table collusion looks like chat. Storage and hub tested, including the version race and the never-block publish. No handlers wired yet, so nothing a player can see has changed. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
773 lines
28 KiB
Go
773 lines
28 KiB
Go
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.
|
|
//
|
|
// This is the standalone form, for a caller with no transaction of its own. A
|
|
// settle must not use it — see award, and the warning on CommitHand.
|
|
func Award(user string, amount int64) error {
|
|
if amount <= 0 {
|
|
return nil
|
|
}
|
|
tx, err := Get().Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("games: begin award: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
|
|
|
if err := award(tx, user, amount, nowUnix()); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("games: commit award: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// award credits a stack inside an open transaction.
|
|
//
|
|
// It differs from addChips in one deliberate way: it moves last_played, because
|
|
// being paid is something that happens at a table and the reaper should see it.
|
|
// A buy-in is not — that is why addChips leaves the idle clock alone.
|
|
func award(tx *sql.Tx, user string, amount int64, now int64) error {
|
|
if amount <= 0 {
|
|
return nil
|
|
}
|
|
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, 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 {
|
|
tx, err := Get().Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("games: begin record hand: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
|
|
|
if err := recordHand(tx, h, nowUnix()); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("games: commit record hand: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recordHand writes the audit row inside an open transaction.
|
|
func recordHand(tx *sql.Tx, h Hand, now int64) error {
|
|
if _, err := tx.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), now,
|
|
); 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)
|
|
}
|
|
|
|
// ---- the hand in progress -------------------------------------------------
|
|
|
|
var (
|
|
// ErrNoLiveHand means the player isn't in a hand right now.
|
|
ErrNoLiveHand = errors.New("games: no hand in progress")
|
|
// ErrHandInProgress means they already are, and may not be dealt another.
|
|
ErrHandInProgress = errors.New("games: already in a hand")
|
|
)
|
|
|
|
// LiveHand is a hand a player is in the middle of. State is the engine's own
|
|
// State, serialized whole — the shoe is in there, which is exactly why this row
|
|
// never leaves the server.
|
|
type LiveHand struct {
|
|
Game string
|
|
State []byte
|
|
Seed1 uint64
|
|
Seed2 uint64
|
|
// TableID is set when the player is sitting at a shared table instead of playing
|
|
// alone. The cards are then in game_tables and State here is empty: this row is
|
|
// the occupancy claim and nothing else. One row per player either way, which is
|
|
// the point — the primary key that stops a second solo hand is the same one that
|
|
// stops a second seat.
|
|
TableID string
|
|
}
|
|
|
|
// StartLiveHand seats a *new* hand, and refuses if the player is already in one.
|
|
// The plain INSERT is the point: it is the primary key, not a prior read, that
|
|
// decides. Two Deal clicks racing each other would otherwise both see an empty
|
|
// felt, both take a stake, and the second would overwrite the first — taking the
|
|
// player's chips for a hand that no longer exists anywhere.
|
|
func StartLiveHand(user string, h LiveHand) error {
|
|
res, err := Get().Exec(
|
|
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(matrix_user) DO NOTHING`,
|
|
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), nowUnix(),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("games: start live hand: %w", err)
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrHandInProgress
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SaveLiveHand stores the hand a player is in, replacing any earlier one. The
|
|
// player's stake has already left their stack by the time this is called, so
|
|
// the write is what makes the hand recoverable if Pete restarts mid-deal.
|
|
func SaveLiveHand(user string, h LiveHand) error {
|
|
now := nowUnix()
|
|
if _, err := Get().Exec(
|
|
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(matrix_user) DO UPDATE SET
|
|
game = excluded.game, state = excluded.state,
|
|
seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
|
|
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), now,
|
|
); err != nil {
|
|
return fmt.Errorf("games: save live hand: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoadLiveHand returns the hand a player is in, or ErrNoLiveHand.
|
|
func LoadLiveHand(user string) (LiveHand, error) {
|
|
var h LiveHand
|
|
var state string
|
|
var s1, s2 int64
|
|
var tableID sql.NullString
|
|
err := Get().QueryRow(
|
|
`SELECT game, state, seed1, seed2, table_id FROM game_live_hands WHERE matrix_user = ?`, user,
|
|
).Scan(&h.Game, &state, &s1, &s2, &tableID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return LiveHand{}, ErrNoLiveHand
|
|
}
|
|
if err != nil {
|
|
return LiveHand{}, fmt.Errorf("games: load live hand: %w", err)
|
|
}
|
|
h.State, h.Seed1, h.Seed2, h.TableID = []byte(state), uint64(s1), uint64(s2), tableID.String
|
|
return h, nil
|
|
}
|
|
|
|
// ClearLiveHand ends a hand. Called when it settles — the audit log in
|
|
// game_hands is what survives it.
|
|
func ClearLiveHand(user string) error {
|
|
if _, err := Get().Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
|
|
return fmt.Errorf("games: clear live hand: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- the settle ------------------------------------------------------------
|
|
|
|
// Commit is one write-back of a game: the state, and — if the game is over —
|
|
// everything settling it takes.
|
|
type Commit struct {
|
|
Live LiveHand
|
|
Fresh bool // a game just started, which is the one write that may be refused
|
|
|
|
// Stake is what the player put up to open this game. It is refunded, in this
|
|
// same transaction, if the seat turns out to be taken. Only meaningful with
|
|
// Fresh.
|
|
Stake int64
|
|
|
|
Done bool
|
|
Payout int64 // stake plus winnings, net of rake. Zero on a loss.
|
|
Audit Hand // the audit row. Ignored unless Done.
|
|
}
|
|
|
|
// CommitHand writes a game back and settles it if it is over — all of it in one
|
|
// transaction.
|
|
//
|
|
// It used to be four separate autocommit statements (save, award, record,
|
|
// clear), which was survivable while a game belonged to exactly one player: the
|
|
// ordering paid first and cleared second, so a crash in between left a settled
|
|
// game on the felt, which reads as done and can be cleared. It does not survive
|
|
// a game with a pot in it. Pay the winner, die before the state write, and the
|
|
// table still says the hand is live — so it settles a second time and the winner
|
|
// is paid twice. Chips minted from nothing, and gogobee will happily turn them
|
|
// into euros.
|
|
//
|
|
// So: one Begin, one Commit, and the money and the state move together or not at
|
|
// all.
|
|
//
|
|
// The rule this enforces, and the reason award/recordHand exist in tx-taking
|
|
// form at all: **nothing inside here may call Get().Exec**. The pool runs at
|
|
// MaxOpenConns(1), so a bare Exec inside an open transaction waits for the one
|
|
// connection that this transaction is holding — forever. It is not an error, it
|
|
// is a hung process, and since the news app shares the pool it takes that down
|
|
// too. The tx-taking helper is the pattern; addChips has done it this way since
|
|
// the escrow ledger was written.
|
|
func CommitHand(user string, c Commit) error {
|
|
now := nowUnix()
|
|
|
|
tx, err := Get().Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("games: begin commit: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
|
|
|
// Seat the game first, even one that is already over — a blackjack natural
|
|
// settles the instant it is dealt. The INSERT is what enforces one game at a
|
|
// time, and it has to happen for *every* new one, or a natural dealt on top of
|
|
// a game already in progress would settle, clear the felt, and take the other
|
|
// game's stake with it.
|
|
if c.Fresh {
|
|
res, err := tx.Exec(
|
|
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(matrix_user) DO NOTHING`,
|
|
user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("games: start live hand: %w", err)
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
// Somebody is already sitting here. This game was never seated, so the
|
|
// chips it staked go back — and they go back *in this transaction*, which
|
|
// is the point. As two statements, a crash between the refusal and the
|
|
// refund took the player's stake for a game that never existed anywhere.
|
|
if err := award(tx, user, c.Stake, now); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("games: commit refund: %w", err)
|
|
}
|
|
return ErrHandInProgress
|
|
}
|
|
} else if _, err := tx.Exec(
|
|
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(matrix_user) DO UPDATE SET
|
|
game = excluded.game, state = excluded.state,
|
|
seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
|
|
user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
|
|
); err != nil {
|
|
return fmt.Errorf("games: save live hand: %w", err)
|
|
}
|
|
|
|
if c.Done {
|
|
if err := award(tx, user, c.Payout, now); err != nil {
|
|
return err
|
|
}
|
|
// The audit row is now inside the transaction with the payout, which means a
|
|
// failure to write it rolls the payout back rather than paying quietly and
|
|
// logging. That is a deliberate change: the two are the same fact, and a
|
|
// payout nobody can account for is worse than a payout that didn't happen —
|
|
// the game stays live and settles again on the next request.
|
|
if err := recordHand(tx, c.Audit, now); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
|
|
return fmt.Errorf("games: clear live hand: %w", err)
|
|
}
|
|
}
|
|
|
|
// Touch, folded in: a deliberate action at a table, so the reaper leaves them
|
|
// alone. A player with no chip row yet has nothing to touch, and this is a
|
|
// no-op for them.
|
|
if _, err := tx.Exec(
|
|
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
|
now, now, user,
|
|
); err != nil {
|
|
return fmt.Errorf("games: touch session: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("games: commit hand: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|