Hangman, and it plays for chips — which the plan had down as a free game, on the grounds that trivia has no euro coupling in gogobee. But a free game in a casino reads as a demo, so it stakes like everything else. The idea that makes it a casino game rather than hangman with a wager stapled on: the gallows is the payout meter. A wrong guess draws a limb *and* takes a tenth off what a win is worth, because those are the same event and showing them as one is the entire reason to bet on this. Short phrases pay 2.6x (fewer letters, less to go on), long ones 1.6x — the floor is 1x, so a win never hands back less than the stake, and the rake still comes out of winnings only. State.Pays() is the number the felt quotes and the number settle() lands on. They were briefly two sums, and the table spent an afternoon advertising a pre-rake payout it didn't honour. Two things the storage layer had already decided for us, and one it hadn't: game_live_hands is keyed on the player, so "one game at a time" holds across games for free (a live hangman 409s a blackjack deal, stake intact). But table() unmarshalled every live row as a blackjack hand, which does not fail on a hangman row — it quietly yields an empty hand. It dispatches on the game now. commit() is the settle path both games share, and casinoRoutes() the one route list, since the dev rig wires its own mux and a second copy is a copy that stops including the newest game. Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on it; six wrong took the stake and no more; a reload mid-phrase brought back the board, the limbs, the multiple, the spent keys and the chips on the spot. The browser found the two bugs a Go test can't — a lives counter under the house rack, and a word wrapping early because the rack's clearance was on the whole column instead of the one row beside it.
370 lines
11 KiB
Go
370 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|