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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user