package web import ( "encoding/json" "errors" "log/slog" "math/rand/v2" "net/http" "time" "pete/internal/games/blackjack" "pete/internal/games/trivia" "pete/internal/storage" ) // Trivia, played for chips. // // The same shape as the other tables: the browser sends intents, the server // holds the state, and the payload carries only what the player is entitled to // see. Here that means the four answers *without* which of them is right. The // right one is an index in the engine state, which is in game_live_hands, on // this side of the wire — and it only ever crosses in the event that reveals it, // once the question has been decided and it can't be used to answer. // // The clock is the other half. The countdown in the browser is decoration: the // only clock that scores anything is time.Now() here, measured against the // AskedAt the server stamped when it served the question. A player who stops // their own countdown, or reloads to restart it, changes nothing. // triviaView is a game as its player may see it. type triviaView struct { Tier trivia.Tier `json:"tier"` Rung int `json:"rung"` // how many they've answered Rungs int `json:"rungs"` // how many there are Category string `json:"category,omitempty"` Question string `json:"question,omitempty"` Answers []string `json:"answers,omitempty"` // and *not* which one is right Limit int `json:"limit"` // the tier's seconds per question Left float64 `json:"left"` // seconds this question has left, by the server's clock Multiple float64 `json:"multiple"` Bet int64 `json:"bet"` Stands int64 `json:"stands"` // what taking the money right now actually pays CanWalk bool `json:"can_walk"` // false on the first question: see the engine Phase string `json:"phase"` Outcome string `json:"outcome,omitempty"` Payout int64 `json:"payout,omitempty"` Rake int64 `json:"rake,omitempty"` Net int64 `json:"net"` } func viewTrivia(g trivia.State, now time.Time) triviaView { v := triviaView{ Tier: g.Tier, Rung: g.Rung, Rungs: trivia.Rungs, Limit: g.Tier.Limit, // What the player would actually collect, rake already out of it — quoting // the pre-rake figure would have the felt advertising a payout the house // doesn't hand over. Stands: g.Pays(), Multiple: g.Multiple, Bet: g.Bet, CanWalk: g.Rung > 0, Phase: string(g.Phase), Outcome: string(g.Outcome), Payout: g.Payout, Rake: g.Rake, Net: g.Net(), } // A finished game has no live question, and must not ship the next one — the // ladder still has rungs on it that a later game might deal. if g.Phase == trivia.PhasePlaying { q := g.Live() v.Category = q.Category v.Question = q.Text v.Answers = q.Answers v.Left = g.Left(now).Seconds() } return v } // handleTriviaStart takes the bet and builds a ladder. Same order as every other // table: the chips are staked first, in the same statement that checks they // exist, so two starts fired at once cannot bet the same chip. func (s *Server) handleTriviaStart(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var req struct { Bet int64 `json:"bet"` Tier string `json:"tier"` } if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"}) return } tier, err := trivia.TierBySlug(req.Tier) if err != nil { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a difficulty"}) return } seed1, seed2 := newSeeds() rng := rand.New(rand.NewPCG(seed1, seed2)) // Draw the ladder *before* taking the money. A bank too thin to deal from is // the one failure here that isn't the player's fault, and they should not have // to be refunded for it. qs, err := storage.DrawTrivia(tier.Difficulty, trivia.Rungs, rng) if errors.Is(err, storage.ErrBankEmpty) { writeJSONStatus(w, http.StatusServiceUnavailable, map[string]string{ "error": "the question bank is still filling up — give it a minute", }) return } if err != nil { slog.Error("games: trivia draw", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) 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: trivia stake", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } now := time.Now() g, evs, err := trivia.New(req.Bet, tier, blackjack.DefaultRules().RakePct, qs, now, rng) if err != nil { // The game never happened, so the stake never should have left. _ = storage.Award(user, req.Bet) slog.Error("games: trivia start", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } s.persistTrivia(w, user, g, evs, seed1, seed2, true, now) } // handleTriviaAnswer plays one move: pick an answer, or take the money. func (s *Server) handleTriviaAnswer(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var move trivia.Move if err := decodeJSON(r, &move); 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 game in progress"}) return } if err != nil { slog.Error("games: trivia load", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if live.Game != gameTrivia { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"}) return } var g trivia.State if err := json.Unmarshal(live.State, &g); err != nil { slog.Error("games: unreadable trivia game", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } // The server's clock, and the only one that counts. Read once, so the answer // and the view that reports it agree about what time it is. now := time.Now() next, evs, err := trivia.ApplyMove(g, move, now) if err != nil { msg := "that move isn't legal here" if errors.Is(err, trivia.ErrNothingBanked) { msg = "answer one before you walk" } writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg}) return } s.persistTrivia(w, user, next, evs, live.Seed1, live.Seed2, false, now) } // persistTrivia writes the game back and answers the browser. func (s *Server) persistTrivia(w http.ResponseWriter, user string, g trivia.State, evs []trivia.Event, seed1, seed2 uint64, fresh bool, now time.Time) { blob, err := json.Marshal(g) if err != nil { slog.Error("games: marshal trivia", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } done := g.Phase == trivia.PhaseDone v, ok := s.commit(w, user, finished{ Game: gameTrivia, Blob: blob, Bet: g.Bet, Payout: g.Payout, Rake: g.Rake, Outcome: string(g.Outcome), Done: done, Seed1: seed1, Seed2: seed2, Fresh: fresh, }) if !ok { return } // A finished game is gone from storage, so the table has none to show — but the // browser still needs the final board to land the verdict on. if done { tv := viewTrivia(g, now) v.Trivia = &tv } v.TrivEvents = evs writeJSON(w, v) }