Files
Pete/internal/games/trivia/trivia.go
2026-07-14 02:11:09 -07:00

370 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()
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
}