diff --git a/internal/storage/games.go b/internal/storage/games.go new file mode 100644 index 0000000..9b93580 --- /dev/null +++ b/internal/storage/games.go @@ -0,0 +1,516 @@ +package storage + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "time" +) + +// The chip ledger and the euro/chip border. +// +// Chips are euros that have crossed into the casino. They are 1:1 with euros and +// they are not a second wallet: every chip that exists came from a euro gogobee +// debited, and every chip destroyed becomes a euro gogobee credits back. Pete +// never writes a euro balance. The border is crossed only by a game_escrow row, +// whose guid is the idempotency key gogobee hands to DebitIdem/CreditIdem — so a +// claim whose acknowledgement is lost on the wire can be retried without the +// player paying for it twice. +// +// The whole reason for the border is latency. gogobee has no inbound API and is +// not getting one, so it polls; a bet that round-tripped through a poll loop +// would take seconds to be dealt. Instead the poll loop runs twice per *session* +// — buy in, cash out — and every hand in between plays against chips held here, +// at full speed, with no economy call in the hot path. + +// MaxChipsOnTable caps how many chips a player can hold at once. A buy-in that +// would push them over is refused before it ever reaches gogobee. +// +// This is the inflation brake. A web casino runs orders of magnitude more hands +// per hour than a Matrix-paced one ever did, so whatever the house edge is, it +// compounds far faster in both directions. The cap bounds the worst case for a +// single sitting; the rake (see the blackjack engine) bleeds the rest back out. +const MaxChipsOnTable int64 = 10_000 + +// EscrowStaleAfter is how long a claimed-but-unsettled escrow row waits before +// the poll endpoint offers it again. gogobee can die between claiming a row and +// pushing its result; without a re-offer, the player's money sits in limbo +// forever. Re-claiming is safe precisely because the guid makes it idempotent. +const EscrowStaleAfter = 90 * time.Second + +// SessionIdleAfter is when the reaper decides a player has walked away and cashes +// their chips back to euros on their behalf. Chips in an abandoned session are +// euros in limbo, and limbo is not a state a player's money should be in. +const SessionIdleAfter = 30 * time.Minute + +// Escrow kinds and states. These strings cross the wire to gogobee, so they are +// part of the contract — see §4 of pete_games_plan.md. +const ( + KindBuyIn = "buyin" + KindCashOut = "cashout" + + EscrowRequested = "requested" // the player asked; gogobee hasn't seen it yet + EscrowClaimed = "claimed" // gogobee has it and is moving the euros + EscrowFunded = "funded" // buy-in landed; the chips are spendable + EscrowRejected = "rejected" // buy-in refused; no chips, no euros moved + EscrowSettled = "settled" // cash-out landed; chips destroyed, euros credited +) + +var ( + ErrInsufficientChips = errors.New("games: not enough chips") + ErrOverTableCap = errors.New("games: that would put more than the cap on the table") + ErrBadAmount = errors.New("games: amount must be positive") + ErrNoSuchEscrow = errors.New("games: no such escrow row") +) + +// Escrow is one crossing of the euro/chip border. +type Escrow struct { + GUID string `json:"guid"` + MatrixUser string `json:"matrix_user"` + Kind string `json:"kind"` + Amount int64 `json:"amount"` + State string `json:"state,omitempty"` + Reason string `json:"reason,omitempty"` + BalanceAfter float64 `json:"balance_after,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` +} + +// ChipStack is what a player has on the table right now. +type ChipStack struct { + Chips int64 // spendable + // Pending is chips asked for but not yet funded — a buy-in gogobee hasn't + // claimed or settled. Shown as "buying chips…", never spendable. + Pending int64 + EuroBalance float64 // advisory, from the last gogobee push; may be minutes stale + LastPlayed int64 +} + +// newGUID mints an escrow id. It's the idempotency key for a real money move, so +// it comes from crypto/rand rather than anything a caller could collide with. +func newGUID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("games: mint guid: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// Chips reports a player's stack. A player who has never played has no row and +// reads as an empty stack rather than an error. +func Chips(user string) (ChipStack, error) { + var st ChipStack + var euro sql.NullFloat64 + err := Get().QueryRow( + `SELECT chips, euro_balance, last_played FROM game_chips WHERE matrix_user = ?`, user, + ).Scan(&st.Chips, &euro, &st.LastPlayed) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return ChipStack{}, fmt.Errorf("games: read chips: %w", err) + } + st.EuroBalance = euro.Float64 + + if err := Get().QueryRow( + `SELECT COALESCE(SUM(amount), 0) FROM game_escrow + WHERE matrix_user = ? AND kind = ? AND state IN (?, ?)`, + user, KindBuyIn, EscrowRequested, EscrowClaimed, + ).Scan(&st.Pending); err != nil { + return ChipStack{}, fmt.Errorf("games: read pending buy-ins: %w", err) + } + return st, nil +} + +// RequestBuyIn opens a buy-in: the player wants `amount` euros turned into chips. +// No chips exist yet — they appear only when gogobee confirms it took the euros. +// The table cap is enforced here, against chips already held *plus* buy-ins still +// in flight, so a player can't clear the cap by firing several at once. +func RequestBuyIn(user string, amount int64) (Escrow, error) { + if amount <= 0 { + return Escrow{}, ErrBadAmount + } + st, err := Chips(user) + if err != nil { + return Escrow{}, err + } + if st.Chips+st.Pending+amount > MaxChipsOnTable { + return Escrow{}, ErrOverTableCap + } + + guid, err := newGUID() + if err != nil { + return Escrow{}, err + } + now := nowUnix() + if _, err := Get().Exec( + `INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + guid, user, KindBuyIn, amount, EscrowRequested, now, now, + ); err != nil { + return Escrow{}, fmt.Errorf("games: request buy-in: %w", err) + } + return Escrow{GUID: guid, MatrixUser: user, Kind: KindBuyIn, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil +} + +// RequestCashOut opens a cash-out: chips are destroyed *now*, and the matching +// euros arrive when gogobee claims the row. +// +// Destroying them up front is what keeps the invariant true. If the chips lingered +// until gogobee confirmed, a player could bet them while the cash-out was in +// flight and the same euro would exist on both sides of the border. If the credit +// somehow fails, RefundCashOut puts the chips back. +func RequestCashOut(user string, amount int64) (Escrow, error) { + if amount <= 0 { + return Escrow{}, ErrBadAmount + } + guid, err := newGUID() + if err != nil { + return Escrow{}, err + } + now := nowUnix() + + tx, err := Get().Begin() + if err != nil { + return Escrow{}, fmt.Errorf("games: begin cash-out: %w", err) + } + defer tx.Rollback() //nolint:errcheck // no-op once committed + + // Conditional update: the chips leave only if they're actually there. + res, err := tx.Exec( + `UPDATE game_chips SET chips = chips - ?, updated_at = ? + WHERE matrix_user = ? AND chips >= ?`, + amount, now, user, amount, + ) + if err != nil { + return Escrow{}, fmt.Errorf("games: debit chips: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return Escrow{}, ErrInsufficientChips + } + + if _, err := tx.Exec( + `INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + guid, user, KindCashOut, amount, EscrowRequested, now, now, + ); err != nil { + return Escrow{}, fmt.Errorf("games: request cash-out: %w", err) + } + if err := tx.Commit(); err != nil { + return Escrow{}, fmt.Errorf("games: commit cash-out: %w", err) + } + return Escrow{GUID: guid, MatrixUser: user, Kind: KindCashOut, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil +} + +// PendingEscrow is what gogobee's poll loop reads: everything waiting to be moved. +// +// It returns rows nobody has claimed, *and* rows claimed long enough ago that we +// have to assume gogobee died holding them. Re-offering a claimed row is safe +// because the guid is idempotent end to end: if gogobee already moved the euros, +// the retry is a no-op that reports the same answer. +func PendingEscrow(limit int) ([]Escrow, error) { + if limit <= 0 { + limit = 100 + } + stale := nowUnix() - int64(EscrowStaleAfter.Seconds()) + rows, err := Get().Query( + `SELECT guid, matrix_user, kind, amount, state, created_at + FROM game_escrow + WHERE state = ? + OR (state = ? AND COALESCE(claimed_at, 0) < ?) + ORDER BY created_at + LIMIT ?`, + EscrowRequested, EscrowClaimed, stale, limit, + ) + if err != nil { + return nil, fmt.Errorf("games: pending escrow: %w", err) + } + defer rows.Close() + + var out []Escrow + for rows.Next() { + var e Escrow + if err := rows.Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("games: scan escrow: %w", err) + } + out = append(out, e) + } + return out, rows.Err() +} + +// ClaimEscrow marks a row as taken by gogobee. Claiming is idempotent and is not +// a lock: a row already claimed can be claimed again (that's how a stale re-offer +// works), but a row already *finished* cannot be, which is what stops a settled +// cash-out from being paid a second time. +func ClaimEscrow(guid string) (Escrow, error) { + now := nowUnix() + res, err := Get().Exec( + `UPDATE game_escrow SET state = ?, claimed_at = ?, updated_at = ? + WHERE guid = ? AND state IN (?, ?)`, + EscrowClaimed, now, now, guid, EscrowRequested, EscrowClaimed, + ) + if err != nil { + return Escrow{}, fmt.Errorf("games: claim escrow: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + // Either it doesn't exist or it's already finished. Tell the caller which. + e, err := EscrowByGUID(guid) + if err != nil { + return Escrow{}, err + } + return e, nil + } + return EscrowByGUID(guid) +} + +// EscrowByGUID reads one row. +func EscrowByGUID(guid string) (Escrow, error) { + var e Escrow + var reason sql.NullString + var bal sql.NullFloat64 + err := Get().QueryRow( + `SELECT guid, matrix_user, kind, amount, state, reason, balance_after, created_at + FROM game_escrow WHERE guid = ?`, guid, + ).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &reason, &bal, &e.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Escrow{}, ErrNoSuchEscrow + } + if err != nil { + return Escrow{}, fmt.Errorf("games: read escrow: %w", err) + } + e.Reason, e.BalanceAfter = reason.String, bal.Float64 + return e, nil +} + +// SettleEscrow applies gogobee's verdict on a claimed row, and is the only place +// chips are created or finally destroyed. +// +// buy-in, ok -> chips appear (funded) +// buy-in, !ok -> nothing happens, nothing moved (rejected) +// cash-out, ok -> chips stay destroyed, euros paid (settled) +// cash-out, !ok -> chips come back (funded — the player never lost them) +// +// It is idempotent: gogobee's push queue retries, so the same verdict can arrive +// more than once and only the first one may move chips. A row that has already +// reached a terminal state is a no-op, not an error. +func SettleEscrow(guid string, ok bool, reason string, balanceAfter float64) (Escrow, error) { + now := nowUnix() + + tx, err := Get().Begin() + if err != nil { + return Escrow{}, fmt.Errorf("games: begin settle: %w", err) + } + defer tx.Rollback() //nolint:errcheck // no-op once committed + + var e Escrow + var st string + if err := tx.QueryRow( + `SELECT guid, matrix_user, kind, amount, state FROM game_escrow WHERE guid = ?`, guid, + ).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &st); errors.Is(err, sql.ErrNoRows) { + return Escrow{}, ErrNoSuchEscrow + } else if err != nil { + return Escrow{}, fmt.Errorf("games: settle lookup: %w", err) + } + + // Terminal already — a retried push. Report what we decided the first time. + if st == EscrowFunded || st == EscrowRejected || st == EscrowSettled { + if err := tx.Commit(); err != nil { + return Escrow{}, fmt.Errorf("games: commit settle: %w", err) + } + return EscrowByGUID(guid) + } + + final := EscrowFunded + switch { + case e.Kind == KindBuyIn && ok: + if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil { + return Escrow{}, err + } + case e.Kind == KindBuyIn && !ok: + final = EscrowRejected // gogobee took nothing, so we create nothing + case e.Kind == KindCashOut && ok: + final = EscrowSettled // the chips were destroyed when the row was opened + case e.Kind == KindCashOut && !ok: + // gogobee couldn't pay. The chips were already destroyed on our side, so + // give them back rather than vanishing the player's money. + if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil { + return Escrow{}, err + } + } + + if _, err := tx.Exec( + `UPDATE game_escrow SET state = ?, reason = ?, balance_after = ?, updated_at = ? + WHERE guid = ?`, + final, reason, balanceAfter, now, guid, + ); err != nil { + return Escrow{}, fmt.Errorf("games: settle update: %w", err) + } + + // The euro balance gogobee just reported is the freshest one we'll get. + // Advisory only — we display it, we never decide anything with it. + if _, err := tx.Exec( + `UPDATE game_chips SET euro_balance = ?, updated_at = ? WHERE matrix_user = ?`, + balanceAfter, now, e.MatrixUser, + ); err != nil { + return Escrow{}, fmt.Errorf("games: cache euro balance: %w", err) + } + + if err := tx.Commit(); err != nil { + return Escrow{}, fmt.Errorf("games: commit settle: %w", err) + } + return EscrowByGUID(guid) +} + +// addChips credits a stack inside an open transaction, creating the row if the +// player has never held chips before. +func addChips(tx *sql.Tx, user string, amount int64, now int64) error { + if _, err := tx.Exec( + `INSERT INTO game_chips (matrix_user, chips, last_played, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(matrix_user) DO UPDATE SET chips = chips + excluded.chips, updated_at = excluded.updated_at`, + user, amount, now, now, + ); err != nil { + return fmt.Errorf("games: credit chips: %w", err) + } + return nil +} + +// Stake takes chips off a player's stack to put them at risk on a hand. It is the +// conditional-update kind of debit: the chips leave in the same statement that +// checks they're there, so two hands opened at once can't spend the same chip. +func Stake(user string, amount int64) error { + if amount <= 0 { + return ErrBadAmount + } + now := nowUnix() + res, err := Get().Exec( + `UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ? + WHERE matrix_user = ? AND chips >= ?`, + amount, now, now, user, amount, + ) + if err != nil { + return fmt.Errorf("games: stake: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrInsufficientChips + } + return nil +} + +// Award returns chips to a player when a hand settles: stake plus winnings, net +// of rake, exactly as the engine computed it. A losing hand awards nothing and +// should not call this. +func Award(user string, amount int64) error { + if amount <= 0 { + return nil + } + now := nowUnix() + if _, err := Get().Exec( + `INSERT INTO game_chips (matrix_user, chips, last_played, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(matrix_user) DO UPDATE SET + chips = chips + excluded.chips, last_played = excluded.last_played, updated_at = excluded.updated_at`, + user, amount, now, now, + ); err != nil { + return fmt.Errorf("games: award chips: %w", err) + } + return nil +} + +// Hand is one settled hand, as the audit log keeps it. +type Hand struct { + MatrixUser string + Game string + Bet int64 + Payout int64 + Rake int64 + Outcome string + Seed1 uint64 + Seed2 uint64 +} + +// RecordHand writes a finished hand to the audit trail. The seeds are the point: +// with them, any hand in the log can be dealt again exactly as it fell, which is +// how a dispute gets answered with a fact instead of an apology. +func RecordHand(h Hand) error { + if _, err := Get().Exec( + `INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome, + int64(h.Seed1), int64(h.Seed2), nowUnix(), + ); err != nil { + return fmt.Errorf("games: record hand: %w", err) + } + return nil +} + +// IdleStacks lists players holding chips who stopped playing a while ago. The +// reaper cashes these out on their behalf: chips in an abandoned session are +// euros in limbo, and they should be back in the player's balance where they can +// see them. +func IdleStacks(idleFor time.Duration) ([]ChipStack, []string, error) { + cutoff := nowUnix() - int64(idleFor.Seconds()) + rows, err := Get().Query( + `SELECT matrix_user, chips, last_played FROM game_chips + WHERE chips > 0 AND last_played > 0 AND last_played < ?`, cutoff, + ) + if err != nil { + return nil, nil, fmt.Errorf("games: idle stacks: %w", err) + } + defer rows.Close() + + var stacks []ChipStack + var users []string + for rows.Next() { + var st ChipStack + var user string + if err := rows.Scan(&user, &st.Chips, &st.LastPlayed); err != nil { + return nil, nil, fmt.Errorf("games: scan idle stack: %w", err) + } + stacks = append(stacks, st) + users = append(users, user) + } + return stacks, users, rows.Err() +} + +// ReapIdleSessions cashes out everyone who walked away, and reports how many it +// sent home. Safe to run on a timer: a player who comes back simply buys in again, +// and a cash-out that's already in flight can't be opened twice because the chips +// are gone from the stack the moment the first one is. +func ReapIdleSessions(idleFor time.Duration) (int, error) { + stacks, users, err := IdleStacks(idleFor) + if err != nil { + return 0, err + } + reaped := 0 + for i, user := range users { + if _, err := RequestCashOut(user, stacks[i].Chips); err != nil { + // One player's stack failing to reap shouldn't strand everyone else's. + if !errors.Is(err, ErrInsufficientChips) { + return reaped, fmt.Errorf("games: reap %s: %w", user, err) + } + continue + } + reaped++ + } + return reaped, nil +} + +// Touch marks a player as active, so the reaper leaves them alone. Called on any +// deliberate action at a table — not on a page load, or an open tab would keep a +// walked-away player's chips hostage forever. +func Touch(user string) { + exec("games: touch session", + `UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`, + nowUnix(), nowUnix(), user) +} + +// HouseTake is the total rake collected since a given time — the number that +// answers "is this economy inflating". +func HouseTake(since int64) (int64, error) { + var total int64 + if err := Get().QueryRow( + `SELECT COALESCE(SUM(rake), 0) FROM game_hands WHERE played_at >= ?`, since, + ).Scan(&total); err != nil { + return 0, fmt.Errorf("games: house take: %w", err) + } + return total, nil +} diff --git a/internal/storage/games_test.go b/internal/storage/games_test.go new file mode 100644 index 0000000..58f6adc --- /dev/null +++ b/internal/storage/games_test.go @@ -0,0 +1,494 @@ +package storage + +import ( + "errors" + "testing" + "time" +) + +const player = "@reala:parodia.dev" + +// fund runs a buy-in all the way through the happy path, so a test that needs +// chips on the table can just say so. +func fund(t *testing.T, user string, amount int64) { + t.Helper() + e, err := RequestBuyIn(user, amount) + if err != nil { + t.Fatal(err) + } + if _, err := ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil { + t.Fatal(err) + } +} + +func chipsOf(t *testing.T, user string) int64 { + t.Helper() + st, err := Chips(user) + if err != nil { + t.Fatal(err) + } + return st.Chips +} + +func TestBuyIn_ChipsOnlyExistOnceGogobeeConfirms(t *testing.T) { + setupTestDB(t) + + e, err := RequestBuyIn(player, 500) + if err != nil { + t.Fatal(err) + } + if e.State != EscrowRequested { + t.Fatalf("state = %q, want %q", e.State, EscrowRequested) + } + + // Requested is not funded. Nothing is spendable yet — gogobee hasn't taken + // the euros, so creating chips here would mint money out of nothing. + st, err := Chips(player) + if err != nil { + t.Fatal(err) + } + if st.Chips != 0 { + t.Fatalf("chips = %d before settlement, want 0", st.Chips) + } + if st.Pending != 500 { + t.Fatalf("pending = %d, want 500", st.Pending) + } + + if _, err := ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil { + t.Fatal(err) + } + + st, err = Chips(player) + if err != nil { + t.Fatal(err) + } + if st.Chips != 500 { + t.Fatalf("chips = %d after funding, want 500", st.Chips) + } + if st.Pending != 0 { + t.Fatalf("pending = %d after funding, want 0", st.Pending) + } + if st.EuroBalance != 4500 { + t.Fatalf("advisory euro balance = %v, want 4500", st.EuroBalance) + } +} + +func TestBuyIn_RejectedCreatesNoChips(t *testing.T) { + setupTestDB(t) + + e, err := RequestBuyIn(player, 500) + if err != nil { + t.Fatal(err) + } + if _, err := ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 12) + if err != nil { + t.Fatal(err) + } + if got.State != EscrowRejected { + t.Fatalf("state = %q, want %q", got.State, EscrowRejected) + } + if c := chipsOf(t, player); c != 0 { + t.Fatalf("chips = %d after a rejected buy-in, want 0", c) + } +} + +// The push queue retries. A verdict that lands twice must only move chips once. +func TestSettle_IsIdempotent(t *testing.T) { + setupTestDB(t) + + e, err := RequestBuyIn(player, 500) + if err != nil { + t.Fatal(err) + } + if _, err := ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil { + t.Fatalf("settle %d: %v", i, err) + } + } + if c := chipsOf(t, player); c != 500 { + t.Fatalf("chips = %d after three identical pushes, want 500", c) + } +} + +// A late, contradictory push must not overturn a settled row — otherwise a +// delayed "rejected" could confiscate chips the player already won hands with. +func TestSettle_TerminalStateWins(t *testing.T) { + setupTestDB(t) + + e, err := RequestBuyIn(player, 500) + if err != nil { + t.Fatal(err) + } + if _, err := ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil { + t.Fatal(err) + } + got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 0) + if err != nil { + t.Fatal(err) + } + if got.State != EscrowFunded { + t.Fatalf("state = %q, want the original %q", got.State, EscrowFunded) + } + if c := chipsOf(t, player); c != 500 { + t.Fatalf("chips = %d, want 500 — a late rejection took funded chips", c) + } +} + +func TestCashOut_ChipsLeaveImmediately(t *testing.T) { + setupTestDB(t) + fund(t, player, 1000) + + e, err := RequestCashOut(player, 400) + if err != nil { + t.Fatal(err) + } + // The chips are gone the moment the row opens. If they lingered until gogobee + // confirmed, the player could bet them while the euros were also in flight — + // the same euro on both sides of the border. + if c := chipsOf(t, player); c != 600 { + t.Fatalf("chips = %d immediately after cash-out, want 600", c) + } + + if _, err := ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + got, err := SettleEscrow(e.GUID, true, "", 4400) + if err != nil { + t.Fatal(err) + } + if got.State != EscrowSettled { + t.Fatalf("state = %q, want %q", got.State, EscrowSettled) + } + if c := chipsOf(t, player); c != 600 { + t.Fatalf("chips = %d after settlement, want 600", c) + } +} + +func TestCashOut_FailedCreditGivesTheChipsBack(t *testing.T) { + setupTestDB(t) + fund(t, player, 1000) + + e, err := RequestCashOut(player, 400) + if err != nil { + t.Fatal(err) + } + if _, err := ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + // gogobee couldn't pay. The chips were already destroyed here, so they have to + // come back — the player's money cannot simply evaporate at the border. + if _, err := SettleEscrow(e.GUID, false, "ledger_error", 0); err != nil { + t.Fatal(err) + } + if c := chipsOf(t, player); c != 1000 { + t.Fatalf("chips = %d after a failed cash-out, want the full 1000 back", c) + } +} + +func TestCashOut_CannotExceedTheStack(t *testing.T) { + setupTestDB(t) + fund(t, player, 100) + + if _, err := RequestCashOut(player, 500); !errors.Is(err, ErrInsufficientChips) { + t.Fatalf("err = %v, want ErrInsufficientChips", err) + } + if c := chipsOf(t, player); c != 100 { + t.Fatalf("chips = %d after a refused cash-out, want 100", c) + } +} + +func TestBuyIn_TableCapCountsChipsAndInFlightBuyIns(t *testing.T) { + setupTestDB(t) + + if _, err := RequestBuyIn(player, MaxChipsOnTable+1); !errors.Is(err, ErrOverTableCap) { + t.Fatalf("err = %v, want ErrOverTableCap", err) + } + + fund(t, player, MaxChipsOnTable-1000) + + // A second buy-in that fits is fine. + if _, err := RequestBuyIn(player, 1000); err != nil { + t.Fatal(err) + } + // A third, while the second is still in flight, must not clear the cap by + // racing — pending buy-ins count against it. + if _, err := RequestBuyIn(player, 1); !errors.Is(err, ErrOverTableCap) { + t.Fatalf("err = %v, want ErrOverTableCap — in-flight buy-ins must count", err) + } +} + +func TestRequest_RejectsNonPositiveAmounts(t *testing.T) { + setupTestDB(t) + fund(t, player, 100) + + for _, amount := range []int64{0, -50} { + if _, err := RequestBuyIn(player, amount); !errors.Is(err, ErrBadAmount) { + t.Errorf("buy-in %d: err = %v, want ErrBadAmount", amount, err) + } + if _, err := RequestCashOut(player, amount); !errors.Is(err, ErrBadAmount) { + t.Errorf("cash-out %d: err = %v, want ErrBadAmount", amount, err) + } + } + if c := chipsOf(t, player); c != 100 { + t.Fatalf("chips = %d, want 100", c) + } +} + +func TestStakeAndAward(t *testing.T) { + setupTestDB(t) + fund(t, player, 500) + + if err := Stake(player, 100); err != nil { + t.Fatal(err) + } + if c := chipsOf(t, player); c != 400 { + t.Fatalf("chips = %d after staking 100, want 400", c) + } + + // A win pays the stake back plus the profit, net of rake. + if err := Award(player, 195); err != nil { + t.Fatal(err) + } + if c := chipsOf(t, player); c != 595 { + t.Fatalf("chips = %d after a 195 payout, want 595", c) + } + + // You cannot bet chips you don't have. + if err := Stake(player, 10_000); !errors.Is(err, ErrInsufficientChips) { + t.Fatalf("err = %v, want ErrInsufficientChips", err) + } + if c := chipsOf(t, player); c != 595 { + t.Fatalf("chips = %d after a refused stake, want 595", c) + } +} + +func TestStake_UnknownPlayerHasNothingToBet(t *testing.T) { + setupTestDB(t) + if err := Stake("@stranger:parodia.dev", 10); !errors.Is(err, ErrInsufficientChips) { + t.Fatalf("err = %v, want ErrInsufficientChips", err) + } +} + +func TestPendingEscrow_OffersUnclaimedAndAbandonedRows(t *testing.T) { + setupTestDB(t) + + fresh, err := RequestBuyIn(player, 100) + if err != nil { + t.Fatal(err) + } + abandoned, err := RequestBuyIn("@other:parodia.dev", 200) + if err != nil { + t.Fatal(err) + } + + pending, err := PendingEscrow(10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 2 { + t.Fatalf("%d pending rows, want 2", len(pending)) + } + + // Claim both: neither should be offered again while gogobee is working on them. + for _, e := range []Escrow{fresh, abandoned} { + if _, err := ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + } + pending, err = PendingEscrow(10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 0 { + t.Fatalf("%d rows offered while claimed, want 0", len(pending)) + } + + // Now pretend gogobee died holding one of them. A claim that never came back + // must be re-offered, or the player's money sits in limbo forever. + stale := nowUnix() - int64(EscrowStaleAfter.Seconds()) - 1 + if _, err := Get().Exec(`UPDATE game_escrow SET claimed_at = ? WHERE guid = ?`, stale, abandoned.GUID); err != nil { + t.Fatal(err) + } + pending, err = PendingEscrow(10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 || pending[0].GUID != abandoned.GUID { + t.Fatalf("stale claim was not re-offered: got %d rows", len(pending)) + } +} + +func TestClaim_CannotReopenAFinishedRow(t *testing.T) { + setupTestDB(t) + fund(t, player, 300) + + e, err := RequestCashOut(player, 300) + if err != nil { + t.Fatal(err) + } + if _, err := ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil { + t.Fatal(err) + } + + // Re-claiming a settled cash-out must not walk it back to claimed, or gogobee + // would pay the same euros out twice. + got, err := ClaimEscrow(e.GUID) + if err != nil { + t.Fatal(err) + } + if got.State != EscrowSettled { + t.Fatalf("state = %q after re-claiming a settled row, want %q", got.State, EscrowSettled) + } +} + +func TestEscrow_UnknownGUID(t *testing.T) { + setupTestDB(t) + if _, err := EscrowByGUID("nope"); !errors.Is(err, ErrNoSuchEscrow) { + t.Fatalf("err = %v, want ErrNoSuchEscrow", err) + } + if _, err := SettleEscrow("nope", true, "", 0); !errors.Is(err, ErrNoSuchEscrow) { + t.Fatalf("err = %v, want ErrNoSuchEscrow", err) + } +} + +func TestReaper_CashesOutTheWalkedAway(t *testing.T) { + setupTestDB(t) + fund(t, player, 700) + + // Still playing: the reaper must leave them alone. + Touch(player) + n, err := ReapIdleSessions(SessionIdleAfter) + if err != nil { + t.Fatal(err) + } + if n != 0 || chipsOf(t, player) != 700 { + t.Fatalf("reaped %d active players (chips now %d)", n, chipsOf(t, player)) + } + + // Now they've been gone an hour. + old := nowUnix() - int64((2 * time.Hour).Seconds()) + if _, err := Get().Exec(`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`, old, player); err != nil { + t.Fatal(err) + } + n, err = ReapIdleSessions(SessionIdleAfter) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("reaped %d, want 1", n) + } + if c := chipsOf(t, player); c != 0 { + t.Fatalf("chips = %d after the reaper ran, want 0 — they should be euros now", c) + } + + // And it must be a real cash-out, waiting for gogobee to pay it out. + pending, err := PendingEscrow(10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 || pending[0].Kind != KindCashOut || pending[0].Amount != 700 { + t.Fatalf("reaper did not queue a 700 cash-out: %+v", pending) + } + + // Running again finds nothing left to reap. + if n, err := ReapIdleSessions(SessionIdleAfter); err != nil || n != 0 { + t.Fatalf("second sweep reaped %d (err=%v), want 0", n, err) + } +} + +func TestRecordHand_AndHouseTake(t *testing.T) { + setupTestDB(t) + + hands := []Hand{ + {MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 195, Rake: 5, Outcome: "win", Seed1: 42, Seed2: 7}, + {MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 0, Rake: 0, Outcome: "bust", Seed1: 43, Seed2: 8}, + {MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 486, Rake: 14, Outcome: "blackjack", Seed1: 44, Seed2: 9}, + } + for _, h := range hands { + if err := RecordHand(h); err != nil { + t.Fatal(err) + } + } + + take, err := HouseTake(0) + if err != nil { + t.Fatal(err) + } + if take != 19 { + t.Fatalf("house take = %d, want 19", take) + } + + // The seeds have to survive the round trip, or a disputed hand can't be re-dealt. + var s1, s2 int64 + if err := Get().QueryRow( + `SELECT seed1, seed2 FROM game_hands WHERE outcome = 'blackjack'`, + ).Scan(&s1, &s2); err != nil { + t.Fatal(err) + } + if s1 != 44 || s2 != 9 { + t.Fatalf("seeds came back as (%d, %d), want (44, 9)", s1, s2) + } +} + +// The invariant, end to end: every euro that entered the casino is either a chip +// on the table or a euro on its way home. None are minted, none evaporate. +func TestBorder_ChipsAreConserved(t *testing.T) { + setupTestDB(t) + fund(t, player, 1000) + + // Play a losing hand and a winning one. + if err := Stake(player, 100); err != nil { + t.Fatal(err) + } // 900 + if err := Stake(player, 100); err != nil { + t.Fatal(err) + } // 800 + if err := Award(player, 195); err != nil { + t.Fatal(err) + } // 995 — one loss, one win less rake + + if c := chipsOf(t, player); c != 995 { + t.Fatalf("chips = %d, want 995", c) + } + + e, err := RequestCashOut(player, 995) + if err != nil { + t.Fatal(err) + } + if _, err := ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + if _, err := SettleEscrow(e.GUID, true, "", 4995); err != nil { + t.Fatal(err) + } + + if c := chipsOf(t, player); c != 0 { + t.Fatalf("chips = %d after cashing out everything, want 0", c) + } + // 1000 in, 995 out, 5 lost to the table and the rake. Nothing left stranded. + st, err := Chips(player) + if err != nil { + t.Fatal(err) + } + if st.Pending != 0 { + t.Fatalf("pending = %d, want 0", st.Pending) + } +} diff --git a/internal/storage/schema.go b/internal/storage/schema.go index 6f4bbd5..ee51bc7 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -159,6 +159,72 @@ CREATE TABLE IF NOT EXISTS story_views ( PRIMARY KEY (story_id, day) ); +-- --------------------------------------------------------------------------- +-- games.parodia.dev +-- +-- The invariant the whole casino rests on: a euro is either in gogobee's +-- euro_balances or in Pete's chip escrow, never both. It crosses between them +-- only via a GUID-idempotent claim, and Pete never writes a euro balance — +-- gogobee does, when it claims the escrow row and tells us how it went. +-- --------------------------------------------------------------------------- + +-- A player's chips: euros that have crossed into the casino and haven't crossed +-- back yet. 1:1 with euros. Keyed by Matrix user id, because that's the identity +-- gogobee's ledger uses and the one an Authentik username maps onto. +CREATE TABLE IF NOT EXISTS game_chips ( + matrix_user TEXT PRIMARY KEY, + chips INTEGER NOT NULL DEFAULT 0, + -- Advisory only, and stale by design: the last euro balance gogobee told us + -- about. Displayed, never trusted. The authoritative check is the debit at + -- claim time, which happens on gogobee's box against gogobee's ledger. + euro_balance REAL, + last_played INTEGER NOT NULL DEFAULT 0, -- unix; the reaper reads this + updated_at INTEGER NOT NULL DEFAULT 0 +); + +-- One crossing of the euro/chip border, in either direction. +-- +-- requested -> claimed -> funded (buy-in: gogobee debited, chips spendable) +-- -> rejected (buy-in: insufficient funds, no chips) +-- requested -> claimed -> settled (cash-out: chips gone, euros credited) +-- +-- The guid is the idempotency key end to end: it's what gogobee passes to +-- DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be retried +-- without the player paying twice. +CREATE TABLE IF NOT EXISTS game_escrow ( + guid TEXT PRIMARY KEY, + matrix_user TEXT NOT NULL, + kind TEXT NOT NULL, -- 'buyin' | 'cashout' + amount INTEGER NOT NULL, -- euros == chips + state TEXT NOT NULL, -- see the ladder above + reason TEXT, -- 'insufficient_funds', when rejected + balance_after REAL, -- gogobee's euro balance after the move + created_at INTEGER NOT NULL, + claimed_at INTEGER, -- when gogobee took it; drives the re-poll + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_game_escrow_state ON game_escrow(state, created_at); +CREATE INDEX IF NOT EXISTS idx_game_escrow_user ON game_escrow(matrix_user, created_at DESC); + +-- Every hand played, for money. This is the audit trail: seeds so a disputed +-- hand can be re-dealt exactly as it fell, rake so the house's take is +-- accountable, and enough shape to answer "how fast is this economy actually +-- moving" before the answer becomes a problem. +CREATE TABLE IF NOT EXISTS game_hands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + matrix_user TEXT NOT NULL, + game TEXT NOT NULL, -- 'blackjack' + bet INTEGER NOT NULL, + payout INTEGER NOT NULL, -- chips returned, net of rake + rake INTEGER NOT NULL, + outcome TEXT NOT NULL, + seed1 INTEGER NOT NULL, -- the shoe, reproducible + seed2 INTEGER NOT NULL, + played_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_game_hands_user ON game_hands(matrix_user, played_at DESC); +CREATE INDEX IF NOT EXISTS idx_game_hands_played ON game_hands(played_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel); CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id); CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);