package web import ( "encoding/json" "errors" "fmt" "io" "log/slog" "math/rand/v2" "net/http" "time" "pete/internal/games/blackjack" "pete/internal/games/cards" "pete/internal/games/hangman" "pete/internal/games/klondike" "pete/internal/games/trivia" "pete/internal/storage" ) // The table, as a browser sees it. // // Everything here is server-authoritative. The browser sends intents — deal me // in, hit, stand — and gets back a *view*: the cards it is entitled to see and // nothing else. The shoe stays in game_live_hands, on this side of the wire. // That is not belt-and-braces, it is the whole reason the engines are Go: a game // with money on it cannot trust a client-reported result, and a client that // holds the deck can read the next card. // // The stake leaves the player's stack *before* the hand is dealt, and comes back // only through the engine's own payout. So a hand that crashes halfway costs the // player their bet and nothing more, and a hand that Pete restarts through is // still sitting there when they come back. // gamesReady reports whether the casino can actually run: it needs sign-in (a // player has to be someone) and a Matrix server name (that someone has to exist // in gogobee's ledger). func (s *Server) gamesReady() bool { return s.cfg.Games.Enabled && s.auth != nil && s.cfg.Games.MatrixServer != "" } // player resolves the signed-in visitor to their Matrix id, or writes the // failure. An empty id means a session from before games existed, which carries // no username: sending them back through sign-in mints one. func (s *Server) player(w http.ResponseWriter, r *http.Request) (string, bool) { if !s.gamesReady() { http.NotFound(w, r) return "", false } u := s.auth.userFromRequest(r) if u == nil { writeJSONStatus(w, http.StatusUnauthorized, map[string]string{"error": "sign in to play"}) return "", false } mx := u.MatrixUser(s.cfg.Games.MatrixServer) if mx == "" { writeJSONStatus(w, http.StatusForbidden, map[string]string{ "error": "your session predates the casino — sign out and back in", }) return "", false } return mx, true } // ---- what the browser is allowed to see ----------------------------------- // cardView is one card, pre-rendered. The browser draws faces, not logic: it // gets the glyph and the colour rather than a rank it has to map itself. type cardView struct { Label string `json:"label"` // "A♠" Rank string `json:"rank"` // "A" Suit string `json:"suit"` // "♠" Red bool `json:"red"` } func viewCard(c cards.Card) cardView { label := c.String() // String() renders rank then a three-byte suit glyph, except for a card that // isn't one ("??"), which has no glyph to split off. if len(label) <= len("♠") { return cardView{Label: label, Rank: label} } cut := len(label) - len("♠") return cardView{Label: label, Rank: label[:cut], Suit: label[cut:], Red: c.Red()} } // handView is a blackjack hand as its player may see it. While they are still // acting, the dealer's hole card is *absent* — not sent and flagged hidden, but // genuinely not in the payload. A field the browser is told to ignore is a field // somebody reads in devtools. type handView struct { Phase string `json:"phase"` Bet int64 `json:"bet"` Player []cardView `json:"player"` Dealer []cardView `json:"dealer"` Hole bool `json:"hole"` // true: the dealer has a face-down card Total int `json:"total"` Soft bool `json:"soft"` DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to Outcome string `json:"outcome,omitempty"` Payout int64 `json:"payout,omitempty"` Rake int64 `json:"rake,omitempty"` Net int64 `json:"net"` Double bool `json:"can_double"` } func viewHand(st blackjack.State) handView { v := handView{ Phase: string(st.Phase), Bet: st.Bet, Outcome: string(st.Outcome), Payout: st.Payout, Rake: st.Rake, Net: st.Net(), Double: st.CanDouble(), } for _, c := range st.Player { v.Player = append(v.Player, viewCard(c)) } v.Total, v.Soft = blackjack.HandValue(st.Player) dealer := st.Dealer if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 { dealer = dealer[:1] // the hole card is the dealer's business until it isn't v.Hole = true } for _, c := range dealer { v.Dealer = append(v.Dealer, viewCard(c)) } v.DTotal, _ = blackjack.HandValue(dealer) return v } // eventView is the dealing script. The engine emits one event per card off the // shoe, in order, and the table animates them one at a time — which is why the // events go over the wire at all rather than the browser diffing two states. type eventView struct { Kind string `json:"kind"` Card *cardView `json:"card,omitempty"` Text string `json:"text,omitempty"` } // viewEvents renders the engine's events for the browser, dropping the cards it // is not yet allowed to see: the dealer's second card is dealt face-down, and // only the "reveal" event turns it over. func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView { out := make([]eventView, 0, len(evs)) dealerCards := 0 for _, e := range evs { v := eventView{Kind: e.Kind, Text: e.Text} if e.Card != nil { c := viewCard(*e.Card) v.Card = &c } if e.Kind == "dealer_card" { dealerCards++ // The hole card, while the hand is still the player's to play: send // the event so the table deals a face-down card, but not the face. if dealerCards == 2 && phase == blackjack.PhasePlayer { v.Card = nil v.Kind = "dealer_hole" } } out = append(out, v) } return out } // tableView is the whole page state: the money, and whatever game is in progress. // // A player is in at most one game at a time — game_live_hands is keyed on the // player, so the primary key enforces it — and Game says which. Each game gets // its own field rather than a shared blob, because a hangman phrase and a // blackjack shoe have nothing in common and pretending otherwise would mean a // browser that has to guess what it's holding. type tableView struct { Chips int64 `json:"chips"` Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale Cap int64 `json:"cap"` Game string `json:"game,omitempty"` // "blackjack" | "hangman" | "solitaire", if one is live Hand *handView `json:"hand,omitempty"` // blackjack Events []eventView `json:"events,omitempty"` // blackjack, only on a move Hangman *hangmanView `json:"hangman,omitempty"` HangEvents []hangman.Event `json:"hang_events,omitempty"` Solitaire *solitaireView `json:"solitaire,omitempty"` SolEvents []solEventView `json:"sol_events,omitempty"` Trivia *triviaView `json:"trivia,omitempty"` TrivEvents []trivia.Event `json:"triv_events,omitempty"` Rake float64 `json:"rake_pct"` } // table reads the player's money and any game in progress. func (s *Server) table(user string) (tableView, error) { st, err := storage.Chips(user) if err != nil { return tableView{}, err } v := tableView{ Chips: st.Chips, Pending: st.Pending, Euros: st.EuroBalance, Cap: storage.MaxChipsOnTable, Rake: blackjack.DefaultRules().RakePct, } live, err := storage.LoadLiveHand(user) if errors.Is(err, storage.ErrNoLiveHand) { return v, nil } if err != nil { return tableView{}, err } // Dispatch on the game the row says it is. Unmarshalling a hangman state into // a blackjack one would not fail — JSON is happy to fill nothing in — it would // just quietly produce an empty hand, which is the worst of both. v.Game = live.Game switch live.Game { case gameBlackjack: var hand blackjack.State if err := json.Unmarshal(live.State, &hand); err != nil { return s.dropUnreadable(user, v, err) } hv := viewHand(hand) v.Hand = &hv case gameHangman: var g hangman.State if err := json.Unmarshal(live.State, &g); err != nil { return s.dropUnreadable(user, v, err) } hv := viewHangman(g) v.Hangman = &hv case gameSolitaire: var g klondike.State if err := json.Unmarshal(live.State, &g); err != nil { return s.dropUnreadable(user, v, err) } sv := viewSolitaire(g) v.Solitaire = &sv case gameTrivia: var g trivia.State if err := json.Unmarshal(live.State, &g); err != nil { return s.dropUnreadable(user, v, err) } // The clock does not stop for a reload: Left is measured from the AskedAt // the server stamped, so a player who refreshes to buy themselves a fresh // twenty seconds finds the countdown exactly where they left it. tv := viewTrivia(g, time.Now()) v.Trivia = &tv default: return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game)) } return v, nil } // dropUnreadable throws away a live game nobody can play. Rather than wedge the // player out of the casino forever, it goes, and their stake with it — which is // why it is logged loudly. The alternative is a player who can never be dealt // another hand because an old one won't parse. func (s *Server) dropUnreadable(user string, v tableView, err error) (tableView, error) { slog.Error("games: unreadable live game, discarding", "user", user, "err", err) _ = storage.ClearLiveHand(user) v.Game = "" return v, nil } // ---- handlers ------------------------------------------------------------- // handleTable is the page's poll: chips, euros, and whatever hand is on the felt. func (s *Server) handleTable(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } v, err := s.table(user) if err != nil { slog.Error("games: table", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, v) } // amountBody is {amount: n} — chips, which are euros. type amountBody struct { Amount int64 `json:"amount"` } func decodeJSON(r *http.Request, v any) error { return json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(v) } // handleBuyIn opens a buy-in. It creates no chips: it writes an escrow row, and // gogobee decides — up to three seconds later — whether the player could afford // it. The browser watches `pending` fall to zero to know how it went. func (s *Server) handleBuyIn(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var req amountBody if err := decodeJSON(r, &req); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } e, err := storage.RequestBuyIn(user, req.Amount) switch { case errors.Is(err, storage.ErrBadAmount): writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "buy in for something more than nothing"}) return case errors.Is(err, storage.ErrOverTableCap): writeJSONStatus(w, http.StatusBadRequest, map[string]string{ "error": "that would put more than the table cap in front of you", }) return case err != nil: slog.Error("games: buy-in", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } storage.Touch(user) slog.Info("games: buy-in requested", "user", user, "amount", e.Amount, "guid", e.GUID) v, err := s.table(user) if err != nil { slog.Error("games: table after buy-in", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, v) } // handleCashOut sends chips back across the border. The chips are destroyed // here and now — see storage.RequestCashOut for why that has to happen before // gogobee has said anything — so the response already shows an empty stack. An // amount of zero or less means "all of it", which is what the button does. func (s *Server) handleCashOut(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var req amountBody if err := decodeJSON(r, &req); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } // You cannot walk away from a hand you have chips riding on. The stake is // already off the stack, so this isn't about the money — it's that a hand // left half-played would settle into a session that no longer exists. if _, err := storage.LoadLiveHand(user); err == nil { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand first"}) return } else if !errors.Is(err, storage.ErrNoLiveHand) { slog.Error("games: cash-out live-hand check", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } amount := req.Amount if amount <= 0 { st, err := storage.Chips(user) if err != nil { slog.Error("games: cash-out", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } amount = st.Chips } e, err := storage.RequestCashOut(user, amount) switch { case errors.Is(err, storage.ErrBadAmount), errors.Is(err, storage.ErrInsufficientChips): writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"}) return case err != nil: slog.Error("games: cash-out", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } slog.Info("games: cash-out requested", "user", user, "amount", e.Amount, "guid", e.GUID) v, err := s.table(user) if err != nil { slog.Error("games: table after cash-out", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, v) } // ---- blackjack ------------------------------------------------------------ // handleDeal takes the bet and deals. The order matters: chips are staked first, // in the same statement that checks they exist, so two deals fired at once // cannot bet the same chip. Only then is a hand dealt. func (s *Server) handleDeal(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var req struct { Bet int64 `json:"bet"` } if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"}) return } if err := storage.Stake(user, req.Bet); err != nil { if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"}) return } slog.Error("games: stake", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } seed1, seed2 := newSeeds() rng := rand.New(rand.NewPCG(seed1, seed2)) st, evs, err := blackjack.New(req.Bet, blackjack.DefaultRules(), rng) if err != nil { // The hand never happened, so the stake never should have left. Give it back. _ = storage.Award(user, req.Bet) slog.Error("games: deal", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } s.persist(w, user, st, evs, seed1, seed2, true) } // handleMove plays one move of the hand in progress. func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var req struct { Move string `json:"move"` } if err := decodeJSON(r, &req); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } live, err := storage.LoadLiveHand(user) if errors.Is(err, storage.ErrNoLiveHand) { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no hand in progress"}) return } if err != nil { slog.Error("games: load hand", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } var st blackjack.State if err := json.Unmarshal(live.State, &st); err != nil { slog.Error("games: unreadable live hand", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } move := blackjack.Move(req.Move) // A double doubles the stake, so the extra chips have to be taken before the // move is applied — and if they aren't there, the move simply isn't legal. // Take them first: if the engine then refuses the move, they go straight back. doubled := false if move == blackjack.Double { if !st.CanDouble() { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards"}) return } if err := storage.Stake(user, st.Bet); err != nil { if errors.Is(err, storage.ErrInsufficientChips) { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to double"}) return } slog.Error("games: stake double", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } doubled = true } next, evs, err := blackjack.ApplyMove(st, move) if err != nil { if doubled { _ = storage.Award(user, st.Bet) // the move didn't happen; neither did the raise } writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"}) return } s.persist(w, user, next, evs, live.Seed1, live.Seed2, false) } // The games a live row can be. They're the storage key, so they're constants: // a typo here is a game nobody can ever load again. const ( gameBlackjack = "blackjack" gameHangman = "hangman" gameSolitaire = "solitaire" gameTrivia = "trivia" ) // finished is what commit needs to know about a game it's writing back: enough // to settle it, and nothing about how it's played. Both engines produce one. type finished struct { Game string Blob []byte // the engine's whole state, shoe or phrase and all Bet int64 Payout int64 Rake int64 Outcome string Done bool Seed1 uint64 Seed2 uint64 Fresh bool // a game just started, which is the one write that may be refused } // commit writes a game back and settles it if it's over. It is the money path, // and both games go through it so that neither has to re-derive an ordering // that took a while to get right. // // It returns the table as it now stands. ok is false when it has already // written an error response and the caller must simply return. func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) { // Seat the game before doing anything else with it — even one that is already // over, because a blackjack natural settles the instant it's dealt. The insert // is what enforces one game at a time, and it has to happen for *every* new // one: a natural dealt on top of a game already in progress would otherwise // settle, clear the felt, and take the other game's stake down with it. live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2} save := storage.SaveLiveHand if f.Fresh { save = storage.StartLiveHand } if err := save(user, live); err != nil { if errors.Is(err, storage.ErrHandInProgress) { // Somebody was already sitting here. This game was never seated, so the // chips it staked go back: the player is in one game, not two. _ = storage.Award(user, f.Bet) writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"}) return tableView{}, false } slog.Error("games: save game", "user", user, "game", f.Game, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return tableView{}, false } if f.Done { // Pay first, then clear. If Pete dies between the two, the player has been // paid and the worst case is a settled game still showing on the felt — // which reads as done and can be cleared. The other order loses them a win. if err := storage.Award(user, f.Payout); err != nil { slog.Error("games: award", "user", user, "payout", f.Payout, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return tableView{}, false } if err := storage.RecordHand(storage.Hand{ MatrixUser: user, Game: f.Game, Bet: f.Bet, Payout: f.Payout, Rake: f.Rake, Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2, }); err != nil { slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's game } if err := storage.ClearLiveHand(user); err != nil { slog.Error("games: clear game", "user", user, "err", err) } } storage.Touch(user) v, err := s.table(user) if err != nil { slog.Error("games: table", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return tableView{}, false } return v, true } // persist writes a blackjack hand back and answers the browser. func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) { blob, err := json.Marshal(st) if err != nil { slog.Error("games: marshal hand", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } done := st.Phase == blackjack.PhaseDone v, ok := s.commit(w, user, finished{ Game: gameBlackjack, Blob: blob, Bet: st.Bet, Payout: st.Payout, Rake: st.Rake, Outcome: string(st.Outcome), Done: done, Seed1: seed1, Seed2: seed2, Fresh: fresh, }) if !ok { return } // A settled hand is gone from storage, so the table view has no hand to show — // but the browser still needs the final cards to animate the reveal onto. if done { hv := viewHand(st) v.Hand = &hv } v.Events = viewEvents(evs, st.Phase) writeJSON(w, v) } // newSeeds mints the shoe's seed. It goes in the audit log, so a hand somebody // disputes can be dealt again exactly as it fell. func newSeeds() (uint64, uint64) { return rand.Uint64(), uint64(time.Now().UnixNano()) } func writeJSONStatus(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) if err := json.NewEncoder(w).Encode(v); err != nil { slog.Error("games: write response", "err", err) } }