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: hangman.Guessable(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 } // 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) }