games: a blackjack table you can actually sit down at
The engine, the escrow and the wire were all in place; nothing had a browser on the end of it. This is that end: a lobby, a table, and the five endpoints between them. The browser holds no game. It sends intents and gets back a view — the cards it is entitled to see, and the script of how they arrived, one event per card off the shoe. The dealer's hole card is not in the payload at all until the reveal, because a field the client is told to ignore is a field somebody reads in devtools. The shoe lives in game_live_hands, which also means a redeploy mid-hand no longer costs a player their stake: the hand is still there when they come back. The money is ordered so nothing can be spent twice. The stake leaves the stack in the same statement that checks it exists, before a card is dealt. Every new hand is seated with a plain INSERT, so a double-clicked Deal is decided by the primary key rather than by a read that raced — it loses, gets its chips back, and the hand in progress is untouched. A double takes its raise up front and hands it straight back if the engine refuses the move. Cards are dealt rather than swapped in — they fly out of the shoe and turn over, which was a requirement and not a flourish. The faces and the chips are still plain; that's next.
This commit is contained in:
530
internal/web/games_play.go
Normal file
530
internal/web/games_play.go
Normal file
@@ -0,0 +1,530 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/cards"
|
||||
"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 the hand if there is one.
|
||||
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"`
|
||||
Hand *handView `json:"hand,omitempty"`
|
||||
Events []eventView `json:"events,omitempty"` // only on a move, for the animation
|
||||
Rake float64 `json:"rake_pct"`
|
||||
}
|
||||
|
||||
// table reads the player's money and any hand 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
|
||||
}
|
||||
var hand blackjack.State
|
||||
if err := json.Unmarshal(live.State, &hand); err != nil {
|
||||
// A hand we can't read is a hand nobody can play. Rather than wedge the
|
||||
// player out of the casino forever, drop it and tell them.
|
||||
slog.Error("games: unreadable live hand, discarding", "user", user, "err", err)
|
||||
_ = storage.ClearLiveHand(user)
|
||||
return v, nil
|
||||
}
|
||||
hv := viewHand(hand)
|
||||
v.Hand = &hv
|
||||
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)
|
||||
}
|
||||
|
||||
// persist writes the hand back and answers the browser. A finished hand pays
|
||||
// out, goes in the audit log, and leaves the felt; an unfinished one is saved
|
||||
// as it stands, so a redeploy mid-hand is survivable.
|
||||
//
|
||||
// fresh marks a hand that has just been dealt, which is the one case where the
|
||||
// write may be refused: the primary key, not an earlier read, is what enforces
|
||||
// one hand at a time. A Deal that loses that race gets its stake back.
|
||||
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
|
||||
}
|
||||
|
||||
// Seat the hand before doing anything else with it — even one that is already
|
||||
// over, because a natural settles the instant it's dealt. The insert is what
|
||||
// enforces one hand at a time, and it has to happen for *every* new hand: a
|
||||
// natural dealt on top of a hand already in progress would otherwise settle,
|
||||
// clear the felt, and take the other hand's stake down with it.
|
||||
hand := storage.LiveHand{Game: "blackjack", State: blob, Seed1: seed1, Seed2: seed2}
|
||||
save := storage.SaveLiveHand
|
||||
if fresh {
|
||||
save = storage.StartLiveHand
|
||||
}
|
||||
if err := save(user, hand); err != nil {
|
||||
if errors.Is(err, storage.ErrHandInProgress) {
|
||||
// Somebody was already sitting here. This hand was never seated, so the
|
||||
// chips it staked go back: the player is in one hand, not two.
|
||||
_ = storage.Award(user, st.Bet)
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a hand"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: save hand", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if st.Phase == blackjack.PhaseDone {
|
||||
// Pay first, then clear. If Pete dies between the two, the player has been
|
||||
// paid and the worst case is a settled hand 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, st.Payout); err != nil {
|
||||
slog.Error("games: award", "user", user, "payout", st.Payout, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := storage.RecordHand(storage.Hand{
|
||||
MatrixUser: user, Game: "blackjack",
|
||||
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
|
||||
Outcome: string(st.Outcome), Seed1: seed1, Seed2: seed2,
|
||||
}); err != nil {
|
||||
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's hand
|
||||
}
|
||||
if err := storage.ClearLiveHand(user); err != nil {
|
||||
slog.Error("games: clear hand", "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
|
||||
}
|
||||
// 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 st.Phase == blackjack.PhaseDone {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user