games: a ladder you climb against the clock

This commit is contained in:
prosolis
2026-07-14 02:11:09 -07:00
parent feb353f789
commit c62d736223
17 changed files with 2292 additions and 4 deletions

View File

@@ -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))
}

View File

@@ -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

View 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)
}

View File

@@ -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 {

View File

@@ -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

View 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);
});
}
// 14 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();
});
})();

View File

@@ -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">

View 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
View 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
}
}