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

@@ -44,13 +44,7 @@ func TestDevCasino(t *testing.T) {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
mux.HandleFunc("GET /games", s.handleLobby)
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)
s.casinoRoutes(mux)
ln, err := net.Listen("tcp", addr)
if err != nil {
@@ -63,7 +57,7 @@ func TestDevCasino(t *testing.T) {
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}
t.Cleanup(func() { _ = srv.Close() })

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)
}

View 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]
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"pete/internal/games/blackjack"
"pete/internal/games/hangman"
"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: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
{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.
@@ -70,6 +70,30 @@ type gamesPage struct {
RakePct int
Soon []gameTeaser
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.
@@ -96,6 +120,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
RakePct: int(blackjack.DefaultRules().RakePct * 100),
Soon: comingSoon,
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))
}
func (s *Server) handleHangman(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "hangman", s.gamesPage(r))
}

View File

@@ -3,6 +3,7 @@ package web
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
@@ -11,6 +12,7 @@ import (
"pete/internal/games/blackjack"
"pete/internal/games/cards"
"pete/internal/games/hangman"
"pete/internal/storage"
)
@@ -162,18 +164,31 @@ func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
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 {
Chips int64 `json:"chips"`
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
Cap int64 `json:"cap"`
Hand *handView `json:"hand,omitempty"`
Events []eventView `json:"events,omitempty"` // only on a move, for the animation
Rake float64 `json:"rake_pct"`
Chips int64 `json:"chips"`
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
Cap int64 `json:"cap"`
Game string `json:"game,omitempty"` // "blackjack" | "hangman", if one is live
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) {
st, err := storage.Chips(user)
if err != nil {
@@ -193,16 +208,40 @@ func (s *Server) table(user string) (tableView, error) {
if err != nil {
return tableView{}, err
}
var hand blackjack.State
if err := json.Unmarshal(live.State, &hand); err != nil {
// A hand we can't read is a hand nobody can play. Rather than wedge the
// player out of the casino forever, drop it and tell them.
slog.Error("games: unreadable live hand, discarding", "user", user, "err", err)
_ = storage.ClearLiveHand(user)
return v, nil
// Dispatch on the game the row says it is. Unmarshalling a hangman state into
// a blackjack one would not fail — JSON is happy to fill nothing in — it would
// just quietly produce an empty hand, which is the worst of both.
v.Game = live.Game
switch live.Game {
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)
v.Hand = &hv
return v, nil
}
// 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
}
@@ -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)
}
// persist writes the hand back and answers the browser. A finished hand pays
// out, goes in the audit log, and leaves the felt; an unfinished one is saved
// as it stands, so a redeploy mid-hand is survivable.
//
// fresh marks a hand that has just been dealt, which is the one case where the
// 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
}
// The games a live row can be. They're the storage key, so they're constants:
// a typo here is a game nobody can ever load again.
const (
gameBlackjack = "blackjack"
gameHangman = "hangman"
)
// Seat the hand before doing anything else with it — even one that is already
// over, because a natural settles the instant it's dealt. The insert is what
// enforces one hand at a time, and it has to happen for *every* new hand: a
// natural dealt on top of a hand already in progress would otherwise settle,
// clear the felt, and take the other hand's stake down with it.
hand := storage.LiveHand{Game: "blackjack", State: blob, Seed1: seed1, Seed2: seed2}
// finished is what commit needs to know about a game it's writing back: enough
// to settle it, and nothing about how it's played. Both engines produce one.
type finished struct {
Game string
Blob []byte // the engine's whole state, shoe or phrase and all
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
if fresh {
if f.Fresh {
save = storage.StartLiveHand
}
if err := save(user, hand); err != nil {
if err := save(user, live); err != nil {
if errors.Is(err, storage.ErrHandInProgress) {
// Somebody was already sitting here. This hand was never seated, so the
// chips it staked go back: the player is in one hand, not two.
_ = storage.Award(user, st.Bet)
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a hand"})
return
// Somebody was already sitting here. This game was never seated, so the
// chips it staked go back: the player is in one game, not two.
_ = storage.Award(user, f.Bet)
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
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)
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
// 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.
if err := storage.Award(user, st.Payout); err != nil {
slog.Error("games: award", "user", user, "payout", st.Payout, "err", err)
if err := storage.Award(user, f.Payout); err != nil {
slog.Error("games: award", "user", user, "payout", f.Payout, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
return tableView{}, false
}
if err := storage.RecordHand(storage.Hand{
MatrixUser: user, Game: "blackjack",
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
Outcome: string(st.Outcome), Seed1: seed1, Seed2: seed2,
MatrixUser: user, Game: f.Game,
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
}); 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 {
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 {
slog.Error("games: table", "user", user, "err", err)
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
}
// 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.
if st.Phase == blackjack.PhaseDone {
if done {
hv := viewHand(st)
v.Hand = &hv
}

View File

@@ -181,7 +181,7 @@ func TestAdventurePageRendersBoard(t *testing.T) {
}
body := w.Body.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
"Quack", "in town", "quiet for 2 days",
"/api/roster", // the client re-poll, so an open tab stays true

View File

@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
pages []string
}{
{"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)
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
// one, no player can be named to gogobee's ledger and the tables stay shut.
if s.gamesReady() {
mux.HandleFunc("GET /games", s.handleLobby)
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)
s.casinoRoutes(mux)
}
if s.auth != nil {

View File

@@ -877,6 +877,207 @@ html[data-phase="night"] {
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) {
.pete-card,
.pete-card::after,
@@ -884,6 +1085,10 @@ html[data-phase="night"] {
.pete-dealer-think { animation: none; }
.pete-card-inner { transition: 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

View File

@@ -62,8 +62,10 @@
}
}
// You cannot cash out mid-hand: the stake is already on the table.
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.hand;
// You cannot cash out mid-game: the stake is already on the table. `game` is
// 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;
listeners.forEach(function (fn) { fn(v); });

View 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();
});
})();

View File

@@ -42,6 +42,22 @@
</p>
</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}}
<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">

View 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}}

View File

@@ -19,7 +19,7 @@ import (
)
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
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
)