package hangman import ( "encoding/json" "math/rand/v2" "strings" "testing" ) // tierShort is the tier most tests play on: base 2.6. func tierShort(t *testing.T) Tier { t.Helper() tr, err := TierBySlug("short") if err != nil { t.Fatal(err) } return tr } // play runs a sequence of single-letter guesses against a pinned phrase. func play(t *testing.T, phrase string, bet int64, rake float64, guesses ...string) State { t.Helper() s, _, err := start(bet, tierShort(t), phrase, rake) if err != nil { t.Fatal(err) } for _, g := range guesses { next, _, err := ApplyMove(s, Move{Letter: g}) if err != nil { t.Fatalf("guess %q: %v", g, err) } s = next } return s } func TestStartShowsScaffoldingOnly(t *testing.T) { s, evs, err := start(100, tierShort(t), "Insert Coin", 0.05) if err != nil { t.Fatal(err) } if got, want := s.Masked(), "______ ____"; got != want { t.Errorf("masked = %q, want %q — the space is scaffolding and shows, the letters don't", got, want) } if len(evs) != 1 || evs[0].Kind != "start" { t.Errorf("events = %+v, want one start", evs) } if s.Lives() != MaxWrong { t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong) } } func TestPunctuationAndDigitsAreScaffoldingOrNot(t *testing.T) { // Punctuation shows from the start; a digit is guessable like a letter. s, _, err := start(100, tierShort(t), "Level 9!", 0.05) if err != nil { t.Fatal(err) } if got, want := s.Masked(), "_____ _!"; got != want { t.Fatalf("masked = %q, want %q", got, want) } next, _, err := ApplyMove(s, Move{Letter: "9"}) if err != nil { t.Fatalf("guessing a digit: %v", err) } if got, want := next.Masked(), "_____ 9!"; got != want { t.Errorf("masked = %q, want %q — a digit is a guess", got, want) } } func TestHitRevealsEveryOccurrence(t *testing.T) { // "Blue Shell" has an l at 1, 8 and 9. One guess turns over all three. s := play(t, "Blue Shell", 100, 0.05, "l") if got, want := s.Masked(), "_l__ ___ll"; got != want { t.Errorf("masked = %q, want %q — every l turns over, not just the first", got, want) } if s.Lives() != MaxWrong { t.Errorf("a hit cost a life: lives = %d", s.Lives()) } } func TestMissCostsALifeAndTheMultiple(t *testing.T) { tr := tierShort(t) s := play(t, "Insert Coin", 100, 0.05, "z") if s.Lives() != MaxWrong-1 { t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong-1) } want := tr.Base * 0.9 if got := s.Multiple(); got != want { t.Errorf("multiple = %v, want %v — one wrong guess is a tenth of the base", got, want) } } func TestRepeatedLetterIsRefusedNotPunished(t *testing.T) { s := play(t, "Insert Coin", 100, 0.05, "z") _, _, err := ApplyMove(s, Move{Letter: "z"}) if err != ErrAlreadyTried { t.Fatalf("err = %v, want ErrAlreadyTried", err) } // And the state the caller holds is untouched: a mis-click is not a life. if s.Lives() != MaxWrong-1 { t.Errorf("the refused move moved the state: lives = %d", s.Lives()) } } func TestSixWrongHangsYouAndPaysNothing(t *testing.T) { s := play(t, "Insert Coin", 100, 0.05, "z", "x", "q", "y", "w", "k") if s.Phase != PhaseDone || s.Outcome != OutcomeHung { t.Fatalf("phase/outcome = %s/%s, want done/hung", s.Phase, s.Outcome) } if s.Payout != 0 { t.Errorf("payout = %d, want 0", s.Payout) } if s.Net() != -100 { t.Errorf("net = %d, want -100", s.Net()) } if strings.Contains(s.Masked(), "_") { t.Error("a lost game must still show the phrase — being hung without being told the answer is the one thing hangman can't do") } } func TestFillingTheLastLetterWinsCleanAtFullMultiple(t *testing.T) { // "Blue Shell" — every distinct letter, no misses. s := play(t, "Blue Shell", 100, 0, "b", "l", "u", "e", "s", "h") if s.Outcome != OutcomeFilled { t.Fatalf("outcome = %s, want filled", s.Outcome) } // Base 2.6, no wrong guesses, no rake: 100 -> 260. if s.Payout != 260 { t.Errorf("payout = %d, want 260", s.Payout) } if s.Net() != 160 { t.Errorf("net = %d, want 160", s.Net()) } } func TestSolveOutrightWinsAtTheMultipleYouStillHold(t *testing.T) { s, _, err := start(100, tierShort(t), "Insert Coin", 0) if err != nil { t.Fatal(err) } // Two wrong first: 2.6 -> 2.08. for _, g := range []string{"z", "x"} { s, _, err = ApplyMove(s, Move{Letter: g}) if err != nil { t.Fatal(err) } } s, _, err = ApplyMove(s, Move{Solve: "insert coin"}) if err != nil { t.Fatal(err) } if s.Outcome != OutcomeSolved { t.Fatalf("outcome = %s, want solved", s.Outcome) } if s.Payout != 208 { t.Errorf("payout = %d, want 208 — solving pays the multiple you still hold, not the base", s.Payout) } } func TestSolveIgnoresCaseAndPunctuation(t *testing.T) { s, _, err := start(100, tierShort(t), "How are you gentlemen!!", 0) if err != nil { t.Fatal(err) } s, _, err = ApplyMove(s, Move{Solve: "HOW ARE YOU GENTLEMEN"}) if err != nil { t.Fatal(err) } if s.Outcome != OutcomeSolved { t.Errorf("outcome = %s — a solve shouldn't turn on shouting or the exclamation marks", s.Outcome) } } func TestWrongSolveCostsALife(t *testing.T) { s, _, err := start(100, tierShort(t), "Insert Coin", 0.05) if err != nil { t.Fatal(err) } s, _, err = ApplyMove(s, Move{Solve: "insert quarter"}) if err != nil { t.Fatal(err) } if s.Lives() != MaxWrong-1 { t.Errorf("lives = %d, want %d — a free solve is a solve you spam until it lands", s.Lives(), MaxWrong-1) } if s.Phase != PhasePlaying { t.Errorf("phase = %s, want playing", s.Phase) } } func TestAWinNeverReturnsLessThanTheStake(t *testing.T) { // Long pays 1.6, and five wrong guesses would take it to 0.8 — under water. long, err := TierBySlug("long") if err != nil { t.Fatal(err) } s, _, err := start(100, long, "the quick brown fox jumps over the lazy dog and keeps going", 0.05) if err != nil { t.Fatal(err) } // Five clean misses. Every letter in the pangram is in the pangram, so the // only things guaranteed to miss it are digits. for _, g := range []string{"1", "2", "3", "4", "5"} { s, _, err = ApplyMove(s, Move{Letter: g}) if err != nil { t.Fatal(err) } } if got := s.Multiple(); got != 1 { t.Fatalf("multiple = %v, want 1 — the floor", got) } s, _, err = ApplyMove(s, Move{Solve: "the quick brown fox jumps over the lazy dog and keeps going"}) if err != nil { t.Fatal(err) } if s.Payout != 100 { t.Errorf("payout = %d, want 100 — a win hands back the stake at worst, never less", s.Payout) } if s.Rake != 0 { t.Errorf("rake = %d, want 0 — there was no profit to take a cut of", s.Rake) } } func TestRakeComesOutOfWinningsNeverTheStake(t *testing.T) { s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h") // Total 260, profit 160, rake 5% = 8, so 252 comes back. if s.Rake != 8 { t.Errorf("rake = %d, want 8 (5%% of the 160 profit)", s.Rake) } if s.Payout != 252 { t.Errorf("payout = %d, want 252", s.Payout) } if s.Payout < s.Bet { t.Error("the rake ate into the stake") } } func TestWhatTheFeltQuotesIsWhatTheHousePays(t *testing.T) { // The table shows Pays() while the game is still running. If that number and // the one settle() lands on ever came apart, the felt would be advertising a // payout the house doesn't honour. Walk a game and check they agree at every // step — including after a miss, which is where they'd drift. for _, wrong := range []int{0, 1, 2, 3} { s, _, err := start(200, tierShort(t), "Blue Shell", 0.05) if err != nil { t.Fatal(err) } misses := []string{"z", "x", "q", "y"} for i := 0; i < wrong; i++ { s, _, err = ApplyMove(s, Move{Letter: misses[i]}) if err != nil { t.Fatal(err) } } quoted := s.Pays() // what the felt is telling the player right now s, _, err = ApplyMove(s, Move{Solve: "blue shell"}) if err != nil { t.Fatal(err) } if s.Payout != quoted { t.Errorf("%d wrong: felt quoted %d, house paid %d", wrong, quoted, s.Payout) } if s.Payout != s.Bet+s.Net() { t.Errorf("%d wrong: payout %d doesn't square with net %d on a %d bet", wrong, s.Payout, s.Net(), s.Bet) } } } func TestMoveOnAFinishedGameIsRefused(t *testing.T) { s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h") if _, _, err := ApplyMove(s, Move{Letter: "z"}); err != ErrGameOver { t.Errorf("err = %v, want ErrGameOver", err) } } func TestGarbageGuessesAreRefused(t *testing.T) { s, _, err := start(100, tierShort(t), "Insert Coin", 0.05) if err != nil { t.Fatal(err) } for _, m := range []Move{{Letter: "ab"}, {Letter: "!"}, {Letter: " "}, {}} { if _, _, err := ApplyMove(s, m); err == nil { t.Errorf("move %+v was accepted", m) } } } func TestApplyMoveDoesNotTouchTheCallersState(t *testing.T) { s, _, err := start(100, tierShort(t), "Insert Coin", 0.05) if err != nil { t.Fatal(err) } before := s.Masked() if _, _, err := ApplyMove(s, Move{Letter: "i"}); err != nil { t.Fatal(err) } if s.Masked() != before { t.Errorf("applying a move mutated the state it was given: %q -> %q", before, s.Masked()) } if len(s.Tried) != 0 { t.Errorf("tried = %v, want empty — the caller's state was written through", s.Tried) } } func TestStateSurvivesASerializationRoundTrip(t *testing.T) { // A redeploy mid-game is a JSON round-trip. It has to come back playable. s := play(t, "Insert Coin", 100, 0.05, "i", "z") blob, err := json.Marshal(s) if err != nil { t.Fatal(err) } var back State if err := json.Unmarshal(blob, &back); err != nil { t.Fatal(err) } if back.Masked() != s.Masked() || back.Lives() != s.Lives() || back.Multiple() != s.Multiple() { t.Fatalf("round trip changed the game: %+v", back) } next, _, err := ApplyMove(back, Move{Solve: "insert coin"}) if err != nil { t.Fatal(err) } if next.Outcome != OutcomeSolved { t.Errorf("a game restored from JSON couldn't be finished: %s", next.Outcome) } } func TestNewIsReproducibleFromItsSeed(t *testing.T) { // The seed is in the audit log so a disputed game can be replayed. That is // only true if the phrase comes back the same. one, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9))) if err != nil { t.Fatal(err) } two, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9))) if err != nil { t.Fatal(err) } if one.Phrase != two.Phrase { t.Errorf("same seed dealt different phrases: %q vs %q", one.Phrase, two.Phrase) } } func TestNewDrawsFromTheRightShelf(t *testing.T) { rng := rand.New(rand.NewPCG(1, 2)) for _, tr := range Tiers { for i := 0; i < 50; i++ { s, _, err := New(100, tr, 0.05, rng) if err != nil { t.Fatalf("%s: %v", tr.Slug, err) } if n := len([]rune(s.Phrase)); n < tr.Min || n > tr.Max { t.Fatalf("%s drew %q (%d chars), outside %d-%d", tr.Slug, s.Phrase, n, tr.Min, tr.Max) } } } } func TestEveryTierHasAShelfWorthPlaying(t *testing.T) { // If someone edits phrases.txt and empties a tier, the game 500s at the // table rather than here. Catch it here. for _, tr := range Tiers { if n := Shelf(tr.Slug); n < 20 { t.Errorf("tier %s has %d phrases — too few to not repeat", tr.Slug, n) } } }