games: a gallows you can bet on

Hangman, and it plays for chips — which the plan had down as a free game, on
the grounds that trivia has no euro coupling in gogobee. But a free game in a
casino reads as a demo, so it stakes like everything else.

The idea that makes it a casino game rather than hangman with a wager stapled
on: the gallows is the payout meter. A wrong guess draws a limb *and* takes a
tenth off what a win is worth, because those are the same event and showing
them as one is the entire reason to bet on this. Short phrases pay 2.6x (fewer
letters, less to go on), long ones 1.6x — the floor is 1x, so a win never hands
back less than the stake, and the rake still comes out of winnings only.

State.Pays() is the number the felt quotes and the number settle() lands on.
They were briefly two sums, and the table spent an afternoon advertising a
pre-rake payout it didn't honour.

Two things the storage layer had already decided for us, and one it hadn't:
game_live_hands is keyed on the player, so "one game at a time" holds across
games for free (a live hangman 409s a blackjack deal, stake intact). But
table() unmarshalled every live row as a blackjack hand, which does not fail on
a hangman row — it quietly yields an empty hand. It dispatches on the game now.

commit() is the settle path both games share, and casinoRoutes() the one route
list, since the dev rig wires its own mux and a second copy is a copy that stops
including the newest game.

Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on
it; six wrong took the stake and no more; a reload mid-phrase brought back the
board, the limbs, the multiple, the spent keys and the chips on the spot. The
browser found the two bugs a Go test can't — a lives counter under the house
rack, and a word wrapping early because the rack's clearance was on the whole
column instead of the one row beside it.
This commit is contained in:
prosolis
2026-07-14 01:19:05 -07:00
parent d29a311eff
commit fe2195e85f
19 changed files with 2678 additions and 81 deletions

View File

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