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.
289 lines
9.7 KiB
Go
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)
|
|
}
|