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

317 lines
10 KiB
Go

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")
}
}