Files
Pete/internal/games/trivia/trivia_test.go
prosolis 3e9b93af55 games: the clock beats the walk button, and the rack isn't betting
The trivia ladder handled a walk before it looked at the clock, so the
timeout only ever bit if the browser volunteered it. Sit on a question,
look it up, answer if you find it and walk if you don't, and you never
lose a ladder. The clock is now the first thing that happens to a move.

The house's chip rack was wired up as bet buttons on blackjack and
hangman: it's four spans with data-chip on them and nothing said the
handler only wanted the real ones. Clicking the house's money raised
your bet.

Hangman had two definitions of "a letter you'd guess" — unicode in the
engine, ASCII in the renderer — and a phrase with an accent in it would
have had no tile to fill and no key to fill it with. One definition now.

Plus: trivia's countdown no longer freezes at zero when the server turns
down a timeout report it was early for, questions whose wrong answer
decodes into the right one are dropped at the door, and hangman bets on
PeteFX's spot like every other table instead of its own copy of it.
2026-07-14 06:28:38 -07:00

353 lines
12 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")
}
}
// 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)
}
}