370 lines
13 KiB
Go
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
|
|
}
|