Files
Pete/internal/web/games_play_test.go
T
prosolis 21ed051bc2 games: the natural the deal test didn't expect
TestDealTakesTheStakeAndHidesTheHoleCard checked for a 900 stack straight
after a 100 bet, and only afterwards looked at whether the hand had already
settled. A dealt natural settles inside the deal, so the stake is back (with
the win, or as a push) before the test looks, and it failed whenever the shoe
handed out a blackjack. It now checks for a settled natural first and, if it
finds one, only asks that the stack didn't lose more than the bet.
2026-09-22 22:54:31 -07:00

283 lines
9.2 KiB
Go

package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
const testPlayer = "@reala:parodia.dev"
// newCasino is a server with the tables open and one signed-in player. The
// Authenticator is built by hand rather than through OIDC discovery: sign-in is
// a network call, and none of what's under test is about the handshake.
func newCasino(t *testing.T) *Server {
t.Helper()
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
s.cfg.Games = config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"}
return s
}
// as returns a request carrying the signed session of the given username. An
// empty username is the pre-casino session: signed in, but nobody the economy
// can name.
func as(t *testing.T, s *Server, username, method, path string, body any) *http.Request {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatal(err)
}
}
r := httptest.NewRequest(method, path, &buf)
payload, _ := json.Marshal(SessionUser{
Sub: "sub-1", Username: username, Exp: time.Now().Add(time.Hour).Unix(),
})
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
return r
}
// call runs one request against a handler and decodes the table view.
func call(t *testing.T, s *Server, h http.HandlerFunc, r *http.Request) (tableView, int) {
t.Helper()
w := httptest.NewRecorder()
h(w, r)
var v tableView
if w.Code == 200 {
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
t.Fatalf("decode table: %v (body %q)", err, w.Body.String())
}
}
return v, w.Code
}
// fund puts chips in front of the player the way the border really does it.
func fund(t *testing.T, chips int64) {
t.Helper()
e, err := storage.RequestBuyIn(testPlayer, 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 chipsNow(t *testing.T) int64 {
t.Helper()
st, err := storage.Chips(testPlayer)
if err != nil {
t.Fatal(err)
}
return st.Chips
}
// TestDealTakesTheStakeAndHidesTheHoleCard is the one thing the table cannot get
// wrong: the bet leaves the stack, and the dealer's second card does not leave
// the server.
func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal", map[string]int64{"bet": 100}))
if code != 200 {
t.Fatalf("deal = %d, want 200", code)
}
if v.Hand == nil {
t.Fatal("deal returned no hand")
}
if len(v.Hand.Hands[0].Cards) != 2 {
t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Hands[0].Cards))
}
// A natural settles on the spot: the stake has already come back (with the
// win, or as a push), and both dealer cards are legitimately shown. Checking
// the stack for 900 before this made the test fail on every dealt natural.
if v.Hand.Phase == "done" {
if v.Chips < 900 {
t.Fatalf("chips after a settled natural = %d, want at least the 900 left after the bet", v.Chips)
}
return
}
if v.Chips != 900 {
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
}
if !v.Hand.Hole || len(v.Hand.Dealer) != 1 {
t.Fatalf("dealer shows %d cards (hole=%v), want 1 with the hole card held back",
len(v.Hand.Dealer), v.Hand.Hole)
}
// And it isn't smuggled out in the dealing script either.
for _, e := range v.Events {
if e.Kind == "dealer_hole" && e.Card != nil {
t.Fatal("the hole card's face went to the browser in the events")
}
}
}
// TestHandSettlesIntoTheChipStack plays a hand to the end and checks the chips
// moved by exactly what the engine said they did — and that the hand left the
// felt and landed in the audit log.
func TestHandSettlesIntoTheChipStack(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
for v.Hand != nil && v.Hand.Phase == "player" {
v, _ = call(t, s, s.handleMove, as(t, s, "reala", "POST", "/move", map[string]string{"move": "stand"}))
}
if v.Hand == nil || v.Hand.Phase != "done" {
t.Fatalf("hand didn't finish: %+v", v.Hand)
}
// 1000, minus the stake, plus whatever came back.
want := int64(1000) - 100 + v.Hand.Payout
if got := chipsNow(t); got != want {
t.Fatalf("chips = %d, want %d (payout %d, outcome %q)", got, want, v.Hand.Payout, v.Hand.Outcome)
}
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
t.Fatal("a settled hand is still sitting on the felt")
}
if v.Hand.Outcome == "" {
t.Fatal("a finished hand with no outcome")
}
// The rake only ever comes out of winnings.
if v.Hand.Outcome == "push" && v.Hand.Payout != 100 {
t.Fatalf("a push paid %d, want the 100 back untouched", v.Hand.Payout)
}
if v.Hand.Net < 0 && v.Hand.Rake != 0 {
t.Fatalf("a losing hand was raked %d", v.Hand.Rake)
}
}
// TestOneHandAtATime is the double-click: a second Deal must not overwrite the
// hand the first one is paying for, and must not keep the chips it staked.
func TestOneHandAtATime(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
first, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if first.Hand != nil && first.Hand.Phase == "done" {
t.Skip("dealt a natural; there is no live hand to protect")
}
before := chipsNow(t)
w := httptest.NewRecorder()
s.handleDeal(w, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if w.Code != http.StatusConflict {
t.Fatalf("second deal = %d, want 409", w.Code)
}
if got := chipsNow(t); got != before {
t.Fatalf("the refused deal cost the player %d chips", before-got)
}
// The original hand is untouched.
live, err := storage.LoadLiveHand(testPlayer)
if err != nil {
t.Fatalf("the live hand went missing: %v", err)
}
var st struct {
Hands []struct {
Cards []struct{} `json:"cards"`
} `json:"hands"`
}
if err := json.Unmarshal(live.State, &st); err != nil {
t.Fatal(err)
}
if len(st.Hands) != 1 || len(st.Hands[0].Cards) != len(first.Hand.Hands[0].Cards) {
t.Fatal("the refused deal replaced the hand in progress")
}
}
// TestCannotCashOutMidHand — the stake is on the table, so the session it would
// settle into cannot be closed underneath it.
func TestCannotCashOutMidHand(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if v.Hand != nil && v.Hand.Phase == "done" {
t.Skip("dealt a natural; nothing is in progress")
}
w := httptest.NewRecorder()
s.handleCashOut(w, as(t, s, "reala", "POST", "/cashout", map[string]int64{"amount": 0}))
if w.Code != http.StatusConflict {
t.Fatalf("cash-out mid-hand = %d, want 409", w.Code)
}
if got := chipsNow(t); got != 900 {
t.Fatalf("the refused cash-out moved chips: %d, want 900", got)
}
}
// TestDoubleWithoutTheChipsChangesNothing: a double the player can't cover is
// refused, and refusing it must not quietly pocket the raise.
func TestDoubleWithoutTheChipsChangesNothing(t *testing.T) {
s := newCasino(t)
fund(t, 100)
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if v.Hand == nil || v.Hand.Phase != "player" {
t.Skip("no live hand to double on")
}
if chipsNow(t) != 0 {
t.Fatal("test wants a player with nothing left to raise with")
}
w := httptest.NewRecorder()
s.handleMove(w, as(t, s, "reala", "POST", "/move", map[string]string{"move": "double"}))
if w.Code != http.StatusBadRequest {
t.Fatalf("broke double = %d, want 400", w.Code)
}
if got := chipsNow(t); got != 0 {
t.Fatalf("chips = %d after a refused double, want 0", got)
}
// And the hand is still there, still doubleable once they can afford it.
after, code := call(t, s, s.handleTable, as(t, s, "reala", "GET", "/table", nil))
if code != 200 || after.Hand == nil || !after.Hand.Double {
t.Fatalf("the hand should be intact and still doubleable: %d %+v", code, after.Hand)
}
}
// TestTableNeedsAPlayerTheEconomyCanName. Anonymous is a 401. A session minted
// before the casino existed carries no username, so it can't be mapped to a
// Matrix id — that's a 403, and the fix is to sign in again.
func TestTableNeedsAPlayerTheEconomyCanName(t *testing.T) {
s := newCasino(t)
w := httptest.NewRecorder()
s.handleTable(w, httptest.NewRequest("GET", "/api/games/table", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("anonymous = %d, want 401", w.Code)
}
w = httptest.NewRecorder()
s.handleTable(w, as(t, s, "", "GET", "/api/games/table", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("session with no username = %d, want 403", w.Code)
}
}
// TestCasinoIsShutWithoutAServerName. No Matrix server name means no player can
// be named to gogobee, so the tables 404 rather than dealing hands whose money
// has nowhere to go.
func TestCasinoIsShutWithoutAServerName(t *testing.T) {
s := newCasino(t)
s.cfg.Games.MatrixServer = ""
w := httptest.NewRecorder()
s.handleTable(w, as(t, s, "reala", "GET", "/api/games/table", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("table with no server name = %d, want 404", w.Code)
}
}