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

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