Phase C frontend: the hold'em felt runs on the shared-table runtime. - holdem.js reads view.your_seat instead of assuming seat zero — every "you" test (layout, your cards, the burst on a pot you win, the verdict) is keyed on it now, so a joiner at seat 2 sees their own hand at the bottom. - Leaving is its own endpoint, and a bust closes a solo table; play() animates a session-ending hand (the last showdown) before the felt clears. - A live table: one EventSource per seated player. The server pushes a nudge on every table change and a chat line as it is said; a nudge refetches the player's own redacted view (a hole card must never ride a frame that fans to the table), and a frame that lands mid-animation is held until the script finishes. - Chat on the felt (a _chat panel, messages only) and a lobby that lists tables with a seat going spare. Two-cookie dev rig (reala + bob), with the turn clock and reaper live under it. Browser-confirmed for solo: sit renders your seat and the rail, a hand deals and conserves to the chip (bought in 100, 100 in front), chat sends. The two-browser multiplayer pass (join, live sync between windows, shared-table conservation) is still owed before this deploys. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
129 lines
4.1 KiB
Go
129 lines
4.1 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"pete/internal/games/trivia"
|
|
"pete/internal/opentdb"
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// TestDevCasino is not a test. It is the casino, running, on a port, with one
|
|
// signed-in player who has chips — so the table can be driven in a real browser,
|
|
// which is the only honest way to review an animation.
|
|
//
|
|
// Skipped unless you ask for it:
|
|
//
|
|
// PETE_DEV_CASINO=:7788 go test ./internal/web -run TestDevCasino -timeout 0
|
|
//
|
|
// It prints the session cookie to plant. The routes are wired here rather than
|
|
// taken from New(), because New() decides whether the casino exists at the
|
|
// moment it builds the mux, and the test rig only signs the player in afterwards.
|
|
func TestDevCasino(t *testing.T) {
|
|
addr := os.Getenv("PETE_DEV_CASINO")
|
|
if addr == "" {
|
|
t.Skip("set PETE_DEV_CASINO=:port to run the casino for a browser")
|
|
}
|
|
|
|
s := newCasino(t)
|
|
fund(t, 5000)
|
|
fundUser(t, bobPlayer, 5000)
|
|
seedTriviaBank(t)
|
|
|
|
// The full table runtime, so the turn clock and the reaper are live under the
|
|
// browser exactly as in production.
|
|
s.StartTableClock(context.Background())
|
|
|
|
cookie := devCookie(s, "reala", "Reala")
|
|
// A second player, so a shared table can be reviewed — hold'em is multiplayer
|
|
// now, and one browser cannot see two people at the felt. Plant this cookie in
|
|
// a second browser profile (or a private window) to sit down as Bob.
|
|
bobCookie := devCookie(s, "bob", "Bob")
|
|
|
|
staticSub, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
|
s.casinoRoutes(mux)
|
|
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Written to a file, not printed: `go test` buffers stdout, and the browser
|
|
// driver needs the cookie while the server is still running. The second cookie
|
|
// rides alongside it, newline-separated, for the second browser.
|
|
if out := os.Getenv("PETE_DEV_COOKIE_FILE"); out != "" {
|
|
if err := os.WriteFile(out, []byte(cookie+"\n"+bobCookie), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\nBOB %s=%s\n\n",
|
|
addr, sessionCookie, cookie, sessionCookie, bobCookie)
|
|
|
|
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
|
t.Cleanup(func() { _ = srv.Close() })
|
|
_ = srv.Serve(ln)
|
|
}
|
|
|
|
// devCookie mints a signed session for a player the rig has funded, so the felt
|
|
// can be driven as them.
|
|
func devCookie(s *Server, username, name string) string {
|
|
payload, _ := json.Marshal(SessionUser{
|
|
Sub: "sub-" + username, Username: username, Name: name,
|
|
Exp: time.Now().Add(24 * time.Hour).Unix(),
|
|
})
|
|
return s.auth.sign(payload)
|
|
}
|
|
|
|
// seedTriviaBank puts enough questions in the bank to deal a ladder of each
|
|
// difficulty.
|
|
//
|
|
// The rig does not run StartTriviaBank — a dev casino that spends its first two
|
|
// minutes dripping four hundred questions per difficulty out of a free API is a
|
|
// dev casino you cannot use. But a fresh database has an empty bank, and an
|
|
// empty bank means every start 503s, so the rig would be unable to show you the
|
|
// one game it exists to show you.
|
|
//
|
|
// One real batch per difficulty, through the real client: fifty questions is
|
|
// four ladders' worth, and it means what the browser renders came out of OpenTDB
|
|
// and through the same decode-and-store path production uses, entities and all.
|
|
func seedTriviaBank(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
client := opentdb.New()
|
|
|
|
for i, tier := range trivia.Tiers {
|
|
have, err := storage.CountTrivia(tier.Difficulty)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if have >= trivia.Rungs {
|
|
continue
|
|
}
|
|
if i > 0 {
|
|
time.Sleep(opentdb.Politeness) // the API asks; asking faster earns nothing
|
|
}
|
|
qs, err := client.Fetch(ctx, tier.Difficulty, opentdb.Batch)
|
|
if err != nil {
|
|
t.Fatalf("seeding the trivia bank (%s): %v", tier.Difficulty, err)
|
|
}
|
|
added, err := storage.AddTriviaQuestions(tier.Difficulty, qs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fmt.Printf("BANK %-6s %d questions\n", tier.Difficulty, added)
|
|
}
|
|
}
|