games: a ladder you climb against the clock
This commit is contained in:
224
internal/web/games_trivia.go
Normal file
224
internal/web/games_trivia.go
Normal file
@@ -0,0 +1,224 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user