Files
Pete/internal/web/games_play.go
prosolis 5ca056bf20 games: you buy the deck, and win it back a card at a time
Solitaire, Vegas rules — the only shape solitaire has ever had as a
gambling game. You don't win or lose the deal: the stake buys the deck
outright, and every card you get home to a foundation pays a fifty-second
of the tier's multiple back. Cash the board whenever you like and keep
what you've banked, so a board that has gone dead is a decision rather
than a wall. No undo: the stake is spent the moment the deck is bought,
and an undo would be a way to walk a losing board backwards until it wins.

Three deals, and the two dials are the whole difficulty of Klondike.
Patient draws one with unlimited passes and pays 1.4x, so it takes 38
cards home to get square. Vegas draws three, three times round, 2.2x,
square at 24. Cutthroat draws three and gives you one pass, 3.4x, square
at 16 — most of those boards never clear, and you're ahead long before
they would.

internal/games/klondike is the same pure reducer as the other two, and
Pays() is one function for the same reason hangman's is. Two fuzzers hold
the deck together: no sequence of moves can lose or duplicate a card, and
the board stays well-formed. They earned their keep immediately — the
first thing they caught was a recycle that reversed the waste. It flips as
a block, so the card drawn first comes out first, and reversing it would
have dealt a different game on every pass and quietly broken the seed in
the audit log.

The browser never sees the stock or a face-down card, which here is most
of the deck rather than blackjack's one hole card: a column sends how many
cards are under it, never which.

The table re-renders and animates the difference. Blackjack plays back a
script because a hand only ever grows at one end; solitaire moves runs
from anywhere to anywhere and an auto-finish moves eleven cards at once,
so a script of "append this card there" would be a second engine over here
and it would be the one that's wrong. Instead the board on screen is
always exactly the board the server says exists, and each card is played
from where it just was to where it now is. The events supply only what a
diff can't: where a newly-revealed card came from, and what the board is
worth.

The rules are mirrored in JS on purpose, and only to light up the columns
a held card can go to. Being shown where a card goes is the game teaching
you; being told no after you commit is the game scolding you. The server
still decides, and a disagreement snaps the board back to what it says.

Two things came out into the open rather than being copied, which is the
rule this room runs on: casino-cards.js (the deck — faces, pips, the flip)
and PeteFX.spot() (the pile of chips and the number under it, which now
owns the rule that the number is a readout of the pile). Blackjack uses
both.

Not yet driven in a browser.
2026-07-14 01:40:14 -07:00

617 lines
21 KiB
Go

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/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"`
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
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"
)
// 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)
}
}