Files
Pete/internal/storage/games.go
prosolis 1f1a6cb6e8 games: the payout that survives the crash, and the note that lied twice
The settle was four autocommit statements — save, award, record, clear —
sequenced so a crash between any two of them cost the player as little as
possible. That reasoning holds for a game owned by one player, and the old
comment made it well. It does not survive a pot, which is what the tables are
about to become: pay the winner, die before the state write, and the hand still
reads as live, so it settles again and pays again. Chips minted from nothing,
and gogobee turns those into euros.

The obvious fix is a trap. Award is a bare Get().Exec, so wrapping the settle in
a transaction makes it wait for the connection the transaction is holding. Not
an error — a hung process, and since the news app shares the pool it goes too.

So storage.CommitHand does the lot in one Begin/Commit, with tx-taking award and
recordHand beside the public ones. addChips has done it this way since the escrow
ledger was written; this is only that pattern, applied where the money is.

Two things fell out. A deal landing on a taken seat used to be refused and *then*
refunded in a separate statement, so a crash in between took a stake for a game
that existed nowhere — no felt, no audit row, nothing to find. And the audit row
is now inside the settle, which means failing to write it rolls the payout back
rather than paying quietly and logging: the payout and the audit row are the same
fact, and a payout nobody can account for is worse than one that didn't happen.

TestTheSettleDoesNotDeadlockAgainstItsOwnConnection is a canary, and it has been
made to sing — put the bug back and it doesn't fail with a message, it hangs, and
the timeout is the message. Which is exactly what production would do. A canary
that has never sung is just a bird.

Nothing a player can see has changed: eight blackjack hands conserving to the
chip across win, lose and push (a natural is the sharp one — Fresh and Done in a
single CommitHand), a double-deal 409 that refunds and leaves the live game
alone, hangman, and a hold'em session that bought in for 200 and got up with 197.

Also: the plan's deploy note was stale for the second time, with the lesson from
the first time written directly underneath it. Everything is live and always was.
A hand-written record of what is deployed will rot. Ask the box.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 15:28:54 -07:00

766 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
}
// 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
err := Get().QueryRow(
`SELECT game, state, seed1, seed2 FROM game_live_hands WHERE matrix_user = ?`, user,
).Scan(&h.Game, &state, &s1, &s2)
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 = []byte(state), uint64(s1), uint64(s2)
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
}