The settle was four autocommit statements — save, award, record, clear — sequenced so a crash between any two of them cost the player as little as possible. That reasoning holds for a game owned by one player, and the old comment made it well. It does not survive a pot, 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 again. Chips minted from nothing, and gogobee turns those into euros. The obvious fix is a trap. Award is a bare Get().Exec, so wrapping the settle in a transaction makes it wait for the connection the transaction is holding. Not an error — a hung process, and since the news app shares the pool it goes too. So storage.CommitHand does the lot in one Begin/Commit, with tx-taking award and recordHand beside the public ones. addChips has done it this way since the escrow ledger was written; this is only that pattern, applied where the money is. Two things fell out. A deal landing on a taken seat used to be refused and *then* refunded in a separate statement, so a crash in between took a stake for a game that existed nowhere — no felt, no audit row, nothing to find. And the audit row is now inside the settle, which means failing to write it rolls the payout back rather than paying quietly and logging: the payout and the audit row are the same fact, and a payout nobody can account for is worse than one that didn't happen. TestTheSettleDoesNotDeadlockAgainstItsOwnConnection is a canary, and it has been made to sing — put the bug back and it doesn't fail with a message, it hangs, and the timeout is the message. Which is exactly what production would do. A canary that has never sung is just a bird. Nothing a player can see has changed: eight blackjack hands conserving to the chip across win, lose and push (a natural is the sharp one — Fresh and Done in a single CommitHand), a double-deal 409 that refunds and leaves the live game alone, hangman, and a hold'em session that bought in for 200 and got up with 197. Also: the plan's deploy note was stale for the second time, with the lesson from the first time written directly underneath it. Everything is live and always was. A hand-written record of what is deployed will rot. Ask the box. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
684 lines
23 KiB
Go
684 lines
23 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:
|
|
var g uno.State
|
|
if err := json.Unmarshal(live.State, &g); err != nil {
|
|
return s.dropUnreadable(user, v, err)
|
|
}
|
|
uv := viewUno(g)
|
|
v.Uno = &uv
|
|
case gameHoldem:
|
|
var g holdem.State
|
|
if err := json.Unmarshal(live.State, &g); err != nil {
|
|
return s.dropUnreadable(user, v, err)
|
|
}
|
|
hv := viewHoldem(g)
|
|
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)
|
|
}
|
|
}
|