games: the payout that survives the crash, and the note that lied twice

The settle was four autocommit statements — save, award, record, clear —
sequenced so a crash between any two of them cost the player as little as
possible. That reasoning holds for a game owned by one player, and the old
comment made it well. It does not survive a pot, which is what the tables are
about to become: pay the winner, die before the state write, and the hand still
reads as live, so it settles again and pays again. Chips minted from nothing,
and gogobee turns those into euros.

The obvious fix is a trap. Award is a bare Get().Exec, so wrapping the settle in
a transaction makes it wait for the connection the transaction is holding. Not
an error — a hung process, and since the news app shares the pool it goes too.

So storage.CommitHand does the lot in one Begin/Commit, with tx-taking award and
recordHand beside the public ones. addChips has done it this way since the escrow
ledger was written; this is only that pattern, applied where the money is.

Two things fell out. A deal landing on a taken seat used to be refused and *then*
refunded in a separate statement, so a crash in between took a stake for a game
that existed nowhere — no felt, no audit row, nothing to find. And the audit row
is now inside the settle, which means failing to write it rolls the payout back
rather than paying quietly and logging: the payout and the audit row are the same
fact, and a payout nobody can account for is worse than one that didn't happen.

TestTheSettleDoesNotDeadlockAgainstItsOwnConnection is a canary, and it has been
made to sing — put the bug back and it doesn't fail with a message, it hangs, and
the timeout is the message. Which is exactly what production would do. A canary
that has never sung is just a bird.

Nothing a player can see has changed: eight blackjack hands conserving to the
chip across win, lose and push (a natural is the sharp one — Fresh and Done in a
single CommitHand), a double-deal 409 that refunds and leaves the live game
alone, hangman, and a hold'em session that bought in for 200 and got up with 197.

Also: the plan's deploy note was stale for the second time, with the lesson from
the first time written directly underneath it. Everything is live and always was.
A hand-written record of what is deployed will rot. Ask the box.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 15:28:54 -07:00
parent a5b7e41929
commit 1f1a6cb6e8
4 changed files with 577 additions and 63 deletions

View File

@@ -398,12 +398,38 @@ func Stake(user string, amount int64) error {
// 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.
//
// This is the standalone form, for a caller with no transaction of its own. A
// settle must not use it — see award, and the warning on CommitHand.
func Award(user string, amount int64) error {
if amount <= 0 {
return nil
}
now := nowUnix()
if _, err := Get().Exec(
tx, err := Get().Begin()
if err != nil {
return fmt.Errorf("games: begin award: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
if err := award(tx, user, amount, nowUnix()); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit award: %w", err)
}
return nil
}
// award credits a stack inside an open transaction.
//
// It differs from addChips in one deliberate way: it moves last_played, because
// being paid is something that happens at a table and the reaper should see it.
// A buy-in is not — that is why addChips leaves the idle clock alone.
func award(tx *sql.Tx, user string, amount int64, now int64) error {
if amount <= 0 {
return nil
}
if _, err := tx.Exec(
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(matrix_user) DO UPDATE SET
@@ -431,11 +457,28 @@ type Hand struct {
// 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(
tx, err := Get().Begin()
if err != nil {
return fmt.Errorf("games: begin record hand: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
if err := recordHand(tx, h, nowUnix()); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit record hand: %w", err)
}
return nil
}
// recordHand writes the audit row inside an open transaction.
func recordHand(tx *sql.Tx, h Hand, now int64) error {
if _, err := tx.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(),
int64(h.Seed1), int64(h.Seed2), now,
); err != nil {
return fmt.Errorf("games: record hand: %w", err)
}
@@ -588,6 +631,127 @@ func ClearLiveHand(user string) error {
return nil
}
// ---- the settle ------------------------------------------------------------
// Commit is one write-back of a game: the state, and — if the game is over —
// everything settling it takes.
type Commit struct {
Live LiveHand
Fresh bool // a game just started, which is the one write that may be refused
// Stake is what the player put up to open this game. It is refunded, in this
// same transaction, if the seat turns out to be taken. Only meaningful with
// Fresh.
Stake int64
Done bool
Payout int64 // stake plus winnings, net of rake. Zero on a loss.
Audit Hand // the audit row. Ignored unless Done.
}
// CommitHand writes a game back and settles it if it is over — all of it in one
// transaction.
//
// It used to be four separate autocommit statements (save, award, record,
// clear), which was survivable while a game belonged to exactly one player: the
// ordering paid first and cleared second, so a crash in between left a settled
// game on the felt, which reads as done and can be cleared. It does not survive
// a game with a pot in it. Pay the winner, die before the state write, and the
// table still says the hand is live — so it settles a second time and the winner
// is paid twice. Chips minted from nothing, and gogobee will happily turn them
// into euros.
//
// So: one Begin, one Commit, and the money and the state move together or not at
// all.
//
// The rule this enforces, and the reason award/recordHand exist in tx-taking
// form at all: **nothing inside here may call Get().Exec**. The pool runs at
// MaxOpenConns(1), so a bare Exec inside an open transaction waits for the one
// connection that this transaction is holding — forever. It is not an error, it
// is a hung process, and since the news app shares the pool it takes that down
// too. The tx-taking helper is the pattern; addChips has done it this way since
// the escrow ledger was written.
func CommitHand(user string, c Commit) error {
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return fmt.Errorf("games: begin commit: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
// Seat the game first, even one that is already over — a blackjack natural
// settles the instant it is dealt. The INSERT is what enforces one game at a
// time, and it has to happen for *every* new one, or a natural dealt on top of
// a game already in progress would settle, clear the felt, and take the other
// game's stake with it.
if c.Fresh {
res, err := tx.Exec(
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(matrix_user) DO NOTHING`,
user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
)
if err != nil {
return fmt.Errorf("games: start live hand: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
// Somebody is already sitting here. This game was never seated, so the
// chips it staked go back — and they go back *in this transaction*, which
// is the point. As two statements, a crash between the refusal and the
// refund took the player's stake for a game that never existed anywhere.
if err := award(tx, user, c.Stake, now); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit refund: %w", err)
}
return ErrHandInProgress
}
} else if _, err := tx.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, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
); err != nil {
return fmt.Errorf("games: save live hand: %w", err)
}
if c.Done {
if err := award(tx, user, c.Payout, now); err != nil {
return err
}
// The audit row is now inside the transaction with the payout, which means a
// failure to write it rolls the payout back rather than paying quietly and
// logging. That is a deliberate change: the two are the same fact, and a
// payout nobody can account for is worse than a payout that didn't happen —
// the game stays live and settles again on the next request.
if err := recordHand(tx, c.Audit, now); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
return fmt.Errorf("games: clear live hand: %w", err)
}
}
// Touch, folded in: a deliberate action at a table, so the reaper leaves them
// alone. A player with no chip row yet has nothing to touch, and this is a
// no-op for them.
if _, err := tx.Exec(
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
now, now, user,
); err != nil {
return fmt.Errorf("games: touch session: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit 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) {