Files
Pete/internal/web/games_hub_test.go
prosolis 4b3e5fe4c5 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
2026-07-14 15:43:39 -07:00

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