Files
Pete/internal/web/games_solitaire.go
prosolis 5ca056bf20 games: you buy the deck, and win it back a card at a time
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.
2026-07-14 01:40:14 -07:00

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)
}