games: a ladder you climb against the clock
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/hangman"
|
||||
"pete/internal/games/klondike"
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
@@ -27,7 +28,6 @@ type gameTeaser struct {
|
||||
}
|
||||
|
||||
var comingSoon = []gameTeaser{
|
||||
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
|
||||
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
|
||||
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
|
||||
}
|
||||
@@ -75,6 +75,8 @@ type gamesPage struct {
|
||||
MaxWrong int
|
||||
Deals []klondike.Tier // solitaire's three deals
|
||||
FullDeck int
|
||||
Quizzes []trivia.Tier // trivia's three difficulties
|
||||
Rungs int // how long the trivia ladder is
|
||||
}
|
||||
|
||||
// casinoRoutes hangs every table off the mux.
|
||||
@@ -88,6 +90,7 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
|
||||
mux.HandleFunc("GET /games/hangman", s.handleHangman)
|
||||
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
|
||||
mux.HandleFunc("GET /games/trivia", s.handleTrivia)
|
||||
|
||||
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
||||
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
||||
@@ -101,6 +104,9 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
|
||||
|
||||
mux.HandleFunc("POST /api/games/solitaire/start", s.handleSolitaireStart)
|
||||
mux.HandleFunc("POST /api/games/solitaire/move", s.handleSolitaireMove)
|
||||
|
||||
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
|
||||
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
|
||||
}
|
||||
|
||||
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
|
||||
@@ -131,6 +137,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
|
||||
MaxWrong: hangman.MaxWrong,
|
||||
Deals: klondike.Tiers,
|
||||
FullDeck: klondike.FullDeck,
|
||||
Quizzes: trivia.Tiers,
|
||||
Rungs: trivia.Rungs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,3 +169,10 @@ func (s *Server) handleSolitaire(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.render(w, "solitaire", s.gamesPage(r))
|
||||
}
|
||||
|
||||
func (s *Server) handleTrivia(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requirePlayer(w, r) {
|
||||
return
|
||||
}
|
||||
s.render(w, "trivia", s.gamesPage(r))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"pete/internal/games/cards"
|
||||
"pete/internal/games/hangman"
|
||||
"pete/internal/games/klondike"
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
@@ -189,6 +190,9 @@ type tableView struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -239,6 +243,16 @@ func (s *Server) table(user string) (tableView, error) {
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -494,6 +508,7 @@ const (
|
||||
gameBlackjack = "blackjack"
|
||||
gameHangman = "hangman"
|
||||
gameSolitaire = "solitaire"
|
||||
gameTrivia = "trivia"
|
||||
)
|
||||
|
||||
// finished is what commit needs to know about a game it's writing back: enough
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
pages []string
|
||||
}{
|
||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
||||
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire"}},
|
||||
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia"}},
|
||||
}
|
||||
tpls := make(map[string]*template.Template)
|
||||
for _, set := range sets {
|
||||
|
||||
@@ -1081,6 +1081,122 @@ html[data-phase="night"] {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- trivia --------------------------------------------------------------
|
||||
The clock is the game, so the clock is the biggest thing on the felt: a bar
|
||||
that drains across the top of the question, going hot as it runs out. It is
|
||||
*decoration* — the server timed the answer the moment it arrived — but it
|
||||
has to be honest decoration, so it's driven from the seconds the server said
|
||||
were left rather than from when the browser happened to paint. */
|
||||
|
||||
.pete-clock {
|
||||
position: relative;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.pete-clock-fill {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
transform-origin: left center;
|
||||
/* Driven by a transform the browser can run on its own: a width animated on
|
||||
every frame from JS is a layout on every frame. */
|
||||
transform: scaleX(1);
|
||||
}
|
||||
/* The last few seconds. The bar stops being a progress meter and starts being
|
||||
a warning. */
|
||||
.pete-clock[data-hot="1"] .pete-clock-fill { background: #cc3d4a; }
|
||||
.pete-clock[data-hot="1"] { animation: pete-clock-pulse 0.7s ease-in-out infinite; }
|
||||
@keyframes pete-clock-pulse {
|
||||
0%, 100% { box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.35); }
|
||||
50% { box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.95); }
|
||||
}
|
||||
|
||||
/* The four answers. Big targets, because the clock is already the hard part —
|
||||
a question you lose to a mis-tap is a question about pointing. */
|
||||
.pete-answer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
border-radius: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.26);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.pete-answer:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.36);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.pete-answer:active:not(:disabled) { transform: translateY(1px); }
|
||||
.pete-answer:disabled { cursor: default; }
|
||||
|
||||
/* The letter down the side, so an answer can be picked with the keyboard and
|
||||
the key you press is printed on the thing you're pressing it for. */
|
||||
.pete-answer-key {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 1.75rem;
|
||||
width: 1.75rem;
|
||||
flex: none;
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* How it went. Only ever set once the server has decided — the browser has no
|
||||
idea which of these is right until it's told. */
|
||||
.pete-answer[data-state="right"] {
|
||||
background: rgba(46, 160, 103, 0.9);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.pete-answer[data-state="wrong"] {
|
||||
background: rgba(204, 61, 74, 0.9);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.35);
|
||||
animation: pete-answer-no 0.45s ease;
|
||||
}
|
||||
/* The one they *should* have picked, shown alongside the one they did. */
|
||||
.pete-answer[data-state="missed"] {
|
||||
box-shadow: inset 0 0 0 2px rgba(46, 160, 103, 0.95);
|
||||
}
|
||||
.pete-answer[data-state="dim"] { opacity: 0.4; }
|
||||
@keyframes pete-answer-no {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-6px); }
|
||||
45% { transform: translateX(5px); }
|
||||
70% { transform: translateX(-3px); }
|
||||
}
|
||||
|
||||
/* The ladder: one pip per rung, filling as they're climbed. It is the only
|
||||
picture of how far there is left to fall. */
|
||||
.pete-ladder {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.pete-rung {
|
||||
height: 0.5rem;
|
||||
width: 1.1rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
transition: background 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
.pete-rung[data-on="1"] {
|
||||
background: var(--accent);
|
||||
transform: scaleY(1.35);
|
||||
}
|
||||
|
||||
/* ---- solitaire -----------------------------------------------------------
|
||||
Seven columns, four foundations, a stock and a waste, all of which have to
|
||||
fit across the felt on a phone as well as a desk. So the card size is one
|
||||
@@ -1299,6 +1415,11 @@ html[data-phase="night"] {
|
||||
.pete-nope,
|
||||
.pete-home-flash { animation: none; }
|
||||
.pete-card[data-held="1"] { transition: none; }
|
||||
/* The clock still drains — it is information, not decoration — but it stops
|
||||
pulsing at you, and a wrong answer stops shaking. */
|
||||
.pete-clock[data-hot="1"],
|
||||
.pete-answer[data-state="wrong"] { animation: none; }
|
||||
.pete-rung { transition: none; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
546
internal/web/static/js/trivia.js
Normal file
546
internal/web/static/js/trivia.js
Normal file
@@ -0,0 +1,546 @@
|
||||
// The trivia table.
|
||||
//
|
||||
// Same bargain as every other table in the room: the browser holds no game. It
|
||||
// sends an answer, and the server says how it went. The four buttons arrive
|
||||
// without any mark on which of them is right — that index is in the engine
|
||||
// state, on the server — and the reveal only comes back in the event that
|
||||
// decides the question, by which point knowing it is worth nothing.
|
||||
//
|
||||
// The countdown here is decoration, and it is important to be clear about that.
|
||||
// Nothing it does scores anything: the server timed the answer the moment it
|
||||
// arrived, against the clock it started when it served the question. Stopping
|
||||
// this bar, or reloading to restart it, changes nothing at all. What the bar
|
||||
// owes the player is *honesty* — so it is seeded from the seconds the server
|
||||
// says are left, not from when the browser got round to painting.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.querySelector("[data-trivia]");
|
||||
if (!root) return;
|
||||
|
||||
var FX = window.PeteFX;
|
||||
|
||||
var questionEl = root.querySelector("[data-question]");
|
||||
var categoryEl = root.querySelector("[data-category]");
|
||||
var answersEl = root.querySelector("[data-answers]");
|
||||
var clockEl = root.querySelector("[data-clock]");
|
||||
var clockFillEl = root.querySelector("[data-clock-fill]");
|
||||
var countdownEl = root.querySelector("[data-countdown]");
|
||||
var ladderEl = root.querySelector("[data-ladder]");
|
||||
var multEl = root.querySelector("[data-multiple]");
|
||||
var meterEl = root.querySelector("[data-meter]");
|
||||
var standsEl = root.querySelector("[data-stands]");
|
||||
var standsLbl = root.querySelector("[data-stands-label]");
|
||||
var rungEl = root.querySelector("[data-rung]");
|
||||
var verdictEl = root.querySelector("[data-verdict]");
|
||||
|
||||
var betting = root.querySelector("[data-betting]");
|
||||
var playing = root.querySelector("[data-playing]");
|
||||
var walkBtn = root.querySelector("[data-walk]");
|
||||
var walkAmtEl = root.querySelector("[data-walk-amount]");
|
||||
var betAmount = root.querySelector("[data-bet-amount]");
|
||||
var startBtn = root.querySelector("[data-start]");
|
||||
var msgEl = root.querySelector("[data-table-msg]");
|
||||
var gameMsgEl = root.querySelector("[data-game-msg]");
|
||||
|
||||
var purseEl = document.querySelector("[data-chips]");
|
||||
var spotEl = root.querySelector("[data-spot]");
|
||||
var houseEl = root.querySelector("[data-house]");
|
||||
|
||||
// The bet spot, and the rule that comes with it: the number under the pile is
|
||||
// a readout of the pile, never the other way round.
|
||||
var spot = FX.spot({
|
||||
spot: spotEl,
|
||||
stack: root.querySelector("[data-stack]"),
|
||||
total: root.querySelector("[data-spot-total]"),
|
||||
});
|
||||
|
||||
var bet = 0; // what you're building between games
|
||||
var busy = false;
|
||||
var game = null; // the round as the server last described it
|
||||
var tier = "medium";
|
||||
|
||||
var reduced = FX.reduced;
|
||||
function pace(ms) { return reduced ? 0 : ms; }
|
||||
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
|
||||
|
||||
function say(text, tone, where) {
|
||||
var el = where || msgEl;
|
||||
if (!el) return;
|
||||
if (!text) { el.classList.add("hidden"); return; }
|
||||
el.textContent = text;
|
||||
el.classList.remove("hidden");
|
||||
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||
}
|
||||
|
||||
// ---- the clock -------------------------------------------------------------
|
||||
|
||||
var raf = null;
|
||||
var clockDeadline = 0; // performance.now() ms at which this question dies
|
||||
var clockLimit = 1;
|
||||
var timedOut = false;
|
||||
|
||||
// HOT is when the bar stops being a progress meter and starts being a warning.
|
||||
var HOT = 5;
|
||||
|
||||
// GRACE is how long past its own zero the browser waits before reporting the
|
||||
// timeout. It cannot be negative: the browser's countdown starts when the
|
||||
// response *arrives*, so it is already behind the server's by the latency of
|
||||
// that response, and it therefore always reaches zero after the server has.
|
||||
// The grace is only there so that "always" survives a rounding error.
|
||||
var GRACE = 400;
|
||||
|
||||
function stopClock() {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = null;
|
||||
}
|
||||
|
||||
function startClock(left, limit) {
|
||||
stopClock();
|
||||
timedOut = false;
|
||||
clockLimit = limit > 0 ? limit : 1;
|
||||
clockDeadline = performance.now() + left * 1000;
|
||||
tick();
|
||||
}
|
||||
|
||||
function tick() {
|
||||
var left = (clockDeadline - performance.now()) / 1000;
|
||||
if (left < 0) left = 0;
|
||||
|
||||
// A transform, not a width: the browser can run this one without laying the
|
||||
// page out again on every frame.
|
||||
clockFillEl.style.transform = "scaleX(" + (left / clockLimit).toFixed(4) + ")";
|
||||
countdownEl.textContent = left.toFixed(1) + "s";
|
||||
clockEl.dataset.hot = left <= HOT && left > 0 ? "1" : "0";
|
||||
|
||||
if (left <= 0) {
|
||||
countdownEl.textContent = "0.0s";
|
||||
clockEl.dataset.hot = "0";
|
||||
timeUp();
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// timeUp reports the clock running out. It is not the browser *deciding* the
|
||||
// question — it is the browser telling the server the player never answered,
|
||||
// and the server (which has been holding the real clock all along) agreeing.
|
||||
function timeUp() {
|
||||
stopClock();
|
||||
if (timedOut || busy || !game || game.phase !== "playing") return;
|
||||
timedOut = true;
|
||||
lockAnswers();
|
||||
setTimeout(function () {
|
||||
// -1 is "no answer". The engine checks the clock before it checks the
|
||||
// choice, so this resolves as the timeout it is rather than a bad move.
|
||||
send("/api/games/trivia/answer", { choice: -1 }, gameMsgEl);
|
||||
}, GRACE);
|
||||
}
|
||||
|
||||
function clearClock() {
|
||||
stopClock();
|
||||
clockFillEl.style.transform = "scaleX(0)";
|
||||
countdownEl.textContent = "";
|
||||
clockEl.dataset.hot = "0";
|
||||
}
|
||||
|
||||
// ---- the question ----------------------------------------------------------
|
||||
|
||||
var KEYS = ["1", "2", "3", "4"];
|
||||
|
||||
function renderQuestion(v) {
|
||||
answersEl.innerHTML = "";
|
||||
if (!v || v.phase !== "playing" || !v.answers) {
|
||||
questionEl.textContent = "";
|
||||
categoryEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
categoryEl.textContent = v.category || "";
|
||||
questionEl.textContent = v.question || "";
|
||||
|
||||
v.answers.forEach(function (text, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "pete-answer";
|
||||
b.dataset.at = String(i);
|
||||
|
||||
var key = document.createElement("span");
|
||||
key.className = "pete-answer-key";
|
||||
key.textContent = KEYS[i] || "";
|
||||
key.setAttribute("aria-hidden", "true");
|
||||
|
||||
var label = document.createElement("span");
|
||||
label.textContent = text;
|
||||
|
||||
b.appendChild(key);
|
||||
b.appendChild(label);
|
||||
b.addEventListener("click", function () { answer(i); });
|
||||
answersEl.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function lockAnswers() {
|
||||
answersEl.querySelectorAll(".pete-answer").forEach(function (b) { b.disabled = true; });
|
||||
}
|
||||
|
||||
// reveal marks the board once the server has decided. Nothing in here is known
|
||||
// until it comes back: the right answer arrives in the event, not in the view.
|
||||
function reveal(choice, correct) {
|
||||
answersEl.querySelectorAll(".pete-answer").forEach(function (b) {
|
||||
var i = parseInt(b.dataset.at, 10);
|
||||
b.disabled = true;
|
||||
if (i === choice && i === correct) b.dataset.state = "right";
|
||||
else if (i === choice) b.dataset.state = "wrong";
|
||||
else if (i === correct) b.dataset.state = "missed";
|
||||
else b.dataset.state = "dim";
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the meters ------------------------------------------------------------
|
||||
|
||||
function renderLadder(v) {
|
||||
ladderEl.innerHTML = "";
|
||||
var rungs = (v && v.rungs) || 12;
|
||||
var done = (v && v.rung) || 0;
|
||||
for (var i = 0; i < rungs; i++) {
|
||||
var pip = document.createElement("span");
|
||||
pip.className = "pete-rung";
|
||||
pip.dataset.on = i < done ? "1" : "0";
|
||||
ladderEl.appendChild(pip);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMeter(v) {
|
||||
if (!v) {
|
||||
multEl.textContent = "—";
|
||||
standsEl.textContent = "—";
|
||||
standsLbl.textContent = "if you walk";
|
||||
meterEl.dataset.cold = "1";
|
||||
rungEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
multEl.textContent = v.multiple.toFixed(2) + "×";
|
||||
standsEl.textContent = (v.stands || 0).toLocaleString();
|
||||
meterEl.dataset.cold = v.rung === 0 ? "1" : "0";
|
||||
|
||||
if (v.phase === "done") {
|
||||
standsLbl.textContent = v.net > 0 ? "banked" : "gone";
|
||||
rungEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
standsLbl.textContent = v.can_walk ? "if you walk" : "answer one to unlock";
|
||||
rungEl.textContent = "Question " + (v.rung + 1) + " of " + v.rungs;
|
||||
}
|
||||
|
||||
// knock rolls the multiple up to its new value rather than swapping it, so a
|
||||
// right answer reads as the total *growing* — which is the thing you're
|
||||
// deciding whether to risk.
|
||||
function climb(v) {
|
||||
var from = parseFloat(multEl.textContent) || 1;
|
||||
var to = v.multiple;
|
||||
if (reduced) { renderMeter(v); return; }
|
||||
var t0 = performance.now();
|
||||
meterEl.dataset.hit = "0";
|
||||
|
||||
(function step(now) {
|
||||
var p = Math.min(1, (now - t0) / 420);
|
||||
var eased = 1 - Math.pow(1 - p, 3);
|
||||
multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
else renderMeter(v);
|
||||
})(t0);
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
function settleChips(final) {
|
||||
var payout = final.payout || 0;
|
||||
var back = payout - final.bet;
|
||||
|
||||
if (payout <= 0) {
|
||||
var chain = spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||
return chain;
|
||||
}
|
||||
return spot
|
||||
.pour(houseEl, back, { gap: 60 })
|
||||
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||
}
|
||||
|
||||
// standing puts the stake back on the spot for the next ladder, the way every
|
||||
// other table in the room leaves your bet up.
|
||||
function standing(amount) {
|
||||
var money = window.PeteGames.view();
|
||||
if (!amount || !money || money.chips < amount) {
|
||||
bet = 0;
|
||||
showBet();
|
||||
return;
|
||||
}
|
||||
bet = amount;
|
||||
showBet();
|
||||
spot.amount = amount;
|
||||
return spot.pour(purseEl, amount);
|
||||
}
|
||||
|
||||
// ---- phases ----------------------------------------------------------------
|
||||
|
||||
var VERDICTS = {
|
||||
walked: "Banked it.",
|
||||
cleared: "Cleared the board! 🎉",
|
||||
wrong: "Wrong.",
|
||||
timeout: "Out of time.",
|
||||
};
|
||||
|
||||
function verdict(v) {
|
||||
var text = VERDICTS[v.outcome] || "";
|
||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||
verdictEl.textContent = text;
|
||||
verdictEl.classList.remove("hidden");
|
||||
|
||||
// Confetti only for clearing all twelve — the one thing in here worth it.
|
||||
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 });
|
||||
}
|
||||
|
||||
function setPhase(v) {
|
||||
game = v;
|
||||
var live = !!v && v.phase === "playing";
|
||||
betting.classList.toggle("hidden", live);
|
||||
playing.classList.toggle("hidden", !live);
|
||||
if (walkBtn) {
|
||||
walkBtn.disabled = !live || !v.can_walk;
|
||||
walkAmtEl.textContent = (v && v.can_walk ? v.stands : 0).toLocaleString();
|
||||
}
|
||||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
// paint puts a round up with no animation: the resume path, after a reload or a
|
||||
// redeploy. The clock picks up exactly where the server says it is — which is
|
||||
// the whole point of it being the server's clock.
|
||||
function paint(v) {
|
||||
if (!v) {
|
||||
clearClock();
|
||||
renderQuestion(null);
|
||||
renderLadder(null);
|
||||
renderMeter(null);
|
||||
spot.render(0);
|
||||
setPhase(null);
|
||||
return;
|
||||
}
|
||||
renderQuestion(v);
|
||||
renderLadder(v);
|
||||
renderMeter(v);
|
||||
spot.render(v.phase === "done" ? 0 : v.bet);
|
||||
setPhase(v);
|
||||
|
||||
if (v.phase === "playing") startClock(v.left, v.limit);
|
||||
else clearClock();
|
||||
}
|
||||
|
||||
// ---- the script ------------------------------------------------------------
|
||||
|
||||
// play walks the server's events. Same rule as the other tables: on a live
|
||||
// round the money is already right (your stake left your pile when you pressed
|
||||
// Play, and it's on the spot), but on a settling one the chip bar is held back
|
||||
// until the chips have physically come home. A counter that pays you before
|
||||
// the reveal is a counter that has told you the ending.
|
||||
function play(view, money) {
|
||||
var events = view.triv_events || [];
|
||||
var final = view.trivia;
|
||||
var settles = !!final && final.phase === "done";
|
||||
var chain = Promise.resolve();
|
||||
|
||||
if (!settles) money();
|
||||
|
||||
stopClock();
|
||||
|
||||
events.forEach(function (e) {
|
||||
chain = chain.then(function () {
|
||||
switch (e.kind) {
|
||||
case "ask":
|
||||
// A fresh question. Everything about the last one goes.
|
||||
verdictEl.classList.add("hidden");
|
||||
renderQuestion(final);
|
||||
renderLadder(final);
|
||||
if (final && final.phase === "playing") startClock(final.left, final.limit);
|
||||
return;
|
||||
|
||||
case "right":
|
||||
reveal(e.choice, e.correct);
|
||||
if (final) {
|
||||
// The rung lighting and the multiple climbing are one event,
|
||||
// because they are one event: this is what the answer was worth.
|
||||
climb({ multiple: e.multiple, rung: final.rung, rungs: final.rungs,
|
||||
stands: final.stands, can_walk: true, phase: "playing" });
|
||||
renderLadder(final);
|
||||
}
|
||||
return wait(900);
|
||||
|
||||
case "wrong":
|
||||
reveal(e.choice, e.correct);
|
||||
return wait(1100);
|
||||
|
||||
case "timeout":
|
||||
reveal(-1, e.correct);
|
||||
return wait(1100);
|
||||
|
||||
case "settle":
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return chain.then(function () {
|
||||
if (!final) { paint(null); money(); return; }
|
||||
|
||||
if (!settles) {
|
||||
renderMeter(final);
|
||||
setPhase(final);
|
||||
return;
|
||||
}
|
||||
|
||||
// Over: the clock stops, the money moves, and only then does the bar catch up.
|
||||
clearClock();
|
||||
playing.classList.add("hidden");
|
||||
renderMeter(final);
|
||||
renderLadder(final);
|
||||
verdict(final);
|
||||
return settleChips(final)
|
||||
.then(money)
|
||||
.then(function () { return standing(final.bet); })
|
||||
.then(function () { setPhase(final); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- talking to the table ---------------------------------------------------
|
||||
|
||||
function send(path, body, where) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
say("", null, where);
|
||||
return window.PeteGames.post(path, body)
|
||||
.then(function (view) {
|
||||
return play(view, function () { window.PeteGames.apply(view); });
|
||||
})
|
||||
.catch(function (err) {
|
||||
say(err.message, "bad", where);
|
||||
return window.PeteGames.refresh().then(function (v) {
|
||||
if (v && v.trivia) paint(v.trivia);
|
||||
else { paint(null); spot.render(0); }
|
||||
});
|
||||
})
|
||||
.then(function () { busy = false; });
|
||||
}
|
||||
|
||||
function answer(i) {
|
||||
if (busy || timedOut || !game || game.phase !== "playing") return;
|
||||
stopClock();
|
||||
lockAnswers();
|
||||
send("/api/games/trivia/answer", { choice: i }, gameMsgEl);
|
||||
}
|
||||
|
||||
if (walkBtn) {
|
||||
walkBtn.addEventListener("click", function () {
|
||||
if (busy || !game || game.phase !== "playing" || !game.can_walk) return;
|
||||
stopClock();
|
||||
lockAnswers();
|
||||
send("/api/games/trivia/answer", { walk: true }, gameMsgEl);
|
||||
});
|
||||
}
|
||||
|
||||
// 1–4 answers the question. The key is printed on the button it answers.
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
|
||||
if (!game || game.phase !== "playing" || busy) return;
|
||||
var i = KEYS.indexOf(e.key);
|
||||
if (i === -1) return;
|
||||
var btn = answersEl.querySelector('.pete-answer[data-at="' + i + '"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
e.preventDefault();
|
||||
answer(i);
|
||||
});
|
||||
|
||||
// ---- betting ----------------------------------------------------------------
|
||||
|
||||
function showBet() {
|
||||
betAmount.textContent = bet.toLocaleString();
|
||||
var money = window.PeteGames.view();
|
||||
if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
|
||||
}
|
||||
|
||||
function pickTier(slug) {
|
||||
tier = slug;
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
|
||||
});
|
||||
showBet();
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
pickTier(b.dataset.tier);
|
||||
});
|
||||
});
|
||||
|
||||
// The chip you click is the chip that flies. Scoped to buttons: the bare
|
||||
// [data-chip] spans in the corner are the house's rack, and it is not betting.
|
||||
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
var d = parseInt(btn.dataset.chip, 10);
|
||||
var money = window.PeteGames.view();
|
||||
if (money && bet + d > money.chips) {
|
||||
say("You haven't got that many chips.", "bad");
|
||||
return;
|
||||
}
|
||||
bet += d;
|
||||
showBet();
|
||||
|
||||
var target = bet;
|
||||
spot.amount = bet;
|
||||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||
bet = 0;
|
||||
showBet();
|
||||
});
|
||||
}
|
||||
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
if (!tier) { say("Pick a difficulty first.", "bad"); return; }
|
||||
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||||
// The stake stays on the spot for the whole ladder: it is what's at risk,
|
||||
// and it is riding on every question until you take it back or lose it.
|
||||
send("/api/games/trivia/start", { bet: bet, tier: tier });
|
||||
});
|
||||
}
|
||||
|
||||
pickTier(tier);
|
||||
|
||||
var resumed = false;
|
||||
window.PeteGames.onUpdate(function (v) {
|
||||
if (!resumed) {
|
||||
resumed = true;
|
||||
if (v.trivia) {
|
||||
paint(v.trivia);
|
||||
if (v.trivia.phase === "done") verdict(v.trivia);
|
||||
} else {
|
||||
paint(null);
|
||||
}
|
||||
}
|
||||
showBet();
|
||||
});
|
||||
})();
|
||||
@@ -74,6 +74,22 @@
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="/games/trivia"
|
||||
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🧠</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-display text-xl font-bold">Trivia</h3>
|
||||
<p class="text-sm text-[color:var(--ink)]/60">Climb the ladder, or take the money.</p>
|
||||
</div>
|
||||
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
|
||||
{{.Rungs}} questions, and every right answer multiplies what you're holding. A wrong
|
||||
one loses the lot. Answer fast: the multiple decays as the clock runs.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
{{range .Soon}}
|
||||
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
149
internal/web/templates/trivia.html
Normal file
149
internal/web/templates/trivia.html
Normal file
@@ -0,0 +1,149 @@
|
||||
{{define "title"}}Trivia · {{.Room.Name}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<div class="space-y-6" data-trivia>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
|
||||
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
<span class="sr-only">Back to the casino</span>
|
||||
</a>
|
||||
<h1 class="font-display text-3xl font-bold">Trivia</h1>
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--ink)]/50">{{.Rungs}} questions · answer fast, or don't bother</p>
|
||||
</div>
|
||||
|
||||
{{template "_chipbar" .}}
|
||||
|
||||
<!-- The felt. The clock is the biggest thing on it, because the clock is the
|
||||
game: a right answer is worth what it's worth *when you give it*. -->
|
||||
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
|
||||
|
||||
<div class="pete-rack" data-house aria-hidden="true">
|
||||
<span data-chip="500" style="--stack: 5"></span>
|
||||
<span data-chip="100" style="--stack: 7"></span>
|
||||
<span data-chip="25" style="--stack: 4"></span>
|
||||
<span data-chip="5" style="--stack: 6"></span>
|
||||
</div>
|
||||
|
||||
<!-- The meter and the ladder. This row is the only one level with the house
|
||||
rack in the corner, so it is the only one that has to keep clear of it. -->
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 pr-24 sm:pr-28">
|
||||
<div class="pete-meter" data-meter>
|
||||
<span class="pete-meter-label">Worth</span>
|
||||
<span data-multiple class="pete-meter-value">—</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/60">
|
||||
<span data-stands class="font-bold tabular-nums text-white/90">—</span>
|
||||
<span data-stands-label>if you walk</span>
|
||||
</p>
|
||||
<div class="pete-ladder ml-auto" data-ladder aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<!-- The question. -->
|
||||
<div class="mt-7 min-h-[16rem]" data-round>
|
||||
|
||||
<div class="pete-clock" data-clock>
|
||||
<div class="pete-clock-fill" data-clock-fill></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p data-category class="text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
||||
<p data-countdown class="font-display text-lg font-bold tabular-nums text-white/70"></p>
|
||||
</div>
|
||||
|
||||
<h2 data-question class="mt-1 font-display text-xl font-bold leading-snug text-white sm:text-2xl" aria-live="polite"></h2>
|
||||
|
||||
<div data-answers class="mt-5 grid gap-2.5 sm:grid-cols-2"></div>
|
||||
|
||||
<div class="mt-5 flex min-h-[2.75rem] items-center">
|
||||
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The stake, on the same spot every other table puts it. -->
|
||||
<div class="mt-2 flex items-center gap-4">
|
||||
<div class="pete-spot" data-spot>
|
||||
<span class="pete-spot-label">Bet</span>
|
||||
<div class="pete-stack" data-stack></div>
|
||||
<span data-spot-total class="pete-spot-total hidden"></span>
|
||||
</div>
|
||||
<p data-rung class="text-xs font-bold uppercase tracking-wider text-white/50"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Playing: shown while a ladder is live. -->
|
||||
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<p class="text-sm text-[color:var(--ink)]/50">
|
||||
Press <span class="font-bold">1</span>–<span class="font-bold">4</span>, or click one. A wrong answer, or the clock, loses the lot.
|
||||
</p>
|
||||
<button type="button" data-walk
|
||||
class="ml-auto rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Take the money · <span data-walk-amount>0</span>
|
||||
</button>
|
||||
</div>
|
||||
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||
</section>
|
||||
|
||||
<!-- Betting: shown between games. -->
|
||||
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">How hard?</div>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{{range .Quizzes}}
|
||||
<button type="button" data-tier="{{.Slug}}"
|
||||
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
||||
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.2f" .Fast}}×</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
||||
{{.Limit}}s a question · slowest answer still pays {{printf "%.2f" .Buzzer}}×
|
||||
</p>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
|
||||
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{range .Denominations}}
|
||||
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
|
||||
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
|
||||
<span>{{.}}</span>
|
||||
</button>
|
||||
{{end}}
|
||||
<button type="button" data-bet-clear
|
||||
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
|
||||
</div>
|
||||
|
||||
<button type="button" data-start
|
||||
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Play
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||
The first question is the price of sitting down: you can only walk once you've answered one.
|
||||
</p>
|
||||
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/casino-fx.js" defer></script>
|
||||
<script src="/static/js/games.js" defer></script>
|
||||
<script src="/static/js/trivia.js" defer></script>
|
||||
{{end}}
|
||||
126
internal/web/trivia_bank.go
Normal file
126
internal/web/trivia_bank.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/opentdb"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Keeping the trivia bank stocked.
|
||||
//
|
||||
// The bank is not consumed by play — a question drawn is still there afterwards
|
||||
// — so this loop is about *variety*, not supply. It fills each difficulty up to
|
||||
// a target and then has nothing to do, which is why a pass that finds the bank
|
||||
// full costs three COUNT queries and no network at all.
|
||||
|
||||
// bankTarget is how many questions of each difficulty we want to hold. Twelve
|
||||
// rungs drawn from four hundred is enough that a regular player doesn't start
|
||||
// recognising them, and it's a size OpenTDB's pool can actually fill.
|
||||
const bankTarget = 400
|
||||
|
||||
// bankMaxFetches bounds one pass. At OpenTDB's politeness interval this is a
|
||||
// couple of minutes of drip, after which the loop goes back to sleep rather than
|
||||
// hammering a free API for an hour to top up the last few questions.
|
||||
const bankMaxFetches = 12
|
||||
|
||||
// bankInterval is how often we go back and look. The bank is a slow-moving
|
||||
// thing: it only grows, and it only needs to grow once.
|
||||
const bankInterval = 12 * time.Hour
|
||||
|
||||
// StartTriviaBank launches the refill loop if the casino is on. Safe to call
|
||||
// unconditionally; a no-op when games are off.
|
||||
func (s *Server) StartTriviaBank(ctx context.Context) {
|
||||
if !s.gamesReady() {
|
||||
return
|
||||
}
|
||||
go s.runTriviaBank(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) runTriviaBank(ctx context.Context) {
|
||||
slog.Info("games: trivia bank refill started", "target", bankTarget, "interval", bankInterval)
|
||||
|
||||
s.refillTriviaBank(ctx)
|
||||
|
||||
ticker := time.NewTicker(bankInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.refillTriviaBank(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refillTriviaBank tops each difficulty up toward the target, politely.
|
||||
//
|
||||
// Every failure here is survivable and none of them stop the loop: OpenTDB is a
|
||||
// free API that is sometimes down, and a thin bank costs a player nothing worse
|
||||
// than a "give it a minute" when they try to start a ladder.
|
||||
func (s *Server) refillTriviaBank(ctx context.Context) {
|
||||
client := opentdb.New()
|
||||
fetches := 0
|
||||
|
||||
for _, t := range trivia.Tiers {
|
||||
for fetches < bankMaxFetches {
|
||||
have, err := storage.CountTrivia(t.Difficulty)
|
||||
if err != nil {
|
||||
slog.Error("games: trivia bank count", "difficulty", t.Difficulty, "err", err)
|
||||
break
|
||||
}
|
||||
if have >= bankTarget {
|
||||
break
|
||||
}
|
||||
|
||||
qs, err := client.Fetch(ctx, t.Difficulty, opentdb.Batch)
|
||||
fetches++
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
slog.Warn("games: trivia bank fetch", "difficulty", t.Difficulty, "err", err)
|
||||
// Whatever went wrong, waiting is the only sensible response: the
|
||||
// likeliest cause is the rate limit, and retrying at once earns another.
|
||||
if !sleepCtx(ctx, opentdb.Politeness) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
added, err := storage.AddTriviaQuestions(t.Difficulty, qs)
|
||||
if err != nil {
|
||||
slog.Error("games: trivia bank store", "difficulty", t.Difficulty, "err", err)
|
||||
break
|
||||
}
|
||||
slog.Info("games: trivia bank filled",
|
||||
"difficulty", t.Difficulty, "fetched", len(qs), "new", added, "have", have+added)
|
||||
|
||||
// The API hands back random batches, so once the bank is deep the
|
||||
// overlap gets heavy and a batch adds almost nothing new. When it adds
|
||||
// nothing at all, this difficulty has given us what it has: stop asking.
|
||||
if added == 0 {
|
||||
break
|
||||
}
|
||||
if !sleepCtx(ctx, opentdb.Politeness) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sleepCtx waits, unless we're being shut down. Reports false if we are.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user