Phase B foundation for the multiplayer casino: the shared-table storage layer, the SSE fan-out, and the lock that only ever pretends to be the authority. - game_tables/game_seats/game_chat, plus a nullable table_id on game_live_hands so occupancy stays one row per player — the same primary key that stops a second solo hand stops a second seat. No second uniqueness domain, no split brain, no cash-out-to-zero while sitting on a pot. - The money model the plan sketched turned out simpler than it drew: chips cross the border only at sit-down and get-up, so a hand settles by moving the pot *within* the state blob and credits nobody. That deletes the payout ledger the design called for — there is no money write to make idempotent, only a state write conditional on the version. A replayed settle affects zero rows. - CommitTable/SitDown/LeaveTable each one transaction with the state write in it; the version column is the concurrency authority and the striped in-memory lock is only an optimisation over it, because a mutex does not survive a redeploy. - The SSE hub is a dumb byte fan-out: non-blocking sends (a stalled phone must not hold the table lock and freeze the clock for the room) and never a DB touch after the first read (holding the one connection open bricks the app). - DueTables/PushDeadlines for the turn clock to come; Chat keeps the hand_no it was said during, because at a money table collusion looks like chat. Storage and hub tested, including the version race and the never-block publish. No handlers wired yet, so nothing a player can see has changed. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
118 lines
4.0 KiB
Go
118 lines
4.0 KiB
Go
package web
|
|
|
|
import (
|
|
"sync"
|
|
"sync/atomic"
|
|
)
|
|
|
|
// The SSE hub: how a move one player makes reaches the phones of everyone else
|
|
// at the felt.
|
|
//
|
|
// It is in-memory and it is intentionally dumb. It holds no game state and makes
|
|
// no decisions — it is a fan-out of opaque byte frames, keyed by table id. The
|
|
// authority is always the database; a frame is a nudge that says "the table at
|
|
// this version changed, come and look", and a subscriber that misses one (a
|
|
// dropped send, a reconnect) refetches the table, which is authoritative anyway.
|
|
// So a lost frame is a cosmetic hiccup, never a wrong balance.
|
|
//
|
|
// Two rules hold it together, and both are load-bearing:
|
|
//
|
|
// 1. **Sends are non-blocking.** A subscriber's channel is buffered, and a send
|
|
// that would block is dropped, not waited on. The publish happens under the
|
|
// table lock (which is what orders frames correctly for free), so a blocking
|
|
// send would hold that lock while one phone on a train stalls — and the turn
|
|
// clock behind that lock stalls with it, for the whole casino. A dropped frame
|
|
// costs that one subscriber a refetch; a held lock costs everyone the room.
|
|
//
|
|
// 2. **The publisher never touches the database.** The hub is reached only after
|
|
// the DB work is done and the connection released. Holding a *sql.Rows or a tx
|
|
// open for the life of a stream would hold the one connection in the pool
|
|
// forever, and a single subscriber would brick the whole application.
|
|
|
|
// hubFrame is what goes down the wire: an opaque payload the browser knows how to
|
|
// read (a JSON table view), tagged with the version it represents so a subscriber
|
|
// can tell a frame it already has from one it missed.
|
|
type hubFrame struct {
|
|
Version int64
|
|
Data []byte
|
|
}
|
|
|
|
// tableSub is one open EventSource: a buffered channel and the id that lets the
|
|
// subscriber unregister itself when the stream closes.
|
|
type tableSub struct {
|
|
id int64
|
|
ch chan hubFrame
|
|
}
|
|
|
|
// gamesHub fans table frames out to whoever is watching each table.
|
|
type gamesHub struct {
|
|
mu sync.Mutex
|
|
tables map[string]map[int64]*tableSub
|
|
nextID atomic.Int64
|
|
}
|
|
|
|
func newGamesHub() *gamesHub {
|
|
return &gamesHub{tables: make(map[string]map[int64]*tableSub)}
|
|
}
|
|
|
|
// subChanBuffer is how many frames a slow subscriber can fall behind before the
|
|
// hub starts dropping theirs. A few is plenty: a subscriber that far behind is
|
|
// going to refetch the authoritative table anyway, so buffering more just delays
|
|
// that with staler frames.
|
|
const subChanBuffer = 8
|
|
|
|
// subscribe registers a new watcher of a table and returns its channel plus the
|
|
// unsubscribe to defer. The channel is buffered so a publish never blocks on a
|
|
// reader that is mid-write to its socket.
|
|
func (h *gamesHub) subscribe(tableID string) (<-chan hubFrame, func()) {
|
|
sub := &tableSub{id: h.nextID.Add(1), ch: make(chan hubFrame, subChanBuffer)}
|
|
|
|
h.mu.Lock()
|
|
subs := h.tables[tableID]
|
|
if subs == nil {
|
|
subs = make(map[int64]*tableSub)
|
|
h.tables[tableID] = subs
|
|
}
|
|
subs[sub.id] = sub
|
|
h.mu.Unlock()
|
|
|
|
return sub.ch, func() { h.unsubscribe(tableID, sub.id) }
|
|
}
|
|
|
|
func (h *gamesHub) unsubscribe(tableID string, id int64) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
subs := h.tables[tableID]
|
|
if subs == nil {
|
|
return
|
|
}
|
|
delete(subs, id)
|
|
if len(subs) == 0 {
|
|
delete(h.tables, tableID)
|
|
}
|
|
}
|
|
|
|
// publish pushes a frame to everyone watching a table, dropping it for any
|
|
// subscriber whose buffer is full rather than waiting on them. See rule 1: this
|
|
// is called under the table lock, so it must never block.
|
|
func (h *gamesHub) publish(tableID string, f hubFrame) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
for _, sub := range h.tables[tableID] {
|
|
select {
|
|
case sub.ch <- f:
|
|
default:
|
|
// Full buffer: this subscriber is behind. Dropping is correct — they will
|
|
// refetch the authoritative table when they next read a version gap.
|
|
}
|
|
}
|
|
}
|
|
|
|
// watchers reports how many streams are open on a table. Used by the caller that
|
|
// decides whether a frame is worth rendering at all.
|
|
func (h *gamesHub) watchers(tableID string) int {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
return len(h.tables[tableID])
|
|
}
|