The two-browser pass found it: at a table two humans share, the felt quoted each of them the pair's total. "Bought in for 200" to a player who put in 100, and a session-rake line that climbed on a pot the other one won. Both were table totals the view read straight off the engine — correct while a table had one human, wrong the moment it had two. Fixed along the border it already draws: bought_in is border accounting, so it comes from the viewer's own game_seats.staked; session rake is a within-table event, so it rides a new per-seat Seat.Paid beside the audit's table-total s.Paid. And top-up never grew game_seats.staked, so the storage invariant drifted by every top-up and the felt under-reported the buy-in — it does now. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
231 lines
7.4 KiB
Go
231 lines
7.4 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|