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.
396 lines
12 KiB
Go
396 lines
12 KiB
Go
// 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
|
|
}
|