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

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