Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.
The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.
The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
734 lines
25 KiB
Go
734 lines
25 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/holdem"
|
|
"pete/internal/games/klondike"
|
|
"pete/internal/games/trivia"
|
|
"pete/internal/games/uno"
|
|
"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"` // everything staked this deal, across every hand
|
|
Hands []spotView `json:"hands"`
|
|
Active int `json:"active"`
|
|
Dealer []cardView `json:"dealer"`
|
|
Hole bool `json:"hole"` // true: the dealer has a face-down card
|
|
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"`
|
|
Split bool `json:"can_split"`
|
|
}
|
|
|
|
// spotView is one of the player's hands: its cards, its own chips, its own
|
|
// verdict. Before split there was only ever one and it was flattened into the
|
|
// hand itself; a split is the moment that stops being true.
|
|
type spotView struct {
|
|
Cards []cardView `json:"cards"`
|
|
Bet int64 `json:"bet"`
|
|
Total int `json:"total"`
|
|
Soft bool `json:"soft"`
|
|
Doubled bool `json:"doubled"`
|
|
Done bool `json:"done"`
|
|
Outcome string `json:"outcome,omitempty"`
|
|
Payout int64 `json:"payout,omitempty"`
|
|
}
|
|
|
|
func viewHand(st blackjack.State) handView {
|
|
v := handView{
|
|
Phase: string(st.Phase),
|
|
Bet: st.Bet,
|
|
Active: st.Active,
|
|
Outcome: string(st.Outcome),
|
|
Payout: st.Payout,
|
|
Rake: st.Rake,
|
|
Net: st.Net(),
|
|
Double: st.CanDouble(),
|
|
Split: st.CanSplit(),
|
|
}
|
|
for _, h := range st.Hands {
|
|
s := spotView{
|
|
Bet: h.Bet,
|
|
Doubled: h.Doubled,
|
|
Done: h.Done,
|
|
Outcome: string(h.Outcome),
|
|
Payout: h.Payout,
|
|
}
|
|
for _, c := range h.Cards {
|
|
s.Cards = append(s.Cards, viewCard(c))
|
|
}
|
|
s.Total, s.Soft = h.Value()
|
|
v.Hands = append(v.Hands, s)
|
|
}
|
|
|
|
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"`
|
|
Hand int `json:"hand"` // which of the player's hands the card landed on
|
|
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, Hand: e.Hand}
|
|
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"`
|
|
|
|
Uno *unoView `json:"uno,omitempty"`
|
|
UnoEvents []unoEventView `json:"uno_events,omitempty"`
|
|
|
|
Holdem *holdemView `json:"holdem,omitempty"`
|
|
HoldemEvents []holdemEventView `json:"holdem_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
|
|
case gameUno:
|
|
// A seated UNO player's cards are in game_tables, not here — this row is only
|
|
// their occupancy claim. Load the table and render it as their own seat sees it.
|
|
if live.TableID == "" {
|
|
return s.dropUnreadable(user, v, fmt.Errorf("uno row with no table"))
|
|
}
|
|
t, tableSeats, err := storage.LoadTable(live.TableID)
|
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
|
return s.dropUnreadable(user, v, fmt.Errorf("uno table %s gone", live.TableID))
|
|
}
|
|
if err != nil {
|
|
return tableView{}, err
|
|
}
|
|
_, seat, err := storage.PlayerSeat(user)
|
|
if err != nil {
|
|
return tableView{}, err
|
|
}
|
|
var g uno.State
|
|
if err := json.Unmarshal(t.State, &g); err != nil {
|
|
return s.dropUnreadable(user, v, err)
|
|
}
|
|
uv := viewUno(g, seat)
|
|
for _, ts := range tableSeats {
|
|
if ts.Seat == seat {
|
|
uv.BoughtIn = ts.Staked
|
|
break
|
|
}
|
|
}
|
|
v.Uno = &uv
|
|
case gameHoldem:
|
|
// A seated hold'em player's cards are in game_tables, not here — this row is
|
|
// only their occupancy claim, so its state is empty. Load the table and render
|
|
// it as their own seat sees it.
|
|
if live.TableID == "" {
|
|
return s.dropUnreadable(user, v, fmt.Errorf("holdem row with no table"))
|
|
}
|
|
t, tableSeats, err := storage.LoadTable(live.TableID)
|
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
|
// The table closed under them (reaped, or the last hand cashed them out).
|
|
// Their claim is stale; clear it so they can sit down again.
|
|
return s.dropUnreadable(user, v, fmt.Errorf("holdem table %s gone", live.TableID))
|
|
}
|
|
if err != nil {
|
|
return tableView{}, err
|
|
}
|
|
_, seat, err := storage.PlayerSeat(user)
|
|
if err != nil {
|
|
return tableView{}, err
|
|
}
|
|
var g holdem.State
|
|
if err := json.Unmarshal(t.State, &g); err != nil {
|
|
return s.dropUnreadable(user, v, err)
|
|
}
|
|
hv := viewHoldem(g, seat)
|
|
// bought_in is a per-player figure — "you bought in for X" — but the engine's
|
|
// BoughtIn is the table's total across every human. The player's own stake is
|
|
// border accounting, which lives in storage; take it from their seat row.
|
|
for _, ts := range tableSeats {
|
|
if ts.Seat == seat {
|
|
hv.BoughtIn = ts.Staked
|
|
break
|
|
}
|
|
}
|
|
v.Holdem = &hv
|
|
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)
|
|
|
|
// Double and split are the two moves that put more chips on the table *after*
|
|
// the cards are out, so the money has to move before the move does — and if the
|
|
// chips aren't there, the move simply isn't legal. Take them first: if the
|
|
// engine then refuses the move, they go straight back.
|
|
//
|
|
// Both cost the active hand's bet, not the whole stake. Once a hand can be
|
|
// split those are different numbers, and doubling the third hand of a split for
|
|
// the total of all three would be quite a thing to discover in production.
|
|
var staked int64
|
|
switch move {
|
|
case blackjack.Double:
|
|
if !st.CanDouble() {
|
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards of a hand"})
|
|
return
|
|
}
|
|
staked = st.DoubleCost()
|
|
case blackjack.Split:
|
|
if !st.CanSplit() {
|
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only split two cards of the same rank, and only up to four hands"})
|
|
return
|
|
}
|
|
staked = st.SplitCost()
|
|
}
|
|
if staked > 0 {
|
|
if err := storage.Stake(user, staked); err != nil {
|
|
if errors.Is(err, storage.ErrInsufficientChips) {
|
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that"})
|
|
return
|
|
}
|
|
slog.Error("games: stake raise", "user", user, "move", move, "err", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
next, evs, err := blackjack.ApplyMove(st, move)
|
|
if err != nil {
|
|
if staked > 0 {
|
|
_ = storage.Award(user, staked) // 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"
|
|
gameUno = "uno"
|
|
gameHoldem = "holdem"
|
|
)
|
|
|
|
// 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 every game goes through it so that none has to re-derive an ordering that
|
|
// took a while to get right.
|
|
//
|
|
// The ordering now lives in storage.CommitHand, which does the whole thing —
|
|
// seat, pay, record, clear, touch — in one transaction. It used to be four
|
|
// autocommit statements here, carefully sequenced so that a crash between them
|
|
// cost the player as little as possible. That was survivable for a game owned by
|
|
// one player. It is not survivable for a game with a pot in it, which is what
|
|
// the tables are about to become: pay the winner, die before the state write,
|
|
// and the hand still reads as live, so it settles again and pays them twice.
|
|
//
|
|
// 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) {
|
|
err := storage.CommitHand(user, storage.Commit{
|
|
Live: storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2},
|
|
Fresh: f.Fresh,
|
|
Stake: f.Bet,
|
|
Done: f.Done,
|
|
Payout: f.Payout,
|
|
Audit: 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,
|
|
},
|
|
})
|
|
switch {
|
|
case errors.Is(err, storage.ErrHandInProgress):
|
|
// Somebody was already sitting here. The game was never seated and the chips
|
|
// it staked have gone back — in the same transaction that refused it.
|
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
|
|
return tableView{}, false
|
|
case err != nil:
|
|
slog.Error("games: commit", "user", user, "game", f.Game, "err", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return tableView{}, false
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|