Files
Pete/internal/storage/settle_test.go
prosolis 1f1a6cb6e8 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
2026-07-14 15:28:54 -07:00

194 lines
6.7 KiB
Go

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