games: the euro/chip border, and the ledger that keeps it honest

A euro is either in gogobee's balances or in Pete's chip escrow, never both. It
crosses only via a game_escrow row whose guid is the same idempotency key gogobee
hands to DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be
retried without the player paying twice.

The border exists because gogobee has no inbound API and isn't getting one, so it
polls. A bet that round-tripped through a poll loop would take seconds to be
dealt. Instead the loop runs twice per session — buy in, cash out — and every hand
between them plays against chips held here, with no economy call in the hot path.

Two rules do most of the work. Chips appear only when gogobee confirms it took the
euros, so a buy-in can't mint money out of a pending request. Chips are destroyed
the moment a cash-out opens, so a player can't bet chips whose euros are already
in flight — and if the credit fails, they come back rather than evaporating.

Also: the €10k table cap counts in-flight buy-ins, so it can't be cleared by
firing several at once; a reaper cashes out anyone idle for 30 minutes, because
chips in an abandoned session are euros in limbo; and every hand is logged with
its seed, so a disputed hand gets answered with a re-deal instead of an apology.
This commit is contained in:
prosolis
2026-07-13 22:48:55 -07:00
parent 8310b30439
commit f9a98f72a6
3 changed files with 1076 additions and 0 deletions

516
internal/storage/games.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -159,6 +159,72 @@ CREATE TABLE IF NOT EXISTS story_views (
PRIMARY KEY (story_id, day) 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 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_event_id ON post_log(event_id);
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at); CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);