package web import ( "encoding/json" "errors" "log/slog" "math/rand/v2" "net/http" "pete/internal/games/blackjack" "pete/internal/games/cards" "pete/internal/games/klondike" "pete/internal/storage" ) // Solitaire, played for chips. Vegas scoring: you buy the deck, and every card // you get home pays a slice of it back. // // The withheld information here is bigger than blackjack's single hole card — // it's the stock and every face-down card in the tableau, which between them are // most of the deck. So the view sends *counts* for both: a column says how many // cards are face-down under it, never which. A browser that held the stock would // be a browser that knows whether the next pull is worth taking, and this game is // nothing but that decision, repeated. // // The events, on the other hand, need no filtering at all, and that's worth // saying out loud because blackjack's do. Every card a klondike event carries is // a card the move itself just turned face up: the draw puts cards in the waste, // the flip turns a column's top card over, a move carries cards that were already // face up. There is no event here that mentions a card the player isn't now // looking at. // solPileView is one tableau column: how deep the face-down stack is, and the // run sitting face up on top of it. type solPileView struct { Down int `json:"down"` Up []cardView `json:"up"` } // solFoundView is one foundation. Only the top card matters — it's the only one // that can be played back off — so it's the only one sent, with a count for the // height of the pile. type solFoundView struct { Suit string `json:"suit"` // the glyph, so the empty pile can show what it wants Red bool `json:"red"` N int `json:"n"` Top *cardView `json:"top,omitempty"` } // solitaireView is a board as its player may see it. type solitaireView struct { Tier klondike.Tier `json:"tier"` Stock int `json:"stock"` // how many cards are left in it, not which Waste []cardView `json:"waste"` // the top few, in the order they were turned WasteN int `json:"waste_n"` Table []solPileView `json:"table"` Found []solFoundView `json:"found"` Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited Moves int `json:"moves"` CanAuto bool `json:"can_auto"` Home int `json:"home"` // cards on the foundations PerCard float64 `json:"per_card"` // what one more is worth BreakEven int `json:"break_even"` // how many gets you square with the house Bet int64 `json:"bet"` Stands int64 `json:"stands"` // what cashing out right now actually pays Phase string `json:"phase"` Outcome string `json:"outcome,omitempty"` Payout int64 `json:"payout,omitempty"` Rake int64 `json:"rake,omitempty"` Net int64 `json:"net"` } // wasteShown is how much of the waste the felt fans out. Three, because that is // what a three-card draw puts down and the rest of the pile is just a pile. const wasteShown = 3 func viewSolitaire(g klondike.State) solitaireView { v := solitaireView{ Tier: g.Tier, Stock: len(g.Stock), WasteN: len(g.Waste), Passes: g.PassesLeft(), Moves: g.Moves, CanAuto: g.CanAuto(), Home: g.Home(), PerCard: g.PerCard(), BreakEven: g.Tier.BreakEven(), Bet: g.Bet, // What cashing out right now would actually land on the stack, rake already // out of it. The pre-rake figure would have the felt advertising a number // 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(), } from := len(g.Waste) - wasteShown if from < 0 { from = 0 } for _, c := range g.Waste[from:] { v.Waste = append(v.Waste, viewCard(c)) } v.Table = make([]solPileView, klondike.Piles) for i, p := range g.Table { v.Table[i] = solPileView{Down: len(p.Down)} for _, c := range p.Up { v.Table[i].Up = append(v.Table[i].Up, viewCard(c)) } } v.Found = make([]solFoundView, klondike.Foundations) for i, f := range g.Found { suit := cards.Suit(i) fv := solFoundView{Suit: suit.String(), Red: suit == cards.Hearts || suit == cards.Diamonds, N: len(f)} if len(f) > 0 { top := viewCard(f[len(f)-1]) fv.Top = &top } v.Found[i] = fv } return v } // solEventView is one thing the table animates. See the note at the top: unlike // blackjack's, these need nothing stripped out of them. type solEventView struct { Kind string `json:"kind"` Cards []cardView `json:"cards,omitempty"` From string `json:"from,omitempty"` To string `json:"to,omitempty"` Home int `json:"home"` Pays int64 `json:"pays"` } func viewSolEvents(evs []klondike.Event) []solEventView { out := make([]solEventView, 0, len(evs)) for _, e := range evs { v := solEventView{Kind: e.Kind, From: e.From, To: e.To, Home: e.Home, Pays: e.Pays} for _, c := range e.Cards { v.Cards = append(v.Cards, viewCard(c)) } out = append(out, v) } return out } // handleSolitaireStart takes the stake and deals the board. Same order as a // blackjack deal: the chips are staked first, in the same statement that checks // they exist, so two starts fired at once cannot buy the same deck twice. func (s *Server) handleSolitaireStart(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 := klondike.TierBySlug(req.Tier) if err != nil { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a deal"}) 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 deck"}) return } slog.Error("games: solitaire 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 := klondike.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng) if err != nil { // The board never happened, so the stake never should have left. _ = storage.Award(user, req.Bet) slog.Error("games: solitaire deal", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } s.persistSolitaire(w, user, g, evs, seed1, seed2, true) } // solitaireErrors are the illegal moves a player makes by playing, rather than // by tampering. Each gets said back to them in words, because "that move isn't // legal" over a board with 60 legal-looking targets on it is not an answer. var solitaireErrors = map[error]string{ klondike.ErrWontGo: "that card doesn't go there", klondike.ErrNotASequence: "you can only lift a run that goes down in rank and alternates colour", klondike.ErrEmptyPile: "there's nothing there", klondike.ErrNoDraw: "the stock is empty", klondike.ErrNoPasses: "that was your last pass through the stock", klondike.ErrNothingHome: "nothing can go home right now", klondike.ErrGameOver: "that board is finished", } // handleSolitaireMove plays one move: a draw, a card moved, a card sent home, an // auto-finish, or cashing the board in. func (s *Server) handleSolitaireMove(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var move klondike.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: solitaire load", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if live.Game != gameSolitaire { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"}) return } var g klondike.State if err := json.Unmarshal(live.State, &g); err != nil { slog.Error("games: unreadable solitaire board", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } next, evs, err := klondike.ApplyMove(g, move) if err != nil { msg, known := solitaireErrors[err] if !known { msg = "that move isn't legal here" } writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg}) return } s.persistSolitaire(w, user, next, evs, live.Seed1, live.Seed2, false) } // persistSolitaire writes the board back and answers the browser. func (s *Server) persistSolitaire(w http.ResponseWriter, user string, g klondike.State, evs []klondike.Event, seed1, seed2 uint64, fresh bool) { blob, err := json.Marshal(g) if err != nil { slog.Error("games: marshal solitaire", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } done := g.Phase == klondike.PhaseDone v, ok := s.commit(w, user, finished{ Game: gameSolitaire, 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 board is gone from storage, so the table has none to show — but // the browser still needs the final one to animate the last cards onto. if done { sv := viewSolitaire(g) v.Solitaire = &sv } v.SolEvents = viewSolEvents(evs) writeJSON(w, v) }