games: the felt other people can sit at, and the version that settles the race
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
This commit is contained in:
94
internal/web/games_hub_test.go
Normal file
94
internal/web/games_hub_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user