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