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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user