games: a ladder you climb against the clock
This commit is contained in:
369
internal/games/trivia/trivia.go
Normal file
369
internal/games/trivia/trivia.go
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
// Package trivia is a pure trivia-ladder engine, played for chips.
|
||||||
|
//
|
||||||
|
// Same seam as blackjack and hangman: ApplyMove(state, move, now) (state,
|
||||||
|
// events, error), where an error means the move was illegal and nothing else.
|
||||||
|
// The one difference is that clock: trivia is the only game in the room where
|
||||||
|
// *when* you move changes what it pays, and a pure reducer cannot own a timer.
|
||||||
|
// So the time is an argument. The engine stays a value in, value out, and the
|
||||||
|
// only thing that knows what o'clock it is remains the caller.
|
||||||
|
//
|
||||||
|
// The shape is a ladder. You stake once, and then answer a run of questions:
|
||||||
|
// every right answer multiplies what the stake is worth, a wrong one loses the
|
||||||
|
// lot, and you may walk with what you've built at any point after the first.
|
||||||
|
// It is the oldest quiz-show bet there is — the tension is entirely in whether
|
||||||
|
// you take the money.
|
||||||
|
//
|
||||||
|
// The reason for the clock is less pretty: trivia answers are googlable, and a
|
||||||
|
// game that paid the same for a slow right answer as a fast one would be a game
|
||||||
|
// about typing into another tab. So the multiple a question is worth decays
|
||||||
|
// from Fast to Buzzer across the tier's time limit, and running out of time
|
||||||
|
// loses exactly as much as being wrong. The countdown in the browser is
|
||||||
|
// decoration; this is the clock that counts.
|
||||||
|
package trivia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors an illegal move can produce.
|
||||||
|
var (
|
||||||
|
ErrGameOver = errors.New("trivia: the game is already over")
|
||||||
|
ErrUnknownMove = errors.New("trivia: unknown move")
|
||||||
|
ErrBadBet = errors.New("trivia: bet must be positive")
|
||||||
|
ErrUnknownTier = errors.New("trivia: no such tier")
|
||||||
|
ErrShortLadder = errors.New("trivia: not enough questions to build a ladder")
|
||||||
|
ErrNothingBanked = errors.New("trivia: answer one before you walk")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rungs is how long the ladder is. Clearing it is a win in itself: the run ends
|
||||||
|
// and banks, because a ladder with no top is just a slot machine you can't stop
|
||||||
|
// playing, and eventually every player loses everything to one bad question.
|
||||||
|
const Rungs = 12
|
||||||
|
|
||||||
|
// Tier is a difficulty, chosen before the bet. It sets three things that move
|
||||||
|
// together: how hard the questions are, how long you get, and what a right
|
||||||
|
// answer is worth. Hard questions pay more and give you less time to look them
|
||||||
|
// up, which is the whole bargain.
|
||||||
|
type Tier struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Difficulty string `json:"difficulty"` // what OpenTDB calls it: easy | medium | hard
|
||||||
|
Fast float64 `json:"fast"` // what a right answer multiplies by, answered instantly
|
||||||
|
Buzzer float64 `json:"buzzer"` // ...and what it's worth answered on the last tick
|
||||||
|
Limit int `json:"limit"` // seconds on the clock, per question
|
||||||
|
Blurb string `json:"blurb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiers are the three tables.
|
||||||
|
var Tiers = []Tier{
|
||||||
|
{Slug: "easy", Name: "Easy", Difficulty: "easy", Fast: 1.30, Buzzer: 1.10, Limit: 20,
|
||||||
|
Blurb: "Things you know. The clock is the only thing in your way."},
|
||||||
|
{Slug: "medium", Name: "Medium", Difficulty: "medium", Fast: 1.55, Buzzer: 1.20, Limit: 18,
|
||||||
|
Blurb: "Things you nearly know."},
|
||||||
|
{Slug: "hard", Name: "Hard", Difficulty: "hard", Fast: 1.90, Buzzer: 1.30, Limit: 15,
|
||||||
|
Blurb: "Things you don't. Fifteen seconds is not enough to find out."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierBySlug finds a tier by the name the browser sent.
|
||||||
|
func TierBySlug(slug string) (Tier, error) {
|
||||||
|
for _, t := range Tiers {
|
||||||
|
if t.Slug == slug {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Tier{}, ErrUnknownTier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is what a right answer multiplies the running total by, given how long
|
||||||
|
// it took. Fast at nought seconds, Buzzer at the limit, straight line between.
|
||||||
|
//
|
||||||
|
// Answering at the buzzer still pays *something* — the decay is a reason to be
|
||||||
|
// quick, not a punishment for thinking. The punishment for thinking too long is
|
||||||
|
// the timeout, and that one takes everything.
|
||||||
|
func (t Tier) Step(elapsed time.Duration) float64 {
|
||||||
|
limit := t.Clock()
|
||||||
|
switch {
|
||||||
|
case elapsed <= 0:
|
||||||
|
return t.Fast
|
||||||
|
case elapsed >= limit:
|
||||||
|
return t.Buzzer
|
||||||
|
}
|
||||||
|
speed := 1 - float64(elapsed)/float64(limit) // 1 answering instantly, 0 at the buzzer
|
||||||
|
return t.Buzzer + (t.Fast-t.Buzzer)*speed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock is the tier's time limit as a duration.
|
||||||
|
func (t Tier) Clock() time.Duration { return time.Duration(t.Limit) * time.Second }
|
||||||
|
|
||||||
|
// Question is one rung. It carries its own correct index, which is exactly why
|
||||||
|
// a State never crosses the wire — the browser is sent the answers and not
|
||||||
|
// which of them is right.
|
||||||
|
type Question struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Answers []string `json:"answers"` // already shuffled: the right one is not always first
|
||||||
|
Correct int `json:"correct"` // index into Answers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase is where the game is.
|
||||||
|
type Phase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhasePlaying Phase = "playing"
|
||||||
|
PhaseDone Phase = "done"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outcome is how it ended.
|
||||||
|
type Outcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutcomeNone Outcome = ""
|
||||||
|
OutcomeWalked Outcome = "walked" // took the money
|
||||||
|
OutcomeCleared Outcome = "cleared" // answered all twelve
|
||||||
|
OutcomeWrong Outcome = "wrong" // picked the wrong one
|
||||||
|
OutcomeTimeout Outcome = "timeout" // ran out of clock
|
||||||
|
)
|
||||||
|
|
||||||
|
// Won reports whether this outcome pays.
|
||||||
|
func (o Outcome) Won() bool { return o == OutcomeWalked || o == OutcomeCleared }
|
||||||
|
|
||||||
|
// State is one game. The ladder — every question, and every right answer — is
|
||||||
|
// in here, which is why this value stays on the server. The browser gets a view
|
||||||
|
// of the current rung and nothing about the ones ahead of it.
|
||||||
|
type State struct {
|
||||||
|
Tier Tier `json:"tier"`
|
||||||
|
Ladder []Question `json:"ladder"` // the whole run, drawn up front
|
||||||
|
Rung int `json:"rung"` // how many answered right; also the index of the live question
|
||||||
|
|
||||||
|
// AskedAt is when the current question was *put to the player*, by the
|
||||||
|
// server's clock. It is the only clock in the game. A reload does not reset
|
||||||
|
// it: you cannot stop time by refreshing.
|
||||||
|
AskedAt time.Time `json:"asked_at"`
|
||||||
|
|
||||||
|
Multiple float64 `json:"multiple"` // 1.0 at the start; the product of every step earned
|
||||||
|
RakePct float64 `json:"rake_pct"`
|
||||||
|
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Phase Phase `json:"phase"`
|
||||||
|
Outcome Outcome `json:"outcome"`
|
||||||
|
Payout int64 `json:"payout"`
|
||||||
|
Rake int64 `json:"rake"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is something the table animates.
|
||||||
|
type Event struct {
|
||||||
|
Kind string `json:"kind"` // "ask" | "right" | "wrong" | "timeout" | "settle"
|
||||||
|
Choice int `json:"choice"` // what the player picked (-1 when they didn't)
|
||||||
|
Correct int `json:"correct"` // which one was right — sent only once it's decided
|
||||||
|
Step float64 `json:"step,omitempty"` // what this answer multiplied by
|
||||||
|
Multiple float64 `json:"multiple,omitempty"` // the running total, after
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move is a player action: pick an answer, or take the money.
|
||||||
|
type Move struct {
|
||||||
|
Choice int `json:"choice"` // index into the live question's answers
|
||||||
|
Walk bool `json:"walk"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New starts a game on a ladder of questions the caller has already drawn. The
|
||||||
|
// engine does not reach for a database any more than blackjack reaches for a
|
||||||
|
// deck: the questions arrive as a value, so a game is reproducible and a test
|
||||||
|
// can pin every rung.
|
||||||
|
//
|
||||||
|
// The answers are shuffled here, with the caller's seeded rng, because a bank
|
||||||
|
// that always stores the right answer first would otherwise be a game about
|
||||||
|
// clicking first.
|
||||||
|
func New(bet int64, t Tier, rakePct float64, qs []Question, now time.Time, rng *rand.Rand) (State, []Event, error) {
|
||||||
|
if bet <= 0 {
|
||||||
|
return State{}, nil, ErrBadBet
|
||||||
|
}
|
||||||
|
if len(qs) < Rungs {
|
||||||
|
return State{}, nil, ErrShortLadder
|
||||||
|
}
|
||||||
|
ladder := make([]Question, Rungs)
|
||||||
|
for i := range ladder {
|
||||||
|
ladder[i] = shuffleAnswers(qs[i], rng)
|
||||||
|
}
|
||||||
|
s := State{
|
||||||
|
Tier: t, Ladder: ladder, RakePct: rakePct,
|
||||||
|
Multiple: 1,
|
||||||
|
AskedAt: now,
|
||||||
|
Bet: bet, Phase: PhasePlaying,
|
||||||
|
}
|
||||||
|
return s, []Event{{Kind: "ask", Choice: -1, Correct: -1}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shuffleAnswers moves the right answer somewhere the player can't guess from
|
||||||
|
// position, and keeps track of where it went.
|
||||||
|
func shuffleAnswers(q Question, rng *rand.Rand) Question {
|
||||||
|
answers := append([]string(nil), q.Answers...)
|
||||||
|
correct := q.Answers[q.Correct]
|
||||||
|
rng.Shuffle(len(answers), func(i, j int) { answers[i], answers[j] = answers[j], answers[i] })
|
||||||
|
out := q
|
||||||
|
out.Answers = answers
|
||||||
|
for i, a := range answers {
|
||||||
|
if a == correct {
|
||||||
|
out.Correct = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live is the question the player is looking at.
|
||||||
|
func (s State) Live() Question {
|
||||||
|
if s.Rung < 0 || s.Rung >= len(s.Ladder) {
|
||||||
|
return Question{}
|
||||||
|
}
|
||||||
|
return s.Ladder[s.Rung]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left is how much clock the live question has, at the given moment. It goes to
|
||||||
|
// the browser so its countdown starts where the server's does — but the browser
|
||||||
|
// is never asked what it says.
|
||||||
|
func (s State) Left(now time.Time) time.Duration {
|
||||||
|
d := s.Tier.Clock() - now.Sub(s.AskedAt)
|
||||||
|
if d < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMove is the engine. now is the server's clock, and the only one that
|
||||||
|
// counts toward the answer.
|
||||||
|
func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) {
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
return s, nil, ErrGameOver
|
||||||
|
}
|
||||||
|
s = s.clone()
|
||||||
|
|
||||||
|
if m.Walk {
|
||||||
|
// You cannot walk off a rung you haven't climbed. If you could, seeing the
|
||||||
|
// first question and walking away would be a free look: stake, peek, walk,
|
||||||
|
// stake again, and keep reshuffling until the question is one you know.
|
||||||
|
// The first question is therefore the price of sitting down.
|
||||||
|
if s.Rung == 0 {
|
||||||
|
return s, nil, ErrNothingBanked
|
||||||
|
}
|
||||||
|
evs := []Event{}
|
||||||
|
s.settle(OutcomeWalked, &evs)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
q := s.Live()
|
||||||
|
if len(q.Answers) == 0 {
|
||||||
|
return s, nil, ErrUnknownMove
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(s.AskedAt)
|
||||||
|
|
||||||
|
// Out of time. This is a loss, and it has to be — a timeout that merely cost
|
||||||
|
// you the speed bonus would make "leave it open in another tab and go and
|
||||||
|
// look it up" the strongest way to play.
|
||||||
|
if elapsed > s.Tier.Clock() {
|
||||||
|
evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}}
|
||||||
|
s.settle(OutcomeTimeout, &evs)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Choice < 0 || m.Choice >= len(q.Answers) {
|
||||||
|
return s, nil, ErrUnknownMove
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Choice != q.Correct {
|
||||||
|
evs := []Event{{Kind: "wrong", Choice: m.Choice, Correct: q.Correct}}
|
||||||
|
s.settle(OutcomeWrong, &evs)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right, and quick enough to be worth something.
|
||||||
|
step := s.Tier.Step(elapsed)
|
||||||
|
s.Multiple *= step
|
||||||
|
s.Rung++
|
||||||
|
evs := []Event{{
|
||||||
|
Kind: "right", Choice: m.Choice, Correct: q.Correct,
|
||||||
|
Step: step, Multiple: s.Multiple,
|
||||||
|
}}
|
||||||
|
|
||||||
|
if s.Rung >= Rungs {
|
||||||
|
s.settle(OutcomeCleared, &evs)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next question goes up, and its clock starts now.
|
||||||
|
s.AskedAt = now
|
||||||
|
evs = append(evs, Event{Kind: "ask", Choice: -1, Correct: -1})
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pays is what banking *right now* would put back on the player's stack: the
|
||||||
|
// stake, plus the winnings, less the house's cut of the winnings.
|
||||||
|
//
|
||||||
|
// It exists for the same reason hangman's does. The felt quotes this number
|
||||||
|
// while the game is still running — it is the "take the money" button's label —
|
||||||
|
// and settle() calls it rather than doing the sum a second time, so the table
|
||||||
|
// can never advertise a payout the house doesn't hand over.
|
||||||
|
func (s State) Pays() int64 {
|
||||||
|
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
|
||||||
|
if total < s.Bet {
|
||||||
|
total = s.Bet // banking never hands back less than the stake
|
||||||
|
}
|
||||||
|
profit := total - s.Bet
|
||||||
|
if profit > 0 {
|
||||||
|
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||||
|
if rake > 0 {
|
||||||
|
profit -= rake
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.Bet + profit
|
||||||
|
}
|
||||||
|
|
||||||
|
// rakeNow is the other half of what Pays works out: the house's cut of a win
|
||||||
|
// banked at this moment. Never taken from the stake, so a player who walks
|
||||||
|
// having answered nothing — which they can't — and one who loses, pay nothing.
|
||||||
|
func (s State) rakeNow() int64 {
|
||||||
|
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
|
||||||
|
if total <= s.Bet {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rake := int64(math.Floor(float64(total-s.Bet) * s.RakePct))
|
||||||
|
if rake < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return rake
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle decides the payout. Same rule as every other table in the room: the
|
||||||
|
// rake comes out of winnings, never out of the stake, and a loss is never
|
||||||
|
// charged a fee.
|
||||||
|
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||||
|
s.Outcome = o
|
||||||
|
s.Phase = PhaseDone
|
||||||
|
|
||||||
|
if o.Won() {
|
||||||
|
s.Payout = s.Pays()
|
||||||
|
s.Rake = s.rakeNow()
|
||||||
|
} else {
|
||||||
|
s.Payout = 0
|
||||||
|
}
|
||||||
|
*evs = append(*evs, Event{Kind: "settle", Choice: -1, Correct: -1, Text: string(o)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Net is what the game did to the player's stack.
|
||||||
|
func (s State) Net() int64 {
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.Payout - s.Bet
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone deep-copies the ladder, so a derived state shares no backing array with
|
||||||
|
// the one it came from and a game can be replayed freely.
|
||||||
|
func (s State) clone() State {
|
||||||
|
s.Ladder = append([]Question(nil), s.Ladder...)
|
||||||
|
return s
|
||||||
|
}
|
||||||
316
internal/games/trivia/trivia_test.go
Normal file
316
internal/games/trivia/trivia_test.go
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
package trivia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rng() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) }
|
||||||
|
|
||||||
|
var epoch = time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// bank builds n questions whose right answer is always "right", so a test can
|
||||||
|
// find it after the shuffle without caring where it landed.
|
||||||
|
func bank(n int) []Question {
|
||||||
|
qs := make([]Question, n)
|
||||||
|
for i := range qs {
|
||||||
|
qs[i] = Question{
|
||||||
|
Category: "General",
|
||||||
|
Text: "question?",
|
||||||
|
Answers: []string{"right", "wrong1", "wrong2", "wrong3"},
|
||||||
|
Correct: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return qs
|
||||||
|
}
|
||||||
|
|
||||||
|
func tier(slug string) Tier {
|
||||||
|
t, err := TierBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGame(t *testing.T, bet int64, slug string) State {
|
||||||
|
t.Helper()
|
||||||
|
s, evs, err := New(bet, tier(slug), 0.05, bank(Rungs), epoch, rng())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
if len(evs) != 1 || evs[0].Kind != "ask" {
|
||||||
|
t.Fatalf("New should open with one ask, got %+v", evs)
|
||||||
|
}
|
||||||
|
if s.Multiple != 1 {
|
||||||
|
t.Fatalf("a fresh ladder is worth the stake, got multiple %v", s.Multiple)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerRight plays the live question correctly, after `took` on the clock.
|
||||||
|
func answerRight(t *testing.T, s State, took time.Duration) (State, []Event) {
|
||||||
|
t.Helper()
|
||||||
|
q := s.Live()
|
||||||
|
next, evs, err := ApplyMove(s, Move{Choice: q.Correct}, s.AskedAt.Add(took))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("right answer refused: %v", err)
|
||||||
|
}
|
||||||
|
return next, evs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewShufflesButKeepsTheAnswer(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "medium")
|
||||||
|
moved := 0
|
||||||
|
for _, q := range s.Ladder {
|
||||||
|
if q.Answers[q.Correct] != "right" {
|
||||||
|
t.Fatalf("Correct points at %q, not the right answer", q.Answers[q.Correct])
|
||||||
|
}
|
||||||
|
if q.Correct != 0 {
|
||||||
|
moved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All twelve landing on index 0 would mean the shuffle isn't running, and the
|
||||||
|
// game would be "always click the first one".
|
||||||
|
if moved == 0 {
|
||||||
|
t.Fatal("the right answer is first in every question — the shuffle did nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortBankIsRefused(t *testing.T) {
|
||||||
|
if _, _, err := New(100, tier("easy"), 0.05, bank(Rungs-1), epoch, rng()); err != ErrShortLadder {
|
||||||
|
t.Fatalf("a ladder with a missing rung should be refused, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one that matters most: the number the felt quotes is the number the
|
||||||
|
// player is actually paid, at every rung, exactly as in hangman.
|
||||||
|
func TestTheQuoteIsThePayout(t *testing.T) {
|
||||||
|
s := newGame(t, 200, "hard")
|
||||||
|
for rung := 1; rung < Rungs; rung++ {
|
||||||
|
s, _ = answerRight(t, s, 3*time.Second)
|
||||||
|
|
||||||
|
quoted := s.Pays() // what the "take the money" button says it's worth
|
||||||
|
|
||||||
|
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rung %d: walk refused: %v", rung, err)
|
||||||
|
}
|
||||||
|
if banked.Payout != quoted {
|
||||||
|
t.Fatalf("rung %d: the felt quoted %d and the house paid %d", rung, quoted, banked.Payout)
|
||||||
|
}
|
||||||
|
if banked.Phase != PhaseDone || banked.Outcome != OutcomeWalked {
|
||||||
|
t.Fatalf("rung %d: walking should end the game, got %s/%s", rung, banked.Phase, banked.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walking before answering anything would be a free look at the first question:
|
||||||
|
// stake, peek, walk, restake until the question is one you happen to know.
|
||||||
|
func TestYouCannotWalkOffTheFirstRung(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "easy")
|
||||||
|
if _, _, err := ApplyMove(s, Move{Walk: true}, epoch); err != ErrNothingBanked {
|
||||||
|
t.Fatalf("walking on rung 0 should be refused, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One right answer, and now you may.
|
||||||
|
s, _ = answerRight(t, s, time.Second)
|
||||||
|
if _, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt); err != nil {
|
||||||
|
t.Fatalf("walking after a right answer should be allowed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWrongAnswerLosesTheLot(t *testing.T) {
|
||||||
|
s := newGame(t, 300, "medium")
|
||||||
|
// Build a decent ladder first, so there is something real to lose.
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
s, _ = answerRight(t, s, time.Second)
|
||||||
|
}
|
||||||
|
if s.Pays() <= 300 {
|
||||||
|
t.Fatalf("four right answers should be worth more than the stake, got %d", s.Pays())
|
||||||
|
}
|
||||||
|
|
||||||
|
q := s.Live()
|
||||||
|
wrong := (q.Correct + 1) % len(q.Answers)
|
||||||
|
out, evs, err := ApplyMove(s, Move{Choice: wrong}, s.AskedAt.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a wrong answer is a legal move: %v", err)
|
||||||
|
}
|
||||||
|
if out.Outcome != OutcomeWrong || out.Payout != 0 {
|
||||||
|
t.Fatalf("a wrong answer should pay nothing, got %s/%d", out.Outcome, out.Payout)
|
||||||
|
}
|
||||||
|
if out.Rake != 0 {
|
||||||
|
t.Fatalf("a loss must never be charged a rake, got %d", out.Rake)
|
||||||
|
}
|
||||||
|
if out.Net() != -300 {
|
||||||
|
t.Fatalf("a wrong answer costs the stake and nothing more, got %d", out.Net())
|
||||||
|
}
|
||||||
|
// The player is told which one it was.
|
||||||
|
if evs[0].Kind != "wrong" || evs[0].Correct != q.Correct {
|
||||||
|
t.Fatalf("a wrong answer should reveal the right one, got %+v", evs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clock is the whole anti-google mechanism: running out of it has to cost
|
||||||
|
// as much as being wrong, or leaving the tab open and looking it up wins.
|
||||||
|
func TestTheClockTakesEverything(t *testing.T) {
|
||||||
|
s := newGame(t, 250, "hard")
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
s, _ = answerRight(t, s, time.Second)
|
||||||
|
}
|
||||||
|
banked := s.Pays()
|
||||||
|
|
||||||
|
q := s.Live()
|
||||||
|
late := s.AskedAt.Add(s.Tier.Clock() + time.Millisecond)
|
||||||
|
out, evs, err := ApplyMove(s, Move{Choice: q.Correct}, late) // the *right* answer, too late
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a late answer is a legal move: %v", err)
|
||||||
|
}
|
||||||
|
if out.Outcome != OutcomeTimeout {
|
||||||
|
t.Fatalf("answering past the limit should time out, got %s", out.Outcome)
|
||||||
|
}
|
||||||
|
if out.Payout != 0 {
|
||||||
|
t.Fatalf("a timeout pays nothing — it was worth %d a moment ago, and paid %d", banked, out.Payout)
|
||||||
|
}
|
||||||
|
if evs[0].Kind != "timeout" {
|
||||||
|
t.Fatalf("expected a timeout event, got %+v", evs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// And answering on the final tick still counts.
|
||||||
|
onTime := s.AskedAt.Add(s.Tier.Clock())
|
||||||
|
if out, _, err = ApplyMove(s, Move{Choice: q.Correct}, onTime); err != nil {
|
||||||
|
t.Fatalf("an answer on the buzzer is legal: %v", err)
|
||||||
|
}
|
||||||
|
if out.Rung != s.Rung+1 {
|
||||||
|
t.Fatal("an answer on the final tick should still count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed is the only thing separating a slow right answer from a fast one.
|
||||||
|
func TestFasterPaysMore(t *testing.T) {
|
||||||
|
base := newGame(t, 1000, "hard")
|
||||||
|
|
||||||
|
quick, _ := answerRight(t, base, time.Second)
|
||||||
|
slow, _ := answerRight(t, base, 14*time.Second)
|
||||||
|
|
||||||
|
if quick.Multiple <= slow.Multiple {
|
||||||
|
t.Fatalf("a quick answer should be worth more: quick %v, slow %v", quick.Multiple, slow.Multiple)
|
||||||
|
}
|
||||||
|
if quick.Pays() <= slow.Pays() {
|
||||||
|
t.Fatalf("a quick answer should pay more: quick %d, slow %d", quick.Pays(), slow.Pays())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ends of the scale are the tier's own numbers, and nothing is outside them.
|
||||||
|
instant, _ := answerRight(t, base, 0)
|
||||||
|
buzzer, _ := answerRight(t, base, base.Tier.Clock())
|
||||||
|
if instant.Multiple != base.Tier.Fast {
|
||||||
|
t.Fatalf("an instant answer is worth Fast (%v), got %v", base.Tier.Fast, instant.Multiple)
|
||||||
|
}
|
||||||
|
if buzzer.Multiple != base.Tier.Buzzer {
|
||||||
|
t.Fatalf("an answer on the buzzer is worth Buzzer (%v), got %v", base.Tier.Buzzer, buzzer.Multiple)
|
||||||
|
}
|
||||||
|
if quick.Multiple > base.Tier.Fast || slow.Multiple < base.Tier.Buzzer {
|
||||||
|
t.Fatal("a step escaped the tier's range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clearing the ladder ends the run and banks it, rather than leaving the player
|
||||||
|
// on a rung that doesn't exist.
|
||||||
|
func TestClearingTheLadderBanks(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "easy")
|
||||||
|
for i := 0; i < Rungs; i++ {
|
||||||
|
if s.Phase != PhasePlaying {
|
||||||
|
t.Fatalf("the game ended early, on rung %d", i)
|
||||||
|
}
|
||||||
|
s, _ = answerRight(t, s, time.Second)
|
||||||
|
}
|
||||||
|
if s.Outcome != OutcomeCleared {
|
||||||
|
t.Fatalf("twelve right answers should clear the ladder, got %s", s.Outcome)
|
||||||
|
}
|
||||||
|
if s.Rung != Rungs {
|
||||||
|
t.Fatalf("expected to be on rung %d, got %d", Rungs, s.Rung)
|
||||||
|
}
|
||||||
|
if s.Payout != s.Pays() || s.Payout <= s.Bet {
|
||||||
|
t.Fatalf("clearing should bank a win, got payout %d on a %d stake", s.Payout, s.Bet)
|
||||||
|
}
|
||||||
|
if _, _, err := ApplyMove(s, Move{Choice: 0}, s.AskedAt); err != ErrGameOver {
|
||||||
|
t.Fatalf("a cleared ladder takes no more moves, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rake comes out of winnings, never out of the stake.
|
||||||
|
func TestRakeOnlyBitesWinnings(t *testing.T) {
|
||||||
|
s := newGame(t, 1000, "medium")
|
||||||
|
s, _ = answerRight(t, s, 0) // instant: multiple is exactly Fast, so the sum is checkable by hand
|
||||||
|
|
||||||
|
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int64(float64(1000) * s.Tier.Fast) // 1550
|
||||||
|
profit := total - 1000 // 550
|
||||||
|
rake := int64(float64(profit) * 0.05) // 27
|
||||||
|
want := 1000 + profit - rake // 1523
|
||||||
|
|
||||||
|
if banked.Payout != want {
|
||||||
|
t.Fatalf("payout should be stake + winnings - 5%% of winnings = %d, got %d", want, banked.Payout)
|
||||||
|
}
|
||||||
|
if banked.Rake != rake {
|
||||||
|
t.Fatalf("rake should be %d, got %d", rake, banked.Rake)
|
||||||
|
}
|
||||||
|
if banked.Payout < banked.Bet {
|
||||||
|
t.Fatal("a win handed back less than the stake")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A move must not scribble on the state it came from — a game has to replay.
|
||||||
|
func TestApplyMoveDoesNotMutateItsInput(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "easy")
|
||||||
|
before := s.Live()
|
||||||
|
|
||||||
|
next, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("move: %v", err)
|
||||||
|
}
|
||||||
|
if s.Rung != 0 || s.Multiple != 1 || s.Phase != PhasePlaying {
|
||||||
|
t.Fatalf("the original state moved underneath us: rung %d multiple %v", s.Rung, s.Multiple)
|
||||||
|
}
|
||||||
|
if next.Rung != 1 {
|
||||||
|
t.Fatalf("the derived state should have climbed a rung, got %d", next.Rung)
|
||||||
|
}
|
||||||
|
// The same move replays to the same place.
|
||||||
|
again, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replay: %v", err)
|
||||||
|
}
|
||||||
|
if again.Multiple != next.Multiple || again.Rung != next.Rung {
|
||||||
|
t.Fatal("the same move from the same state landed somewhere else")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeftCountsDown(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "hard") // 15s
|
||||||
|
if got := s.Left(epoch); got != 15*time.Second {
|
||||||
|
t.Fatalf("a fresh question has the whole clock, got %v", got)
|
||||||
|
}
|
||||||
|
if got := s.Left(epoch.Add(10 * time.Second)); got != 5*time.Second {
|
||||||
|
t.Fatalf("expected 5s left, got %v", got)
|
||||||
|
}
|
||||||
|
// It floors at nought rather than going negative, so a browser can render it.
|
||||||
|
if got := s.Left(epoch.Add(time.Hour)); got != 0 {
|
||||||
|
t.Fatalf("the clock should stop at zero, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGarbageMovesAreRefused(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "easy")
|
||||||
|
for _, choice := range []int{-1, 4, 99} {
|
||||||
|
if _, _, err := ApplyMove(s, Move{Choice: choice}, s.AskedAt); err != ErrUnknownMove {
|
||||||
|
t.Fatalf("choice %d should be refused, got %v", choice, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Phase != PhasePlaying {
|
||||||
|
t.Fatal("a refused move should leave the game alone")
|
||||||
|
}
|
||||||
|
}
|
||||||
166
internal/opentdb/opentdb.go
Normal file
166
internal/opentdb/opentdb.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// Package opentdb fills the casino's trivia bank from the Open Trivia Database.
|
||||||
|
//
|
||||||
|
// The questions are *prefetched* into a local table, not fetched per question,
|
||||||
|
// and that is a deliberate call rather than an optimisation. A trivia ladder
|
||||||
|
// asks a question every fifteen seconds with money on the clock: a per-question
|
||||||
|
// fetch would put somebody else's latency, rate limit and downtime inside a
|
||||||
|
// timed round the player is being scored against. Pull the bank in the
|
||||||
|
// background, and a round becomes a local read that either works or doesn't.
|
||||||
|
//
|
||||||
|
// OpenTDB allows one request every five seconds per IP and caps a batch at 50,
|
||||||
|
// so the refill is a slow, polite drip, run in the background and never in the
|
||||||
|
// path of anything a player is waiting for.
|
||||||
|
package opentdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/trivia"
|
||||||
|
"pete/internal/safehttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// endpoint is the API. It is the only host this package ever talks to, and it
|
||||||
|
// goes through safehttp like every other outbound fetch in Pete.
|
||||||
|
const endpoint = "https://opentdb.com/api.php"
|
||||||
|
|
||||||
|
// Batch is the most OpenTDB will hand over in one request.
|
||||||
|
const Batch = 50
|
||||||
|
|
||||||
|
// Politeness is the gap the API asks for between requests. Going faster earns a
|
||||||
|
// response_code 5 and nothing else.
|
||||||
|
const Politeness = 6 * time.Second
|
||||||
|
|
||||||
|
// fetchTimeout bounds a single request. The refill runs in the background, so a
|
||||||
|
// slow answer costs nothing but its own goroutine — but it must still end.
|
||||||
|
const fetchTimeout = 20 * time.Second
|
||||||
|
|
||||||
|
// maxBody caps what we will read from the API, hostile or merely broken.
|
||||||
|
const maxBody = 1 << 20
|
||||||
|
|
||||||
|
// apiResponse is OpenTDB's envelope. ResponseCode is the part that matters:
|
||||||
|
// zero is the only one that means "here are your questions".
|
||||||
|
type apiResponse struct {
|
||||||
|
ResponseCode int `json:"response_code"`
|
||||||
|
Results []struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
Correct string `json:"correct_answer"`
|
||||||
|
Incorrect []string `json:"incorrect_answers"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseErr turns a non-zero code into something a log line can explain.
|
||||||
|
func responseErr(code int) error {
|
||||||
|
switch code {
|
||||||
|
case 1:
|
||||||
|
return fmt.Errorf("opentdb: no results for that query")
|
||||||
|
case 2:
|
||||||
|
return fmt.Errorf("opentdb: the query was invalid")
|
||||||
|
case 3, 4:
|
||||||
|
return fmt.Errorf("opentdb: session token expired or exhausted")
|
||||||
|
case 5:
|
||||||
|
return fmt.Errorf("opentdb: rate limited — slow down")
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("opentdb: response code %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client fetches questions.
|
||||||
|
type Client struct {
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Client {
|
||||||
|
return &Client{http: safehttp.NewClient(fetchTimeout)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch pulls up to n multiple-choice questions of one difficulty.
|
||||||
|
//
|
||||||
|
// Only "multiple" questions are asked for: the ladder is four buttons, and a
|
||||||
|
// true/false question on the same felt would be a coin flip dressed up as a
|
||||||
|
// question — and a coin flip the player is being paid a difficulty multiple for.
|
||||||
|
func (c *Client) Fetch(ctx context.Context, difficulty string, n int) ([]trivia.Question, error) {
|
||||||
|
if n <= 0 || n > Batch {
|
||||||
|
n = Batch
|
||||||
|
}
|
||||||
|
q := url.Values{
|
||||||
|
"amount": {fmt.Sprint(n)},
|
||||||
|
"difficulty": {difficulty},
|
||||||
|
"type": {"multiple"},
|
||||||
|
}
|
||||||
|
raw := endpoint + "?" + q.Encode()
|
||||||
|
if err := safehttp.ValidateURL(raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "pete-games/1.0 (+https://games.parodia.dev)")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("opentdb: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(safehttp.LimitedBody(resp.Body, maxBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out apiResponse
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("opentdb: %w", err)
|
||||||
|
}
|
||||||
|
if out.ResponseCode != 0 {
|
||||||
|
return nil, responseErr(out.ResponseCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
qs := make([]trivia.Question, 0, len(out.Results))
|
||||||
|
for _, r := range out.Results {
|
||||||
|
// The API hands back HTML entities ("Who wrote "Dune"?"), which
|
||||||
|
// would otherwise be drawn literally onto a button.
|
||||||
|
text := clean(r.Question)
|
||||||
|
correct := clean(r.Correct)
|
||||||
|
if text == "" || correct == "" || len(r.Incorrect) != 3 {
|
||||||
|
continue // a malformed question is one we simply don't take
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct: 0 here is a convention, not a tell. The engine reshuffles every
|
||||||
|
// question against the game's own seed as it builds the ladder, so where
|
||||||
|
// the right answer sits in the bank never reaches a player.
|
||||||
|
answers := make([]string, 0, 4)
|
||||||
|
answers = append(answers, correct)
|
||||||
|
for _, w := range r.Incorrect {
|
||||||
|
answers = append(answers, clean(w))
|
||||||
|
}
|
||||||
|
qs = append(qs, trivia.Question{
|
||||||
|
Category: clean(r.Category),
|
||||||
|
Text: text,
|
||||||
|
Answers: answers,
|
||||||
|
Correct: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return qs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean turns an API string into something you can put on a button: entities
|
||||||
|
// decoded, whitespace tidied.
|
||||||
|
func clean(s string) string {
|
||||||
|
return strings.TrimSpace(html.UnescapeString(s))
|
||||||
|
}
|
||||||
@@ -242,6 +242,31 @@ CREATE TABLE IF NOT EXISTS game_live_hands (
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- The trivia bank: questions pulled from the Open Trivia Database ahead of time,
|
||||||
|
-- so that asking one is a local read.
|
||||||
|
--
|
||||||
|
-- Prefetched rather than fetched per question because a trivia ladder asks a
|
||||||
|
-- question every fifteen seconds with money on a clock the player is scored
|
||||||
|
-- against. A live fetch would put somebody else's latency and rate limit inside
|
||||||
|
-- that clock. The refill is a slow background drip (internal/opentdb); a round
|
||||||
|
-- never waits on it.
|
||||||
|
--
|
||||||
|
-- The question text is UNIQUE, which is the whole dedup strategy: OpenTDB hands back
|
||||||
|
-- overlapping batches and the bank would otherwise fill up with the same forty
|
||||||
|
-- questions. correct/incorrect are stored as the API gives them; the *shuffle*
|
||||||
|
-- happens in the engine, per game, against that game's seed — so where the right
|
||||||
|
-- answer sits in this table tells a player nothing.
|
||||||
|
CREATE TABLE IF NOT EXISTS trivia_questions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
difficulty TEXT NOT NULL, -- 'easy' | 'medium' | 'hard'
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
question TEXT NOT NULL UNIQUE,
|
||||||
|
correct TEXT NOT NULL,
|
||||||
|
incorrect TEXT NOT NULL, -- JSON array of the three wrong answers
|
||||||
|
fetched_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trivia_difficulty ON trivia_questions(difficulty);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
||||||
|
|||||||
147
internal/storage/trivia.go
Normal file
147
internal/storage/trivia.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/trivia"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The trivia bank.
|
||||||
|
//
|
||||||
|
// Questions are pulled from OpenTDB in the background (internal/opentdb) and
|
||||||
|
// drawn from here when a ladder is built. Nothing in a player's round ever
|
||||||
|
// touches the network.
|
||||||
|
|
||||||
|
// ErrBankEmpty means the bank hasn't got enough questions of that difficulty to
|
||||||
|
// build a ladder. It is a real state, not a bug: a fresh database has an empty
|
||||||
|
// bank until the refill loop has been round a few times.
|
||||||
|
var ErrBankEmpty = fmt.Errorf("trivia: the bank is short of questions")
|
||||||
|
|
||||||
|
// AddTriviaQuestions files a fetched batch. Questions already in the bank are
|
||||||
|
// ignored rather than replaced — OpenTDB hands back overlapping batches, and the
|
||||||
|
// UNIQUE on the text is what stops the bank becoming forty questions deep.
|
||||||
|
// Returns how many were actually new, which is what the refill loop logs.
|
||||||
|
func AddTriviaQuestions(difficulty string, qs []trivia.Question) (int, error) {
|
||||||
|
if len(qs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("trivia: begin: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(
|
||||||
|
`INSERT OR IGNORE INTO trivia_questions
|
||||||
|
(difficulty, category, question, correct, incorrect, fetched_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("trivia: prepare: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
added := 0
|
||||||
|
for _, q := range qs {
|
||||||
|
if len(q.Answers) < 2 || q.Correct < 0 || q.Correct >= len(q.Answers) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
correct := q.Answers[q.Correct]
|
||||||
|
wrong := make([]string, 0, len(q.Answers)-1)
|
||||||
|
for i, a := range q.Answers {
|
||||||
|
if i != q.Correct {
|
||||||
|
wrong = append(wrong, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(wrong)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res, err := stmt.Exec(difficulty, q.Category, q.Text, correct, string(blob), now)
|
||||||
|
if err != nil {
|
||||||
|
return added, fmt.Errorf("trivia: insert: %w", err)
|
||||||
|
}
|
||||||
|
if n, err := res.RowsAffected(); err == nil {
|
||||||
|
added += int(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, fmt.Errorf("trivia: commit: %w", err)
|
||||||
|
}
|
||||||
|
return added, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountTrivia is how many questions of a difficulty the bank holds. The refill
|
||||||
|
// loop reads it to decide whether to bother.
|
||||||
|
func CountTrivia(difficulty string) (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM trivia_questions WHERE difficulty = ?`, difficulty,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("trivia: count: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawTrivia deals a ladder: n distinct questions of one difficulty, chosen with
|
||||||
|
// the game's own rng.
|
||||||
|
//
|
||||||
|
// The choice is made in Go rather than with ORDER BY RANDOM() so that the seed
|
||||||
|
// in the audit log means something: the same seed against the same bank deals
|
||||||
|
// the same ladder, which is what lets a disputed game be replayed. It reads the
|
||||||
|
// ids first and picks from them, so a bank of a few thousand questions costs one
|
||||||
|
// small scan rather than a sort of the whole table.
|
||||||
|
func DrawTrivia(difficulty string, n int, rng *rand.Rand) ([]trivia.Question, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT id FROM trivia_questions WHERE difficulty = ? ORDER BY id`, difficulty)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("trivia: draw ids: %w", err)
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("trivia: scan id: %w", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("trivia: draw ids: %w", err)
|
||||||
|
}
|
||||||
|
if len(ids) < n {
|
||||||
|
return nil, ErrBankEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||||
|
pick := ids[:n]
|
||||||
|
|
||||||
|
out := make([]trivia.Question, 0, n)
|
||||||
|
for _, id := range pick {
|
||||||
|
var q trivia.Question
|
||||||
|
var correct, blob string
|
||||||
|
if err := Get().QueryRow(
|
||||||
|
`SELECT category, question, correct, incorrect FROM trivia_questions WHERE id = ?`, id,
|
||||||
|
).Scan(&q.Category, &q.Text, &correct, &blob); err != nil {
|
||||||
|
return nil, fmt.Errorf("trivia: load question: %w", err)
|
||||||
|
}
|
||||||
|
var wrong []string
|
||||||
|
if err := json.Unmarshal([]byte(blob), &wrong); err != nil {
|
||||||
|
return nil, fmt.Errorf("trivia: unreadable answers: %w", err)
|
||||||
|
}
|
||||||
|
// Correct: 0 is a convention the engine immediately destroys — New()
|
||||||
|
// reshuffles every question against the game's seed. Nothing that reaches a
|
||||||
|
// player depends on the order they come out of the table in.
|
||||||
|
q.Answers = append([]string{correct}, wrong...)
|
||||||
|
q.Correct = 0
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"pete/internal/games/blackjack"
|
"pete/internal/games/blackjack"
|
||||||
"pete/internal/games/hangman"
|
"pete/internal/games/hangman"
|
||||||
"pete/internal/games/klondike"
|
"pete/internal/games/klondike"
|
||||||
|
"pete/internal/games/trivia"
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +28,6 @@ type gameTeaser struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var comingSoon = []gameTeaser{
|
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: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
|
||||||
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
|
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,8 @@ type gamesPage struct {
|
|||||||
MaxWrong int
|
MaxWrong int
|
||||||
Deals []klondike.Tier // solitaire's three deals
|
Deals []klondike.Tier // solitaire's three deals
|
||||||
FullDeck int
|
FullDeck int
|
||||||
|
Quizzes []trivia.Tier // trivia's three difficulties
|
||||||
|
Rungs int // how long the trivia ladder is
|
||||||
}
|
}
|
||||||
|
|
||||||
// casinoRoutes hangs every table off the mux.
|
// 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/blackjack", s.handleBlackjack)
|
||||||
mux.HandleFunc("GET /games/hangman", s.handleHangman)
|
mux.HandleFunc("GET /games/hangman", s.handleHangman)
|
||||||
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
|
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
|
||||||
|
mux.HandleFunc("GET /games/trivia", s.handleTrivia)
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
||||||
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
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/start", s.handleSolitaireStart)
|
||||||
mux.HandleFunc("POST /api/games/solitaire/move", s.handleSolitaireMove)
|
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.
|
// 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,
|
MaxWrong: hangman.MaxWrong,
|
||||||
Deals: klondike.Tiers,
|
Deals: klondike.Tiers,
|
||||||
FullDeck: klondike.FullDeck,
|
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))
|
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/cards"
|
||||||
"pete/internal/games/hangman"
|
"pete/internal/games/hangman"
|
||||||
"pete/internal/games/klondike"
|
"pete/internal/games/klondike"
|
||||||
|
"pete/internal/games/trivia"
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,6 +190,9 @@ type tableView struct {
|
|||||||
Solitaire *solitaireView `json:"solitaire,omitempty"`
|
Solitaire *solitaireView `json:"solitaire,omitempty"`
|
||||||
SolEvents []solEventView `json:"sol_events,omitempty"`
|
SolEvents []solEventView `json:"sol_events,omitempty"`
|
||||||
|
|
||||||
|
Trivia *triviaView `json:"trivia,omitempty"`
|
||||||
|
TrivEvents []trivia.Event `json:"triv_events,omitempty"`
|
||||||
|
|
||||||
Rake float64 `json:"rake_pct"`
|
Rake float64 `json:"rake_pct"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +243,16 @@ func (s *Server) table(user string) (tableView, error) {
|
|||||||
}
|
}
|
||||||
sv := viewSolitaire(g)
|
sv := viewSolitaire(g)
|
||||||
v.Solitaire = &sv
|
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:
|
default:
|
||||||
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
|
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
|
||||||
}
|
}
|
||||||
@@ -494,6 +508,7 @@ const (
|
|||||||
gameBlackjack = "blackjack"
|
gameBlackjack = "blackjack"
|
||||||
gameHangman = "hangman"
|
gameHangman = "hangman"
|
||||||
gameSolitaire = "solitaire"
|
gameSolitaire = "solitaire"
|
||||||
|
gameTrivia = "trivia"
|
||||||
)
|
)
|
||||||
|
|
||||||
// finished is what commit needs to know about a game it's writing back: enough
|
// 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
|
pages []string
|
||||||
}{
|
}{
|
||||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
{"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)
|
tpls := make(map[string]*template.Template)
|
||||||
for _, set := range sets {
|
for _, set := range sets {
|
||||||
|
|||||||
@@ -1081,6 +1081,122 @@ html[data-phase="night"] {
|
|||||||
border-color: var(--accent);
|
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 -----------------------------------------------------------
|
/* ---- solitaire -----------------------------------------------------------
|
||||||
Seven columns, four foundations, a stock and a waste, all of which have to
|
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
|
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-nope,
|
||||||
.pete-home-flash { animation: none; }
|
.pete-home-flash { animation: none; }
|
||||||
.pete-card[data-held="1"] { transition: 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>
|
</p>
|
||||||
</a>
|
</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}}
|
{{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="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">
|
<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
|
||||||
|
}
|
||||||
|
}
|
||||||
1
main.go
1
main.go
@@ -299,6 +299,7 @@ func main() {
|
|||||||
go ws.Start(ctx)
|
go ws.Start(ctx)
|
||||||
ws.StartPushSender(ctx)
|
ws.StartPushSender(ctx)
|
||||||
ws.StartAdventureDigest(ctx)
|
ws.StartAdventureDigest(ctx)
|
||||||
|
ws.StartTriviaBank(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,9 +244,61 @@ A multi-session build. This section is the handover; read it before anything els
|
|||||||
hits the rack, nothing happens, and it looks like the bet is broken. Blackjack's
|
hits the rack, nothing happens, and it looks like the bet is broken. Blackjack's
|
||||||
action buttons are also `[data-move="stand"]`, not `[data-stand]`.
|
action buttons are also `[data-move="stand"]`, not `[data-stand]`.
|
||||||
|
|
||||||
|
- **Trivia, and it plays for chips.** *(2026-07-14. Built, tested, and — say it
|
||||||
|
plainly — **NOT YET DRIVEN IN A BROWSER**. Every Go test passes and `go vet` is
|
||||||
|
clean, which per this plan's own hard-won rule means nothing at all about the
|
||||||
|
table. Do that first next session; see the bottom of this entry.)*
|
||||||
|
- **A ladder.** Stake once, then answer a run of twelve. Every right answer
|
||||||
|
multiplies what you're holding, a wrong one loses the lot, and you may walk
|
||||||
|
with what you've built. Clearing all twelve ends the run and banks it — a
|
||||||
|
ladder with no top is a slot machine you can't stop playing, and eventually
|
||||||
|
every player loses everything to one bad question.
|
||||||
|
- **The clock is the game, and it is the anti-google mechanism.** Trivia answers
|
||||||
|
are lookupable, so a right answer is worth what it's worth *when you give it*:
|
||||||
|
the multiple decays from Fast to Buzzer across the tier's limit (easy 1.30→1.10
|
||||||
|
over 20s, medium 1.55→1.20 over 18s, hard 1.90→1.30 over 15s), and running out
|
||||||
|
of time loses exactly as much as being wrong. A timeout that merely cost you the
|
||||||
|
speed bonus would make "look it up in the other tab" the strongest way to play.
|
||||||
|
The countdown in the browser is decoration; the clock that scores is
|
||||||
|
`time.Now()` against the `AskedAt` the server stamped. A reload does not restart
|
||||||
|
it.
|
||||||
|
- **A pure reducer still, but the time is an argument** — `ApplyMove(state, move,
|
||||||
|
now)`. A reducer cannot own a timer, so it doesn't: the only thing that knows
|
||||||
|
what o'clock it is remains the caller, and the engine stays value-in, value-out.
|
||||||
|
- **You cannot walk off the first rung** (`ErrNothingBanked`). If you could, seeing
|
||||||
|
question one and walking would be a free look: stake, peek, walk, restake, and
|
||||||
|
reshuffle until the question is one you happen to know. The first question is the
|
||||||
|
price of sitting down.
|
||||||
|
- **The browser never learns which answer is right.** The four answers cross the
|
||||||
|
wire without the index; that index is in the engine state, on the server. It
|
||||||
|
comes back only in the event that *decides* the question, by which point knowing
|
||||||
|
it is worth nothing. The ladder's remaining questions are never sent at all.
|
||||||
|
- `internal/games/trivia` — engine, 11 tests. The one that matters most is the
|
||||||
|
same one hangman needed: the number the felt quotes (`Pays()`) is asserted equal
|
||||||
|
to the number `settle()` lands on, at every rung.
|
||||||
|
- **The bank is prefetched, not fetched per question** (`internal/opentdb`,
|
||||||
|
`storage.DrawTrivia`, table `trivia_questions`). A ladder asks a question every
|
||||||
|
fifteen seconds with money on a clock the player is scored against; a live fetch
|
||||||
|
would put OpenTDB's latency and rate limit *inside* that clock. The refill is a
|
||||||
|
slow background drip (`StartTriviaBank`, 400 per difficulty, one request per six
|
||||||
|
seconds, stops early when a batch adds nothing new), and a round never waits on
|
||||||
|
it. Answers are shuffled per-game against the game's own seed, so where the right
|
||||||
|
answer sits in the table tells a player nothing.
|
||||||
|
- **Left to do, and it is the whole of the risk:** `PETE_DEV_CASINO=:7788 go test
|
||||||
|
./internal/web -run TestDevCasino -timeout 0` and *play it*. Watch especially:
|
||||||
|
the bank is empty on a fresh dev database, so the first start will 503 with "the
|
||||||
|
question bank is still filling up" until the refill loop has run — the dev rig
|
||||||
|
does not start that loop, so it likely needs seeding by hand or a call to
|
||||||
|
`refillTriviaBank`. Then: does the clock bar drain honestly, does the auto-submit
|
||||||
|
at zero land as a timeout rather than a "that move isn't legal" (the GRACE window
|
||||||
|
in trivia.js is what defends this), does the reveal mark the right answer, and
|
||||||
|
does the money settle onto the shared spot the way the other three tables do.
|
||||||
|
|
||||||
### Next, in order
|
### Next, in order
|
||||||
|
|
||||||
1. Phase 2's other half: **trivia**. Decided but not built: the question bank is
|
1. **Drive trivia in a browser** (see above) — it has never been played, and that
|
||||||
|
means nothing about it is known.
|
||||||
|
2. Phase 2's other half is now built but unproven: the question bank is
|
||||||
**prefetched from OpenTDB into a local table** (a per-question fetch in a web
|
**prefetched from OpenTDB into a local table** (a per-question fetch in a web
|
||||||
game loop is a latency and rate-limit problem gogobee never had), through
|
game loop is a latency and rate-limit problem gogobee never had), through
|
||||||
`internal/safehttp`. It stakes chips too. The shape that fits the room is a
|
`internal/safehttp`. It stakes chips too. The shape that fits the room is a
|
||||||
|
|||||||
Reference in New Issue
Block a user