The trivia ladder handled a walk before it looked at the clock, so the timeout only ever bit if the browser volunteered it. Sit on a question, look it up, answer if you find it and walk if you don't, and you never lose a ladder. The clock is now the first thing that happens to a move. The house's chip rack was wired up as bet buttons on blackjack and hangman: it's four spans with data-chip on them and nothing said the handler only wanted the real ones. Clicking the house's money raised your bet. Hangman had two definitions of "a letter you'd guess" — unicode in the engine, ASCII in the renderer — and a phrase with an accent in it would have had no tile to fill and no key to fill it with. One definition now. Plus: trivia's countdown no longer freezes at zero when the server turns down a timeout report it was early for, questions whose wrong answer decodes into the right one are dropped at the door, and hangman bets on PeteFX's spot like every other table instead of its own copy of it.
401 lines
12 KiB
Go
401 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 !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
|
|
}
|