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:
395
internal/games/hangman/hangman.go
Normal file
395
internal/games/hangman/hangman.go
Normal 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
|
||||||
|
}
|
||||||
369
internal/games/hangman/hangman_test.go
Normal file
369
internal/games/hangman/hangman_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
internal/games/hangman/phrases.go
Normal file
68
internal/games/hangman/phrases.go
Normal 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
|
||||||
|
}
|
||||||
237
internal/games/hangman/phrases.txt
Normal file
237
internal/games/hangman/phrases.txt
Normal 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?
|
||||||
@@ -44,13 +44,7 @@ func TestDevCasino(t *testing.T) {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||||
mux.HandleFunc("GET /games", s.handleLobby)
|
s.casinoRoutes(mux)
|
||||||
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)
|
|
||||||
|
|
||||||
ln, err := net.Listen("tcp", addr)
|
ln, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,7 +57,7 @@ func TestDevCasino(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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}
|
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||||
t.Cleanup(func() { _ = srv.Close() })
|
t.Cleanup(func() { _ = srv.Close() })
|
||||||
|
|||||||
217
internal/web/games_hangman.go
Normal file
217
internal/web/games_hangman.go
Normal 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)
|
||||||
|
}
|
||||||
188
internal/web/games_hangman_test.go
Normal file
188
internal/web/games_hangman_test.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The one thing this table cannot get wrong: the stake leaves the stack, and the
|
||||||
|
// phrase does not leave the server.
|
||||||
|
func TestHangmanStartTakesTheStakeAndKeepsThePhrase(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
v, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "short"}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("start = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if v.Chips != 900 {
|
||||||
|
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||||
|
}
|
||||||
|
if v.Hangman == nil {
|
||||||
|
t.Fatal("start returned no game")
|
||||||
|
}
|
||||||
|
if v.Game != gameHangman {
|
||||||
|
t.Errorf("game = %q, want hangman", v.Game)
|
||||||
|
}
|
||||||
|
if v.Hangman.Phrase != "" {
|
||||||
|
t.Fatalf("the phrase was sent to the browser before it was won: %q", v.Hangman.Phrase)
|
||||||
|
}
|
||||||
|
// Nothing is revealed at the start except the scaffolding, and a space is not
|
||||||
|
// a letter you have to earn.
|
||||||
|
for _, c := range v.Hangman.Cells {
|
||||||
|
if c.Slot && c.Ch != "" {
|
||||||
|
t.Fatalf("a letter was face up before it was guessed: %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.Hangman.Lives != 6 {
|
||||||
|
t.Errorf("lives = %d, want 6", v.Hangman.Lives)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A win pays what the felt said it would, and the rake comes out of the winnings.
|
||||||
|
func TestHangmanWinPaysWhatTheFeltQuoted(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
v, _ := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "short"}))
|
||||||
|
quoted := v.Hangman.Stands
|
||||||
|
|
||||||
|
// The server holds the phrase, so read it out of the live row — which is the
|
||||||
|
// only place it exists — and solve it.
|
||||||
|
phrase := livePhrase(t)
|
||||||
|
v, code := call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
|
||||||
|
map[string]string{"solve": phrase}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("solve = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if v.Hangman.Outcome != "solved" {
|
||||||
|
t.Fatalf("outcome = %q, want solved", v.Hangman.Outcome)
|
||||||
|
}
|
||||||
|
// No wrong guesses, so the full 2.6×: 260 gross, 160 profit, 8 rake, 252 back.
|
||||||
|
if v.Hangman.Payout != quoted {
|
||||||
|
t.Errorf("felt quoted %d, house paid %d", quoted, v.Hangman.Payout)
|
||||||
|
}
|
||||||
|
if v.Hangman.Payout != 252 || v.Hangman.Rake != 8 {
|
||||||
|
t.Errorf("payout/rake = %d/%d, want 252/8", v.Hangman.Payout, v.Hangman.Rake)
|
||||||
|
}
|
||||||
|
if got := chipsNow(t); got != 900+252 {
|
||||||
|
t.Errorf("chips = %d, want %d", got, 900+252)
|
||||||
|
}
|
||||||
|
// And the phrase is finally allowed out, now that it decides nothing.
|
||||||
|
if v.Hangman.Phrase == "" {
|
||||||
|
t.Error("a finished game never told the player what the phrase was")
|
||||||
|
}
|
||||||
|
// The game is off the felt.
|
||||||
|
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
|
||||||
|
t.Error("a settled game is still sitting in game_live_hands")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Six wrong guesses take the stake and nothing more.
|
||||||
|
func TestHangmanHangingCostsExactlyTheStake(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "short"}))
|
||||||
|
|
||||||
|
// Six solves that are certainly wrong — a wrong solve costs a life, same as a
|
||||||
|
// wrong letter, and this needs no knowledge of the phrase.
|
||||||
|
var v tableView
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
v, _ = call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
|
||||||
|
map[string]string{"solve": "definitely not the phrase at all"}))
|
||||||
|
}
|
||||||
|
if v.Hangman == nil || v.Hangman.Outcome != "hung" {
|
||||||
|
t.Fatalf("outcome = %+v, want hung", v.Hangman)
|
||||||
|
}
|
||||||
|
if v.Hangman.Payout != 0 {
|
||||||
|
t.Errorf("payout = %d, want 0", v.Hangman.Payout)
|
||||||
|
}
|
||||||
|
if got := chipsNow(t); got != 900 {
|
||||||
|
t.Errorf("chips = %d, want 900 — a loss costs the stake and no more", got)
|
||||||
|
}
|
||||||
|
if v.Hangman.Phrase == "" {
|
||||||
|
t.Error("hung without being told the answer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One game at a time, across games: you cannot walk from a hangman into a hand of
|
||||||
|
// blackjack with chips still riding on a phrase.
|
||||||
|
func TestHangmanHoldsTheSeatAgainstBlackjack(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "short"}))
|
||||||
|
|
||||||
|
_, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal",
|
||||||
|
map[string]int64{"bet": 100}))
|
||||||
|
if code != 409 {
|
||||||
|
t.Fatalf("dealt blackjack on top of a live hangman: %d, want 409", code)
|
||||||
|
}
|
||||||
|
// And the stake that was refused came back: 1000 - 100 (the hangman) and not a
|
||||||
|
// chip more.
|
||||||
|
if got := chipsNow(t); got != 900 {
|
||||||
|
t.Errorf("chips = %d, want 900 — the refused deal kept the stake", got)
|
||||||
|
}
|
||||||
|
if _, err := storage.LoadLiveHand(testPlayer); err != nil {
|
||||||
|
t.Errorf("the hangman was evicted by the deal it refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cashing out mid-phrase is refused, for the same reason as mid-hand.
|
||||||
|
func TestCannotCashOutMidPhrase(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "short"}))
|
||||||
|
|
||||||
|
_, code := call(t, s, s.handleCashOut, as(t, s, "reala", "POST", "/api/games/cashout",
|
||||||
|
map[string]int64{"amount": 0}))
|
||||||
|
if code != 409 {
|
||||||
|
t.Fatalf("cash-out mid-phrase = %d, want 409", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tier the browser made up is refused, and costs nothing.
|
||||||
|
func TestHangmanRefusesAnInventedTier(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
_, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "impossible"}))
|
||||||
|
if code != 400 {
|
||||||
|
t.Fatalf("start on a made-up tier = %d, want 400", code)
|
||||||
|
}
|
||||||
|
if got := chipsNow(t); got != 1000 {
|
||||||
|
t.Errorf("chips = %d, want 1000 — a refused game must not take a stake", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// livePhrase digs the phrase out of the live row. Only a test may do this: it is
|
||||||
|
// reaching past the wire on purpose, to prove the wire doesn't carry it.
|
||||||
|
func livePhrase(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
live, err := storage.LoadLiveHand(testPlayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
blob := string(live.State)
|
||||||
|
const key = `"phrase":"`
|
||||||
|
i := strings.Index(blob, key)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("no phrase in the live row: %s", blob)
|
||||||
|
}
|
||||||
|
rest := blob[i+len(key):]
|
||||||
|
j := strings.Index(rest, `"`)
|
||||||
|
if j < 0 {
|
||||||
|
t.Fatal("unterminated phrase in the live row")
|
||||||
|
}
|
||||||
|
return rest[:j]
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pete/internal/games/blackjack"
|
"pete/internal/games/blackjack"
|
||||||
|
"pete/internal/games/hangman"
|
||||||
"pete/internal/storage"
|
"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: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
|
||||||
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
|
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
|
||||||
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
|
{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.
|
// betDenominations are the chips you build a bet out of.
|
||||||
@@ -70,6 +70,30 @@ type gamesPage struct {
|
|||||||
RakePct int
|
RakePct int
|
||||||
Soon []gameTeaser
|
Soon []gameTeaser
|
||||||
Denominations []int64
|
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.
|
// 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),
|
RakePct: int(blackjack.DefaultRules().RakePct * 100),
|
||||||
Soon: comingSoon,
|
Soon: comingSoon,
|
||||||
Denominations: betDenominations,
|
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))
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"pete/internal/games/blackjack"
|
"pete/internal/games/blackjack"
|
||||||
"pete/internal/games/cards"
|
"pete/internal/games/cards"
|
||||||
|
"pete/internal/games/hangman"
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,18 +164,31 @@ func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
|
|||||||
return out
|
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 {
|
type tableView struct {
|
||||||
Chips int64 `json:"chips"`
|
Chips int64 `json:"chips"`
|
||||||
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
|
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
|
||||||
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
|
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
|
||||||
Cap int64 `json:"cap"`
|
Cap int64 `json:"cap"`
|
||||||
Hand *handView `json:"hand,omitempty"`
|
|
||||||
Events []eventView `json:"events,omitempty"` // only on a move, for the animation
|
Game string `json:"game,omitempty"` // "blackjack" | "hangman", if one is live
|
||||||
Rake float64 `json:"rake_pct"`
|
|
||||||
|
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) {
|
func (s *Server) table(user string) (tableView, error) {
|
||||||
st, err := storage.Chips(user)
|
st, err := storage.Chips(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -193,16 +208,40 @@ func (s *Server) table(user string) (tableView, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return tableView{}, err
|
return tableView{}, err
|
||||||
}
|
}
|
||||||
var hand blackjack.State
|
|
||||||
if err := json.Unmarshal(live.State, &hand); err != nil {
|
// Dispatch on the game the row says it is. Unmarshalling a hangman state into
|
||||||
// A hand we can't read is a hand nobody can play. Rather than wedge the
|
// a blackjack one would not fail — JSON is happy to fill nothing in — it would
|
||||||
// player out of the casino forever, drop it and tell them.
|
// just quietly produce an empty hand, which is the worst of both.
|
||||||
slog.Error("games: unreadable live hand, discarding", "user", user, "err", err)
|
v.Game = live.Game
|
||||||
_ = storage.ClearLiveHand(user)
|
switch live.Game {
|
||||||
return v, nil
|
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)
|
return v, nil
|
||||||
v.Hand = &hv
|
}
|
||||||
|
|
||||||
|
// 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
|
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)
|
s.persist(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// persist writes the hand back and answers the browser. A finished hand pays
|
// The games a live row can be. They're the storage key, so they're constants:
|
||||||
// out, goes in the audit log, and leaves the felt; an unfinished one is saved
|
// a typo here is a game nobody can ever load again.
|
||||||
// as it stands, so a redeploy mid-hand is survivable.
|
const (
|
||||||
//
|
gameBlackjack = "blackjack"
|
||||||
// fresh marks a hand that has just been dealt, which is the one case where the
|
gameHangman = "hangman"
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seat the hand before doing anything else with it — even one that is already
|
// finished is what commit needs to know about a game it's writing back: enough
|
||||||
// over, because a natural settles the instant it's dealt. The insert is what
|
// to settle it, and nothing about how it's played. Both engines produce one.
|
||||||
// enforces one hand at a time, and it has to happen for *every* new hand: a
|
type finished struct {
|
||||||
// natural dealt on top of a hand already in progress would otherwise settle,
|
Game string
|
||||||
// clear the felt, and take the other hand's stake down with it.
|
Blob []byte // the engine's whole state, shoe or phrase and all
|
||||||
hand := storage.LiveHand{Game: "blackjack", State: blob, Seed1: seed1, Seed2: seed2}
|
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
|
save := storage.SaveLiveHand
|
||||||
if fresh {
|
if f.Fresh {
|
||||||
save = storage.StartLiveHand
|
save = storage.StartLiveHand
|
||||||
}
|
}
|
||||||
if err := save(user, hand); err != nil {
|
if err := save(user, live); err != nil {
|
||||||
if errors.Is(err, storage.ErrHandInProgress) {
|
if errors.Is(err, storage.ErrHandInProgress) {
|
||||||
// Somebody was already sitting here. This hand was never seated, so the
|
// Somebody was already sitting here. This game was never seated, so the
|
||||||
// chips it staked go back: the player is in one hand, not two.
|
// chips it staked go back: the player is in one game, not two.
|
||||||
_ = storage.Award(user, st.Bet)
|
_ = storage.Award(user, f.Bet)
|
||||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a hand"})
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
|
||||||
return
|
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)
|
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
|
// 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.
|
// which reads as done and can be cleared. The other order loses them a win.
|
||||||
if err := storage.Award(user, st.Payout); err != nil {
|
if err := storage.Award(user, f.Payout); err != nil {
|
||||||
slog.Error("games: award", "user", user, "payout", st.Payout, "err", err)
|
slog.Error("games: award", "user", user, "payout", f.Payout, "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return tableView{}, false
|
||||||
}
|
}
|
||||||
if err := storage.RecordHand(storage.Hand{
|
if err := storage.RecordHand(storage.Hand{
|
||||||
MatrixUser: user, Game: "blackjack",
|
MatrixUser: user, Game: f.Game,
|
||||||
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
|
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
|
||||||
Outcome: string(st.Outcome), Seed1: seed1, Seed2: seed2,
|
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
|
||||||
}); err != nil {
|
}); 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 {
|
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 {
|
if err != nil {
|
||||||
slog.Error("games: table", "user", user, "err", err)
|
slog.Error("games: table", "user", user, "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
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
|
return
|
||||||
}
|
}
|
||||||
// A settled hand is gone from storage, so the table view has no hand to show —
|
// 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.
|
// but the browser still needs the final cards to animate the reveal onto.
|
||||||
if st.Phase == blackjack.PhaseDone {
|
if done {
|
||||||
hv := viewHand(st)
|
hv := viewHand(st)
|
||||||
v.Hand = &hv
|
v.Hand = &hv
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func TestAdventurePageRendersBoard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
for _, want := range []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
|
"Josie", "holymachina", "day 3", // live zone, shown while she's still in it
|
||||||
"Quack", "in town", "quiet for 2 days",
|
"Quack", "in town", "quiet for 2 days",
|
||||||
"/api/roster", // the client re-poll, so an open tab stays true
|
"/api/roster", // the client re-poll, so an open tab stays true
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
pages []string
|
pages []string
|
||||||
}{
|
}{
|
||||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
{"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)
|
tpls := make(map[string]*template.Template)
|
||||||
for _, set := range sets {
|
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
|
// 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.
|
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||||
if s.gamesReady() {
|
if s.gamesReady() {
|
||||||
mux.HandleFunc("GET /games", s.handleLobby)
|
s.casinoRoutes(mux)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.auth != nil {
|
if s.auth != nil {
|
||||||
|
|||||||
@@ -877,6 +877,207 @@ html[data-phase="night"] {
|
|||||||
50% { opacity: 1; transform: translateY(-1px); }
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.pete-card,
|
.pete-card,
|
||||||
.pete-card::after,
|
.pete-card::after,
|
||||||
@@ -884,6 +1085,10 @@ html[data-phase="night"] {
|
|||||||
.pete-dealer-think { animation: none; }
|
.pete-dealer-think { animation: none; }
|
||||||
.pete-card-inner { transition: none; }
|
.pete-card-inner { transition: none; }
|
||||||
.pete-hand[data-won="1"] .pete-card { animation: 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
@@ -62,8 +62,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// You cannot cash out mid-hand: the stake is already on the table.
|
// You cannot cash out mid-game: the stake is already on the table. `game` is
|
||||||
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.hand;
|
// 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;
|
if (buyBtn) buyBtn.disabled = v.chips + v.pending >= v.cap;
|
||||||
|
|
||||||
listeners.forEach(function (fn) { fn(v); });
|
listeners.forEach(function (fn) { fn(v); });
|
||||||
|
|||||||
583
internal/web/static/js/hangman.js
Normal file
583
internal/web/static/js/hangman.js
Normal 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -42,6 +42,22 @@
|
|||||||
</p>
|
</p>
|
||||||
</a>
|
</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}}
|
{{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="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">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
176
internal/web/templates/hangman.html
Normal file
176
internal/web/templates/hangman.html
Normal 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}}
|
||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
ttsTimeout = 60 * time.Second
|
||||||
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
|
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -147,9 +147,55 @@ A multi-session build. This section is the handover; read it before anything els
|
|||||||
that adventure news already set. Restarted, it logs
|
that adventure news already set. Restarted, it logs
|
||||||
`pete games: escrow loop started interval=3s`.
|
`pete games: escrow loop started interval=3s`.
|
||||||
|
|
||||||
|
- **Hangman, and it plays for chips.** *(2026-07-14. This revises §7's "Phase 2 —
|
||||||
|
no escrow": the decision was that a free game in a casino reads as a demo, so
|
||||||
|
hangman stakes chips like everything else and reuses the money path whole.)*
|
||||||
|
- **The gallows is the payout meter.** You pick a tier, stake, and get six
|
||||||
|
lives. Every wrong guess draws a limb *and* takes a tenth off the base
|
||||||
|
multiple — one event, shown as one event. Short phrases pay 2.6×, medium 2.0×,
|
||||||
|
long 1.6× (short is hardest: fewer letters, less to go on). Floored at 1×, so
|
||||||
|
a win never hands back less than the stake, and the rake still comes out of
|
||||||
|
winnings only.
|
||||||
|
- `internal/games/hangman` — the same pure reducer as blackjack, phrases
|
||||||
|
embedded (`phrases.txt`, 205 of them, video-game flavoured, lifted from
|
||||||
|
gogobee). `State.Pays()` is the number the felt quotes *and* the number
|
||||||
|
settle() lands on: they were briefly two sums and the table advertised a
|
||||||
|
pre-rake payout it didn't honour. One function now, and a test that walks a
|
||||||
|
game asserting the quote equals the payout at every step.
|
||||||
|
- **The browser never sees the phrase.** Cells carry the letter or an empty
|
||||||
|
string — not the letter with a hidden flag — and the phrase itself is only
|
||||||
|
added to the payload once the game is over and it decides nothing.
|
||||||
|
- Two things the storage layer already gave us for free, and one it didn't:
|
||||||
|
`game_live_hands` is keyed on the *player*, so "one game at a time" holds
|
||||||
|
across games with no new code (a live hangman 409s a blackjack deal). But
|
||||||
|
`table()` used to unmarshal any live row as a blackjack hand — which does not
|
||||||
|
*fail* on a hangman row, it just silently yields an empty hand. It now
|
||||||
|
dispatches on `live.Game`.
|
||||||
|
- `commit()` in games_play.go is the shared settle path (seat → pay → audit →
|
||||||
|
clear → touch). Both games go through it so neither re-derives an ordering
|
||||||
|
that took a while to get right. `casinoRoutes()` is likewise the single route
|
||||||
|
list, because devcasino_test.go has to wire its own mux and a second copy is
|
||||||
|
a copy that stops including the newest game.
|
||||||
|
- Driven in a real browser, win and loss: a 200 stake at 2.34× paid 455 and the
|
||||||
|
bar landed on it; six wrong took the stake and nothing more; a reload
|
||||||
|
mid-phrase brought back the board, the limbs, the multiple, the spent keys and
|
||||||
|
the stake on the spot. Two layout bugs only the browser could show: the lives
|
||||||
|
counter ran under the house rack, and the board wrapped a word early because
|
||||||
|
the rack's clearance padding was on the whole column instead of the one row
|
||||||
|
level with it.
|
||||||
|
|
||||||
### Next, in order
|
### Next, in order
|
||||||
|
|
||||||
1. Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below.
|
1. Phase 2's other half: **trivia**. Decided but not built: the question bank is
|
||||||
|
**prefetched from OpenTDB into a local table** (a per-question fetch in a web
|
||||||
|
game loop is a latency and rate-limit problem gogobee never had), through
|
||||||
|
`internal/safehttp`. It stakes chips too. The shape that fits the room is a
|
||||||
|
**ladder**: stake once, answer a run of questions, each right answer compounds
|
||||||
|
the multiple and a wrong one loses the lot, with the option to walk. Note the
|
||||||
|
real risk — trivia answers are googlable, so a tight per-question clock is the
|
||||||
|
only thing making a slow-but-correct answer worth less than a fast one. Score
|
||||||
|
the clock server-side; the browser's countdown is decoration.
|
||||||
|
2. Phase 3 (UNO), Phase 4 (hold'em) as below.
|
||||||
|
|
||||||
Still open on the table itself, none of it blocking: **split** isn't implemented (the
|
Still open on the table itself, none of it blocking: **split** isn't implemented (the
|
||||||
engine has no move for it), the felt is roomy at desktop widths with only one seat on
|
engine has no move for it), the felt is roomy at desktop widths with only one seat on
|
||||||
|
|||||||
Reference in New Issue
Block a user