Files
Pete/internal/web/games_holdem_multiplayer_test.go
prosolis 5139385350 games: the poker table others can walk up to, and the one that empties when they leave
Phase C's handler cutover: hold'em now runs on the shared-table runtime instead
of the solo game_live_hands blob. Solo is just a table nobody else has joined.

- holdem implements tableGame (name/timeout/stacks). timeout auto-checks-or-folds
  a walked-away seat and marks it away; the audit is per-hand, with each pot's
  rake on the winner's row alone so HouseTake cannot 4x itself.
- New endpoints: sit opens a table (or joins an open bot seat), leave gets you up
  (LeaveTable + CloseTable behind the last human), plus tables (lobby), stream
  (SSE), chat and say. The move path loads the player's table, applies at their
  seat, commits under the version guard, and fans an SSE nudge.
- Engine grows Vacate/Occupy (a human leaving/joining between hands) and
  TableSeats (a named human + bots). The view carries your_seat, since a shared
  table has no seat-zero-is-you convention.
- Storage grows OpenSoloTable (stake+claim+create+seat in one tx), PlayerSeat,
  and the abandoned-table reaper (AbandonedTables/ReapTable) — the seated-player
  counterpart to the session reaper, since a walked-away stack is inside a blob
  the session reaper cannot see. upsertSeat preserves last_seen so an auto-fold
  never refreshes an away player's clock.

Not deployed, and the felt is not rewired yet: the frontend still assumes
seat zero is you, so this is browser-unverified. Solo sit/deal/play/leave and
two-human join/leave/reaper are covered by tests; the whole suite is green.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 16:37:49 -07:00

201 lines
6.3 KiB
Go

package web
import (
"testing"
"time"
"pete/internal/storage"
)
const bobPlayer = "@bob:parodia.dev"
// fundUser puts chips in front of a named player the way the border really does.
func fundUser(t *testing.T, user string, chips int64) {
t.Helper()
e, err := storage.RequestBuyIn(user, chips)
if err != nil {
t.Fatal(err)
}
if _, err := storage.ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil {
t.Fatal(err)
}
}
func chipsOf(t *testing.T, user string) int64 {
t.Helper()
st, err := storage.Chips(user)
if err != nil {
t.Fatal(err)
}
return st.Chips
}
// A second person can sit down at a table somebody else opened, taking a chair a
// bot was keeping warm — which is the whole point of the thing being multiplayer.
func TestHoldemJoinTakesAnOpenSeat(t *testing.T) {
s := newCasino(t)
fund(t, 5000) // reala
fundUser(t, bobPlayer, 5000)
if _, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500})); code != 200 {
t.Fatalf("reala sit = %d, want 200", code)
}
tableID, err := storage.TableOf(testPlayer)
if err != nil {
t.Fatal(err)
}
v, code := call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
map[string]any{"table": tableID, "buyin": 500}))
if code != 200 {
t.Fatalf("bob join = %d, want 200", code)
}
if v.Chips != 4500 {
t.Errorf("bob's chips after a 500 buy-in = %d, want 4500", v.Chips)
}
if v.Holdem == nil {
t.Fatal("join returned no table")
}
_, bobSeat, err := storage.PlayerSeat(bobPlayer)
if err != nil {
t.Fatal(err)
}
if bobSeat == 0 {
t.Errorf("bob took reala's seat %d", bobSeat)
}
if v.Holdem.YourSeat != bobSeat {
t.Errorf("the view says bob is at seat %d, storage says %d", v.Holdem.YourSeat, bobSeat)
}
_, seats, err := storage.LoadTable(tableID)
if err != nil {
t.Fatal(err)
}
humans := 0
for _, seat := range seats {
if seat.MatrixUser != "" {
humans++
}
}
if humans != 2 {
t.Errorf("two people at the table, storage says %d humans", humans)
}
}
// Leaving a shared table gives you your stack and hands your chair back to the
// house; the table stays open for the people still on it. Only the last human out
// closes the felt.
func TestHoldemLeavingSharedTableKeepsItOpen(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
fundUser(t, bobPlayer, 5000)
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
tableID, _ := storage.TableOf(testPlayer)
call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
map[string]any{"table": tableID, "buyin": 500}))
// Bob gets up. His 500 comes home and his chair goes back to a bot.
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "bob", "POST", "/api/games/holdem/leave", nil)); code != 200 {
t.Fatalf("bob leave = %d, want 200", code)
}
if got := chipsOf(t, bobPlayer); got != 5000 {
t.Errorf("bob left with %d, want his 5000 back", got)
}
if _, _, err := storage.PlayerSeat(bobPlayer); err != storage.ErrNoLiveHand {
t.Errorf("bob still holds a seat after leaving: %v", err)
}
if _, _, err := storage.LoadTable(tableID); err != nil {
t.Errorf("the table closed under reala when bob left: %v", err)
}
// Reala is the last one out, so the felt closes behind them.
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "reala", "POST", "/api/games/holdem/leave", nil)); code != 200 {
t.Fatalf("reala leave = %d, want 200", code)
}
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
t.Errorf("an empty table survived the last human: %v", err)
}
}
// A table everyone has walked away from is cashed out and closed by the reaper —
// the chips inside a walked-away poker session are not left in limbo.
func TestHoldemReaperCashesOutAbandonedTable(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
tableID, _ := storage.TableOf(testPlayer)
// The seat has been away, and last acted for itself longer ago than the idle
// cutoff — the state the reaper is meant to find.
tbl, seats, err := storage.LoadTable(tableID)
if err != nil {
t.Fatal(err)
}
seats[0].Away = true
seats[0].LastSeen = time.Now().Unix() - int64(storage.SessionIdleAfter.Seconds()) - 60
if err := storage.CommitTable(storage.TableCommit{Table: tbl, Seats: []storage.Seat{seats[0]}}); err != nil {
t.Fatal(err)
}
s.reapAbandonedTables()
if got := chipsOf(t, testPlayer); got != 5000 {
t.Errorf("the reaper sent home %d, want the whole 5000 back (buy-in and all)", got)
}
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
t.Errorf("the reaper left the table standing: %v", err)
}
if _, err := storage.TableOf(testPlayer); err != storage.ErrNoLiveHand {
t.Errorf("the reaper left the occupancy claim behind: %v", err)
}
}
// A fresh table is not abandoned, and the reaper leaves it alone.
func TestHoldemReaperSparesALiveTable(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
tableID, _ := storage.TableOf(testPlayer)
s.reapAbandonedTables()
if _, _, err := storage.LoadTable(tableID); err != nil {
t.Errorf("the reaper closed a table someone is sitting at: %v", err)
}
if got := chipsOf(t, testPlayer); got != 4500 {
t.Errorf("chips = %d — the reaper should not have moved a live table's money", got)
}
}
// The lobby lists a table with a seat going spare, and drops it once it is full.
func TestHoldemLobbyListsJoinableTables(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
// A one-bot table: two seats, one human, one open.
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 1, "buyin": 500}))
tables, err := storage.LobbyTables(gameHoldem, 50)
if err != nil {
t.Fatal(err)
}
if len(tables) != 1 {
t.Fatalf("lobby has %d tables, want 1", len(tables))
}
if tables[0].Humans != 1 || tables[0].Seats != 2 {
t.Errorf("lobby table = %d/%d humans/seats, want 1/2", tables[0].Humans, tables[0].Seats)
}
}