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
95 lines
2.0 KiB
Go
95 lines
2.0 KiB
Go
package web
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func TestHub_DeliversToSubscribers(t *testing.T) {
|
|
h := newGamesHub()
|
|
ch, done := h.subscribe("t1")
|
|
defer done()
|
|
|
|
h.publish("t1", hubFrame{Version: 3, Data: []byte("hi")})
|
|
f := <-ch
|
|
if f.Version != 3 || string(f.Data) != "hi" {
|
|
t.Fatalf("got %+v", f)
|
|
}
|
|
}
|
|
|
|
func TestHub_OnlyToTheRightTable(t *testing.T) {
|
|
h := newGamesHub()
|
|
ch1, d1 := h.subscribe("t1")
|
|
defer d1()
|
|
ch2, d2 := h.subscribe("t2")
|
|
defer d2()
|
|
|
|
h.publish("t1", hubFrame{Version: 1})
|
|
select {
|
|
case <-ch2:
|
|
t.Fatal("t2 should not have received t1's frame")
|
|
default:
|
|
}
|
|
if f := <-ch1; f.Version != 1 {
|
|
t.Fatalf("t1 got %+v", f)
|
|
}
|
|
}
|
|
|
|
// TestHub_PublishNeverBlocks is the load-bearing property: a subscriber that
|
|
// never reads must not be able to hold up a publish, because publish happens
|
|
// under the table lock and a blocked publish stalls the turn clock for everyone.
|
|
func TestHub_PublishNeverBlocks(t *testing.T) {
|
|
h := newGamesHub()
|
|
_, done := h.subscribe("t1") // never read from
|
|
defer done()
|
|
|
|
// Far more than the buffer. If any of these blocked, the test would hang.
|
|
blocked := make(chan struct{})
|
|
go func() {
|
|
for i := 0; i < subChanBuffer*10; i++ {
|
|
h.publish("t1", hubFrame{Version: int64(i)})
|
|
}
|
|
close(blocked)
|
|
}()
|
|
<-blocked
|
|
}
|
|
|
|
func TestHub_UnsubscribeStopsDelivery(t *testing.T) {
|
|
h := newGamesHub()
|
|
ch, done := h.subscribe("t1")
|
|
done()
|
|
|
|
if h.watchers("t1") != 0 {
|
|
t.Fatalf("watchers should be 0 after unsubscribe, got %d", h.watchers("t1"))
|
|
}
|
|
h.publish("t1", hubFrame{Version: 1})
|
|
select {
|
|
case _, ok := <-ch:
|
|
if ok {
|
|
t.Fatal("a frame arrived after unsubscribe")
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestHub_ConcurrentSubscribers(t *testing.T) {
|
|
h := newGamesHub()
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
ch, done := h.subscribe("t1")
|
|
defer done()
|
|
<-ch
|
|
}()
|
|
}
|
|
// Let them all register, then flood so every one of them reads at least one.
|
|
for h.watchers("t1") < 50 {
|
|
}
|
|
for i := 0; i < subChanBuffer; i++ {
|
|
h.publish("t1", hubFrame{Version: int64(i)})
|
|
}
|
|
wg.Wait()
|
|
}
|