The engine, the escrow and the wire were all in place; nothing had a browser on the end of it. This is that end: a lobby, a table, and the five endpoints between them. The browser holds no game. It sends intents and gets back a view — the cards it is entitled to see, and the script of how they arrived, one event per card off the shoe. The dealer's hole card is not in the payload at all until the reveal, because a field the client is told to ignore is a field somebody reads in devtools. The shoe lives in game_live_hands, which also means a redeploy mid-hand no longer costs a player their stake: the hand is still there when they come back. The money is ordered so nothing can be spent twice. The stake leaves the stack in the same statement that checks it exists, before a card is dealt. Every new hand is seated with a plain INSERT, so a double-clicked Deal is decided by the primary key rather than by a read that raced — it loses, gets its chips back, and the hand in progress is untouched. A double takes its raise up front and hands it straight back if the engine refuses the move. Cards are dealt rather than swapped in — they fly out of the shoe and turn over, which was a requirement and not a flourish. The faces and the chips are still plain; that's next.
602 lines
22 KiB
Go
602 lines
22 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.
|
|
func Award(user string, amount int64) error {
|
|
if amount <= 0 {
|
|
return nil
|
|
}
|
|
now := nowUnix()
|
|
if _, err := Get().Exec(
|
|
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(matrix_user) DO UPDATE SET
|
|
chips = chips + excluded.chips, last_played = excluded.last_played, updated_at = excluded.updated_at`,
|
|
user, amount, now, now,
|
|
); err != nil {
|
|
return fmt.Errorf("games: award chips: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Hand is one settled hand, as the audit log keeps it.
|
|
type Hand struct {
|
|
MatrixUser string
|
|
Game string
|
|
Bet int64
|
|
Payout int64
|
|
Rake int64
|
|
Outcome string
|
|
Seed1 uint64
|
|
Seed2 uint64
|
|
}
|
|
|
|
// RecordHand writes a finished hand to the audit trail. The seeds are the point:
|
|
// with them, any hand in the log can be dealt again exactly as it fell, which is
|
|
// how a dispute gets answered with a fact instead of an apology.
|
|
func RecordHand(h Hand) error {
|
|
if _, err := Get().Exec(
|
|
`INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome,
|
|
int64(h.Seed1), int64(h.Seed2), nowUnix(),
|
|
); err != nil {
|
|
return fmt.Errorf("games: record hand: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IdleStacks lists players holding chips who stopped playing a while ago. The
|
|
// reaper cashes these out on their behalf: chips in an abandoned session are
|
|
// euros in limbo, and they should be back in the player's balance where they can
|
|
// see them.
|
|
func IdleStacks(idleFor time.Duration) ([]ChipStack, []string, error) {
|
|
cutoff := nowUnix() - int64(idleFor.Seconds())
|
|
rows, err := Get().Query(
|
|
`SELECT matrix_user, chips, last_played FROM game_chips
|
|
WHERE chips > 0 AND last_played > 0 AND last_played < ?`, cutoff,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("games: idle stacks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var stacks []ChipStack
|
|
var users []string
|
|
for rows.Next() {
|
|
var st ChipStack
|
|
var user string
|
|
if err := rows.Scan(&user, &st.Chips, &st.LastPlayed); err != nil {
|
|
return nil, nil, fmt.Errorf("games: scan idle stack: %w", err)
|
|
}
|
|
stacks = append(stacks, st)
|
|
users = append(users, user)
|
|
}
|
|
return stacks, users, rows.Err()
|
|
}
|
|
|
|
// ReapIdleSessions cashes out everyone who walked away, and reports how many it
|
|
// sent home. Safe to run on a timer: a player who comes back simply buys in again,
|
|
// and a cash-out that's already in flight can't be opened twice because the chips
|
|
// are gone from the stack the moment the first one is.
|
|
func ReapIdleSessions(idleFor time.Duration) (int, error) {
|
|
stacks, users, err := IdleStacks(idleFor)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
reaped := 0
|
|
for i, user := range users {
|
|
if _, err := RequestCashOut(user, stacks[i].Chips); err != nil {
|
|
// One player's stack failing to reap shouldn't strand everyone else's.
|
|
if !errors.Is(err, ErrInsufficientChips) {
|
|
return reaped, fmt.Errorf("games: reap %s: %w", user, err)
|
|
}
|
|
continue
|
|
}
|
|
reaped++
|
|
}
|
|
return reaped, nil
|
|
}
|
|
|
|
// Touch marks a player as active, so the reaper leaves them alone. Called on any
|
|
// deliberate action at a table — not on a page load, or an open tab would keep a
|
|
// walked-away player's chips hostage forever.
|
|
func Touch(user string) {
|
|
exec("games: touch session",
|
|
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
|
nowUnix(), nowUnix(), user)
|
|
}
|
|
|
|
// ---- 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
|
|
}
|
|
|
|
// 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
|
|
}
|