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) } } // The felt tells each player "you bought in for X" — their own stake, not the // table's. The engine's BoughtIn is the sum across every human (the audit wants // that), so a two-human table would quote both of them the pair's total if the // view read it straight. It reads each seat's own staked row from storage instead. func TestBoughtInIsPerPlayerNotTheTableTotal(t *testing.T) { s := newCasino(t) fund(t, 5000) // reala 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})) for _, who := range []string{testPlayer, bobPlayer} { v, err := s.table(who) if err != nil { t.Fatal(err) } if v.Holdem == nil { t.Fatalf("%s has no table", who) } if v.Holdem.BoughtIn != 500 { t.Errorf("%s is shown bought-in %d, want their own 500 — not the table's 1000", who, v.Holdem.BoughtIn) } } } // 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) } }