Blackjack has a split. It was the last rule missing from a game that has been live for a week, and it is the only move in blackjack that takes chips out of your stack *after* the cards are out — which is most of what there is to get wrong about it. So the state stops pretending. State.Player is gone; there is a slice of Hands, each with its own cards, its own bet, its own outcome and its own payout, and an Active index the player works left to right. Settle runs per hand and rakes per hand: netting them against each other first would mean a player who won one and lost one paid no rake at all, which is not a rake, it's a discount for splitting. The web layer takes the second bet before the move and hands it straight back if the engine refuses — the same shape double already used, except double was staking st.Bet, the whole table's stake, which was the same number as the hand's until today and is now emphatically not. DoubleCost/SplitCost are the active hand's, and the felt would have found this by charging you 300 to double the third hand of a split. The rules that cost money if you guess them: split aces get one card each and no say (a pair of aces is otherwise the best hand in the game, forever), 21 on a split hand is twenty-one and not a natural (it does not pay 3:2 — the test that pins this is the most expensive one in the file), same rank rather than same value (a king and a queen are not a pair), four hands maximum, double after split allowed, and if every hand busts the dealer does not turn over. A live hand outlives a deploy, so State.UnmarshalJSON still reads the old blobs: "player" with no "hands" becomes one hand holding the whole stake. Without it, a player mid-hand at restart is a player whose cards vanished — which is not a decode error, and would not have looked like one. On the felt a hand is now a box with its own spot, and a split is a card lifting out of one hand into a new one with a second stack of chips flying after it from your pile. Verified in a browser against a real pair: chips 4738 -> 4638 on the split, two hands played out, one push and one loss, "Down on the deal. -100", 4738 back. Three hands stack without collision at 390px. Settled hands come back to full brightness — dimming means "not your turn", and when the deal is over they are the thing you are reading. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
278 lines
8.9 KiB
Go
278 lines
8.9 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.Chips != 900 {
|
|
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
|
}
|
|
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 and legitimately shows both dealer cards.
|
|
if v.Hand.Phase == "done" {
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
}
|