Files
Pete/internal/web/games_solitaire.go
prosolis b814f936a8 solitaire: detect a won board and finish it in one press
A drained, all-face-up board is a guaranteed clear that auto() sweeps home
in a single cascade, but the only way to trigger it was double-clicking
thirty cards home by hand. Add State.Won(), surface it in the view, and
swap the ordinary controls for one pulsing finish button when it's true.
2026-07-15 18:50:43 -07:00

289 lines
9.7 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"`
Won bool `json:"won"` // every card face up, stock and waste empty: one press finishes it
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(),
Won: g.Won(),
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)
}