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

@@ -593,58 +593,44 @@ type finished struct {
}
// 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
// that took a while to get right.
// and every game goes through it so that none has to re-derive an ordering that
// 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
// written an error response and the caller must simply return.
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
// over, because a blackjack natural settles the instant it's dealt. The insert
// is what enforces one game at a time, and it has to happen for *every* new
// one: a natural dealt on top of a game already in progress would otherwise
// settle, clear the felt, and take the other game's stake down with it.
live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2}
save := storage.SaveLiveHand
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{
err := storage.CommitHand(user, storage.Commit{
Live: storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2},
Fresh: f.Fresh,
Stake: f.Bet,
Done: f.Done,
Payout: f.Payout,
Audit: storage.Hand{
MatrixUser: user, Game: f.Game,
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
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
}
if err := storage.ClearLiveHand(user); err != nil {
slog.Error("games: clear game", "user", user, "err", err)
}
},
})
switch {
case errors.Is(err, storage.ErrHandInProgress):
// 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)
if err != nil {
slog.Error("games: table", "user", user, "err", err)