// 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 !Guessable(r) { s.Shown[i] = true } } return s, []Event{{Kind: "start"}}, nil } // Guessable reports whether a rune is one you'd guess: a letter or a digit. // Everything else is scaffolding and is shown from the start. // // Exported because the renderer needs the same answer — a rune you'd guess is a // rune that gets a tile to guess it into. Asking the drawing side to decide that // for itself is how you get a board with no tile for a letter the engine is // waiting on, and a phrase that cannot be finished. func Guessable(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 || !Guessable(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 Guessable(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 }