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.
211 lines
6.8 KiB
Go
211 lines
6.8 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: 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)
|
|
}
|