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") } } // The clock has to beat the walk button, or it is not a deadline. // // If a dead clock could still be walked away from, the ladder would carry no // risk at all: sit on every question for as long as you like, answer the ones // you can look up, and walk off the ones you can't. The timeout has to be the // first thing that happens to a move. func TestWalkingOffADeadClockIsATimeout(t *testing.T) { s := newGame(t, 500, "hard") s, _ = answerRight(t, s, time.Second) // one rung banked, so a walk is otherwise legal late := s.AskedAt.Add(s.Tier.Clock() + time.Second) out, evs, err := ApplyMove(s, Move{Walk: true}, late) if err != nil { t.Fatalf("walking after the clock died should resolve, not error: %v", err) } if out.Outcome != OutcomeTimeout { t.Fatalf("a walk after the clock ran out is a timeout, got %q", out.Outcome) } if out.Payout != 0 { t.Fatalf("a timeout pays nothing, got %d", out.Payout) } if len(evs) == 0 || evs[0].Kind != "timeout" { t.Fatalf("expected the timeout event first, got %+v", evs) } // And the same walk, one tick inside the limit, still banks. intime := s.AskedAt.Add(s.Tier.Clock() - time.Millisecond) banked, _, err := ApplyMove(s, Move{Walk: true}, intime) if err != nil { t.Fatalf("walk with the clock still running: %v", err) } if banked.Outcome != OutcomeWalked || banked.Payout <= 0 { t.Fatalf("a walk inside the clock banks, got %q paying %d", banked.Outcome, banked.Payout) } }