package storage import ( "crypto/rand" "database/sql" "encoding/base64" "errors" "fmt" "strings" ) // Shared tables: the casino with more than one person at it. // // The money model is the thing to understand first, because everything else // follows from it, and it is *not* the one the plan sketched. // // **Chips cross into a table when you sit down, and back out when you get up. // Nothing in between touches game_chips at all.** Your buy-in leaves your stack // and becomes a stack inside the engine's own state; antes, bets, pots and // payouts are all moves within that blob; and getting up is the single write // that turns what is in front of you back into chips. // // This is how hold'em already worked as a solo session, and generalising it is // what makes a pot safe. The obvious alternative — settle each hand by crediting // every winner's game_chips row — puts a real money write on the end of every // hand, and a crash between the credit and the state write pays the winner twice. // Here a settle credits nobody: it is a state write, conditional on the version, // and a replay of it affects zero rows and rolls back. The pot cannot be paid // twice because it is never *paid* at all, only moved. // // So the money invariant across a table's whole life is: // // sum(seat stacks in the blob) + pot == sum(game_seats.staked) - rake taken // // and the only two statements that move chips across the border are the stake in // SitDown and the award in LeaveTable — each of them one transaction, each of // them carrying the state write that justifies it. // // The other half is concurrency. A table has two writers where a solo game had // one: an HTTP move, and a turn clock acting for whoever walked away. The version // column is the authority — every state write is conditional on the version its // writer read — and the striped mutex in the web layer is only an optimisation on // top. Correctness has to live in the database, because a mutex does not survive // a redeploy: during a drain, two processes hold two different mutexes over the // same row and both of them believe they are alone. var ( // ErrStaleTable means somebody else wrote the table first. The caller's read is // out of date; it must reload and decide again. This is not an error condition // so much as the normal outcome of a race, and it is what a 409 is made of. ErrStaleTable = errors.New("games: the table moved on") // ErrNoSuchTable means there is no table with that id. ErrNoSuchTable = errors.New("games: no such table") // ErrSeatTaken means somebody sat down there between the read and the write. ErrSeatTaken = errors.New("games: that seat is taken") ) // Table is a felt other people can sit at. // // State is the engine's State, serialized whole — the same blob game_live_hands // holds for a solo game, and for the same reason: the deck is in it, so it never // leaves the server, and a hand survives a redeploy. type Table struct { ID string Game string Tier string State []byte Seed1 uint64 Seed2 uint64 Phase string // HandNo, with the id, is the identity of one hand. It is what the audit trail // keys on now that a seed no longer reproduces a hand: at a shared table the // cards fall the way they do because of the order the others acted, not just // the seed, so "deal it again from seed1/seed2" stopped being a true story the // moment there was a second player. HandNo int64 // Version is the concurrency authority. Read it, write against it, and a write // that finds it moved is a write that lost the race. Version int64 // Deadline is the unix second by which the seat to act has to act, or 0 for no // clock at all. Only a *human* to act sets one: bots resolve inside ApplyMove // and there is nobody to wait for. Deadline int64 CreatedAt int64 UpdatedAt int64 } // Seat is one chair. A seat with no MatrixUser is a bot, which is what makes solo // play just "a table nobody else has joined yet" rather than a second mode. type Seat struct { Seat int MatrixUser string // "" for a bot Name string // Staked is what this player brought and has not yet taken home. The chips are // off their game_chips stack and inside the table blob, where the idle reaper // cannot see them — so this is the row that says they exist. Staked int64 Away bool LastSeen int64 } // Bot reports whether nobody is sitting here. func (s Seat) Bot() bool { return s.MatrixUser == "" } // NewTableID mints a table id. Short enough to put in a URL, random enough that // nobody guesses their way onto somebody else's felt. func NewTableID() (string, error) { b := make([]byte, 9) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("games: mint table id: %w", err) } return base64.RawURLEncoding.EncodeToString(b), nil } // OpenTable creates a table and seats it — bots in every chair nobody has taken. func OpenTable(t Table, seats []Seat) error { now := nowUnix() tx, err := Get().Begin() if err != nil { return fmt.Errorf("games: begin open table: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op once committed if _, err := tx.Exec( `INSERT INTO game_tables (id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, t.ID, t.Game, t.Tier, string(t.State), int64(t.Seed1), int64(t.Seed2), t.Phase, t.HandNo, t.Deadline, now, now, ); err != nil { return fmt.Errorf("games: open table: %w", err) } for _, s := range seats { if err := upsertSeat(tx, t.ID, s, now); err != nil { return err } } if err := tx.Commit(); err != nil { return fmt.Errorf("games: commit open table: %w", err) } return nil } // OpenSoloTable opens a table with the player already sitting at it — the "solo // is just a table nobody else has joined yet" path. It is SitDown and OpenTable // fused into one transaction: stake the buy-in, claim the occupancy row, create // the table, and seat everyone (the human, and the bots filling the rest of the // ring). Any step failing rolls the buy-in back with it, so a crash never leaves // a player charged for a felt that does not exist. // // The occupancy claim is the same primary key that stops a second solo hand, so a // player already at a table (or in another game) is refused here with // ErrHandInProgress and their buy-in returned untouched. func OpenSoloTable(t Table, seats []Seat, buyIn int64) error { if buyIn <= 0 { return ErrBadAmount } // The human seat is the one row that is not a bot; its player claims the table. var user, name string for _, s := range seats { if !s.Bot() { user, name = s.MatrixUser, s.Name break } } if user == "" { return ErrBadAmount // a solo table with no human is a bug, not a table } now := nowUnix() tx, err := Get().Begin() if err != nil { return fmt.Errorf("games: begin open solo: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op once committed res, err := tx.Exec( `UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ? WHERE matrix_user = ? AND chips >= ?`, buyIn, now, now, user, buyIn, ) if err != nil { return fmt.Errorf("games: stake solo buy-in: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrInsufficientChips } res, err = tx.Exec( `INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, table_id, updated_at) VALUES (?, ?, '', 0, 0, ?, ?) ON CONFLICT(matrix_user) DO NOTHING`, user, t.Game, t.ID, now, ) if err != nil { return fmt.Errorf("games: claim solo seat: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrHandInProgress } _ = name if _, err := tx.Exec( `INSERT INTO game_tables (id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, t.ID, t.Game, t.Tier, string(t.State), int64(t.Seed1), int64(t.Seed2), t.Phase, t.HandNo, t.Deadline, now, now, ); err != nil { return fmt.Errorf("games: open solo table: %w", err) } for _, sc := range seats { if err := upsertSeat(tx, t.ID, sc, now); err != nil { return err } } if err := tx.Commit(); err != nil { return fmt.Errorf("games: commit open solo: %w", err) } return nil } // upsertSeat writes a seat row inside an open transaction, bots included. // // last_seen is the caller's if they set one, and only falls back to now when they // did not. That distinction is load-bearing: the turn clock rewrites a seat to // mark it away, and it must carry the seat's *existing* last_seen through // unchanged — otherwise every auto-fold refreshes the away player's clock, and // the abandoned-table reaper (which keys on how long ago a human last acted for // themselves) could never fire. func upsertSeat(tx *sql.Tx, tableID string, s Seat, now int64) error { var user any if s.MatrixUser != "" { user = s.MatrixUser } seen := s.LastSeen if seen == 0 { seen = now } if _, err := tx.Exec( `INSERT INTO game_seats (table_id, seat, matrix_user, name, staked, away, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(table_id, seat) DO UPDATE SET matrix_user = excluded.matrix_user, name = excluded.name, staked = excluded.staked, away = excluded.away, last_seen = excluded.last_seen`, tableID, s.Seat, user, s.Name, s.Staked, boolInt(s.Away), seen, ); err != nil { return fmt.Errorf("games: seat: %w", err) } return nil } func boolInt(b bool) int64 { if b { return 1 } return 0 } // LoadTable reads a table and everyone at it. func LoadTable(id string) (Table, []Seat, error) { var t Table var state string var s1, s2 int64 err := Get().QueryRow( `SELECT id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at FROM game_tables WHERE id = ?`, id, ).Scan(&t.ID, &t.Game, &t.Tier, &state, &s1, &s2, &t.Phase, &t.HandNo, &t.Version, &t.Deadline, &t.CreatedAt, &t.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return Table{}, nil, ErrNoSuchTable } if err != nil { return Table{}, nil, fmt.Errorf("games: load table: %w", err) } t.State, t.Seed1, t.Seed2 = []byte(state), uint64(s1), uint64(s2) seats, err := tableSeats(id) if err != nil { return Table{}, nil, err } return t, seats, nil } // tableSeats reads the chairs, in seat order. func tableSeats(id string) ([]Seat, error) { rows, err := Get().Query( `SELECT seat, matrix_user, name, staked, away, last_seen FROM game_seats WHERE table_id = ? ORDER BY seat`, id) if err != nil { return nil, fmt.Errorf("games: table seats: %w", err) } defer rows.Close() var out []Seat for rows.Next() { var s Seat var user sql.NullString var away int64 if err := rows.Scan(&s.Seat, &user, &s.Name, &s.Staked, &away, &s.LastSeen); err != nil { return nil, fmt.Errorf("games: scan seat: %w", err) } s.MatrixUser, s.Away = user.String, away != 0 out = append(out, s) } return out, rows.Err() } // TableSummary is a table as the lobby lists it: enough to decide whether to sit // down, and nothing that would give away a card. type TableSummary struct { ID string `json:"id"` Game string `json:"game"` Tier string `json:"tier"` Phase string `json:"phase"` Humans int `json:"humans"` Seats int `json:"seats"` UpdatedAt int64 `json:"updated_at"` } // LobbyTables lists the live tables, most recently played first. A game of "" is // all of them. func LobbyTables(game string, limit int) ([]TableSummary, error) { if limit <= 0 { limit = 50 } q := `SELECT t.id, t.game, t.tier, t.phase, t.updated_at, COUNT(s.seat), COUNT(s.matrix_user) FROM game_tables t LEFT JOIN game_seats s ON s.table_id = t.id` args := []any{} if game != "" { q += ` WHERE t.game = ?` args = append(args, game) } q += ` GROUP BY t.id ORDER BY t.updated_at DESC LIMIT ?` args = append(args, limit) rows, err := Get().Query(q, args...) if err != nil { return nil, fmt.Errorf("games: lobby: %w", err) } defer rows.Close() var out []TableSummary for rows.Next() { var s TableSummary if err := rows.Scan(&s.ID, &s.Game, &s.Tier, &s.Phase, &s.UpdatedAt, &s.Seats, &s.Humans); err != nil { return nil, fmt.Errorf("games: scan lobby row: %w", err) } out = append(out, s) } return out, rows.Err() } // ---- writing a table back -------------------------------------------------- // TableCommit is one write-back of a shared table. // // There is no payout field, and its absence is the design. A hand ending at a // shared table moves chips *within* the blob — the pot becomes somebody's stack — // so settling one credits nobody and mints nothing. What it writes is the state, // the audit rows, and whatever the seats now look like. The version makes it // exactly-once: a settle that runs twice loses the race with itself. type TableCommit struct { // Table carries the new state and the version that was *read*. The write is // conditional on that version and bumps it. Table Table // Seats to rewrite — a stack that changed hands, a seat that came back from // away. Seats not named here are left alone. Seats []Seat // Audit is the per-seat record of a hand that just ended. Empty mid-hand. Audit []Hand } // CommitTable writes a table back, and the hand it just finished with it, in one // transaction. // // The version check is the whole safety property, and it is why this can be // called by the turn clock and an HTTP move at the same instant without either // having to trust the other. Whoever gets there first bumps the version; the // loser's UPDATE matches zero rows, the transaction rolls back, and it comes back // ErrStaleTable with nothing written — no half-settled hand, no audit row for a // hand that did not happen. // // Nothing in here may call Get().Exec. The pool runs at MaxOpenConns(1), so a // bare Exec inside an open transaction waits forever for the connection that this // transaction is holding — and takes the news app down with it. See CommitHand. func CommitTable(c TableCommit) error { now := nowUnix() tx, err := Get().Begin() if err != nil { return fmt.Errorf("games: begin commit table: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op once committed if err := saveTable(tx, c.Table, now); err != nil { return err } for _, s := range c.Seats { if err := upsertSeat(tx, c.Table.ID, s, now); err != nil { return err } } for _, h := range c.Audit { if err := recordHand(tx, h, now); err != nil { return err } // Playing a hand is the most deliberate thing a player does. Keep the reaper // off them — their chips are inside the table blob, where it cannot see them // anyway, but their game_chips row is what it reads. if h.MatrixUser == "" { continue } if _, err := tx.Exec( `UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`, now, now, h.MatrixUser, ); err != nil { return fmt.Errorf("games: touch session: %w", err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("games: commit table: %w", err) } return nil } // saveTable is the conditional state write: it lands only if the version is still // the one the caller read. func saveTable(tx *sql.Tx, t Table, now int64) error { res, err := tx.Exec( `UPDATE game_tables SET state = ?, phase = ?, hand_no = ?, seed1 = ?, seed2 = ?, deadline = ?, version = version + 1, updated_at = ? WHERE id = ? AND version = ?`, string(t.State), t.Phase, t.HandNo, int64(t.Seed1), int64(t.Seed2), t.Deadline, now, t.ID, t.Version, ) if err != nil { return fmt.Errorf("games: save table: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrStaleTable } return nil } // ---- sitting down and getting up ------------------------------------------- // Sit is one player taking one chair, with the table state that has them in it. type Sit struct { Table Table // the new state, and the version that was read Seat Seat // MatrixUser, Name and the chair; Staked is the buy-in BuyIn int64 } // SitDown moves a player's chips onto a table and puts them in a seat — the first // of the only two statements in the casino that cross the chip/table border. // // It is one transaction and every step of it can refuse: // // - the chips leave in the same statement that checks they are there, so two // joins fired at once cannot spend the same chip; // - the occupancy claim is game_live_hands' primary key, exactly as it is for a // solo hand, so a player cannot be at two tables (or at a table and in a solo // game) at once, and a double-clicked Join is a 409; // - the seat is taken only if a bot is sitting in it, so two players racing for // the last chair cannot both win; // - and the state write is conditional on the version, so the engine's idea of // who is at the table cannot drift from the seat rows. // // Any of those failing rolls back the buy-in with it. func SitDown(s Sit) error { if s.BuyIn <= 0 { return ErrBadAmount } now := nowUnix() tx, err := Get().Begin() if err != nil { return fmt.Errorf("games: begin sit: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op once committed res, err := tx.Exec( `UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ? WHERE matrix_user = ? AND chips >= ?`, s.BuyIn, now, now, s.Seat.MatrixUser, s.BuyIn, ) if err != nil { return fmt.Errorf("games: stake buy-in: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrInsufficientChips } // The occupancy claim. The state column is empty on purpose: the cards live in // game_tables, and this row exists to be a primary key. res, err = tx.Exec( `INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, table_id, updated_at) VALUES (?, ?, '', 0, 0, ?, ?) ON CONFLICT(matrix_user) DO NOTHING`, s.Seat.MatrixUser, s.Table.Game, s.Table.ID, now, ) if err != nil { return fmt.Errorf("games: claim seat: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrHandInProgress } // Take the chair, but only out of a bot's hands. res, err = tx.Exec( `UPDATE game_seats SET matrix_user = ?, name = ?, staked = ?, away = 0, last_seen = ? WHERE table_id = ? AND seat = ? AND matrix_user IS NULL`, s.Seat.MatrixUser, s.Seat.Name, s.BuyIn, now, s.Table.ID, s.Seat.Seat, ) if err != nil { return fmt.Errorf("games: take seat: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrSeatTaken } if err := saveTable(tx, s.Table, now); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("games: commit sit: %w", err) } return nil } // Leave is one player getting up, with the table state that no longer has them. type Leave struct { Table Table // the new state, and the version that was read Seat int MatrixUser string // Bot is who takes the chair over. A table always has a full complement, so // getting up hands the seat back to the house rather than leaving a hole. Bot string // Amount is what is in front of them: everything they are taking home. It may // be more than they brought, or nothing at all. Amount int64 // Audit, if the leaving itself settles something worth recording. Audit []Hand } // LeaveTable turns what is in front of a player back into chips — the second and // last statement that crosses the chip/table border. // // One transaction, and the state write is in it. As two statements this is a // double-pay waiting to happen: award 1,240 chips, fail the state write, and the // player reloads to find their seat still there with 1,240 in front of it. They // get up again, and again. func LeaveTable(l Leave) error { now := nowUnix() tx, err := Get().Begin() if err != nil { return fmt.Errorf("games: begin leave: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op once committed if err := saveTable(tx, l.Table, now); err != nil { return err } if err := upsertSeat(tx, l.Table.ID, Seat{Seat: l.Seat, Name: l.Bot}, now); err != nil { return err } if _, err := tx.Exec( `DELETE FROM game_live_hands WHERE matrix_user = ? AND table_id = ?`, l.MatrixUser, l.Table.ID, ); err != nil { return fmt.Errorf("games: release seat claim: %w", err) } if err := award(tx, l.MatrixUser, l.Amount, now); err != nil { return err } for _, h := range l.Audit { if err := recordHand(tx, h, now); err != nil { return err } } if err := tx.Commit(); err != nil { return fmt.Errorf("games: commit leave: %w", err) } return nil } // PlayerSeat reports the table and chair a player is sitting at. It reads the // seat row, which sit and leave keep in lockstep with the occupancy claim in one // transaction, so a row here means a live-hand row there and vice versa. func PlayerSeat(user string) (tableID string, seat int, err error) { err = Get().QueryRow( `SELECT table_id, seat FROM game_seats WHERE matrix_user = ?`, user, ).Scan(&tableID, &seat) if errors.Is(err, sql.ErrNoRows) { return "", 0, ErrNoLiveHand } if err != nil { return "", 0, fmt.Errorf("games: player seat: %w", err) } return tableID, seat, nil } // TableOf reports which table a player is sitting at, if any. Read off the // occupancy claim, so it agrees with the cash-out check by construction. func TableOf(user string) (string, error) { var id sql.NullString err := Get().QueryRow( `SELECT table_id FROM game_live_hands WHERE matrix_user = ?`, user, ).Scan(&id) if errors.Is(err, sql.ErrNoRows) { return "", ErrNoLiveHand } if err != nil { return "", fmt.Errorf("games: table of: %w", err) } return id.String, nil } // CloseTable deletes a table nobody is sitting at. Called when the last human // gets up: a felt with six bots on it and nobody watching is not a game, it is a // row that the lobby would advertise forever. func CloseTable(id string) error { tx, err := Get().Begin() if err != nil { return fmt.Errorf("games: begin close table: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op once committed var humans int if err := tx.QueryRow( `SELECT COUNT(*) FROM game_seats WHERE table_id = ? AND matrix_user IS NOT NULL`, id, ).Scan(&humans); err != nil { return fmt.Errorf("games: count humans: %w", err) } if humans > 0 { return nil // somebody is still playing; the table stays } for _, q := range []string{ `DELETE FROM game_seats WHERE table_id = ?`, `DELETE FROM game_chat WHERE table_id = ?`, `DELETE FROM game_tables WHERE id = ?`, } { if _, err := tx.Exec(q, id); err != nil { return fmt.Errorf("games: close table: %w", err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("games: commit close table: %w", err) } return nil } // AbandonedTables lists tables everyone walked away from: every human seat is // away, and the most recent one acted for themselves longer ago than the cutoff. // // It is the seated-player half of the reaper. The session reaper cashes out loose // chips on a game_chips stack; it cannot see a player whose chips are inside a // table blob, and those are exactly the chips a walked-away poker player has. So // this finds the tables where nobody is coming back and hands them to ReapTable. // // A table with a live hand is never abandoned in this sense — the turn clock is // still folding it forward — so only tables parked between hands qualify. Like // DueTables it closes its rows before returning, because the caller is about to // take a lock and open a transaction against the one connection. func AbandonedTables(cutoff int64) ([]TableRef, error) { rows, err := Get().Query( `SELECT t.id, t.version FROM game_tables t WHERE t.phase = 'handover' AND EXISTS (SELECT 1 FROM game_seats s WHERE s.table_id = t.id AND s.matrix_user IS NOT NULL) AND NOT EXISTS (SELECT 1 FROM game_seats s WHERE s.table_id = t.id AND s.matrix_user IS NOT NULL AND (s.away = 0 OR s.last_seen >= ?))`, cutoff, ) if err != nil { return nil, fmt.Errorf("games: abandoned tables: %w", err) } defer rows.Close() var out []TableRef for rows.Next() { var r TableRef if err := rows.Scan(&r.ID, &r.Version); err != nil { return nil, fmt.Errorf("games: scan abandoned table: %w", err) } out = append(out, r) } return out, rows.Err() } // Reap is one abandoned table being cashed out and closed. Stacks is what each // human seat has in front of it, read from the engine blob by the caller — the // only game-specific fact the reaper needs, since the chips-home number lives // inside a state only the engine can decode. type Reap struct { TableID string Version int64 // Humans is the seats to cash out, each paired with the stack going home. A // seat's Amount may be zero (they busted and never got up), which still has to // close their occupancy row so they can play again. Humans []ReapSeat } // ReapSeat is one human being sent home from an abandoned table. type ReapSeat struct { Seat int MatrixUser string Amount int64 } // ReapTable cashes out every human at an abandoned table and deletes it, in one // transaction, conditional on the version so it cannot race a player who came // back to the felt in the same instant. // // It is LeaveTable and CloseTable fused: award each stack, release each occupancy // claim, then drop the seats, chat and table. The version guard is what makes it // safe against a returning player — if their sit or move bumped the version // between the scan and here, every row matches zero and the whole thing rolls // back, leaving the table exactly as the returning player left it. func ReapTable(r Reap) error { now := nowUnix() tx, err := Get().Begin() if err != nil { return fmt.Errorf("games: begin reap: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op once committed // Bump the version first, and refuse if it moved. Nothing below is conditional, // so this one check has to stand for the whole reap. res, err := tx.Exec( `UPDATE game_tables SET version = version + 1, updated_at = ? WHERE id = ? AND version = ?`, now, r.TableID, r.Version, ) if err != nil { return fmt.Errorf("games: reap bump version: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrStaleTable } for _, h := range r.Humans { if h.Amount > 0 { if err := award(tx, h.MatrixUser, h.Amount, now); err != nil { return err } } if _, err := tx.Exec( `DELETE FROM game_live_hands WHERE matrix_user = ? AND table_id = ?`, h.MatrixUser, r.TableID, ); err != nil { return fmt.Errorf("games: reap release claim: %w", err) } } for _, q := range []string{ `DELETE FROM game_seats WHERE table_id = ?`, `DELETE FROM game_chat WHERE table_id = ?`, `DELETE FROM game_tables WHERE id = ?`, } { if _, err := tx.Exec(q, r.TableID); err != nil { return fmt.Errorf("games: reap close: %w", err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("games: commit reap: %w", err) } return nil } // ---- the turn clock -------------------------------------------------------- // TableRef is a table the clock has found expired: which one, and at what version // it was seen. // // The version is the point. The clock acts only if it is *still* that version by // the time it takes the lock, because otherwise: Bob's raise lands in the same // second his clock expires, the action passes to Cara, and the clock — still // holding its scan-time belief that the seat to act has run out of time — folds // Cara, who had twenty-five seconds left. That is a one-second window that recurs // on every single turn of every hand. type TableRef struct { ID string Version int64 } // DueTables lists the tables whose clock has run out. // // It closes the rows before returning, and it must: the caller is about to take a // table lock and open a transaction, and holding a *sql.Rows across that means // holding the only connection in the pool while waiting for it. That is not a // slow query, it is a deadlock. func DueTables(now int64) ([]TableRef, error) { rows, err := Get().Query( `SELECT id, version FROM game_tables WHERE deadline > 0 AND deadline <= ?`, now) if err != nil { return nil, fmt.Errorf("games: due tables: %w", err) } defer rows.Close() var out []TableRef for rows.Next() { var r TableRef if err := rows.Scan(&r.ID, &r.Version); err != nil { return nil, fmt.Errorf("games: scan due table: %w", err) } out = append(out, r) } return out, rows.Err() } // PushDeadlines shoves every live clock out by a grace period. Called once on // boot: a deploy takes a table's clock with it, and without this the first tick // after a restart wakes up to find every deadline in the casino already expired // and auto-folds all of them at once. func PushDeadlines(grace int64) error { if _, err := Get().Exec( `UPDATE game_tables SET deadline = ? WHERE deadline > 0 AND deadline < ?`, nowUnix()+grace, nowUnix()+grace, ); err != nil { return fmt.Errorf("games: push deadlines: %w", err) } return nil } // ---- chat ------------------------------------------------------------------ // ChatLine is one thing somebody said at the felt. type ChatLine struct { ID int64 `json:"id"` HandNo int64 `json:"hand_no"` Name string `json:"name"` Body string `json:"body"` SaidAt int64 `json:"said_at"` // Mine is filled in by the web layer, per reader. It is not in the database. Mine bool `json:"mine,omitempty"` } // MaxChatLen is where a message stops. Long enough for a table read, short enough // that nobody pastes a novel onto the felt. const MaxChatLen = 240 // Say records a line of chat and returns it. Its hand_no is stamped from the // table, which is what makes the log answer the only question chat at a money // table ever really raises: what was said, during which hand. func Say(tableID, user, name, body string) (ChatLine, error) { body = strings.TrimSpace(body) if body == "" { return ChatLine{}, ErrBadAmount } if len(body) > MaxChatLen { body = body[:MaxChatLen] } var handNo int64 if err := Get().QueryRow(`SELECT hand_no FROM game_tables WHERE id = ?`, tableID).Scan(&handNo); errors.Is(err, sql.ErrNoRows) { return ChatLine{}, ErrNoSuchTable } else if err != nil { return ChatLine{}, fmt.Errorf("games: chat hand no: %w", err) } now := nowUnix() res, err := Get().Exec( `INSERT INTO game_chat (table_id, hand_no, matrix_user, name, body, said_at) VALUES (?, ?, ?, ?, ?, ?)`, tableID, handNo, user, name, body, now, ) if err != nil { return ChatLine{}, fmt.Errorf("games: say: %w", err) } id, _ := res.LastInsertId() return ChatLine{ID: id, HandNo: handNo, Name: name, Body: body, SaidAt: now}, nil } // Chat reads the last few lines said at a table, oldest first. func Chat(tableID string, limit int) ([]ChatLine, error) { if limit <= 0 || limit > 200 { limit = 50 } rows, err := Get().Query( `SELECT id, hand_no, name, body, said_at FROM game_chat WHERE table_id = ? ORDER BY id DESC LIMIT ?`, tableID, limit) if err != nil { return nil, fmt.Errorf("games: chat: %w", err) } defer rows.Close() var out []ChatLine for rows.Next() { var c ChatLine if err := rows.Scan(&c.ID, &c.HandNo, &c.Name, &c.Body, &c.SaidAt); err != nil { return nil, fmt.Errorf("games: scan chat: %w", err) } out = append(out, c) } if err := rows.Err(); err != nil { return nil, err } // Read newest-first so the LIMIT takes the right end; hand them back in the // order they were said. for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { out[i], out[j] = out[j], out[i] } return out, nil }