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.
218 lines
7.1 KiB
Go
218 lines
7.1 KiB
Go
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)
|
|
}
|