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:
@@ -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
|
// 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
|
// of rake, exactly as the engine computed it. A losing hand awards nothing and
|
||||||
// should not call this.
|
// 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 {
|
func Award(user string, amount int64) error {
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
now := nowUnix()
|
tx, err := Get().Begin()
|
||||||
if _, err := Get().Exec(
|
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)
|
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(matrix_user) DO UPDATE SET
|
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
|
// 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.
|
// how a dispute gets answered with a fact instead of an apology.
|
||||||
func RecordHand(h Hand) error {
|
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)
|
`INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome,
|
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 {
|
); err != nil {
|
||||||
return fmt.Errorf("games: record hand: %w", err)
|
return fmt.Errorf("games: record hand: %w", err)
|
||||||
}
|
}
|
||||||
@@ -588,6 +631,127 @@ func ClearLiveHand(user string) error {
|
|||||||
return nil
|
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
|
// HouseTake is the total rake collected since a given time — the number that
|
||||||
// answers "is this economy inflating".
|
// answers "is this economy inflating".
|
||||||
func HouseTake(since int64) (int64, error) {
|
func HouseTake(since int64) (int64, error) {
|
||||||
|
|||||||
193
internal/storage/settle_test.go
Normal file
193
internal/storage/settle_test.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The settle used to be four autocommit statements in the web layer — save the
|
||||||
|
// state, award the payout, write the audit row, clear the felt — sequenced so
|
||||||
|
// that a crash between any two of them cost the player as little as possible.
|
||||||
|
//
|
||||||
|
// These are the tests for the thing that replaced it. They are less about the
|
||||||
|
// happy path (the game tests already cover that: a hand pays what it says it
|
||||||
|
// pays) and more about the two properties the old shape did not have, and which
|
||||||
|
// a shared table with a pot in it cannot do without.
|
||||||
|
|
||||||
|
func liveBlob(game string) LiveHand {
|
||||||
|
return LiveHand{Game: game, State: []byte(`{"phase":"player"}`), Seed1: 7, Seed2: 9}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A settled hand moves the money, writes the audit row, and leaves the felt
|
||||||
|
// empty — and it does all three or none of them.
|
||||||
|
//
|
||||||
|
// The old code logged and carried on if the clear failed. That is the double-pay:
|
||||||
|
// a settled hand still sitting in game_live_hands is a hand that settles again on
|
||||||
|
// the next request, and pays again with it.
|
||||||
|
func TestSettleMovesTheMoneyAndTheFeltTogether(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
|
||||||
|
if err := Stake(player, 200); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CommitHand(player, Commit{
|
||||||
|
Live: liveBlob("blackjack"), Fresh: true, Stake: 200,
|
||||||
|
Done: true, Payout: 390,
|
||||||
|
Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 390, Rake: 10, Outcome: "won"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paid: 1000 - 200 staked + 390 back.
|
||||||
|
if c := chipsOf(t, player); c != 1190 {
|
||||||
|
t.Fatalf("chips = %d after a 390 payout on a 200 stake, want 1190", c)
|
||||||
|
}
|
||||||
|
// The felt is empty, which is what stops it settling a second time.
|
||||||
|
if _, err := LoadLiveHand(player); !errors.Is(err, ErrNoLiveHand) {
|
||||||
|
t.Fatalf("live hand after settle: err = %v, want ErrNoLiveHand", err)
|
||||||
|
}
|
||||||
|
// And the house took its cut, once.
|
||||||
|
take, err := HouseTake(0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if take != 10 {
|
||||||
|
t.Fatalf("house take = %d, want 10", take)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A seat that is already taken refuses the game *and hands the stake back*, in
|
||||||
|
// the same transaction that refused it.
|
||||||
|
//
|
||||||
|
// This was two statements: the save came back ErrHandInProgress, and then a
|
||||||
|
// separate Award put the chips back. A crash in between took a player's stake for
|
||||||
|
// a game that never existed anywhere — no felt, no audit row, no way to find it.
|
||||||
|
func TestARefusedSeatGivesTheStakeBackInTheSameBreath(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
|
||||||
|
// A game is already in progress.
|
||||||
|
if err := Stake(player, 200); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CommitHand(player, Commit{Live: liveBlob("uno"), Fresh: true, Stake: 200}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 800 {
|
||||||
|
t.Fatalf("chips = %d with 200 staked on a live game, want 800", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second game is dealt on top of it: the stake leaves, as it must, in the
|
||||||
|
// same statement that checks it's there.
|
||||||
|
if err := Stake(player, 300); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := CommitHand(player, Commit{
|
||||||
|
Live: liveBlob("blackjack"), Fresh: true, Stake: 300,
|
||||||
|
Done: true, Payout: 600, // a natural, which would settle the instant it's dealt
|
||||||
|
Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 300, Payout: 600},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrHandInProgress) {
|
||||||
|
t.Fatalf("err = %v, want ErrHandInProgress", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 300 is back, and the natural was *not* paid — a game that was never
|
||||||
|
// seated must not settle.
|
||||||
|
if c := chipsOf(t, player); c != 800 {
|
||||||
|
t.Fatalf("chips = %d after a refused deal, want 800 (the 300 refunded, the 600 never paid)", c)
|
||||||
|
}
|
||||||
|
// And the game already in progress is untouched. This is the bit that matters:
|
||||||
|
// a natural dealt on top of a live game used to be able to settle, clear the
|
||||||
|
// felt, and take the other game's stake down with it.
|
||||||
|
live, err := LoadLiveHand(player)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the live game is gone: %v", err)
|
||||||
|
}
|
||||||
|
if live.Game != "uno" {
|
||||||
|
t.Fatalf("live game = %q, want the uno game still sitting there", live.Game)
|
||||||
|
}
|
||||||
|
// Nothing was recorded, because nothing finished.
|
||||||
|
if take, err := HouseTake(0); err != nil || take != 0 {
|
||||||
|
t.Fatalf("house take = %d (err %v), want 0 — no hand finished", take, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deadlock canary.
|
||||||
|
//
|
||||||
|
// SQLite runs at MaxOpenConns(1), so the connection is a global mutex. A bare
|
||||||
|
// Get().Exec inside an open transaction waits for the one connection that the
|
||||||
|
// transaction is holding, and waits forever — it is not an error, it is a hung
|
||||||
|
// process, and because the news app shares the pool it hangs that too.
|
||||||
|
//
|
||||||
|
// So: if anybody ever reaches for Award or RecordHand (rather than award/
|
||||||
|
// recordHand) from inside CommitHand, this test stops returning. It does not
|
||||||
|
// fail with a nice message; it hangs, and the timeout is the message. That is
|
||||||
|
// exactly the failure mode in production, which is the point of catching it here.
|
||||||
|
func TestTheSettleDoesNotDeadlockAgainstItsOwnConnection(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
if err := Stake(player, 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- CommitHand(player, Commit{
|
||||||
|
Live: liveBlob("hangman"), Fresh: true, Stake: 100,
|
||||||
|
Done: true, Payout: 234,
|
||||||
|
Audit: Hand{MatrixUser: player, Game: "hangman", Bet: 100, Payout: 234, Rake: 12, Outcome: "won"},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("CommitHand did not return: something inside the transaction is waiting " +
|
||||||
|
"for the connection the transaction is holding. Look for a Get().Exec that " +
|
||||||
|
"should be a tx.Exec.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c := chipsOf(t, player); c != 1134 {
|
||||||
|
t.Fatalf("chips = %d, want 1134", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Being paid moves the idle clock, so the reaper leaves a player who is
|
||||||
|
// mid-session alone. Touch used to be a separate statement after the settle; it
|
||||||
|
// is inside it now, and this is what would notice if it got dropped on the way.
|
||||||
|
func TestSettlingKeepsTheReaperAway(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
|
||||||
|
// Backdate the session well past the reaper's patience.
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`,
|
||||||
|
nowUnix()-int64((2 * SessionIdleAfter).Seconds()), player,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Stake(player, 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CommitHand(player, Commit{
|
||||||
|
Live: liveBlob("trivia"), Fresh: true, Stake: 100,
|
||||||
|
Done: true, Payout: 150,
|
||||||
|
Audit: Hand{MatrixUser: player, Game: "trivia", Bet: 100, Payout: 150, Outcome: "won"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stacks, _, err := IdleStacks(SessionIdleAfter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(stacks) != 0 {
|
||||||
|
t.Fatalf("the reaper found %d idle stacks; a player who just settled a hand is not idle", len(stacks))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -593,58 +593,44 @@ type finished struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// commit writes a game back and settles it if it's over. It is the money path,
|
// commit writes a game back and settles it if it's over. It is the money path,
|
||||||
// and both games go through it so that neither has to re-derive an ordering
|
// and every game goes through it so that none has to re-derive an ordering that
|
||||||
// that took a while to get right.
|
// took a while to get right.
|
||||||
|
//
|
||||||
|
// The ordering now lives in storage.CommitHand, which does the whole thing —
|
||||||
|
// seat, pay, record, clear, touch — in one transaction. It used to be four
|
||||||
|
// autocommit statements here, carefully sequenced so that a crash between them
|
||||||
|
// cost the player as little as possible. That was survivable for a game owned by
|
||||||
|
// one player. It is not survivable for a game with a pot in it, 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 them twice.
|
||||||
//
|
//
|
||||||
// It returns the table as it now stands. ok is false when it has already
|
// It returns the table as it now stands. ok is false when it has already
|
||||||
// written an error response and the caller must simply return.
|
// written an error response and the caller must simply return.
|
||||||
func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) {
|
func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) {
|
||||||
// Seat the game before doing anything else with it — even one that is already
|
err := storage.CommitHand(user, storage.Commit{
|
||||||
// over, because a blackjack natural settles the instant it's dealt. The insert
|
Live: storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2},
|
||||||
// is what enforces one game at a time, and it has to happen for *every* new
|
Fresh: f.Fresh,
|
||||||
// one: a natural dealt on top of a game already in progress would otherwise
|
Stake: f.Bet,
|
||||||
// settle, clear the felt, and take the other game's stake down with it.
|
Done: f.Done,
|
||||||
live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2}
|
Payout: f.Payout,
|
||||||
save := storage.SaveLiveHand
|
Audit: storage.Hand{
|
||||||
if f.Fresh {
|
|
||||||
save = storage.StartLiveHand
|
|
||||||
}
|
|
||||||
if err := save(user, live); err != nil {
|
|
||||||
if errors.Is(err, storage.ErrHandInProgress) {
|
|
||||||
// Somebody was already sitting here. This game was never seated, so the
|
|
||||||
// chips it staked go back: the player is in one game, not two.
|
|
||||||
_ = storage.Award(user, f.Bet)
|
|
||||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
|
|
||||||
return tableView{}, false
|
|
||||||
}
|
|
||||||
slog.Error("games: save game", "user", user, "game", f.Game, "err", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return tableView{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if f.Done {
|
|
||||||
// Pay first, then clear. If Pete dies between the two, the player has been
|
|
||||||
// paid and the worst case is a settled game still showing on the felt —
|
|
||||||
// which reads as done and can be cleared. The other order loses them a win.
|
|
||||||
if err := storage.Award(user, f.Payout); err != nil {
|
|
||||||
slog.Error("games: award", "user", user, "payout", f.Payout, "err", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return tableView{}, false
|
|
||||||
}
|
|
||||||
if err := storage.RecordHand(storage.Hand{
|
|
||||||
MatrixUser: user, Game: f.Game,
|
MatrixUser: user, Game: f.Game,
|
||||||
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
|
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
|
||||||
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
|
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
|
||||||
}); err != nil {
|
},
|
||||||
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's game
|
})
|
||||||
}
|
switch {
|
||||||
if err := storage.ClearLiveHand(user); err != nil {
|
case errors.Is(err, storage.ErrHandInProgress):
|
||||||
slog.Error("games: clear game", "user", user, "err", err)
|
// Somebody was already sitting here. The game was never seated and the chips
|
||||||
}
|
// it staked have gone back — in the same transaction that refused it.
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
|
||||||
|
return tableView{}, false
|
||||||
|
case err != nil:
|
||||||
|
slog.Error("games: commit", "user", user, "game", f.Game, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return tableView{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.Touch(user)
|
|
||||||
|
|
||||||
v, err := s.table(user)
|
v, err := s.table(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("games: table", "user", user, "err", err)
|
slog.Error("games: table", "user", user, "err", err)
|
||||||
|
|||||||
@@ -14,9 +14,25 @@ A multi-session build. This section is the handover; read it before anything els
|
|||||||
|
|
||||||
### Start here (next session)
|
### Start here (next session)
|
||||||
|
|
||||||
**The whole casino is live.** *(2026-07-14.)* All six games (seven, counting the
|
**The casino is going multiplayer, and the settle path has been rewritten to
|
||||||
No Mercy dial) are deployed and playing on https://games.parodia.dev. Nothing is
|
survive it.** *(2026-07-14, later.)* The user asked for Blackjack, UNO and
|
||||||
queued — what is left is the open list at the bottom of "Next, in order".
|
Hold'em to be playable against real people. The design is settled (see "The
|
||||||
|
multiplayer build" below), and **Phase A — the atomic settle — is done, tested
|
||||||
|
and driven.** Nothing a player can see has changed. The next thing to build is
|
||||||
|
Phase B, the table runtime.
|
||||||
|
|
||||||
|
**And the deploy note was stale again — for the second time.** §0 said "a deploy
|
||||||
|
is owed, three commits behind". It wasn't: the box is on `a5b7e41`, its binary was
|
||||||
|
built three minutes after that commit landed, the front door serves a public 200
|
||||||
|
and `/og.png` renders. Everything is live. This is the *same note* that was wrong
|
||||||
|
two sessions ago, with the *same* lesson written directly underneath it, and it
|
||||||
|
went stale again anyway. Draw the obvious conclusion: **a hand-written record of
|
||||||
|
what is deployed will rot. Ask the box.** One `git log -1` in `/opt/pete`, one
|
||||||
|
`stat` on the binary, and one `curl` at a route only the new code serves — that is
|
||||||
|
thirty seconds and it is never wrong.
|
||||||
|
|
||||||
|
**The whole casino is live.** All six games (seven, counting the No Mercy dial)
|
||||||
|
are deployed and playing on https://games.parodia.dev.
|
||||||
|
|
||||||
**And the deploy note this plan had been carrying was wrong.** §0 said "only
|
**And the deploy note this plan had been carrying was wrong.** §0 said "only
|
||||||
blackjack is live" for two sessions running. It wasn't: hangman, solitaire, trivia
|
blackjack is live" for two sessions running. It wasn't: hangman, solitaire, trivia
|
||||||
@@ -110,12 +126,165 @@ restart would otherwise be a player whose cards vanished.
|
|||||||
|
|
||||||
### Deployed, as of right now
|
### Deployed, as of right now
|
||||||
|
|
||||||
The live box (`/opt/pete`) is on **`03524ae`**, and main is three commits past it.
|
Everything through **`a5b7e41`** is on the box and running. *Verified by asking
|
||||||
Undeployed: the **UNO call-uno + live hand redraw** fix (`39ed293`), the **front
|
the box, not by trusting this line — which is the only way this line is worth
|
||||||
door and share card** (`7ca1f7a`), and **blackjack's split** (`6f34a89`). The UNO
|
anything. It has been wrong twice.*
|
||||||
one is a bug that is live right now: your own hand doesn't redraw during a lap.
|
|
||||||
The user has seen this list and chose to hold the deploy for now — *ask before
|
---
|
||||||
assuming it went out.*
|
|
||||||
|
## The multiplayer build
|
||||||
|
|
||||||
|
Blackjack, UNO and Hold'em, played against real people. This inverts the core
|
||||||
|
entity from **player** to **table**, and the whole difficulty is that two things
|
||||||
|
which were safe stop being safe: the settle path was written for one player and
|
||||||
|
one bet, and a table has two writers (an HTTP move, and a turn clock acting for
|
||||||
|
whoever walked away) where it used to have one.
|
||||||
|
|
||||||
|
### Decisions taken (from the user, 2026-07-14)
|
||||||
|
|
||||||
|
- **SSE, not WebSocket.** Latency was never the argument — a turn-based card game
|
||||||
|
does not care. The argument is that moves keep their existing POST endpoints and
|
||||||
|
the whole money path with them; a socket would mean rebuilding auth, ordering
|
||||||
|
and the double-click 409 on a message loop. EventSource also reconnects itself.
|
||||||
|
Chat is a POST up and an event down.
|
||||||
|
- The line where a socket *would* start paying for itself is **typing
|
||||||
|
indicators** — continuous upstream traffic. The user looked at that and said
|
||||||
|
messages only. If that changes, so does the transport.
|
||||||
|
- Plain polling was rejected for a specific reason: `MaxOpenConns(1)`. Every
|
||||||
|
idle player at a felt polling once a second is a serialized query against the
|
||||||
|
single connection the news app also shares.
|
||||||
|
- **Bots fill empty seats.** A human sitting down bumps a bot, so solo play is just
|
||||||
|
"a table nobody else joined yet" and there is no second mode to maintain.
|
||||||
|
- **UNO becomes a pot**, and the house multiple is deleted. Everyone antes, first
|
||||||
|
one out takes the pot less rake, bots ante from the house. The payout becomes
|
||||||
|
arithmetic rather than a measurement — which **kills the tier-drift problem and
|
||||||
|
`TestTheMultiplesAreStillPriced` with it**.
|
||||||
|
- The consequence was surfaced and accepted, and it is worth understanding
|
||||||
|
before anybody "fixes" it: against bots the pot is **harsher** than today.
|
||||||
|
Naive play loses ~20% a game rather than ~8%, because the bots beat a naive
|
||||||
|
player 60/40 heads-up and a pot does not compensate the way a 2.4× multiple
|
||||||
|
did. A pot is only fair between equals. **The casino already ships this exact
|
||||||
|
deal in hold'em** — you play bots for real chips and the house takes only rake
|
||||||
|
— so the outcome is one money model across the room instead of two.
|
||||||
|
- **Chat stays on the felt.** It does not mirror into Matrix.
|
||||||
|
- Tables are found through a **public lobby**. Room codes later, if ever; bots mean
|
||||||
|
a table is never empty.
|
||||||
|
|
||||||
|
### The four rules the runtime has to obey
|
||||||
|
|
||||||
|
Not style preferences. Breaking the first two hangs the process or mints money.
|
||||||
|
|
||||||
|
1. **Table lock first, DB second. Never begin a transaction before you hold the
|
||||||
|
lock; never take a lock while holding one.** SQLite is at `MaxOpenConns(1)`, so
|
||||||
|
the connection *is* a global mutex. A per-table mutex makes two locks, and two
|
||||||
|
locks in two orders is a deadlock that eats the only connection — taking the
|
||||||
|
news app down with the casino.
|
||||||
|
2. **The primary key decides, not a prior read.** Already this repo's idiom
|
||||||
|
(`StartLiveHand`'s `ON CONFLICT DO NOTHING`; `game_escrow.guid`). Every new
|
||||||
|
money path gets the same treatment.
|
||||||
|
3. **A settle is one transaction.** See Phase A.
|
||||||
|
4. **A `version` column is the concurrency authority; the mutex is only an
|
||||||
|
optimisation.** A mutex does not survive a redeploy — during a drain two
|
||||||
|
processes hold two different mutexes over the same row — and a mutex map you can
|
||||||
|
`delete()` from will hand two goroutines two different locks for one table.
|
||||||
|
|
||||||
|
### Phase A — the atomic settle. **Done.**
|
||||||
|
|
||||||
|
Shipped on its own, against the existing single-player games, changing nothing a
|
||||||
|
player can see. Every multiplayer money bug is downstream of it.
|
||||||
|
|
||||||
|
`commit()` was four separate autocommit statements: save → `Award` → `RecordHand`
|
||||||
|
→ `ClearLiveHand`, carefully sequenced so a crash between any two cost the player
|
||||||
|
as little as possible. That reasoning is sound for a game owned by one player, and
|
||||||
|
the old comment made it well. **It does not survive a pot.** 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 them into euros.
|
||||||
|
|
||||||
|
Worse, the obvious fix is a trap. `storage.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 the news app goes
|
||||||
|
with it.
|
||||||
|
|
||||||
|
So: `storage.CommitHand` does the lot — seat, pay, record, clear, touch — in one
|
||||||
|
`Begin`/`Commit`, with tx-taking `award`/`recordHand` helpers beside the public
|
||||||
|
ones. The model already existed: `addChips(tx, …)` has done it this way since the
|
||||||
|
escrow ledger was written.
|
||||||
|
|
||||||
|
Two things fell out that are worth keeping:
|
||||||
|
|
||||||
|
- **The refuse-and-refund is now atomic.** A deal landing on a taken seat used to
|
||||||
|
come back `ErrHandInProgress` and *then* refund in a separate statement — so a
|
||||||
|
crash in between took a player's stake for a game that existed nowhere: no felt,
|
||||||
|
no audit row, no way to find it. It is one transaction now.
|
||||||
|
- **The audit row moved inside the settle**, which means a failure to write it now
|
||||||
|
rolls the payout back rather than paying quietly and logging. Deliberate: 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 — the game stays live and settles again on
|
||||||
|
the next request.
|
||||||
|
|
||||||
|
**`TestTheSettleDoesNotDeadlockAgainstItsOwnConnection` is a canary, and it has
|
||||||
|
been made to sing.** Reintroduce the bug (call `Award` instead of `award` inside
|
||||||
|
`CommitHand`) and it does not fail with a nice message — it *hangs*, and the
|
||||||
|
timeout is the message, which is exactly the production failure. It was verified by
|
||||||
|
putting the bug back and watching it catch it. A canary that has never sung is
|
||||||
|
just a bird.
|
||||||
|
|
||||||
|
Driven end to end through the real HTTP path, not just unit tests: eight blackjack
|
||||||
|
hands conserving to the chip across win/lose/push (a natural is the sharp case —
|
||||||
|
it is `Fresh` *and* `Done` in one `CommitHand`), a double-deal 409 that refunds and
|
||||||
|
leaves the live game untouched, hangman, and a hold'em session (sit 200, fold the
|
||||||
|
blinds, get up with 197).
|
||||||
|
|
||||||
|
### Phase B — the table runtime. **Next.**
|
||||||
|
|
||||||
|
Full design in `~/.claude/plans/imperative-mixing-emerson.md`. The load-bearing
|
||||||
|
parts, which are the ones that are easy to get wrong:
|
||||||
|
|
||||||
|
- **Occupancy stays in `game_live_hands`** — add a nullable `table_id` rather than
|
||||||
|
making `game_seats.matrix_user` a second uniqueness domain. A split brain there
|
||||||
|
silently switches off three guards that already work, and the worst is the
|
||||||
|
cash-out check (`games_play.go`), which reads `LoadLiveHand`: a seated player
|
||||||
|
with no live-hand row could **cash out to zero while sitting at a poker table
|
||||||
|
with chips in the pot.**
|
||||||
|
- **The turn clock is the first goroutine in Pete that has ever mutated game
|
||||||
|
state.** Copy `StartTriviaBank`'s shape. It must collect table ids and *close the
|
||||||
|
rows* before taking any lock (rule 1), and act only if the `version` still
|
||||||
|
matches the one it saw at scan time. Without that check: Bob's raise lands in the
|
||||||
|
same second his clock expires, action passes to Cara, and the clock then **folds
|
||||||
|
Cara**, who had 25 seconds left. A one-second window that recurs on every turn of
|
||||||
|
every hand.
|
||||||
|
- **An absent human is not a bot**, and this is a product bug before it is a money
|
||||||
|
bug. The bot loop stops dead at a disconnected player's seat and waits out the
|
||||||
|
full clock; a table with three ghosts spends a minute an orbit folding air and
|
||||||
|
the one real player leaves. Seats go `away` after a timeout and get auto-acted.
|
||||||
|
- **SSE frames publish under the table lock** (which orders them correctly for
|
||||||
|
free) but with **non-blocking sends only** — one phone on a train must not block
|
||||||
|
a send, hold the lock, and stall the clock for the whole casino. And **the SSE
|
||||||
|
handler must never touch the DB after its first read**: holding rows open for the
|
||||||
|
life of a stream holds the only connection forever, and one subscriber bricks the
|
||||||
|
application.
|
||||||
|
- **`dropUnreadable` must never fire on a multi-seat table.** It discards the game
|
||||||
|
and keeps the stake — defensible for one player, but at a shared table it
|
||||||
|
silently incinerates four players' stacks.
|
||||||
|
- **`ReapIdleSessions` has never actually run.** Zero production callers; its doc
|
||||||
|
says "safe to run on a timer" and nothing ever put it on one, so chips in
|
||||||
|
abandoned sessions have been in limbo all along. Wire it while wiring the clock.
|
||||||
|
|
||||||
|
Then: **C — Hold'em** (its engine is already multiway: real seats, side pots,
|
||||||
|
`ToAct`. Mostly letting more than one seat be `Bot: false`. **Write the per-seat
|
||||||
|
redaction test first** — after SSE, one missed `i == You` fans every hole card to
|
||||||
|
every subscriber). **D — UNO** (`const You = 0` becomes a seat parameter, ~40
|
||||||
|
sites; the pot). **E — Blackjack** (seats invented from scratch, but the simplest
|
||||||
|
turn model. **Shuffle every round**: a persistent shoe broadcast over SSE is a
|
||||||
|
countable shoe with a free API, and counting is a real +EV attack on real euros).
|
||||||
|
|
||||||
|
Two that are easy to miss: **`RecordHand` takes one user, so `HouseTake` will
|
||||||
|
lie** — four rows each carrying the pot's full rake reports 4× the real take, and
|
||||||
|
that number is the inflation brake the economy was sized on. And **seeds stop
|
||||||
|
reproducing a hand**: at a shared table the cards depend on the order the others
|
||||||
|
acted, so the audit trail quietly stops being one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Decisions taken (these close §9's open questions)
|
### Decisions taken (these close §9's open questions)
|
||||||
|
|
||||||
@@ -648,13 +817,15 @@ assuming it went out.*
|
|||||||
|
|
||||||
### Next, in order
|
### Next, in order
|
||||||
|
|
||||||
1. **A deploy is owed.** Three commits are on main and not on the box (see
|
1. **Phase B — the table runtime** (see "The multiplayer build" above). Phase A is
|
||||||
"Deployed, as of right now" above), one of them a live UNO bug. Everything is
|
done; nothing is half-built.
|
||||||
built and browser-verified; nothing is half-done.
|
2. Then C (hold'em), D (UNO), E (blackjack).
|
||||||
2. Then the open list, none of it blocking, none of it promised: **hold'em has no
|
3. The open list, none of it blocking, none of it promised: **hold'em has no
|
||||||
multiway policy**; the trivia bank refills on a 12h tick (`games: trivia bank
|
multiway policy** (and multiplayer makes that matter more, not less — a
|
||||||
refill started target=400`), so a player trying a ladder in the first minute
|
six-handed table of humans is not the heads-up game the policy was trained on);
|
||||||
after a fresh deploy can still meet a 503.
|
the trivia bank refills on a 12h tick (`games: trivia bank refill started
|
||||||
|
target=400`), so a player trying a ladder in the first minute after a fresh
|
||||||
|
deploy can still meet a 503.
|
||||||
|
|
||||||
Still open on hold'em: the policy is **heads-up**, so a six-handed table is an
|
Still open on hold'em: the policy is **heads-up**, so a six-handed table is an
|
||||||
approximation of it (the hit rate falls from 95% to about 17% at six seats, and
|
approximation of it (the hit rate falls from 95% to about 17% at six seats, and
|
||||||
|
|||||||
Reference in New Issue
Block a user