Solitaire, Vegas rules — the only shape solitaire has ever had as a gambling game. You don't win or lose the deal: the stake buys the deck outright, and every card you get home to a foundation pays a fifty-second of the tier's multiple back. Cash the board whenever you like and keep what you've banked, so a board that has gone dead is a decision rather than a wall. No undo: the stake is spent the moment the deck is bought, and an undo would be a way to walk a losing board backwards until it wins. Three deals, and the two dials are the whole difficulty of Klondike. Patient draws one with unlimited passes and pays 1.4x, so it takes 38 cards home to get square. Vegas draws three, three times round, 2.2x, square at 24. Cutthroat draws three and gives you one pass, 3.4x, square at 16 — most of those boards never clear, and you're ahead long before they would. internal/games/klondike is the same pure reducer as the other two, and Pays() is one function for the same reason hangman's is. Two fuzzers hold the deck together: no sequence of moves can lose or duplicate a card, and the board stays well-formed. They earned their keep immediately — the first thing they caught was a recycle that reversed the waste. It flips as a block, so the card drawn first comes out first, and reversing it would have dealt a different game on every pass and quietly broken the seed in the audit log. The browser never sees the stock or a face-down card, which here is most of the deck rather than blackjack's one hole card: a column sends how many cards are under it, never which. The table re-renders and animates the difference. Blackjack plays back a script because a hand only ever grows at one end; solitaire moves runs from anywhere to anywhere and an auto-finish moves eleven cards at once, so a script of "append this card there" would be a second engine over here and it would be the one that's wrong. Instead the board on screen is always exactly the board the server says exists, and each card is played from where it just was to where it now is. The events supply only what a diff can't: where a newly-revealed card came from, and what the board is worth. The rules are mirrored in JS on purpose, and only to light up the columns a held card can go to. Being shown where a card goes is the game teaching you; being told no after you commit is the game scolding you. The server still decides, and a disagreement snaps the board back to what it says. Two things came out into the open rather than being copied, which is the rule this room runs on: casino-cards.js (the deck — faces, pips, the flip) and PeteFX.spot() (the pile of chips and the number under it, which now owns the rule that the number is a readout of the pile). Blackjack uses both. Not yet driven in a browser.
287 lines
9.6 KiB
Go
287 lines
9.6 KiB
Go
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)
|
|
}
|