package web import ( "strings" "testing" "pete/internal/storage" ) // The one thing this table cannot get wrong: the stake leaves the stack, and the // phrase does not leave the server. func TestHangmanStartTakesTheStakeAndKeepsThePhrase(t *testing.T) { s := newCasino(t) fund(t, 1000) v, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", map[string]any{"bet": 100, "tier": "short"})) if code != 200 { t.Fatalf("start = %d, want 200", code) } if v.Chips != 900 { t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips) } if v.Hangman == nil { t.Fatal("start returned no game") } if v.Game != gameHangman { t.Errorf("game = %q, want hangman", v.Game) } if v.Hangman.Phrase != "" { t.Fatalf("the phrase was sent to the browser before it was won: %q", v.Hangman.Phrase) } // Nothing is revealed at the start except the scaffolding, and a space is not // a letter you have to earn. for _, c := range v.Hangman.Cells { if c.Slot && c.Ch != "" { t.Fatalf("a letter was face up before it was guessed: %+v", c) } } if v.Hangman.Lives != 6 { t.Errorf("lives = %d, want 6", v.Hangman.Lives) } } // A win pays what the felt said it would, and the rake comes out of the winnings. func TestHangmanWinPaysWhatTheFeltQuoted(t *testing.T) { s := newCasino(t) fund(t, 1000) v, _ := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", map[string]any{"bet": 100, "tier": "short"})) quoted := v.Hangman.Stands // The server holds the phrase, so read it out of the live row — which is the // only place it exists — and solve it. phrase := livePhrase(t) v, code := call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess", map[string]string{"solve": phrase})) if code != 200 { t.Fatalf("solve = %d, want 200", code) } if v.Hangman.Outcome != "solved" { t.Fatalf("outcome = %q, want solved", v.Hangman.Outcome) } // No wrong guesses, so the full 2.6×: 260 gross, 160 profit, 8 rake, 252 back. if v.Hangman.Payout != quoted { t.Errorf("felt quoted %d, house paid %d", quoted, v.Hangman.Payout) } if v.Hangman.Payout != 252 || v.Hangman.Rake != 8 { t.Errorf("payout/rake = %d/%d, want 252/8", v.Hangman.Payout, v.Hangman.Rake) } if got := chipsNow(t); got != 900+252 { t.Errorf("chips = %d, want %d", got, 900+252) } // And the phrase is finally allowed out, now that it decides nothing. if v.Hangman.Phrase == "" { t.Error("a finished game never told the player what the phrase was") } // The game is off the felt. if _, err := storage.LoadLiveHand(testPlayer); err == nil { t.Error("a settled game is still sitting in game_live_hands") } } // Six wrong guesses take the stake and nothing more. func TestHangmanHangingCostsExactlyTheStake(t *testing.T) { s := newCasino(t) fund(t, 1000) call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", map[string]any{"bet": 100, "tier": "short"})) // Six solves that are certainly wrong — a wrong solve costs a life, same as a // wrong letter, and this needs no knowledge of the phrase. var v tableView for i := 0; i < 6; i++ { v, _ = call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess", map[string]string{"solve": "definitely not the phrase at all"})) } if v.Hangman == nil || v.Hangman.Outcome != "hung" { t.Fatalf("outcome = %+v, want hung", v.Hangman) } if v.Hangman.Payout != 0 { t.Errorf("payout = %d, want 0", v.Hangman.Payout) } if got := chipsNow(t); got != 900 { t.Errorf("chips = %d, want 900 — a loss costs the stake and no more", got) } if v.Hangman.Phrase == "" { t.Error("hung without being told the answer") } } // One game at a time, across games: you cannot walk from a hangman into a hand of // blackjack with chips still riding on a phrase. func TestHangmanHoldsTheSeatAgainstBlackjack(t *testing.T) { s := newCasino(t) fund(t, 1000) call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", map[string]any{"bet": 100, "tier": "short"})) _, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal", map[string]int64{"bet": 100})) if code != 409 { t.Fatalf("dealt blackjack on top of a live hangman: %d, want 409", code) } // And the stake that was refused came back: 1000 - 100 (the hangman) and not a // chip more. if got := chipsNow(t); got != 900 { t.Errorf("chips = %d, want 900 — the refused deal kept the stake", got) } if _, err := storage.LoadLiveHand(testPlayer); err != nil { t.Errorf("the hangman was evicted by the deal it refused: %v", err) } } // Cashing out mid-phrase is refused, for the same reason as mid-hand. func TestCannotCashOutMidPhrase(t *testing.T) { s := newCasino(t) fund(t, 1000) call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", map[string]any{"bet": 100, "tier": "short"})) _, code := call(t, s, s.handleCashOut, as(t, s, "reala", "POST", "/api/games/cashout", map[string]int64{"amount": 0})) if code != 409 { t.Fatalf("cash-out mid-phrase = %d, want 409", code) } } // A tier the browser made up is refused, and costs nothing. func TestHangmanRefusesAnInventedTier(t *testing.T) { s := newCasino(t) fund(t, 1000) _, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", map[string]any{"bet": 100, "tier": "impossible"})) if code != 400 { t.Fatalf("start on a made-up tier = %d, want 400", code) } if got := chipsNow(t); got != 1000 { t.Errorf("chips = %d, want 1000 — a refused game must not take a stake", got) } } // livePhrase digs the phrase out of the live row. Only a test may do this: it is // reaching past the wire on purpose, to prove the wire doesn't carry it. func livePhrase(t *testing.T) string { t.Helper() live, err := storage.LoadLiveHand(testPlayer) if err != nil { t.Fatal(err) } blob := string(live.State) const key = `"phrase":"` i := strings.Index(blob, key) if i < 0 { t.Fatalf("no phrase in the live row: %s", blob) } rest := blob[i+len(key):] j := strings.Index(rest, `"`) if j < 0 { t.Fatal("unterminated phrase in the live row") } return rest[:j] }