632 lines
22 KiB
Go
632 lines
22 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/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)
|
|
}
|
|
}
|