Files
Pete/internal/games/trivia/trivia.go
prosolis 3e9b93af55 games: the clock beats the walk button, and the rack isn't betting
The trivia ladder handled a walk before it looked at the clock, so the
timeout only ever bit if the browser volunteered it. Sit on a question,
look it up, answer if you find it and walk if you don't, and you never
lose a ladder. The clock is now the first thing that happens to a move.

The house's chip rack was wired up as bet buttons on blackjack and
hangman: it's four spans with data-chip on them and nothing said the
handler only wanted the real ones. Clicking the house's money raised
your bet.

Hangman had two definitions of "a letter you'd guess" — unicode in the
engine, ASCII in the renderer — and a phrase with an accent in it would
have had no tile to fill and no key to fill it with. One definition now.

Plus: trivia's countdown no longer freezes at zero when the server turns
down a timeout report it was early for, questions whose wrong answer
decodes into the right one are dropped at the door, and hangman bets on
PeteFX's spot like every other table instead of its own copy of it.
2026-07-14 06:28:38 -07:00

376 lines
13 KiB
Go

// 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()
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.
//
// It is checked before *everything*, walking included. A dead clock that you
// could still walk away from would be no clock at all: you would sit on every
// question for as long as you liked, answer the ones you found and walk off
// the ones you didn't, and never once lose the ladder. The timeout has to be
// the first thing that happens to a move, or it is not a deadline.
if elapsed > s.Tier.Clock() {
evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}}
s.settle(OutcomeTimeout, &evs)
return s, evs, nil
}
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
}
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
}