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
272 lines
7.5 KiB
Go
272 lines
7.5 KiB
Go
package storage
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// openTestTable stands up a table with a full ring of bots and returns it. Six
|
|
// seats, because that is hold'em's ring and the most seats any game here has.
|
|
func openTestTable(t *testing.T, id, game string) Table {
|
|
t.Helper()
|
|
tbl := Table{
|
|
ID: id, Game: game, Tier: "1-2", State: []byte(`{}`),
|
|
Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1,
|
|
}
|
|
seats := make([]Seat, 6)
|
|
for i := range seats {
|
|
seats[i] = Seat{Seat: i, Name: "bot"}
|
|
}
|
|
if err := OpenTable(tbl, seats); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tbl
|
|
}
|
|
|
|
// reload reads a table back and fails the test if it is gone.
|
|
func reload(t *testing.T, id string) (Table, []Seat) {
|
|
t.Helper()
|
|
tbl, seats, err := LoadTable(id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tbl, seats
|
|
}
|
|
|
|
func TestOpenTable_SeatsAreAllBots(t *testing.T) {
|
|
setupTestDB(t)
|
|
openTestTable(t, "t1", "holdem")
|
|
|
|
_, seats := reload(t, "t1")
|
|
if len(seats) != 6 {
|
|
t.Fatalf("want 6 seats, got %d", len(seats))
|
|
}
|
|
for _, s := range seats {
|
|
if !s.Bot() {
|
|
t.Errorf("seat %d should be a bot", s.Seat)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSitDown_MovesChipsOntoTheTable(t *testing.T) {
|
|
setupTestDB(t)
|
|
tbl := openTestTable(t, "t1", "holdem")
|
|
fund(t, player, 5000)
|
|
|
|
if err := SitDown(Sit{
|
|
Table: tbl,
|
|
Seat: Seat{Seat: 2, MatrixUser: player, Name: "reala"},
|
|
BuyIn: 1000,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The chips are off the stack...
|
|
if got := chipsOf(t, player); got != 4000 {
|
|
t.Errorf("stack: want 4000, got %d", got)
|
|
}
|
|
// ...and onto the seat.
|
|
_, seats := reload(t, "t1")
|
|
seat := seats[2]
|
|
if seat.MatrixUser != player || seat.Staked != 1000 {
|
|
t.Errorf("seat 2: want reala staked 1000, got %q staked %d", seat.MatrixUser, seat.Staked)
|
|
}
|
|
// The occupancy claim points at the table.
|
|
id, err := TableOf(player)
|
|
if err != nil || id != "t1" {
|
|
t.Errorf("TableOf: want t1, got %q err %v", id, err)
|
|
}
|
|
}
|
|
|
|
func TestSitDown_CannotTakeATakenSeat(t *testing.T) {
|
|
setupTestDB(t)
|
|
tbl := openTestTable(t, "t1", "holdem")
|
|
fund(t, player, 5000)
|
|
fund(t, "@bob:parodia.dev", 5000)
|
|
|
|
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 2, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tbl2, _ := reload(t, "t1")
|
|
err := SitDown(Sit{Table: tbl2, Seat: Seat{Seat: 2, MatrixUser: "@bob:parodia.dev", Name: "bob"}, BuyIn: 1000})
|
|
if !errors.Is(err, ErrSeatTaken) {
|
|
t.Fatalf("want ErrSeatTaken, got %v", err)
|
|
}
|
|
// Bob's chips did not move.
|
|
if got := chipsOf(t, "@bob:parodia.dev"); got != 5000 {
|
|
t.Errorf("bob's stack should be untouched, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestSitDown_CannotSitAtTwoTables(t *testing.T) {
|
|
setupTestDB(t)
|
|
tbl1 := openTestTable(t, "t1", "holdem")
|
|
tbl2 := openTestTable(t, "t2", "holdem")
|
|
fund(t, player, 5000)
|
|
|
|
if err := SitDown(Sit{Table: tbl1, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := SitDown(Sit{Table: tbl2, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000})
|
|
if !errors.Is(err, ErrHandInProgress) {
|
|
t.Fatalf("want ErrHandInProgress, got %v", err)
|
|
}
|
|
// The buy-in for the second table rolled back.
|
|
if got := chipsOf(t, player); got != 4000 {
|
|
t.Errorf("only the first buy-in should have left the stack, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestSitDown_InsufficientChipsRollsBack(t *testing.T) {
|
|
setupTestDB(t)
|
|
tbl := openTestTable(t, "t1", "holdem")
|
|
fund(t, player, 500)
|
|
|
|
err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000})
|
|
if !errors.Is(err, ErrInsufficientChips) {
|
|
t.Fatalf("want ErrInsufficientChips, got %v", err)
|
|
}
|
|
if _, err := TableOf(player); !errors.Is(err, ErrNoLiveHand) {
|
|
t.Errorf("no seat should have been claimed, got %v", err)
|
|
}
|
|
_, seats := reload(t, "t1")
|
|
if seats[0].MatrixUser != "" {
|
|
t.Errorf("seat should still be a bot")
|
|
}
|
|
}
|
|
|
|
func TestLeaveTable_BringsChipsHome(t *testing.T) {
|
|
setupTestDB(t)
|
|
tbl := openTestTable(t, "t1", "holdem")
|
|
fund(t, player, 5000)
|
|
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tbl2, _ := reload(t, "t1")
|
|
// Got up with 1,240 — up on the session.
|
|
if err := LeaveTable(Leave{Table: tbl2, Seat: 0, MatrixUser: player, Bot: "bot", Amount: 1240}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := chipsOf(t, player); got != 5240 {
|
|
t.Errorf("want 5240 back on the stack, got %d", got)
|
|
}
|
|
if _, err := TableOf(player); !errors.Is(err, ErrNoLiveHand) {
|
|
t.Errorf("seat claim should be gone, got %v", err)
|
|
}
|
|
_, seats := reload(t, "t1")
|
|
if seats[0].MatrixUser != "" {
|
|
t.Errorf("a bot should have taken the empty chair")
|
|
}
|
|
}
|
|
|
|
func TestSaveTable_VersionGuardsTheWrite(t *testing.T) {
|
|
setupTestDB(t)
|
|
openTestTable(t, "t1", "holdem")
|
|
|
|
a, _ := reload(t, "t1") // both read version 0
|
|
b, _ := reload(t, "t1")
|
|
|
|
a.State = []byte(`{"a":1}`)
|
|
if err := CommitTable(TableCommit{Table: a}); err != nil {
|
|
t.Fatalf("first write should win: %v", err)
|
|
}
|
|
b.State = []byte(`{"b":2}`)
|
|
if err := CommitTable(TableCommit{Table: b}); !errors.Is(err, ErrStaleTable) {
|
|
t.Fatalf("second write should be stale, got %v", err)
|
|
}
|
|
|
|
after, _ := reload(t, "t1")
|
|
if string(after.State) != `{"a":1}` {
|
|
t.Errorf("the winning write should stand, got %s", after.State)
|
|
}
|
|
if after.Version != 1 {
|
|
t.Errorf("version should have bumped once, got %d", after.Version)
|
|
}
|
|
}
|
|
|
|
func TestDueTables_OnlyExpiredClocks(t *testing.T) {
|
|
setupTestDB(t)
|
|
now := nowUnix()
|
|
|
|
past := openTestTable(t, "past", "holdem")
|
|
past.Deadline = now - 5
|
|
if err := CommitTable(TableCommit{Table: past}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
future := openTestTable(t, "future", "holdem")
|
|
future.Deadline = now + 60
|
|
if err := CommitTable(TableCommit{Table: future}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
openTestTable(t, "noclock", "holdem") // deadline 0
|
|
|
|
due, err := DueTables(now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(due) != 1 || due[0].ID != "past" {
|
|
t.Fatalf("only the past-due table should show, got %+v", due)
|
|
}
|
|
}
|
|
|
|
func TestChat_KeepsTheHandItWasSaidDuring(t *testing.T) {
|
|
setupTestDB(t)
|
|
openTestTable(t, "t1", "holdem") // hand_no 1
|
|
|
|
if _, err := Say("t1", player, "reala", "nice hand"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The table moves to the next hand.
|
|
tbl2, _ := reload(t, "t1")
|
|
tbl2.HandNo = 2
|
|
if err := CommitTable(TableCommit{Table: tbl2}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Say("t1", player, "reala", "and another"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
lines, err := Chat("t1", 50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(lines) != 2 {
|
|
t.Fatalf("want 2 lines, got %d", len(lines))
|
|
}
|
|
if lines[0].Body != "nice hand" || lines[0].HandNo != 1 {
|
|
t.Errorf("first line should be hand 1: %+v", lines[0])
|
|
}
|
|
if lines[1].HandNo != 2 {
|
|
t.Errorf("second line should be hand 2: %+v", lines[1])
|
|
}
|
|
}
|
|
|
|
func TestCloseTable_KeepsATableWithAHumanAtIt(t *testing.T) {
|
|
setupTestDB(t)
|
|
tbl := openTestTable(t, "t1", "holdem")
|
|
fund(t, player, 5000)
|
|
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := CloseTable("t1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := LoadTable("t1"); err != nil {
|
|
t.Errorf("a table with a human should survive close, got %v", err)
|
|
}
|
|
|
|
// Everyone leaves; now it goes.
|
|
tbl2, _ := reload(t, "t1")
|
|
if err := LeaveTable(Leave{Table: tbl2, Seat: 0, MatrixUser: player, Bot: "bot", Amount: 1000}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := CloseTable("t1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := LoadTable("t1"); !errors.Is(err, ErrNoSuchTable) {
|
|
t.Errorf("an all-bot table should close, got %v", err)
|
|
}
|
|
}
|