games: a gallows you can bet on
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.
This commit is contained in:
188
internal/web/games_hangman_test.go
Normal file
188
internal/web/games_hangman_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user