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:
prosolis
2026-07-14 01:19:05 -07:00
parent d29a311eff
commit fe2195e85f
19 changed files with 2678 additions and 81 deletions

View File

@@ -0,0 +1,395 @@
// Package hangman is a pure hangman engine, played for chips.
//
// Same seam as blackjack: ApplyMove(state, move) (state, events, error), where
// an error means the move was illegal and nothing else. No HTTP, no timers, no
// player names. The state is a plain value, so a game survives a redeploy and
// replays from its seed.
//
// The casino version differs from the one gogobee plays in Matrix in one way
// that matters: there is money on it. That makes the gallows a payout meter as
// well as a death clock — every wrong guess takes a tenth off what a win is
// worth. You can still win from five wrong, you just won't win much.
package hangman
import (
"errors"
"math"
"math/rand/v2"
"strings"
"unicode"
)
// Errors an illegal move can produce.
var (
ErrGameOver = errors.New("hangman: the game is already over")
ErrNotALetter = errors.New("hangman: that is not a letter")
ErrAlreadyTried = errors.New("hangman: that letter has been tried")
ErrUnknownMove = errors.New("hangman: unknown move")
ErrBadBet = errors.New("hangman: bet must be positive")
ErrEmptySolution = errors.New("hangman: guess something")
ErrUnknownTier = errors.New("hangman: no such tier")
)
// MaxWrong is how many wrong guesses the gallows holds: head, body, two arms,
// two legs. It is not a tier setting — the drawing has six parts, so the game
// has six lives, and the tiers vary what a win pays instead.
const MaxWrong = 6
// Decay is what one wrong guess costs, as a fraction of the tier's base
// multiple. Six wrong is death, so the worst a living player can be is 50% off.
const Decay = 0.10
// Tier is a difficulty, chosen before the bet. Short phrases pay the most:
// there is less of them to read, and fewer distinct letters to hit by accident.
// A long phrase mostly reveals itself.
type Tier struct {
Slug string `json:"slug"`
Name string `json:"name"`
Min int `json:"min"` // phrase length, in characters
Max int `json:"max"` // inclusive
Base float64 `json:"base"` // what a win pays, before any wrong guesses
Blurb string `json:"blurb"`
}
// Tiers are the three tables. The bank has 74 short phrases, 67 medium and 64
// long, so none of them runs thin.
var Tiers = []Tier{
{Slug: "short", Name: "Short", Min: 8, Max: 20, Base: 2.6,
Blurb: "A handful of letters and nothing to go on."},
{Slug: "medium", Name: "Medium", Min: 21, Max: 40, Base: 2.0,
Blurb: "Long enough to read once it starts falling open."},
{Slug: "long", Name: "Long", Min: 41, Max: 9999, Base: 1.6,
Blurb: "It gives itself away. It also pays the least."},
}
// TierBySlug finds a tier by the name the browser sent.
func TierBySlug(slug string) (Tier, error) {
for _, t := range Tiers {
if t.Slug == slug {
return t, nil
}
}
return Tier{}, ErrUnknownTier
}
// Phase is where the game is.
type Phase string
const (
PhasePlaying Phase = "playing"
PhaseDone Phase = "done"
)
// Outcome is how it ended.
type Outcome string
const (
OutcomeNone Outcome = ""
OutcomeSolved Outcome = "solved" // guessed the phrase outright
OutcomeFilled Outcome = "filled" // revealed the last letter
OutcomeHung Outcome = "hung" // six wrong
)
// Won reports whether this outcome pays.
func (o Outcome) Won() bool { return o == OutcomeSolved || o == OutcomeFilled }
// State is one game. The phrase is in here, which is exactly why this value
// never leaves the server — the browser gets a Masked() view of it instead.
type State struct {
Tier Tier `json:"tier"`
Phrase string `json:"phrase"`
Runes []rune `json:"runes"` // the phrase, indexable safely
Shown []bool `json:"shown"` // one per rune: is it face up
Tried []rune `json:"tried"` // every letter guessed, in order, right or wrong
Wrong []rune `json:"wrong"` // just the ones that missed
RakePct float64 `json:"rake_pct"`
Bet int64 `json:"bet"`
Phase Phase `json:"phase"`
Outcome Outcome `json:"outcome"`
Payout int64 `json:"payout"`
Rake int64 `json:"rake"`
}
// Event is something the table animates: a letter turning over, a limb being
// drawn. The engine emits them rather than drawing anything itself.
type Event struct {
Kind string `json:"kind"` // "start" | "hit" | "miss" | "solve" | "settle"
Letter string `json:"letter,omitempty"` // the letter guessed
At []int `json:"at,omitempty"` // which rune positions it turned over
Text string `json:"text,omitempty"`
}
// Move is a player action: a single letter, or the whole phrase.
type Move struct {
Letter string `json:"letter"`
Solve string `json:"solve"`
}
// New starts a game on a phrase drawn from the tier's shelf of the bank.
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
phrase, err := drawPhrase(t, rng)
if err != nil {
return State{}, nil, err
}
return start(bet, t, phrase, rakePct)
}
// start builds the opening state for a known phrase. Split out from New so a
// test can pin the phrase instead of the seed.
func start(bet int64, t Tier, phrase string, rakePct float64) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
rs := []rune(phrase)
s := State{
Tier: t, Phrase: phrase, Runes: rs,
Shown: make([]bool, len(rs)),
RakePct: rakePct,
Bet: bet, Phase: PhasePlaying,
}
// Spaces and punctuation are never guessed — they start face up, because a
// row of blanks with the word breaks hidden is a puzzle about typography.
for i, r := range rs {
if !isGuessable(r) {
s.Shown[i] = true
}
}
return s, []Event{{Kind: "start"}}, nil
}
// isGuessable reports whether a rune is one you'd guess: a letter or a digit.
// Everything else is scaffolding and is shown from the start.
func isGuessable(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
// ApplyMove is the engine. A legal move in, the new state and what happened out.
// An error means the move was illegal and the caller's state is untouched.
func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase == PhaseDone {
return s, nil, ErrGameOver
}
s = s.clone()
switch {
case m.Solve != "":
return applySolve(s, m.Solve)
case m.Letter != "":
return applyLetter(s, m.Letter)
default:
return s, nil, ErrUnknownMove
}
}
// applyLetter guesses one letter.
func applyLetter(s State, letter string) (State, []Event, error) {
rs := []rune(strings.ToLower(strings.TrimSpace(letter)))
if len(rs) != 1 || !isGuessable(rs[0]) {
return s, nil, ErrNotALetter
}
g := rs[0]
if containsRune(s.Tried, g) {
// Not a miss — a no-op. Charging a life for a letter the board already
// shows you tried is punishing a mis-click, not a bad guess.
return s, nil, ErrAlreadyTried
}
s.Tried = append(s.Tried, g)
var at []int
for i, r := range s.Runes {
if !s.Shown[i] && foldEq(r, g) {
s.Shown[i] = true
at = append(at, i)
}
}
evs := []Event{}
if len(at) > 0 {
evs = append(evs, Event{Kind: "hit", Letter: string(g), At: at})
if s.filled() {
s.settle(OutcomeFilled, &evs)
}
return s, evs, nil
}
s.Wrong = append(s.Wrong, g)
evs = append(evs, Event{Kind: "miss", Letter: string(g)})
if len(s.Wrong) >= MaxWrong {
s.settle(OutcomeHung, &evs)
}
return s, evs, nil
}
// applySolve guesses the whole phrase. Right, and it's over at the multiple you
// still hold. Wrong, and it costs a life like any other bad guess — otherwise
// solving would be a free roll you could spam until it landed.
func applySolve(s State, attempt string) (State, []Event, error) {
if strings.TrimSpace(attempt) == "" {
return s, nil, ErrEmptySolution
}
evs := []Event{{Kind: "solve", Text: attempt}}
if normalize(attempt) == normalize(s.Phrase) {
for i := range s.Shown {
s.Shown[i] = true
}
s.settle(OutcomeSolved, &evs)
return s, evs, nil
}
// A wrong solve is a miss with no letter attached to it.
s.Wrong = append(s.Wrong, '·')
evs = append(evs, Event{Kind: "miss", Text: attempt})
if len(s.Wrong) >= MaxWrong {
s.settle(OutcomeHung, &evs)
}
return s, evs, nil
}
// Multiple is what a win is worth right now: the tier's base, less a tenth of
// it for every wrong guess so far. Floored at 1, so a win never hands back less
// than the stake — a player who fought through five wrong guesses and got there
// has not earned a loss.
func (s State) Multiple() float64 {
m := s.Tier.Base * (1 - Decay*float64(len(s.Wrong)))
if m < 1 {
return 1
}
return m
}
// Pays is what a win *right now* would actually put back on the player's stack:
// the stake, plus the winnings, less the house's cut of the winnings.
//
// It exists because the felt shows this number while the game is still running,
// and settle() is the only other thing that computes it. If the two ever
// disagreed the table would be quoting a payout it doesn't honour — so settle
// calls this rather than doing the sum a second time.
func (s State) Pays() int64 {
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
if total < s.Bet {
total = s.Bet // a win never hands back less than the stake
}
profit := total - s.Bet
if profit > 0 {
rake := int64(math.Floor(float64(profit) * s.RakePct))
if rake < 0 {
rake = 0
}
profit -= rake
}
return s.Bet + profit
}
// Rake taken on a win right now — the other half of what Pays works out.
func (s State) rakeNow() int64 {
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
if total < s.Bet {
return 0
}
profit := total - s.Bet
if profit <= 0 {
return 0
}
rake := int64(math.Floor(float64(profit) * s.RakePct))
if rake < 0 {
return 0
}
return rake
}
// Lives is how many wrong guesses are left.
func (s State) Lives() int { return MaxWrong - len(s.Wrong) }
// filled reports whether every guessable rune is face up.
func (s State) filled() bool {
for _, up := range s.Shown {
if !up {
return false
}
}
return true
}
// settle decides the payout. Same rule as blackjack: the rake comes out of
// winnings, never out of the stake. A loss is never charged a fee.
func (s *State) settle(o Outcome, evs *[]Event) {
s.Outcome = o
s.Phase = PhaseDone
if o.Won() {
s.Payout = s.Pays()
s.Rake = s.rakeNow()
} else {
s.Payout = 0
}
// The phrase goes face up when it's over, win or lose. Losing without ever
// being told the answer is the one thing hangman must never do.
for i := range s.Shown {
s.Shown[i] = true
}
*evs = append(*evs, Event{Kind: "settle", Text: string(o)})
}
// Net is what the game did to the player's stack.
func (s State) Net() int64 {
if s.Phase != PhaseDone {
return 0
}
return s.Payout - s.Bet
}
// Masked is the phrase as the player may see it: revealed runes as themselves,
// hidden ones as an underscore. This — not Phrase — is what crosses the wire.
func (s State) Masked() string {
var b strings.Builder
for i, r := range s.Runes {
if s.Shown[i] {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
return b.String()
}
// clone deep-copies the slices, so a derived state shares no backing array with
// the one it came from and a state can be replayed freely.
func (s State) clone() State {
s.Runes = append([]rune(nil), s.Runes...)
s.Shown = append([]bool(nil), s.Shown...)
s.Tried = append([]rune(nil), s.Tried...)
s.Wrong = append([]rune(nil), s.Wrong...)
return s
}
// normalize flattens a phrase for comparison: case, spacing and punctuation all
// stop mattering. "How are you gentlemen!!" is solved by "how are you gentlemen".
func normalize(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if isGuessable(r) {
b.WriteRune(r)
}
}
return b.String()
}
// foldEq compares two runes the way a guess should: case-insensitively.
func foldEq(a, b rune) bool {
return unicode.ToLower(a) == unicode.ToLower(b)
}
func containsRune(rs []rune, r rune) bool {
for _, x := range rs {
if foldEq(x, r) {
return true
}
}
return false
}

View File

@@ -0,0 +1,369 @@
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)
}
}
}

View File

@@ -0,0 +1,68 @@
package hangman
import (
"bufio"
_ "embed"
"errors"
"math/rand/v2"
"strings"
"sync"
)
// The bank. gogobee kept its phrases in a file it read at boot out of a path in
// an env var, which meant the game was one missing file away from not existing.
// Embedding it means the casino cannot start without its phrases, which is the
// correct relationship between a game and the thing it is about.
//
//go:embed phrases.txt
var phrasesTxt string
// ErrNoPhrases means the bank has nothing at this length. It can only happen if
// someone edits phrases.txt down past a tier, and it is a programming error
// rather than anything a player did — but it's an error, not a panic, because a
// casino that won't boot is worse than one game being shut.
var ErrNoPhrases = errors.New("hangman: no phrases in that tier")
var (
shelvesOnce sync.Once
shelves map[string][]string // tier slug -> the phrases that fit it
)
// load sorts the bank onto one shelf per tier, once. Comments and blank lines
// are dropped, and so is anything too short to be a game — the tiers' own Min
// is the floor.
func load() {
shelves = make(map[string][]string, len(Tiers))
sc := bufio.NewScanner(strings.NewReader(phrasesTxt))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
n := len([]rune(line))
for _, t := range Tiers {
if n >= t.Min && n <= t.Max {
shelves[t.Slug] = append(shelves[t.Slug], line)
break
}
}
}
}
// Shelf is how many phrases a tier has. Exists so a test can assert the bank
// hasn't been edited out from under a tier.
func Shelf(slug string) int {
shelvesOnce.Do(load)
return len(shelves[slug])
}
// drawPhrase picks one phrase from a tier's shelf. The rng is threaded, never
// the package global, so a game replays exactly from the seed in its audit row.
func drawPhrase(t Tier, rng *rand.Rand) (string, error) {
shelvesOnce.Do(load)
shelf := shelves[t.Slug]
if len(shelf) == 0 {
return "", ErrNoPhrases
}
return shelf[rng.IntN(len(shelf))], nil
}

View File

@@ -0,0 +1,237 @@
# GogoBee Hangman Seed Phrases -- Video Game Edition
#
# Tiers are assigned automatically by character count at load time.
# Section headers below are for human readability only -- the bot ignores them.
#
# Easy: 8-20 characters
# Medium: 21-40 characters
# Hard: 41-80 characters
#
# Add community phrases via: !hangman submit [phrase]
# All submissions require LLM approval before entering the pool.
# This file can be edited directly. Bot reloads on restart.
# ---------------------------------------------------------------------------
# EASY (8-20 characters)
# ---------------------------------------------------------------------------
Finish Him!
Game Over
Insert Coin
Hadouken!
Fatality!
Big Boss
Konami Code
Warp Zone
Blue Shell
God Mode
BFG 9000
Cacodemon
Quad Damage
Samus Aran
Morph Ball
Mother Brain
Dracula!
Simon Belmont
Ecclesia
Outer Heaven
Spread Gun
Power Pellet
Checkpoint
Rocket Jump
Mushroom Kingdom
Princess Peach
Bowser's Castle
Fire Flower
Varia Suit
Space Jump
Vampire Killer
Holy Water
Trevor Belmont
Soma Cruz
Julius Belmont
Waluigi!
Richter!
Phantoon
Speed Run
High Score
Continue?
Press Start
Ryu Hayabusa
Plasma Gun
What is a man?
Serious Sam
Shoryuken!
Duck Hunt
The cake is a lie
War never changes
Would you kindly?
Do a barrel roll!
Praise the Sun!
A winner is you
You're pretty good
La-Li-Lu-Le-Lo
I am error
Leeroy Jenkins!
For the Horde!
For the Alliance!
Frostmourne hungers
Falcon Punch!
Rip and tear!
Vic Viper
Salamander!
Parodius Da!
We'll bang, okay?
What you say!!
You spoony bard!
One-Winged Angel
Morning Star
Glyph Union
TwinBee, scramble!
# ---------------------------------------------------------------------------
# MEDIUM (21-40 characters)
# ---------------------------------------------------------------------------
Stay a while and listen
It's dangerous to go alone!
Kept you waiting, huh?
A man chooses, a slave obeys
Metal Gear?! Metal Gear!!
We're not tools of the government
The winds of destruction
Who are the Patriots?
This is good, isn't it?
You have died of dysentery
The Triforce of Courage
Ganon has broken the seal!
May the wind guide you home
Dodongo dislikes smoke
The right man in the wrong place
Nothing is true, everything is permitted
I used to be an adventurer like you
You can't hide from the Grim Reaper
All your base are belong to us!
Somebody set up us the bomb!
For great justice, take off every Zig!
How are you gentlemen!!
Kain has betrayed us!
Garland will knock you all down!
You are not prepared!
I am Uther the Lightbringer!
Order of Ecclesia calls
The Dark Lord rises again!
Shanoa, bearer of glyphs
In this world, it's kill or be killed
Despite everything, it's still you
The Underground is your home now
Papyrus demands a battle!
I'm going to make spaghetti!
Toriel will protect you
Estus Flask replenished
The age of fire fades
Prepare to die, undead one
Can't let you do that, Star Fox!
Andross' empire spans the Lylat system!
Captain Falcon, show me your moves!
OBJECTION! That testimony is a lie!
Hold it! I have new evidence!
Phoenix Wright, attorney at law!
Does this unit have a soul?
Shepard, the Reapers are coming!
Tali'Zorah vas Normandy!
Gruntilda shall not be defeated!
K. Rool has stolen the banana hoard!
Kirby, hero of Dream Land!
Meta Knight awaits your challenge!
Congraturation! This story is happy end.
Cecil has become a Paladin
Cloud Strife, SOLDIER First Class
Time compression is inevitable
You require more vespene gas
Nuclear launch detected
You must construct additional pylons
I'm Commander Shepard!
War... War has changed.
Rip and tear until it is done!
Dawn of Sorrow awaits
Do you feel like a hero yet?
You're a monster. You know that, Walker?
Halo... it's not a natural formation.
Whip it good, Belmont!
# ---------------------------------------------------------------------------
# HARD (41-80 characters)
# ---------------------------------------------------------------------------
What is a man? A miserable pile of secrets!
Die monster! You don't belong in this world!
My name is Dracula, and I bid you welcome.
You have no chance to survive make your time!
You've met with a terrible fate, haven't you?
Fear the old blood... and welcome, good hunter.
Welcome to the Liandri Grand Tournament!
I am the very model of a scientist Salarian!
Metroid Prime has escaped into the impact crater!
I want to be the very best, like no one ever was
The world needs only one Big Boss... and one Snake.
Snake, do you think love can bloom on a battlefield?
Thank you Mario, but our princess is in another castle!
You must gather your party before venturing forth
Snake, we're not tools of the government, or anyone else
Did I ever tell you what the definition of insanity is?
They're everywhere! The demons... they won't stop coming!
Dracula! Your time has come! The Vampire Killer strikes!
Link... I'm Navi, your fairy companion! Listen!
Hero of Time, your destiny awaits in the Sacred Realm
Master Chief, finish the fight. Earth is counting on you.
Outer Heaven... a place where warriors can find purpose
We passed the point of no return a long time ago, Snake
Liquid! I was the one who was meant to be the successor!
Revolver Ocelot is a triple agent working for the Patriots
Phazon corruption detected! Seek immediate medical attention!
Dark Samus has absorbed the Phazon and grown more powerful!
I'm the Doom Slayer, and I'm here to kill every last one of you!
Praise the Chosen Undead, for they shall link the fire!
Ganon is the evil king who stole the Triforce of Power!
The legendary soldier who defied his genes... Big Boss
Killing spree! Monster kill! Godlike! Unstoppable!
Humanity restored! The bonfire blazes with newfound strength!
You are the last line of defense against an infinite demonic army
Liquid Snake... your dominant genes... give you the edge in battle!
All we did was give meaning to the nuclear age by using nukes!
I'm a soldier who's been betrayed, abandoned... I fight alone now
Samus Aran, the last of the Chozo warriors, descends into the unknown
You are the Chosen Undead, fated to succeed where so many have failed
The price of living in the past is a slow death in the present
A sword wields no strength unless the hands that hold it have courage
The flow of time is always cruel, its speed seems different for each person
Hey! Listen! There's something important you need to know!
We are born of the blood, made men by the blood, undone by the blood
War has changed. It's no longer about nations, ideologies, or ethnicity
Mental has sent his armies, and I am all that stands between them and Earth!
I'm no hero. Never was. Never will be. I'm just an old killer.
What is a man? A miserable pile of secrets! But enough talk... have at you!
I used to be an adventurer like you, then I took an arrow in the knee
I'm not the only one who's responsible. The humans are just as guilty!
The Skaarj have invaded, and you must fight your way to freedom
# ---------------------------------------------------------------------------
# EXTREME (81+ characters) -- Full quotes. No mercy.
# ---------------------------------------------------------------------------
For you, the day Bison graced your village was the most important day of your life. But for me... it was Tuesday.
What is a man? A miserable pile of secrets! But enough talk... have at you!
Die monster! You don't belong in this world! It was not by my hand that I am once again given flesh!
You can't keep a good man down, and that goes double for a soldier who's been fighting his whole life.
They say the definition of insanity is doing the same thing over and over and expecting different results... did I ever tell you that?
I've been waiting for this... a professional killer. I'm so excited I may be sick!
Kept you waiting, huh? Don't worry though. I'll take you somewhere warm. Back to the battlefield.
It's easy to forget what a sin is in the middle of a battlefield. Especially when you're killing to stay alive.
We are not tools of the government or anyone else. Fighting was the only thing, the only thing I was good at. But at least I always fought for what I believed in.
In the 21st century, the battlefield will once again be a symbol of the glory of nations. I am a weapon. A human weapon.
A man's dreams can be his greatest asset... or his most dangerous enemy. The question is: what are you willing to sacrifice to see them through?
I need scissors! 61!
Outer Heaven, a place where soldiers need not justify their actions -- where warriors can be free of political manipulation.
Nothing happened to me. I happened. I'm the one who knocks, the one who causes all the trouble... that is my purpose.
Sun Tzu said that. I think he meant it as a metaphor, but he also said never leave home without a healthy supply of rations, so what does he know?

View File

@@ -44,13 +44,7 @@ func TestDevCasino(t *testing.T) {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
s.casinoRoutes(mux)
ln, err := net.Listen("tcp", addr)
if err != nil {
@@ -63,7 +57,7 @@ func TestDevCasino(t *testing.T) {
t.Fatal(err)
}
}
fmt.Printf("\nCASINO http://localhost%s/games/blackjack\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie)
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie)
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
t.Cleanup(func() { _ = srv.Close() })

View File

@@ -0,0 +1,217 @@
package web
import (
"encoding/json"
"errors"
"log/slog"
"math/rand/v2"
"net/http"
"pete/internal/games/blackjack"
"pete/internal/games/hangman"
"pete/internal/storage"
)
// Hangman, played for chips.
//
// The same shape as the blackjack table: the browser sends intents, the server
// holds the state, and the payload carries only what the player is entitled to
// see. Here that means the *masked* phrase. The unmasked one is in the engine
// state, which is in game_live_hands, which is on this side of the wire — a
// phrase sent down and flagged hidden is a phrase read out of devtools, and the
// game would be a formality.
// cellView is one position in the phrase, as the browser draws it.
//
// Ch is empty while the letter is hidden — not the letter with a flag beside
// it. Slot says whether this is a position you'd guess at all: a space or an
// exclamation mark is scaffolding, shows from the start, and gets no tile.
type cellView struct {
Ch string `json:"ch"`
Slot bool `json:"slot"`
}
// hangmanView is a game as its player may see it.
type hangmanView struct {
Tier hangman.Tier `json:"tier"`
Cells []cellView `json:"cells"`
Tried []string `json:"tried"` // every letter guessed, right or wrong
Wrong []string `json:"wrong"` // just the misses — the gallows counts these
Lives int `json:"lives"`
MaxWrong int `json:"max_wrong"`
Multiple float64 `json:"multiple"` // what a win is worth right now
Bet int64 `json:"bet"`
Stands int64 `json:"stands"` // what the player would actually be paid if they won now
Phase string `json:"phase"`
Outcome string `json:"outcome,omitempty"`
Phrase string `json:"phrase,omitempty"` // only once it's over
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
}
func viewHangman(g hangman.State) hangmanView {
v := hangmanView{
Tier: g.Tier,
Lives: g.Lives(),
MaxWrong: hangman.MaxWrong,
Multiple: g.Multiple(),
Bet: g.Bet,
// What the player would actually collect, rake already taken out. Quoting
// the pre-rake figure here would have the felt advertising a payout the
// house doesn't hand over.
Stands: g.Pays(),
Phase: string(g.Phase),
Outcome: string(g.Outcome),
Payout: g.Payout,
Rake: g.Rake,
Net: g.Net(),
}
for i, r := range g.Runes {
c := cellView{Slot: isSlot(r)}
if i < len(g.Shown) && g.Shown[i] {
c.Ch = string(r)
}
v.Cells = append(v.Cells, c)
}
for _, r := range g.Tried {
v.Tried = append(v.Tried, string(r))
}
for _, r := range g.Wrong {
v.Wrong = append(v.Wrong, string(r))
}
// The phrase goes over the wire exactly once: when the game is over and it no
// longer decides anything.
if g.Phase == hangman.PhaseDone {
v.Phrase = g.Phrase
}
return v
}
// isSlot mirrors the engine's isGuessable — a rune you'd guess gets a tile to
// guess it into. Kept here rather than exported from the engine because it is a
// question about drawing, and the engine doesn't draw.
func isSlot(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}
// handleHangmanStart takes the bet and draws a phrase. Same order as a deal:
// the chips are staked first, in the same statement that checks they exist, so
// two starts fired at once cannot bet the same chip.
func (s *Server) handleHangmanStart(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Bet int64 `json:"bet"`
Tier string `json:"tier"`
}
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
return
}
tier, err := hangman.TierBySlug(req.Tier)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a length"})
return
}
if err := storage.Stake(user, req.Bet); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
return
}
slog.Error("games: hangman stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
seed1, seed2 := newSeeds()
rng := rand.New(rand.NewPCG(seed1, seed2))
g, evs, err := hangman.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
if err != nil {
// The game never happened, so the stake never should have left.
_ = storage.Award(user, req.Bet)
slog.Error("games: hangman start", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistHangman(w, user, g, evs, seed1, seed2, true)
}
// handleHangmanGuess plays one guess: a letter, or the whole phrase.
func (s *Server) handleHangmanGuess(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var move hangman.Move
if err := decodeJSON(r, &move); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
live, err := storage.LoadLiveHand(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
return
}
if err != nil {
slog.Error("games: hangman load", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if live.Game != gameHangman {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
return
}
var g hangman.State
if err := json.Unmarshal(live.State, &g); err != nil {
slog.Error("games: unreadable hangman game", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
next, evs, err := hangman.ApplyMove(g, move)
if err != nil {
// A letter already tried is the one illegal move a player makes by
// accident rather than by trying it on, so it gets its own answer.
msg := "that guess isn't legal here"
if errors.Is(err, hangman.ErrAlreadyTried) {
msg = "you've already tried that one"
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
return
}
s.persistHangman(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// persistHangman writes the game back and answers the browser.
func (s *Server) persistHangman(w http.ResponseWriter, user string, g hangman.State, evs []hangman.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal hangman", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
done := g.Phase == hangman.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameHangman, Blob: blob,
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
Outcome: string(g.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
if !ok {
return
}
// A finished game is gone from storage, so the table has none to show — but
// the browser still needs the final board to reveal the phrase onto.
if done {
hv := viewHangman(g)
v.Hangman = &hv
}
v.HangEvents = evs
writeJSON(w, v)
}

View 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]
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"pete/internal/games/blackjack"
"pete/internal/games/hangman"
"pete/internal/storage"
)
@@ -28,7 +29,6 @@ var comingSoon = []gameTeaser{
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
{Name: "Hangman", Emoji: "🪢", Blurb: "Guess the phrase before the gallows finish."},
}
// betDenominations are the chips you build a bet out of.
@@ -70,6 +70,30 @@ type gamesPage struct {
RakePct int
Soon []gameTeaser
Denominations []int64
Tiers []hangman.Tier // hangman's three lengths, and what each pays
MaxWrong int
}
// casinoRoutes hangs every table off the mux.
//
// It exists so there is exactly one list of them. The dev rig (devcasino_test.go)
// has to wire its own mux — New() decides whether the casino exists before the
// rig has signed anybody in — and a second copy of this list is a list that
// silently stops including the newest game.
func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /games/hangman", s.handleHangman)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
mux.HandleFunc("POST /api/games/hangman/start", s.handleHangmanStart)
mux.HandleFunc("POST /api/games/hangman/guess", s.handleHangmanGuess)
}
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
@@ -96,6 +120,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
RakePct: int(blackjack.DefaultRules().RakePct * 100),
Soon: comingSoon,
Denominations: betDenominations,
Tiers: hangman.Tiers,
MaxWrong: hangman.MaxWrong,
}
}
@@ -112,3 +138,10 @@ func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) {
}
s.render(w, "blackjack", s.gamesPage(r))
}
func (s *Server) handleHangman(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "hangman", s.gamesPage(r))
}

View File

@@ -3,6 +3,7 @@ package web
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
@@ -11,6 +12,7 @@ import (
"pete/internal/games/blackjack"
"pete/internal/games/cards"
"pete/internal/games/hangman"
"pete/internal/storage"
)
@@ -162,18 +164,31 @@ func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
return out
}
// tableView is the whole page state: the money, and the hand if there is one.
// tableView is the whole page state: the money, and whatever game is in progress.
//
// A player is in at most one game at a time — game_live_hands is keyed on the
// player, so the primary key enforces it — and Game says which. Each game gets
// its own field rather than a shared blob, because a hangman phrase and a
// blackjack shoe have nothing in common and pretending otherwise would mean a
// browser that has to guess what it's holding.
type tableView struct {
Chips int64 `json:"chips"`
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
Cap int64 `json:"cap"`
Hand *handView `json:"hand,omitempty"`
Events []eventView `json:"events,omitempty"` // only on a move, for the animation
Rake float64 `json:"rake_pct"`
Chips int64 `json:"chips"`
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
Cap int64 `json:"cap"`
Game string `json:"game,omitempty"` // "blackjack" | "hangman", if one is live
Hand *handView `json:"hand,omitempty"` // blackjack
Events []eventView `json:"events,omitempty"` // blackjack, only on a move
Hangman *hangmanView `json:"hangman,omitempty"`
HangEvents []hangman.Event `json:"hang_events,omitempty"`
Rake float64 `json:"rake_pct"`
}
// table reads the player's money and any hand in progress.
// table reads the player's money and any game in progress.
func (s *Server) table(user string) (tableView, error) {
st, err := storage.Chips(user)
if err != nil {
@@ -193,16 +208,40 @@ func (s *Server) table(user string) (tableView, error) {
if err != nil {
return tableView{}, err
}
var hand blackjack.State
if err := json.Unmarshal(live.State, &hand); err != nil {
// A hand we can't read is a hand nobody can play. Rather than wedge the
// player out of the casino forever, drop it and tell them.
slog.Error("games: unreadable live hand, discarding", "user", user, "err", err)
_ = storage.ClearLiveHand(user)
return v, nil
// Dispatch on the game the row says it is. Unmarshalling a hangman state into
// a blackjack one would not fail — JSON is happy to fill nothing in — it would
// just quietly produce an empty hand, which is the worst of both.
v.Game = live.Game
switch live.Game {
case gameBlackjack:
var hand blackjack.State
if err := json.Unmarshal(live.State, &hand); err != nil {
return s.dropUnreadable(user, v, err)
}
hv := viewHand(hand)
v.Hand = &hv
case gameHangman:
var g hangman.State
if err := json.Unmarshal(live.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
hv := viewHangman(g)
v.Hangman = &hv
default:
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
}
hv := viewHand(hand)
v.Hand = &hv
return v, nil
}
// dropUnreadable throws away a live game nobody can play. Rather than wedge the
// player out of the casino forever, it goes, and their stake with it — which is
// why it is logged loudly. The alternative is a player who can never be dealt
// another hand because an old one won't parse.
func (s *Server) dropUnreadable(user string, v tableView, err error) (tableView, error) {
slog.Error("games: unreadable live game, discarding", "user", user, "err", err)
_ = storage.ClearLiveHand(user)
v.Game = ""
return v, nil
}
@@ -438,62 +477,76 @@ func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
s.persist(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// persist writes the hand back and answers the browser. A finished hand pays
// out, goes in the audit log, and leaves the felt; an unfinished one is saved
// as it stands, so a redeploy mid-hand is survivable.
//
// fresh marks a hand that has just been dealt, which is the one case where the
// write may be refused: the primary key, not an earlier read, is what enforces
// one hand at a time. A Deal that loses that race gets its stake back.
func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(st)
if err != nil {
slog.Error("games: marshal hand", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// The games a live row can be. They're the storage key, so they're constants:
// a typo here is a game nobody can ever load again.
const (
gameBlackjack = "blackjack"
gameHangman = "hangman"
)
// Seat the hand before doing anything else with it — even one that is already
// over, because a natural settles the instant it's dealt. The insert is what
// enforces one hand at a time, and it has to happen for *every* new hand: a
// natural dealt on top of a hand already in progress would otherwise settle,
// clear the felt, and take the other hand's stake down with it.
hand := storage.LiveHand{Game: "blackjack", State: blob, Seed1: seed1, Seed2: seed2}
// finished is what commit needs to know about a game it's writing back: enough
// to settle it, and nothing about how it's played. Both engines produce one.
type finished struct {
Game string
Blob []byte // the engine's whole state, shoe or phrase and all
Bet int64
Payout int64
Rake int64
Outcome string
Done bool
Seed1 uint64
Seed2 uint64
Fresh bool // a game just started, which is the one write that may be refused
}
// commit writes a game back and settles it if it's over. It is the money path,
// and both games go through it so that neither has to re-derive an ordering
// that took a while to get right.
//
// It returns the table as it now stands. ok is false when it has already
// written an error response and the caller must simply return.
func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) {
// Seat the game before doing anything else with it — even one that is already
// over, because a blackjack natural settles the instant it's dealt. The insert
// is what enforces one game at a time, and it has to happen for *every* new
// one: a natural dealt on top of a game already in progress would otherwise
// settle, clear the felt, and take the other game's stake down with it.
live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2}
save := storage.SaveLiveHand
if fresh {
if f.Fresh {
save = storage.StartLiveHand
}
if err := save(user, hand); err != nil {
if err := save(user, live); err != nil {
if errors.Is(err, storage.ErrHandInProgress) {
// Somebody was already sitting here. This hand was never seated, so the
// chips it staked go back: the player is in one hand, not two.
_ = storage.Award(user, st.Bet)
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a hand"})
return
// Somebody was already sitting here. This game was never seated, so the
// chips it staked go back: the player is in one game, not two.
_ = storage.Award(user, f.Bet)
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
return tableView{}, false
}
slog.Error("games: save hand", "user", user, "err", err)
slog.Error("games: save game", "user", user, "game", f.Game, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
return tableView{}, false
}
if st.Phase == blackjack.PhaseDone {
if f.Done {
// Pay first, then clear. If Pete dies between the two, the player has been
// paid and the worst case is a settled hand still showing on the felt —
// paid and the worst case is a settled game still showing on the felt —
// which reads as done and can be cleared. The other order loses them a win.
if err := storage.Award(user, st.Payout); err != nil {
slog.Error("games: award", "user", user, "payout", st.Payout, "err", err)
if err := storage.Award(user, f.Payout); err != nil {
slog.Error("games: award", "user", user, "payout", f.Payout, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
return tableView{}, false
}
if err := storage.RecordHand(storage.Hand{
MatrixUser: user, Game: "blackjack",
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
Outcome: string(st.Outcome), Seed1: seed1, Seed2: seed2,
MatrixUser: user, Game: f.Game,
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
}); err != nil {
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's hand
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's game
}
if err := storage.ClearLiveHand(user); err != nil {
slog.Error("games: clear hand", "user", user, "err", err)
slog.Error("games: clear game", "user", user, "err", err)
}
}
@@ -503,11 +556,32 @@ func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State,
if err != nil {
slog.Error("games: table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return tableView{}, false
}
return v, true
}
// persist writes a blackjack hand back and answers the browser.
func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(st)
if err != nil {
slog.Error("games: marshal hand", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
done := st.Phase == blackjack.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameBlackjack, Blob: blob,
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
Outcome: string(st.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
if !ok {
return
}
// A settled hand is gone from storage, so the table view has no hand to show —
// but the browser still needs the final cards to animate the reveal onto.
if st.Phase == blackjack.PhaseDone {
if done {
hv := viewHand(st)
v.Hand = &hv
}

View File

@@ -181,7 +181,7 @@ func TestAdventurePageRendersBoard(t *testing.T) {
}
body := w.Body.String()
for _, want := range []string{
"Out there right now", // the board's headline
"Out there right now", // the board's headline
"Josie", "holymachina", "day 3", // live zone, shown while she's still in it
"Quack", "in town", "quiet for 2 days",
"/api/roster", // the client re-poll, so an open tab stays true

View File

@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
pages []string
}{
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman"}},
}
tpls := make(map[string]*template.Template)
for _, set := range sets {
@@ -232,13 +232,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
// auth block, and gamesReady() also insists on a Matrix server name: without
// one, no player can be named to gogobee's ledger and the tables stay shut.
if s.gamesReady() {
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
s.casinoRoutes(mux)
}
if s.auth != nil {

View File

@@ -877,6 +877,207 @@ html[data-phase="night"] {
50% { opacity: 1; transform: translateY(-1px); }
}
/* ---- hangman -------------------------------------------------------------
The gallows is the meter: it counts down your lives and your winnings at the
same time. So a miss has to *land* — the limb draws itself in along its own
length, the whole thing flinches, and the multiple falls. Three parts of one
event, and the game is about watching it happen to you. */
.pete-gallows {
overflow: visible;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.pete-gallows-frame path {
stroke: rgba(0, 0, 0, 0.35);
stroke-width: 6;
}
/* The rope. Same post, thinner, so it reads as something that would hold. */
.pete-gallows-frame path:last-child {
stroke: rgba(0, 0, 0, 0.3);
stroke-width: 3.5;
}
.pete-gallows-body > * {
stroke: #fff;
stroke-width: 5;
opacity: 0;
filter: drop-shadow(0 2px 0 rgba(0, 0, 0, 0.25));
}
.pete-gallows-body > [data-on="1"] { opacity: 1; }
/* Drawn, not faded in: the stroke runs on along its own path. 260 is longer
than any limb here, which is all a dash offset needs to be. */
.pete-part-draw {
stroke-dasharray: 260;
animation: pete-part 0.45s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes pete-part {
from { stroke-dashoffset: 260; }
to { stroke-dashoffset: 0; }
}
.pete-shake { animation: pete-shake 0.42s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
@keyframes pete-shake {
10%, 90% { transform: translateX(-1.5px); }
20%, 80% { transform: translateX(3px); }
30%, 50%, 70% { transform: translateX(-5px); }
40%, 60% { transform: translateX(5px); }
}
/* The phrase. Tiles wrap between words, never inside one. */
.pete-board {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1.1rem;
min-height: 5rem;
align-items: center;
}
.pete-word {
display: flex;
gap: 0.3rem;
}
.pete-tile {
display: grid;
place-items: center;
height: 2.9rem;
width: 2.2rem;
border-radius: 0.5rem;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 1.4rem;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.22);
border-bottom: 3px solid rgba(0, 0, 0, 0.28);
text-transform: uppercase;
}
/* A letter you've earned sits proud of the ones you haven't. */
.pete-tile[data-up="1"] {
background: rgba(255, 255, 255, 0.95);
color: #2b2118;
border-bottom-color: rgba(0, 0, 0, 0.35);
}
/* Punctuation is scaffolding: it was never yours to guess, so it gets no tile
to guess it into. */
.pete-tile[data-punct="1"] {
background: none;
border: 0;
width: auto;
min-width: 0.7rem;
color: rgba(255, 255, 255, 0.55);
}
.pete-tile-hit { animation: pete-tile-hit 0.34s cubic-bezier(0.34, 1.56, 0.64, 1); }
@keyframes pete-tile-hit {
0% { transform: rotateX(90deg) scale(1.1); }
100% { transform: rotateX(0) scale(1); }
}
.pete-missed {
display: grid;
place-items: center;
height: 1.5rem;
min-width: 1.5rem;
padding: 0 0.3rem;
border-radius: 0.375rem;
background: rgba(0, 0, 0, 0.25);
font-size: 0.75rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.5);
text-decoration: line-through;
text-transform: uppercase;
}
/* What a win is worth, right now. */
.pete-meter {
display: flex;
align-items: baseline;
gap: 0.5rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
padding: 0.4rem 0.9rem;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.1);
transition: box-shadow 0.3s ease;
}
.pete-meter-label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.45);
}
.pete-meter-value {
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 1.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent);
}
/* Down at the floor: a win now hands back the stake and nothing else. */
.pete-meter[data-cold="1"] .pete-meter-value { color: rgba(255, 255, 255, 0.55); }
.pete-meter[data-hit="1"] {
box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.9);
animation: pete-meter-hit 0.4s ease;
}
@keyframes pete-meter-hit {
0% { transform: scale(1); }
35% { transform: scale(0.94); }
100% { transform: scale(1); }
}
/* The keyboard. */
.pete-keys { display: grid; gap: 0.35rem; }
.pete-key-row {
display: flex;
justify-content: center;
gap: 0.35rem;
}
.pete-key-row[data-digits="1"] { margin-top: 0.25rem; opacity: 0.75; }
.pete-key-row[data-digits="1"] .pete-key {
height: 2rem;
min-width: 1.8rem;
font-size: 0.8rem;
}
.pete-key {
height: 2.75rem;
min-width: 2.2rem;
flex: 0 1 2.4rem;
border-radius: 0.6rem;
background: color-mix(in srgb, var(--ink) 6%, transparent);
border: 2px solid color-mix(in srgb, var(--ink) 10%, transparent);
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-weight: 700;
color: var(--ink);
transition: transform 0.08s ease, background 0.15s ease, opacity 0.15s ease;
}
.pete-key:hover:not(:disabled) { background: color-mix(in srgb, var(--ink) 12%, transparent); }
.pete-key:active:not(:disabled) { transform: translateY(1px) scale(0.96); }
.pete-key:disabled { cursor: default; }
/* A key that's been spent looks spent — otherwise you spend it twice. */
.pete-key[data-state="hit"] {
background: #4caf7d;
border-color: #3d9367;
color: #fff;
opacity: 1;
}
.pete-key[data-state="miss"] {
background: color-mix(in srgb, var(--ink) 4%, transparent);
color: color-mix(in srgb, var(--ink) 30%, transparent);
text-decoration: line-through;
}
.pete-key:disabled[data-state=""] { opacity: 0.4; }
/* Picking a length. The one you're on is lit. */
.pete-tier {
background: color-mix(in srgb, var(--ink) 4%, transparent);
border-color: color-mix(in srgb, var(--ink) 10%, transparent);
}
.pete-tier:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); }
.pete-tier[data-on="1"] {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: var(--accent);
}
@media (prefers-reduced-motion: reduce) {
.pete-card,
.pete-card::after,
@@ -884,6 +1085,10 @@ html[data-phase="night"] {
.pete-dealer-think { animation: none; }
.pete-card-inner { transition: none; }
.pete-hand[data-won="1"] .pete-card { animation: none; }
.pete-part-draw,
.pete-shake,
.pete-tile-hit,
.pete-meter[data-hit="1"] { animation: none; }
}
}

File diff suppressed because one or more lines are too long

View File

@@ -62,8 +62,10 @@
}
}
// You cannot cash out mid-hand: the stake is already on the table.
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.hand;
// You cannot cash out mid-game: the stake is already on the table. `game` is
// set by whichever game you're in, so this holds for any of them — checking
// for a blackjack hand specifically would let you walk out on a hangman.
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.game;
if (buyBtn) buyBtn.disabled = v.chips + v.pending >= v.cap;
listeners.forEach(function (fn) { fn(v); });

View File

@@ -0,0 +1,583 @@
// The hangman table.
//
// Same bargain as the blackjack table: the browser holds no game. It sends a
// letter, and the server answers with the board you're allowed to see — the
// phrase with the letters you haven't earned still blank — plus the script of
// what just happened. The phrase itself only ever arrives once the game is over
// and it no longer decides anything.
//
// The gallows is the meter. It counts your lives down and your winnings down at
// the same time, which is why a miss draws a limb *and* knocks the multiple back
// in the same beat: they are the same event, and showing them as one thing is
// the whole reason to bet on this rather than play it on paper.
(function () {
"use strict";
var root = document.querySelector("[data-hangman]");
if (!root) return;
var FX = window.PeteFX;
var boardEl = root.querySelector("[data-board]");
var gallowsEl = root.querySelector("[data-gallows]");
var wrongEl = root.querySelector("[data-wrong]");
var wrongLbl = root.querySelector("[data-wrong-label]");
var multEl = root.querySelector("[data-multiple]");
var meterEl = root.querySelector("[data-meter]");
var standsEl = root.querySelector("[data-stands]");
var standsLbl = root.querySelector("[data-stands-label]");
var livesEl = root.querySelector("[data-lives]");
var verdictEl = root.querySelector("[data-verdict]");
var betting = root.querySelector("[data-betting]");
var guessing = root.querySelector("[data-guessing]");
var keysEl = root.querySelector("[data-keyboard]");
var betAmount = root.querySelector("[data-bet-amount]");
var startBtn = root.querySelector("[data-start]");
var solveIn = root.querySelector("[data-solve-input]");
var solveBtn = root.querySelector("[data-solve]");
var msgEl = root.querySelector("[data-table-msg]");
var gameMsgEl = root.querySelector("[data-game-msg]");
// The three places a chip can be, exactly as at the other table.
var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var stackEl = root.querySelector("[data-stack]");
var spotTotalEl = root.querySelector("[data-spot-total]");
var houseEl = root.querySelector("[data-house]");
var bet = 0; // what you're building between games
var staked = 0; // what is actually on the spot
var busy = false;
var game = null; // the board as the server last described it
var tier = "medium";
var FLIP_MS = 320;
var MISS_MS = 520;
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
function say(text, tone, where) {
var el = where || msgEl;
if (!el) return;
if (!text) { el.classList.add("hidden"); return; }
el.textContent = text;
el.classList.remove("hidden");
el.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- the board -------------------------------------------------------------
// renderBoard lays the phrase out as tiles. A tile is a letter you have to earn;
// a space or a piece of punctuation is scaffolding and gets no tile, because a
// row of blanks with the word breaks hidden is a puzzle about typography.
//
// Words are kept whole: the board wraps between words, never inside one.
function renderBoard(cells) {
boardEl.innerHTML = "";
if (!cells) return;
var word = document.createElement("div");
word.className = "pete-word";
cells.forEach(function (c, i) {
if (!c.slot && (c.ch === " " || c.ch === "")) {
// A space: end the word and start the next one.
if (word.childNodes.length) boardEl.appendChild(word);
word = document.createElement("div");
word.className = "pete-word";
return;
}
var t = document.createElement("span");
t.className = "pete-tile";
t.dataset.at = String(i);
if (!c.slot) {
t.dataset.punct = "1"; // an exclamation mark is not a blank to fill
t.textContent = c.ch;
} else {
t.dataset.up = c.ch ? "1" : "0";
t.textContent = c.ch || "";
}
word.appendChild(t);
});
if (word.childNodes.length) boardEl.appendChild(word);
}
// turnUp flips the tiles a hit just earned, one after the other so a letter that
// appears three times reads as three finds rather than one repaint.
function turnUp(at, ch) {
if (!at || !at.length) return Promise.resolve();
at.forEach(function (i, n) {
var t = boardEl.querySelector('.pete-tile[data-at="' + i + '"]');
if (!t) return;
setTimeout(function () {
// Left as it comes: the tile is uppercased in CSS, and doing it here too
// would mean the resume path (which paints the phrase's own casing) and
// this one put different text in the same tile.
t.textContent = ch;
t.dataset.up = "1";
t.classList.add("pete-tile-hit");
}, pace(n * 90));
});
return wait(FLIP_MS + (at.length - 1) * 90);
}
// ---- the gallows -----------------------------------------------------------
// drawGallows shows the first n parts. Each one draws itself in along its own
// length rather than fading up — the difference between a limb being *drawn* and
// a limb appearing is the whole character of the game.
function drawGallows(n, animateLast) {
var parts = gallowsEl.querySelectorAll("[data-part]");
parts.forEach(function (p, i) {
var on = i < n;
var was = p.dataset.on === "1";
p.dataset.on = on ? "1" : "0";
if (on && !was && animateLast && i === n - 1 && !reduced) {
// Restart the draw-in animation on the part that was just earned.
p.classList.remove("pete-part-draw");
void p.getBoundingClientRect();
p.classList.add("pete-part-draw");
} else if (on && !animateLast) {
p.classList.remove("pete-part-draw");
}
});
}
function shake() {
if (reduced) return;
gallowsEl.classList.remove("pete-shake");
void gallowsEl.getBoundingClientRect();
gallowsEl.classList.add("pete-shake");
}
// ---- the meter -------------------------------------------------------------
function renderMeter(v) {
if (!v) {
multEl.textContent = "—";
standsEl.textContent = "—";
standsLbl.textContent = "if you get it";
livesEl.textContent = "";
meterEl.dataset.cold = "0";
return;
}
multEl.textContent = v.multiple.toFixed(2) + "×";
standsEl.textContent = (v.stands || 0).toLocaleString();
standsLbl.textContent = v.phase === "done" ? "was on it" : "if you get it";
livesEl.textContent = v.lives + (v.lives === 1 ? " life left" : " lives left");
// The meter goes cold once the multiple is down at its floor: from here a win
// hands back the stake and nothing more.
meterEl.dataset.cold = v.multiple <= 1.001 ? "1" : "0";
}
// knock ticks the multiple down to its new value, so the number falls rather
// than simply being different.
function knock(v) {
if (reduced) { renderMeter(v); return; }
var from = parseFloat(multEl.textContent) || v.multiple;
var to = v.multiple;
var t0 = performance.now();
meterEl.dataset.hit = "1";
setTimeout(function () { meterEl.dataset.hit = "0"; }, 400);
(function step(now) {
var p = Math.min(1, (now - t0) / 380);
var eased = 1 - Math.pow(1 - p, 3);
multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
if (p < 1) requestAnimationFrame(step);
else renderMeter(v);
})(t0);
}
// ---- the keyboard ----------------------------------------------------------
var ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "0123456789"];
function buildKeys() {
keysEl.innerHTML = "";
ROWS.forEach(function (row, r) {
var line = document.createElement("div");
line.className = "pete-key-row";
if (r === 3) line.dataset.digits = "1";
row.split("").forEach(function (ch) {
var b = document.createElement("button");
b.type = "button";
b.className = "pete-key";
b.dataset.key = ch;
b.textContent = ch.toUpperCase();
b.addEventListener("click", function () { guessLetter(ch); });
line.appendChild(b);
});
keysEl.appendChild(line);
});
}
// paintKeys marks every letter that's been tried, and how it went. A key that
// has been spent should look spent — otherwise you spend it again.
function paintKeys(v) {
var tried = (v && v.tried) || [];
var wrong = (v && v.wrong) || [];
keysEl.querySelectorAll(".pete-key").forEach(function (b) {
var ch = b.dataset.key;
var used = tried.indexOf(ch) !== -1;
b.disabled = used || !v || v.phase !== "playing";
b.dataset.state = !used ? "" : wrong.indexOf(ch) !== -1 ? "miss" : "hit";
});
}
function renderWrong(v) {
wrongEl.innerHTML = "";
var wrong = (v && v.wrong) || [];
// A wrong *solve* is recorded as a miss with no letter on it — it cost a life
// and it's on the gallows, but there's no key to grey out for it.
var letters = wrong.filter(function (c) { return c !== "·"; });
wrongLbl.classList.toggle("hidden", letters.length === 0);
letters.forEach(function (ch) {
var s = document.createElement("span");
s.className = "pete-missed";
s.textContent = ch.toUpperCase();
wrongEl.appendChild(s);
});
}
// ---- the money on the felt -------------------------------------------------
// Lifted wholesale from the blackjack table, and deliberately identical: a chip
// has to behave the same way in both rooms or it isn't a chip, it's a widget.
function renderStack(amount) {
staked = amount || 0;
stackEl.innerHTML = "";
spotEl.dataset.live = staked > 0 ? "1" : "0";
if (!staked) { spotTotalEl.classList.add("hidden"); return; }
FX.chipsFor(staked).forEach(function (d, i) {
var c = FX.disc(d);
c.style.setProperty("--i", i);
c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg");
c.style.animationDelay = pace(i * 40) + "ms";
stackEl.appendChild(c);
});
spotTotalEl.textContent = staked.toLocaleString();
spotTotalEl.classList.remove("hidden");
}
function pour(from, to, amount, opts) {
if (amount <= 0) return Promise.resolve();
var base = staked;
var chips = FX.chipsFor(amount, 8);
var run = 0;
return FX.flyMany(from, to, chips, Object.assign({
onLand: function (d, i) {
run += d;
renderStack(base + (i === chips.length - 1 ? amount : run));
},
}, opts || {}));
}
function stake(amount, from) {
return pour(from || purseEl, spotEl, amount);
}
function settleChips(final) {
var payout = final.payout || 0;
var back = payout - final.bet;
if (payout <= 0) {
var lost = FX.chipsFor(final.bet, 8);
var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true });
renderStack(0);
return chain;
}
var pay = pour(houseEl, spotEl, back, { gap: 60 });
return pay
.then(function () { return wait(back > 0 ? 380 : 200); })
.then(function () {
var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 });
renderStack(0);
return home;
});
}
// ---- phases ----------------------------------------------------------------
var VERDICTS = {
solved: "Got it! 🎉",
filled: "That's the lot!",
hung: "Hung.",
};
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
if (!text) { verdictEl.classList.add("hidden"); return; }
if (v.outcome === "hung" && v.phrase) text = "Hung. It was “" + v.phrase + "”.";
if (v.net > 0) text += " +" + v.net.toLocaleString();
else if (v.net < 0) text += " " + v.net.toLocaleString();
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
// Confetti for a phrase guessed outright — the one call you make on your own
// rather than by grinding the alphabet.
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 });
}
function setPhase(v) {
game = v;
var live = !!v && v.phase === "playing";
betting.classList.toggle("hidden", live);
guessing.classList.toggle("hidden", !live);
paintKeys(v);
if (!live && solveIn) solveIn.value = "";
if (!v || !v.outcome) verdictEl.classList.add("hidden");
}
// paint puts a board up with no animation: the resume path, after a reload or a
// redeploy. Your stake is still on the spot because the server still has it.
function paint(v) {
if (!v) {
renderBoard(null);
drawGallows(0, false);
renderWrong(null);
renderMeter(null);
renderStack(0);
setPhase(null);
return;
}
renderBoard(v.cells);
drawGallows((v.wrong || []).length, false);
renderWrong(v);
renderMeter(v);
renderStack(v.phase === "done" ? 0 : v.bet);
setPhase(v);
}
// ---- the script ------------------------------------------------------------
// play walks the server's events. Same rule as the other table: on a live game
// the money is already right (your stake left your pile when you pressed Play,
// and it's on the spot), but on a settling one the chip bar is held back until
// the chips have physically come home.
function play(view, money) {
var events = view.hang_events || [];
var final = view.hangman;
var settles = !!final && final.phase === "done";
var chain = Promise.resolve();
if (!settles) money();
if (final && final.bet > staked) {
var extra = final.bet - staked;
chain = chain.then(function () { return stake(extra); });
}
events.forEach(function (e) {
chain = chain.then(function () {
switch (e.kind) {
case "start":
verdictEl.classList.add("hidden");
renderBoard(final && final.cells);
drawGallows(0, false);
renderWrong(null);
renderMeter(final);
return;
case "hit":
return turnUp(e.at, e.letter);
case "miss":
// The limb, the shake and the multiple falling are one event, because
// they are one event: this is what a wrong guess costs, all of it.
drawGallows(countMisses(events, e), true);
shake();
if (final) knock(final);
return wait(MISS_MS);
case "solve":
return wait(220);
case "settle":
return;
}
});
});
return chain.then(function () {
if (!final) { paint(null); money(); return; }
renderWrong(final);
if (!settles) {
renderMeter(final);
setPhase(final);
return;
}
// Over: the board goes fully face up (the server has finally sent the
// phrase), then the money moves, and only then does the bar catch up.
guessing.classList.add("hidden");
renderBoard(final.cells);
renderMeter(final);
verdict(final);
return settleChips(final)
.then(money)
.then(function () { return standing(final.bet); })
.then(function () { setPhase(final); });
});
}
// countMisses works out how many limbs should be on the gallows by the time
// this miss has been played — the misses already on the board when the request
// went out, plus every miss in this batch up to and including this one.
function countMisses(events, upTo) {
var before = game ? (game.wrong || []).length : 0;
var n = 0;
for (var i = 0; i < events.length; i++) {
if (events[i].kind === "miss") n++;
if (events[i] === upTo) break;
}
return before + n;
}
// standing puts the stake back on the spot for the next phrase, the way the
// blackjack table leaves your bet up.
function standing(amount) {
var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) {
bet = 0;
showBet();
return;
}
bet = amount;
showBet();
return stake(amount);
}
// ---- talking to the table ---------------------------------------------------
function send(path, body, where) {
if (busy) return;
busy = true;
say("", null, where);
return window.PeteGames.post(path, body)
.then(function (view) {
return play(view, function () { window.PeteGames.apply(view); });
})
.catch(function (err) {
say(err.message, "bad", where);
return window.PeteGames.refresh().then(function (v) {
if (v && !v.hangman) renderStack(0);
});
})
.then(function () { busy = false; });
}
function guessLetter(ch) {
if (busy || !game || game.phase !== "playing") return;
if ((game.tried || []).indexOf(ch) !== -1) return;
send("/api/games/hangman/guess", { letter: ch }, gameMsgEl);
}
// ---- betting ----------------------------------------------------------------
function showBet() {
betAmount.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
if (startBtn) startBtn.disabled = bet <= 0 || !money || money.chips < bet;
}
function pickTier(slug) {
tier = slug;
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
});
}
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.addEventListener("click", function () {
if (busy) return;
pickTier(b.dataset.tier);
});
});
root.querySelectorAll("[data-chip]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (busy) return;
var d = parseInt(btn.dataset.chip, 10);
var money = window.PeteGames.view();
if (money && bet + d > money.chips) {
say("You haven't got that many chips.", "bad");
return;
}
bet += d;
showBet();
var target = bet;
staked = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
if (bet >= target) renderStack(target);
});
});
});
var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", function () {
if (busy || !staked) { bet = 0; showBet(); return; }
FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 });
bet = 0;
renderStack(0);
showBet();
});
}
if (startBtn) {
startBtn.addEventListener("click", function () {
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
send("/api/games/hangman/start", { bet: bet, tier: tier });
});
}
function solve() {
if (busy || !game || game.phase !== "playing") return;
var attempt = (solveIn.value || "").trim();
if (!attempt) { say("Say what it is, then.", "bad", gameMsgEl); return; }
send("/api/games/hangman/guess", { solve: attempt }, gameMsgEl);
}
if (solveBtn) solveBtn.addEventListener("click", solve);
if (solveIn) {
solveIn.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); solve(); }
});
}
// Type a letter to guess it — but not while you're typing a solution into the
// box, which is the whole reason this checks what has focus.
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
if (!game || game.phase !== "playing" || busy) return;
var ch = (e.key || "").toLowerCase();
if (!/^[a-z0-9]$/.test(ch)) return;
e.preventDefault();
guessLetter(ch);
});
buildKeys();
pickTier(tier);
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.hangman) {
paint(v.hangman);
if (v.hangman.phase === "done") verdict(v.hangman);
} else {
paint(null);
}
}
showBet();
});
})();

View File

@@ -42,6 +42,22 @@
</p>
</a>
<a href="/games/hangman"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🪢</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">Hangman</h3>
<p class="text-sm text-[color:var(--ink)]/60">Guess the phrase, keep the multiple.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
Short phrases pay up to 2.6×. You get {{.MaxWrong}} lives, and every wrong guess
takes a tenth off what a win is worth.
</p>
</a>
{{range .Soon}}
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
<div class="flex items-center gap-3">

View File

@@ -0,0 +1,176 @@
{{define "title"}}Hangman · {{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-6" data-hangman>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
<span class="sr-only">Back to the casino</span>
</a>
<h1 class="font-display text-3xl font-bold">Hangman</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">{{.MaxWrong}} lives · every wrong guess costs you a tenth of the win</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The gallows is on it because the gallows is the meter: it counts
down your lives and your winnings at the same time. -->
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="pete-rack" data-house aria-hidden="true">
<span data-chip="500" style="--stack: 5"></span>
<span data-chip="100" style="--stack: 7"></span>
<span data-chip="25" style="--stack: 4"></span>
<span data-chip="5" style="--stack: 6"></span>
</div>
<!-- The gallows and the stake stack up on the left; the phrase and what it's
worth take the rest. The right column keeps clear of the rack in the
corner, which is why nothing in it is pushed to the far edge. -->
<div class="relative grid gap-x-10 gap-y-8 lg:grid-cols-[auto,1fr] lg:items-start">
<div class="flex flex-col items-center gap-6 lg:items-start">
<svg data-gallows viewBox="0 0 130 150" class="pete-gallows h-48 w-auto sm:h-56" role="img"
aria-labelledby="gallows-title">
<title id="gallows-title">The gallows</title>
<g class="pete-gallows-frame">
<path d="M8 145 H72" />
<path d="M28 145 V8" />
<path d="M28 8 H92" />
<path d="M92 8 V26" />
</g>
<g class="pete-gallows-body">
<circle data-part="0" cx="92" cy="38" r="12" />
<path data-part="1" d="M92 50 V88" />
<path data-part="2" d="M92 58 L74 76" />
<path data-part="3" d="M92 58 L110 76" />
<path data-part="4" d="M92 88 L76 114" />
<path data-part="5" d="M92 88 L108 114" />
</g>
</svg>
<!-- The stake sits under the gallows it's riding on, the same spot the
blackjack table puts your bet on. -->
<div class="flex items-center gap-4">
<div class="pete-spot" data-spot>
<span class="pete-spot-label">Bet</span>
<div class="pete-stack" data-stack></div>
<span data-spot-total class="pete-spot-total hidden"></span>
</div>
<p data-lives class="text-xs font-bold uppercase tracking-wider text-white/50"></p>
</div>
</div>
<div class="min-w-0 space-y-6">
<!-- What a win is worth right now. It falls as the figure fills in.
This row is the only one level with the house rack in the corner, so
it is the only one that has to keep clear of it — the board below
gets the full width, and needs it. -->
<div class="flex flex-wrap items-center gap-3 pr-24 sm:pr-28">
<div class="pete-meter" data-meter>
<span class="pete-meter-label">Pays</span>
<span data-multiple class="pete-meter-value"></span>
</div>
<p class="text-sm text-white/60">
<span data-stands class="font-bold tabular-nums text-white/90"></span>
<span data-stands-label>if you get it</span>
</p>
</div>
<!-- The phrase. -->
<div data-board class="pete-board" aria-live="polite" aria-label="The phrase"></div>
<!-- Letters that missed. -->
<div class="flex min-h-[1.75rem] flex-wrap items-center gap-1.5">
<span data-wrong-label class="hidden text-xs font-bold uppercase tracking-wider text-white/40">Missed</span>
<div data-wrong class="flex flex-wrap gap-1.5"></div>
</div>
<!-- What just happened. -->
<div class="flex min-h-[2.75rem] items-center">
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
</div>
</div>
</div>
</section>
<!-- Guessing: shown while a game is live. -->
<section data-guessing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div data-keyboard class="pete-keys"></div>
<div class="mt-4 flex flex-wrap items-center gap-2">
<label for="solve" class="sr-only">Guess the whole phrase</label>
<input id="solve" data-solve-input type="text" autocomplete="off" enterkeyhint="go"
placeholder="Or just say what it is…"
class="min-w-0 flex-1 rounded-full bg-[color:var(--ink)]/5 px-4 py-2.5 text-sm font-semibold
border-2 border-[color:var(--ink)]/10 focus:outline-none focus:border-[color:var(--accent)]">
<button type="button" data-solve
class="rounded-full bg-[color:var(--accent)] px-6 py-2.5 font-display font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Solve
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
Type a letter to guess it. A wrong solve costs a life, same as a wrong letter.
</p>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Betting: shown between games. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">How long a phrase?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Tiers}}
<button type="button" data-tier="{{.Slug}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
</button>
{{end}}
</div>
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
</div>
<div class="flex flex-wrap items-center gap-2">
{{range .Denominations}}
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
<span>{{.}}</span>
</button>
{{end}}
<button type="button" data-bet-clear
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
</div>
<button type="button" data-start
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Play
</button>
</div>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/hangman.js" defer></script>
{{end}}

View File

@@ -19,7 +19,7 @@ import (
)
const (
ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars
ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars
ttsTimeout = 60 * time.Second
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
)