games: a blackjack table you can actually sit down at
The engine, the escrow and the wire were all in place; nothing had a browser on the end of it. This is that end: a lobby, a table, and the five endpoints between them. The browser holds no game. It sends intents and gets back a view — the cards it is entitled to see, and the script of how they arrived, one event per card off the shoe. The dealer's hole card is not in the payload at all until the reveal, because a field the client is told to ignore is a field somebody reads in devtools. The shoe lives in game_live_hands, which also means a redeploy mid-hand no longer costs a player their stake: the hand is still there when they come back. The money is ordered so nothing can be spent twice. The stake leaves the stack in the same statement that checks it exists, before a card is dealt. Every new hand is seated with a plain INSERT, so a double-clicked Deal is decided by the primary key rather than by a read that raced — it loses, gets its chips back, and the hand in progress is untouched. A double takes its raise up front and hands it straight back if the engine refuses the move. Cards are dealt rather than swapped in — they fly out of the shoe and turn over, which was a requirement and not a flourish. The faces and the chips are still plain; that's next.
This commit is contained in:
275
internal/web/games_play_test.go
Normal file
275
internal/web/games_play_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
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.Player) != 2 {
|
||||
t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Player))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Player []struct{} `json:"player"`
|
||||
}
|
||||
if err := json.Unmarshal(live.State, &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.Player) != len(first.Hand.Player) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user