main.go

// Command solitaire is a Klondike solitaire built on gophics — one codebase
// for desktop, web, and (via shell/mobile) iOS/Android. The rules engine is the
// pure, exhaustively-tested examples/solitaire/klondike package; this command is
// the board: a single widget.Canvas that draws the cards (no image assets) and
// does its own drag/drop and hit-testing.
package main

import (
	"log"
	"time"

	"golang.org/x/image/font/gofont/gobold"
	"golang.org/x/image/font/gofont/goregular"

	"github.com/doug/gophics/app"
	"github.com/doug/gophics/geom"
)

func main() {
	err := app.Run(Solitaire{Seed: time.Now().UnixNano()}, app.Config{
		Title:        "Solitaire",
		Size:         geom.Size{W: 920, H: 720},
		Background:   colFelt,
		Font:         goregular.TTF,
		FontFamilies: map[string][]byte{"bold": gobold.TTF},
	})
	if err != nil {
		log.Fatal(err)
	}
}

board.go

package main

import (
	"slices"

	"github.com/doug/gophics/examples/solitaire/klondike"
	"github.com/doug/gophics/geom"
)

// Board is the pure geometry of a Klondike layout for a given surface size and
// game — every card's rectangle, shared by rendering and hit-testing so they
// can never disagree. It holds no game state; recompute it each frame (cheap).
type Board struct {
	CardW, CardH float32
	Stock, Waste geom.Rect
	Foundations  [4]geom.Rect
	Slot         [7]geom.Rect   // base rect of each tableau column (empty slot / first card)
	Tableaus     [7][]geom.Rect // one rect per card, fanned down
}

// Layout computes the board for size and the current game.
func Layout(size geom.Size, g *klondike.Game) Board {
	const margin, gapFrac, fanUpFrac, fanDownFrac = 14, 0.16, 0.30, 0.11
	usable := size.W - 2*margin
	cardW := usable / (7 + 6*gapFrac)
	gap := cardW * gapFrac
	cardH := cardW * 1.4
	colX := func(i int) float32 { return margin + float32(i)*(cardW+gap) }
	rect := func(x, y float32) geom.Rect { return geom.RectXYWH(x, y, cardW, cardH) }

	var b Board
	b.CardW, b.CardH = cardW, cardH
	topY := float32(margin)
	b.Stock = rect(colX(0), topY)
	b.Waste = rect(colX(1), topY)
	for i := range 4 {
		b.Foundations[i] = rect(colX(3+i), topY)
	}

	tableTop := topY + cardH + gap*1.4
	fanUp, fanDown := cardH*fanUpFrac, cardH*fanDownFrac
	for j := range 7 {
		x := colX(j)
		b.Slot[j] = rect(x, tableTop)
		col := g.Tableau(j)
		y := tableTop
		for k := range col {
			b.Tableaus[j] = append(b.Tableaus[j], rect(x, y))
			if col[k].Up {
				y += fanUp
			} else {
				y += fanDown
			}
		}
	}
	return b
}

// Hit returns the pile and — for a tableau — the index of the topmost card at p.
// For waste/foundation/stock the returned index is not meaningful (use the top).
// idx == -1 means an empty tableau slot.
func (b Board) Hit(p geom.Pt) (pile klondike.Pile, idx int, ok bool) {
	switch {
	case b.Stock.Contains(p):
		return klondike.Pile{Kind: klondike.Stock}, 0, true
	case b.Waste.Contains(p):
		return klondike.Pile{Kind: klondike.Waste}, 0, true
	}
	for i := range 4 {
		if b.Foundations[i].Contains(p) {
			return klondike.Pile{Kind: klondike.Foundation, Index: i}, 0, true
		}
	}
	for j := range 7 {
		rects := b.Tableaus[j]
		for k, rect := range slices.Backward(rects) {
			if rect.Contains(p) {
				return klondike.Pile{Kind: klondike.Tableau, Index: j}, k, true
			}
		}
		if len(rects) == 0 && b.Slot[j].Contains(p) {
			return klondike.Pile{Kind: klondike.Tableau, Index: j}, -1, true
		}
	}
	return klondike.Pile{}, 0, false
}

// DropTarget is a candidate landing spot for a dragged run.
type DropTarget struct {
	Pile klondike.Pile
	Rect geom.Rect
}

// DropTargets returns the foundations and the landing rect of each tableau
// column (its top card, or the empty slot), for overlap-based drop resolution.
func (b Board) DropTargets(g *klondike.Game) []DropTarget {
	out := make([]DropTarget, 0, 11)
	for i := range 4 {
		out = append(out, DropTarget{klondike.Pile{Kind: klondike.Foundation, Index: i}, b.Foundations[i]})
	}
	for j := range 7 {
		r := b.Slot[j]
		if n := len(b.Tableaus[j]); n > 0 {
			r = b.Tableaus[j][n-1]
		}
		out = append(out, DropTarget{klondike.Pile{Kind: klondike.Tableau, Index: j}, r})
	}
	return out
}

// overlapArea is the area of the intersection of a and b (0 if disjoint).
func overlapArea(a, c geom.Rect) float32 {
	x := min(a.Max.X, c.Max.X) - max(a.Min.X, c.Min.X)
	y := min(a.Max.Y, c.Max.Y) - max(a.Min.Y, c.Min.Y)
	if x <= 0 || y <= 0 {
		return 0
	}
	return x * y
}

cards_draw.go

package main

import (
	"fmt"

	"github.com/doug/gophics/examples/solitaire/klondike"
	"github.com/doug/gophics/geom"
	"github.com/doug/gophics/paint"
)

var (
	colFelt      = paint.RGB(0.10, 0.44, 0.30)
	colFeltHi    = paint.RGB(0.13, 0.50, 0.34)
	colFeltLo    = paint.RGB(0.06, 0.33, 0.22)
	colFace      = paint.RGB(0.99, 0.99, 0.98)
	colEdge      = paint.Color{R: 0, G: 0, B: 0, A: 0.10} // subtle card outline
	colShadow    = paint.Color{R: 0, G: 0, B: 0, A: 0.28}
	colRed       = paint.RGB(0.79, 0.13, 0.17)
	colBlack     = paint.RGB(0.11, 0.12, 0.15)
	colBack1     = paint.RGB(0.28, 0.42, 0.72)
	colBack2     = paint.RGB(0.12, 0.22, 0.46)
	colBack3     = paint.Color{R: 0.60, G: 0.72, B: 0.98, A: 0.55} // light argyle diamond (over gradient)
	colBack4     = paint.Color{R: 0.08, G: 0.16, B: 0.40, A: 0.40} // dark argyle diamond
	colBackFrame = paint.Color{R: 0.85, G: 0.90, B: 1.0, A: 0.35}  // hairline back frame
	colSlot      = paint.Color{R: 1, G: 1, B: 1, A: 0.14}
)

func suitColor(s klondike.Suit) paint.Color {
	if s.Red() {
		return colRed
	}
	return colBlack
}

// suitGlyph returns the Unicode pip; goregular includes all four.
func suitGlyph(s klondike.Suit) string {
	switch s {
	case klondike.Club:
		return "♣"
	case klondike.Diamond:
		return "♦"
	case klondike.Heart:
		return "♥"
	default:
		return "♠"
	}
}

func rankLabel(r uint8) string {
	switch r {
	case 1:
		return "A"
	case 11:
		return "J"
	case 12:
		return "Q"
	case 13:
		return "K"
	default:
		return fmt.Sprintf("%d", r)
	}
}

// drawCard paints one card in r (face up, or a gradient back), with a soft
// drop shadow for depth.
func drawCard(c paint.Canvas, r geom.Rect, card klondike.Card) {
	sz := r.Dx()
	paint.DropShadow(c, r, sz*0.08, geom.Pt{Y: sz * 0.02}, sz*0.05, colShadow)
	drawCardBody(c, r, card)
}

// drawCardFanned paints a card that is mostly hidden under the next one in a
// fan, showing only a strip at the top.
//
// It exists because the back's inset frame is a decoration for a card you can
// see all of. On a strip a few pixels tall only its top edge and two severed
// legs survive, and a column of those reads as loose outlines lying over the
// cards rather than as a deck. Real stacked cards show their pattern and their
// edge, so that is what this draws.
func drawCardFanned(c paint.Canvas, r geom.Rect, card klondike.Card) {
	sz := r.Dx()
	paint.DropShadow(c, r, sz*0.08, geom.Pt{Y: sz * 0.02}, sz*0.05, colShadow)
	if !card.Up {
		drawCardBackNoFrame(c, r, sz*0.08)
		return
	}
	drawCardBody(c, r, card)
}

// drawCardBody paints the card without a shadow (used for the many win-cascade
// trail stamps, where per-card shadows would be too costly).
func drawCardBody(c paint.Canvas, r geom.Rect, card klondike.Card) {
	sz := r.Dx()
	rad := sz * 0.08
	if !card.Up {
		drawCardBack(c, r, rad)
		return
	}
	c.FillRRect(r, rad, colFace)
	c.StrokeRRect(r, rad, 1, colEdge)

	col := suitColor(card.Suit)
	glyph := suitGlyph(card.Suit)
	rl := rankLabel(card.Rank)

	// Two opposing corner indices (top-left, and bottom-right rotated 180°),
	// like a real deck — the second one shows on face-up tops (waste/foundation).
	drawCorner(c, r, rl, glyph, col)
	cx, cy := r.Min.X+sz/2, r.Min.Y+r.Dy()/2
	c.PushTransform(paint.Transform{Rotation: pi, PivotX: cx, PivotY: cy})
	drawCorner(c, r, rl, glyph, col)
	c.PopTransform()

	switch {
	case card.Rank >= 2 && card.Rank <= 10:
		// The traditional pip arrangement: N symbols laid out in the standard
		// grid, with the lower-half pips rotated 180° as on a printed card.
		xs := [3]float32{r.Min.X + sz*0.30, cx, r.Max.X - sz*0.30}
		ps := sz * 0.19
		for _, p := range pipLayout[card.Rank] {
			y := r.Min.Y + r.Dy()*p.y
			pip(c, glyph, xs[p.col], y, ps, col, p.y > 0.5)
		}
	case card.Rank == 1:
		// Ace: one large central pip.
		pip(c, glyph, cx, cy, sz*0.5, col, false)
	default:
		// Court cards: a large rank letter over its suit.
		centerGlyph(c, rl, cx, r.Min.Y+r.Dy()*0.46, sz*0.5, col)
		centerGlyph(c, glyph, cx, r.Min.Y+r.Dy()*0.72, sz*0.26, col)
	}
}

// drawCardBack paints a face-down card: a blue gradient overlaid with an argyle
// diamond lattice (two alternating translucent tones so the gradient still shows
// through for depth), inside a hairline frame — a classic playing-card back. The
// lattice is a rotated square grid clipped to the card's rounded rect, so the
// squares read as diamonds and the pattern runs edge to edge like a real deck.
func drawCardBack(c paint.Canvas, r geom.Rect, rad float32) {
	drawCardBackNoFrame(c, r, rad)
	sz := r.Dx()
	m := sz * 0.06
	c.StrokeRRect(geom.RectXYWH(r.Min.X+m, r.Min.Y+m, r.Dx()-2*m, r.Dy()-2*m), rad*0.7, 1, colBackFrame)
}

// drawCardBackNoFrame paints the back's gradient and lattice without the inset
// frame; see drawCardFanned for why a fanned card omits it.
func drawCardBackNoFrame(c paint.Canvas, r geom.Rect, rad float32) {
	sz := r.Dx()
	c.FillRRectGradient(r, rad, colBack1, colBack2, false)

	cx, cy := r.Min.X+r.Dx()/2, r.Min.Y+r.Dy()/2
	c.PushClipRRect(r, rad)
	c.PushTransform(paint.Transform{Rotation: 0.7853982, PivotX: cx, PivotY: cy})
	cell := sz * 0.26
	reach := r.Dx() + r.Dy() // covers the rotated card with margin; the clip trims the overflow
	n := int(reach/cell) + 2
	x0 := cx - float32(n)*cell/2
	y0 := cy - float32(n)*cell/2
	for iy := range n {
		for ix := range n {
			col := colBack3
			if (ix+iy)%2 == 1 {
				col = colBack4
			}
			c.FillRect(geom.RectXYWH(x0+float32(ix)*cell, y0+float32(iy)*cell, cell, cell), col)
		}
	}
	c.PopTransform()
	c.PopClip()
}

// drawCorner paints the top-left rank index over a small pip. Kept compact so a
// fanned card still reveals it (the fan offset is ~0.42·sz — see Layout).
func drawCorner(c paint.Canvas, r geom.Rect, rl, glyph string, col paint.Color) {
	sz := r.Dx()
	centerGlyph(c, rl, r.Min.X+sz*0.15, r.Min.Y+sz*0.19, sz*0.20, col)
	centerGlyph(c, glyph, r.Min.X+sz*0.15, r.Min.Y+sz*0.36, sz*0.15, col)
}

// pip draws a suit symbol centered at (ax, ay), optionally rotated 180° (as the
// lower-half pips are printed on a real card).
func pip(c paint.Canvas, glyph string, ax, ay, size float32, col paint.Color, flip bool) {
	if flip {
		c.PushTransform(paint.Transform{Rotation: pi, PivotX: ax, PivotY: ay})
		centerGlyph(c, glyph, ax, ay, size, col)
		c.PopTransform()
		return
	}
	centerGlyph(c, glyph, ax, ay, size, col)
}

// centerGlyph draws s centered on (ax, ay). The Canvas has no measure API, so it
// uses fixed fractions calibrated for goregular's near-square suit glyphs and
// digits (baseline-left positioning; pos.Y is the baseline).
func centerGlyph(c paint.Canvas, s string, ax, ay, size float32, col paint.Color) {
	c.TextIn("", s, geom.Pt{X: ax - size*0.30, Y: ay + size*0.36}, size, col)
}

const pi = 3.14159265

// pipPos is a suit-symbol slot: column (0=left, 1=center, 2=right) and a
// vertical fraction of the card height. Lower-half slots (y>0.5) render rotated.
type pipPos struct {
	col int
	y   float32
}

// pipLayout is the standard printed arrangement of N suit symbols for ranks
// 2–10.
var pipLayout = map[uint8][]pipPos{
	2:  {{1, 0.20}, {1, 0.80}},
	3:  {{1, 0.20}, {1, 0.50}, {1, 0.80}},
	4:  {{0, 0.20}, {2, 0.20}, {0, 0.80}, {2, 0.80}},
	5:  {{0, 0.20}, {2, 0.20}, {1, 0.50}, {0, 0.80}, {2, 0.80}},
	6:  {{0, 0.20}, {2, 0.20}, {0, 0.50}, {2, 0.50}, {0, 0.80}, {2, 0.80}},
	7:  {{0, 0.20}, {2, 0.20}, {1, 0.35}, {0, 0.50}, {2, 0.50}, {0, 0.80}, {2, 0.80}},
	8:  {{0, 0.20}, {2, 0.20}, {1, 0.35}, {0, 0.50}, {2, 0.50}, {1, 0.65}, {0, 0.80}, {2, 0.80}},
	9:  {{0, 0.20}, {2, 0.20}, {0, 0.40}, {2, 0.40}, {1, 0.50}, {0, 0.60}, {2, 0.60}, {0, 0.80}, {2, 0.80}},
	10: {{0, 0.20}, {2, 0.20}, {1, 0.30}, {0, 0.40}, {2, 0.40}, {0, 0.60}, {2, 0.60}, {1, 0.70}, {0, 0.80}, {2, 0.80}},
}

// drawStamp paints a cheap card for the win-cascade trail: just the face, a
// hairline edge, and the top-left index — enough to read as a streaking card
// without the cost of a full pip layout across hundreds of stamps per frame.
func drawStamp(c paint.Canvas, r geom.Rect, card klondike.Card) {
	sz := r.Dx()
	c.FillRRect(r, sz*0.08, colFace)
	c.StrokeRRect(r, sz*0.08, 1, colEdge)
	col := suitColor(card.Suit)
	drawCorner(c, r, rankLabel(card.Rank), suitGlyph(card.Suit), col)
}

// drawEmpty paints a ghost slot where a pile can be placed.
func drawEmpty(c paint.Canvas, r geom.Rect) {
	c.StrokeRRect(r, r.Dx()*0.08, 1.5, colSlot)
}

klondike/autocomplete.go

package klondike

// CanAutoComplete reports whether the game can be finished automatically: the
// stock and waste are empty and every tableau card is face up, so nothing is
// hidden and greedy foundation play is guaranteed to win. This is the point a
// "Finish" affordance should appear.
func (g *Game) CanAutoComplete() bool {
	if len(g.stock) != 0 || len(g.waste) != 0 || g.Won() {
		return false
	}
	for i := range g.tab {
		for _, c := range g.tab[i] {
			if !c.Up {
				return false
			}
		}
	}
	return true
}

// AutoComplete plays every remaining card to the foundations by repeatedly
// sending each tableau's top card up until the game is won or wedged. When
// CanAutoComplete was true this always reaches a win.
func (g *Game) AutoComplete() {
	for !g.Won() {
		moved := false
		for i := range g.tab {
			if g.AutoToFoundation(Pile{Kind: Tableau, Index: i}) {
				moved = true
			}
		}
		if g.AutoToFoundation(Pile{Kind: Waste}) {
			moved = true
		}
		if !moved {
			break
		}
	}
}

klondike/card.go

// Package klondike is the pure rules engine for Klondike solitaire: deck,
// piles, legal moves, an O(1) undo journal, and win detection — with no
// rendering, no framework imports, and no global state, so it is exhaustively
// unit-testable with plain `go test`. The example's UI (a widget.Canvas board)
// is built on top of this.
package klondike

import "fmt"

// Suit of a card. The foundation and tableau rules care only about a suit's
// color (red = Diamond/Heart), never its identity.
type Suit uint8

const (
	Club Suit = iota
	Diamond
	Heart
	Spade
)

// Red reports whether the suit is red (Diamond or Heart).
func (s Suit) Red() bool { return s == Diamond || s == Heart }

func (s Suit) String() string {
	switch s {
	case Club:
		return "C"
	case Diamond:
		return "D"
	case Heart:
		return "H"
	default:
		return "S"
	}
}

// Card is one playing card. Rank is 1 (Ace) through 13 (King). Up reports
// whether the card is face up (visible and playable).
type Card struct {
	Suit Suit
	Rank uint8
	Up   bool
}

func (c Card) String() string {
	r := map[uint8]string{1: "A", 11: "J", 12: "Q", 13: "K"}[c.Rank]
	if r == "" {
		r = fmt.Sprintf("%d", c.Rank)
	}
	s := r + c.Suit.String()
	if !c.Up {
		return "(" + s + ")"
	}
	return s
}

// standardDeck returns the 52 distinct cards, face down, in suit-then-rank order.
func standardDeck() []Card {
	d := make([]Card, 0, 52)
	for s := Club; s <= Spade; s++ {
		for r := uint8(1); r <= 13; r++ {
			d = append(d, Card{Suit: s, Rank: r})
		}
	}
	return d
}

klondike/game.go

package klondike

import (
	"math/rand"
	"slices"
)

// PileKind identifies a family of stacks in the Klondike layout.
type PileKind uint8

const (
	Stock      PileKind = iota // the face-down draw pile
	Waste                      // cards drawn from the stock, face up
	Foundation                 // four suit stacks, built up Ace→King
	Tableau                    // seven columns, built down by alternating color
)

// Pile addresses a specific stack. Index selects the foundation (0..3) or
// tableau (0..6); it is unused for Stock/Waste.
type Pile struct {
	Kind  PileKind
	Index int
}

// Move is one applied action, recorded so it can be undone exactly.
type Move struct {
	From, To Pile
	Count    int  // cards moved (a tableau run may be >1)
	Flipped  bool // the move exposed and flipped up a face-down source card
	Draw     int  // this move drew Draw cards stock→waste (0 if not a draw)
	Recycle  bool // this move recycled the waste back into the stock
}

// Game is a Klondike deal in progress. All state is plain data; copy the piles
// out through the accessors to render them.
type Game struct {
	stock   []Card
	waste   []Card
	found   [4][]Card
	tab     [7][]Card
	history []Move
	drawN   int
}

// New deals a game from seed (deterministic) drawing drawN cards at a time
// (1 or 3; anything else means 1).
func New(seed int64, drawN int) *Game {
	if drawN != 3 {
		drawN = 1
	}
	deck := standardDeck()
	rand.New(rand.NewSource(seed)).Shuffle(len(deck), func(i, j int) {
		deck[i], deck[j] = deck[j], deck[i]
	})
	g := &Game{drawN: drawN}
	k := 0
	for col := range 7 {
		for row := 0; row <= col; row++ {
			c := deck[k]
			k++
			c.Up = row == col // only the last card in each column is face up
			g.tab[col] = append(g.tab[col], c)
		}
	}
	for ; k < len(deck); k++ {
		g.stock = append(g.stock, deck[k]) // face down
	}
	return g
}

// Accessors (read-only views for rendering and tests).
func (g *Game) DrawCount() int          { return g.drawN }
func (g *Game) Stock() []Card           { return g.stock }
func (g *Game) Waste() []Card           { return g.waste }
func (g *Game) Foundation(i int) []Card { return g.found[i] }
func (g *Game) Tableau(i int) []Card    { return g.tab[i] }
func (g *Game) MoveCount() int          { return len(g.history) }

func (g *Game) pile(p Pile) *[]Card {
	switch p.Kind {
	case Stock:
		return &g.stock
	case Waste:
		return &g.waste
	case Foundation:
		return &g.found[p.Index]
	case Tableau:
		return &g.tab[p.Index]
	}
	return nil
}

// Draw turns the next drawN cards from the stock onto the waste, or — when the
// stock is empty — recycles the waste back into a fresh stock. Reports whether
// anything happened (false only when stock and waste are both empty).
func (g *Game) Draw() bool {
	if len(g.stock) == 0 {
		if len(g.waste) == 0 {
			return false
		}
		n := len(g.waste)
		for i := n - 1; i >= 0; i-- {
			c := g.waste[i]
			c.Up = false
			g.stock = append(g.stock, c)
		}
		g.waste = g.waste[:0]
		g.history = append(g.history, Move{Recycle: true, Count: n})
		return true
	}
	n := min(g.drawN, len(g.stock))
	for i := 0; i < n; i++ {
		c := g.stock[len(g.stock)-1]
		g.stock = g.stock[:len(g.stock)-1]
		c.Up = true
		g.waste = append(g.waste, c)
	}
	g.history = append(g.history, Move{Draw: n})
	return true
}

// canPlace reports whether the run `moving` (its first element is the card that
// will touch the destination) may legally land on `to`.
func (g *Game) canPlace(moving []Card, to Pile) bool {
	if len(moving) == 0 {
		return false
	}
	bottom := moving[0]
	switch to.Kind {
	case Foundation:
		if len(moving) != 1 {
			return false
		}
		f := g.found[to.Index]
		if len(f) == 0 {
			return bottom.Rank == 1 // an Ace starts a foundation
		}
		t := f[len(f)-1]
		return bottom.Suit == t.Suit && bottom.Rank == t.Rank+1
	case Tableau:
		d := g.tab[to.Index]
		if len(d) == 0 {
			return bottom.Rank == 13 // only a King may start an empty column
		}
		t := d[len(d)-1]
		return t.Up && t.Suit.Red() != bottom.Suit.Red() && bottom.Rank == t.Rank-1
	default:
		return false // nothing may be placed onto the stock or waste
	}
}

// CanMove reports whether the cards from fromIdx to the top of `from` may move
// onto `to`.
func (g *Game) CanMove(from Pile, fromIdx int, to Pile) bool {
	src := g.pile(from)
	if src == nil || from.Kind == Stock || from == to {
		return false
	}
	if fromIdx < 0 || fromIdx >= len(*src) {
		return false
	}
	// Only a tableau exposes a multi-card run; elsewhere only the top card moves.
	if from.Kind != Tableau && fromIdx != len(*src)-1 {
		return false
	}
	moving := (*src)[fromIdx:]
	for _, c := range moving {
		if !c.Up {
			return false
		}
	}
	return g.canPlace(moving, to)
}

// Move applies the move if legal (flipping a newly exposed source card face up)
// and returns whether it did.
func (g *Game) Move(from Pile, fromIdx int, to Pile) bool {
	if !g.CanMove(from, fromIdx, to) {
		return false
	}
	src := g.pile(from)
	dst := g.pile(to)
	moving := (*src)[fromIdx:]
	n := len(moving)
	run := make([]Card, n)
	copy(run, moving)
	*dst = append(*dst, run...)
	*src = (*src)[:fromIdx]

	flipped := false
	if from.Kind == Tableau && len(*src) > 0 && !(*src)[len(*src)-1].Up {
		(*src)[len(*src)-1].Up = true
		flipped = true
	}
	g.history = append(g.history, Move{From: from, To: to, Count: n, Flipped: flipped})
	return true
}

// AutoToFoundation moves the top card of p onto a legal foundation if one
// accepts it. This is the single-tap / double-click convenience.
func (g *Game) AutoToFoundation(p Pile) bool {
	src := g.pile(p)
	if src == nil || p.Kind == Stock || len(*src) == 0 {
		return false
	}
	idx := len(*src) - 1
	if !(*src)[idx].Up {
		return false
	}
	card := (*src)[idx]
	for i := range 4 {
		if g.canPlace([]Card{card}, Pile{Foundation, i}) {
			return g.Move(p, idx, Pile{Foundation, i})
		}
	}
	return false
}

// Undo reverses the most recent move exactly, including re-hiding a card that
// the move had flipped up — the classic Klondike undo bug, made explicit by the
// Move.Flipped bit. Reports whether there was anything to undo.
func (g *Game) Undo() bool {
	if len(g.history) == 0 {
		return false
	}
	m := g.history[len(g.history)-1]
	g.history = g.history[:len(g.history)-1]
	switch {
	case m.Recycle:
		// The recycle emptied the waste into the stock; put it all back.
		for _, c := range slices.Backward(g.stock) {

			c.Up = true
			g.waste = append(g.waste, c)
		}
		g.stock = g.stock[:0]
	case m.Draw > 0:
		for i := 0; i < m.Draw; i++ {
			c := g.waste[len(g.waste)-1]
			g.waste = g.waste[:len(g.waste)-1]
			c.Up = false
			g.stock = append(g.stock, c)
		}
	default:
		src := g.pile(m.From)
		dst := g.pile(m.To)
		if m.Flipped && len(*src) > 0 {
			(*src)[len(*src)-1].Up = false // re-hide before the run lands back on it
		}
		moving := (*dst)[len(*dst)-m.Count:]
		run := make([]Card, m.Count)
		copy(run, moving)
		*dst = (*dst)[:len(*dst)-m.Count]
		*src = append(*src, run...)
	}
	return true
}

// Won reports whether all 52 cards have reached the foundations.
func (g *Game) Won() bool {
	n := 0
	for i := range 4 {
		n += len(g.found[i])
	}
	return n == 52
}

// Action is a concrete legal move (source run → destination).
type Action struct {
	From    Pile
	FromIdx int
	To      Pile
}

// LegalActions lists every card move available right now (not including drawing
// from the stock). Used by hints, the auto-player, and the fuzz test.
func (g *Game) LegalActions() []Action {
	var out []Action
	dests := make([]Pile, 0, 11)
	for i := range 4 {
		dests = append(dests, Pile{Foundation, i})
	}
	for i := range 7 {
		dests = append(dests, Pile{Tableau, i})
	}
	consider := func(from Pile, idx int) {
		for _, to := range dests {
			if g.CanMove(from, idx, to) {
				out = append(out, Action{from, idx, to})
			}
		}
	}
	if len(g.waste) > 0 {
		consider(Pile{Waste, 0}, len(g.waste)-1)
	}
	for i := range 4 {
		if len(g.found[i]) > 0 {
			consider(Pile{Foundation, i}, len(g.found[i])-1)
		}
	}
	for i := range 7 {
		t := g.tab[i]
		for j := range t {
			if t[j].Up {
				consider(Pile{Tableau, i}, j)
			}
		}
	}
	return out
}

klondike/snapshot.go

package klondike

// Snapshot is a serializable capture of a game — plain exported data (all
// fields JSON-safe), the basis for save/resume. Card, Pile and Move are already
// exported, so a Snapshot round-trips through encoding/json unchanged.
type Snapshot struct {
	Stock       []Card
	Waste       []Card
	Foundations [4][]Card
	Tableaus    [7][]Card
	History     []Move
	DrawN       int
}

// Save captures the current game as a Snapshot (deep-copied, so later play does
// not mutate it).
func (g *Game) Save() Snapshot {
	s := Snapshot{DrawN: g.drawN,
		Stock: append([]Card(nil), g.stock...),
		Waste: append([]Card(nil), g.waste...)}
	for i := range g.found {
		s.Foundations[i] = append([]Card(nil), g.found[i]...)
	}
	for i := range g.tab {
		s.Tableaus[i] = append([]Card(nil), g.tab[i]...)
	}
	s.History = append([]Move(nil), g.history...)
	return s
}

// Restore rebuilds a game from a Snapshot.
func Restore(s Snapshot) *Game {
	g := &Game{drawN: s.DrawN}
	if g.drawN != 3 {
		g.drawN = 1
	}
	g.stock = append([]Card(nil), s.Stock...)
	g.waste = append([]Card(nil), s.Waste...)
	for i := range s.Foundations {
		g.found[i] = append([]Card(nil), s.Foundations[i]...)
	}
	for i := range s.Tableaus {
		g.tab[i] = append([]Card(nil), s.Tableaus[i]...)
	}
	g.history = append([]Move(nil), s.History...)
	return g
}

// CardTotal is the number of cards currently in play across every pile — 52 for
// any valid game, so a loader can reject a corrupt or truncated save.
func (g *Game) CardTotal() int {
	n := len(g.stock) + len(g.waste)
	for i := range g.found {
		n += len(g.found[i])
	}
	for i := range g.tab {
		n += len(g.tab[i])
	}
	return n
}

solitaire.go

package main

import (
	"encoding/json"
	"math/rand"
	"time"

	"github.com/doug/gophics/anim"
	"github.com/doug/gophics/examples/solitaire/klondike"
	"github.com/doug/gophics/geom"
	"github.com/doug/gophics/layout"
	"github.com/doug/gophics/paint"
	"github.com/doug/gophics/widget"
)

// Solitaire is the root widget: a full-screen Klondike board. Seed makes the
// deal deterministic (tests pass a fixed value; the command uses the clock).
type Solitaire struct{ Seed int64 }

func (Solitaire) CreateState() widget.State { return &gameState{} }

// stateHook lets tests observe the mounted game state.
var stateHook func(*gameState)

type gameState struct {
	widget.StateBase[Solitaire]
	ctx   widget.Ctx
	g     *klondike.Game
	store store
	board Board
	deal  int64 // current deal's seed; bumped by New game
	won   bool

	// Press/drag transient state.
	pressHit   klondike.Pile
	pressIdx   int
	pressOK    bool
	pressStart geom.Pt

	dragging  bool
	dragPile  klondike.Pile
	dragIdx   int
	dragCards []klondike.Card
	grabOff   geom.Pt // pointer offset within the grabbed top card
	pointer   geom.Pt // live pointer during a drag

	// Snap-back: an illegal drop glides the run home instead of vanishing.
	snapping         bool
	snapCtrl         *anim.Controller
	snapFrom, snapTo geom.Pt

	// Deal: a fresh game flies its tableau cards in from the stock, staggered.
	dealing  bool
	dealCtrl *anim.Controller

	// Win cascade: on a win, the foundation cards fountain off and bounce down
	// the felt, leaving streaks — the classic finale.
	size        geom.Size // last drawn surface size (physics bounds)
	cascading   bool
	cascadeTick *cascadeAnim
	cascade     []fallCard   // cards currently in flight
	stamps      []stamp      // trail left behind (bounded)
	launch      []launchItem // cards waiting to fountain, top-first
	launchT     float32      // countdown to the next launch
	rng         *rand.Rand
}

// fallCard is a bouncing card during the win cascade.
type fallCard struct {
	card     klondike.Card
	pos, vel geom.Pt
}

// stamp is one frame of a card's trail, drawn cheaply so streaks are affordable.
type stamp struct {
	card klondike.Card
	pos  geom.Pt
}

// launchItem is a foundation card queued to fountain, with its source pile.
type launchItem struct {
	card  klondike.Card
	found int
}

func (s *gameState) Init(ctx widget.Ctx) {
	s.ctx = ctx
	s.deal = s.W().Seed
	s.store = makeStore(ctx.Preferences())
	g, resumed := s.loadOrNew()
	s.g = g
	s.won = s.g.Won()
	s.snapCtrl = &anim.Controller{Duration: 170 * time.Millisecond, Curve: anim.EaseOut, OnChange: func() {
		s.SetState(nil)
		// Finalize on completion (Value hits 1) — not on the initial Jump(0),
		// which also leaves the controller not-Running but at Value 0.
		if s.snapping && s.snapCtrl.Value() >= 1 {
			s.snapping, s.dragCards = false, nil
		}
	}}
	ctx.AddTicker(s.snapCtrl)
	s.dealCtrl = &anim.Controller{Duration: 650 * time.Millisecond, Curve: anim.Linear, OnChange: func() {
		s.SetState(nil)
		if s.dealCtrl.Value() >= 1 {
			s.dealing = false
		}
	}}
	ctx.AddTicker(s.dealCtrl)
	s.rng = rand.New(rand.NewSource(s.deal + 1))
	s.cascadeTick = &cascadeAnim{s}
	ctx.AddTicker(s.cascadeTick)
	if !resumed {
		s.startDeal() // animate a fresh deal, but not a resumed game
	}
	if stateHook != nil {
		stateHook(s)
	}
}

func (s *gameState) Dispose() {
	s.ctx.RemoveTicker(s.snapCtrl)
	s.ctx.RemoveTicker(s.dealCtrl)
	s.ctx.RemoveTicker(s.cascadeTick)
}

func (s *gameState) startDeal() {
	s.dealing = true
	s.dealCtrl.Jump(0)
	s.dealCtrl.Forward()
	s.ctx.Invalidate()
}

// maybeWin refreshes the win flag and kicks the cascade off exactly on the
// losing→won transition (and cancels it if an undo takes the win back).
func (s *gameState) maybeWin() {
	won := s.g.Won()
	switch {
	case won && !s.won:
		s.startCascade()
	case !won:
		s.stopCascade()
	}
	s.won = won
}

// startCascade queues every foundation card (top of each pile first, dealt
// round-robin across suits) to fountain off and bounce down the felt.
func (s *gameState) startCascade() {
	s.cascading = true
	s.cascade, s.stamps, s.launch, s.launchT = nil, nil, nil, 0
	maxLen := 0
	for i := range 4 {
		if l := len(s.g.Foundation(i)); l > maxLen {
			maxLen = l
		}
	}
	for row := 0; row < maxLen; row++ {
		for i := range 4 {
			f := s.g.Foundation(i)
			if idx := len(f) - 1 - row; idx >= 0 {
				s.launch = append(s.launch, launchItem{f[idx], i})
			}
		}
	}
	s.ctx.Invalidate()
}

func (s *gameState) stopCascade() {
	s.cascading = false
	s.cascade, s.stamps, s.launch = nil, nil, nil
}

// stepCascade advances the cascade physics by dt seconds: launch the next card
// on a fixed cadence, integrate gravity + floor bounce, and record a trail.
func (s *gameState) stepCascade(dt float32) {
	if s.size.W == 0 {
		return // no frame drawn yet — no bounds to bounce within
	}
	if dt > 0.05 {
		dt = 0.05 // clamp long stalls so the integration stays stable
	}
	for s.launchT -= dt; s.launchT <= 0 && len(s.launch) > 0; s.launchT += 0.11 {
		it := s.launch[0]
		s.launch = s.launch[1:]
		vx := (s.rng.Float32()*2 - 1) // [-1,1]
		if vx > -0.4 && vx < 0.4 {    // ensure a decent sideways throw
			if vx < 0 {
				vx -= 0.4
			} else {
				vx += 0.4
			}
		}
		s.cascade = append(s.cascade, fallCard{
			card: it.card,
			pos:  s.board.Foundations[it.found].Min,
			vel:  geom.Pt{X: vx * 340, Y: -(220 + s.rng.Float32()*180)},
		})
	}
	const gravity = 2100
	floor := s.size.H - s.board.CardH
	alive := s.cascade[:0]
	for _, fc := range s.cascade {
		fc.vel.Y += gravity * dt
		fc.pos.X += fc.vel.X * dt
		fc.pos.Y += fc.vel.Y * dt
		if fc.pos.Y >= floor {
			fc.pos.Y = floor
			if fc.vel.Y = -fc.vel.Y * 0.78; fc.vel.Y > -90 {
				fc.vel.Y = 0 // too slow to rebound — slide off along the floor
			}
		}
		s.stamps = append(s.stamps, stamp{fc.card, fc.pos})
		if fc.pos.X > -s.board.CardW && fc.pos.X < s.size.W {
			alive = append(alive, fc)
		}
	}
	s.cascade = alive
	if n := len(s.stamps); n > 900 { // bound the trail (perf)
		s.stamps = append(s.stamps[:0], s.stamps[n-900:]...)
	}
	if len(s.cascade) == 0 && len(s.launch) == 0 {
		s.cascading = false
	}
}

// cascadeAnim drives the win cascade's per-frame physics.
type cascadeAnim struct{ s *gameState }

func (a *cascadeAnim) Tick(dt float64) bool {
	if !a.s.cascading {
		return false
	}
	a.s.stepCascade(float32(dt))
	a.s.SetState(nil)
	a.s.ctx.Invalidate()
	return a.s.cascading
}

// loadOrNew resumes the saved game (resumed=true), or deals a fresh one if
// there's no valid save.
func (s *gameState) loadOrNew() (g *klondike.Game, resumed bool) {
	if s.store != nil {
		if data, ok := s.store.load(); ok {
			var snap klondike.Snapshot
			if json.Unmarshal(data, &snap) == nil {
				if g := klondike.Restore(snap); fullDeck(g) {
					return g, true
				}
			}
		}
	}
	return klondike.New(s.deal, 1), false
}

// persist autosaves the current game (called after every state change).
func (s *gameState) persist() {
	if s.store == nil {
		return
	}
	if data, err := json.Marshal(s.g.Save()); err == nil {
		s.store.save(data)
	}
}

func (s *gameState) Build(ctx widget.Ctx) widget.Widget {
	board := widget.Interactive{
		Gestures: widget.Gestures{
			OnPress: func(p geom.Pt) {
				if s.dealing { // ignore board input while the deal animates in
					s.pressOK = false
					return
				}
				s.pressHit, s.pressIdx, s.pressOK = s.board.Hit(p)
				s.pressStart, s.dragging = p, false
			},
			OnDrag: func(pos, _ geom.Pt) {
				if !s.pressOK {
					return
				}
				if !s.dragging {
					if s.snapping || !s.grab(s.pressHit, s.pressIdx, s.pressStart) {
						s.pressOK = false
						return
					}
					s.dragging = true
				}
				s.pointer = pos
				s.SetState(nil)
			},
			OnRelease: func() {
				if !s.dragging {
					return
				}
				if s.tryDrop() {
					s.dragging, s.dragCards = false, nil
					s.maybeWin()
					s.persist()
				} else {
					s.startSnapBack()
				}
				s.SetState(nil)
			},
			OnTap: func() {
				if !s.pressOK || s.dragging {
					return
				}
				switch s.pressHit.Kind {
				case klondike.Stock:
					s.g.Draw()
				case klondike.Waste, klondike.Tableau, klondike.Foundation:
					s.g.AutoToFoundation(s.pressHit)
				}
				s.maybeWin()
				s.persist()
				s.SetState(nil)
			},
		},
		Child: widget.Canvas{Clip: true, Draw: s.draw},
	}

	// The board fills the window (so board coordinates are window coordinates);
	// the controls float over the felt at the bottom-right, on top of it.
	var items []widget.Widget
	if s.g.CanAutoComplete() {
		items = append(items, chip("Finish", s.finish), widget.Sized{W: 8})
	}
	items = append(items, chip("Undo", s.undo), widget.Sized{W: 8}, chip("New", s.newGame))
	controls := widget.Row(items...)
	controls.CrossAlign = layout.CrossCenter
	return widget.Stack{Children: []widget.Widget{
		board,
		widget.Align{X: 1, Y: 1, Child: widget.Padding{All: 14, Child: controls}},
	}}
}

func chip(label string, onTap func()) widget.Widget {
	return widget.Interactive{
		Gestures: widget.Gestures{OnTap: onTap},
		Child: widget.Decorated{Color: colBack2, Radius: 8, Child: widget.Padding{
			Insets: geom.InsetsSymmetric(14, 7),
			Child:  widget.Text{Value: label, Size: 14, Color: colFace},
		}},
	}
}

func (s *gameState) undo() {
	s.cancelInteraction()
	s.g.Undo()
	s.maybeWin()
	s.persist()
	s.SetState(nil)
}

func (s *gameState) newGame() {
	s.cancelInteraction()
	s.deal++
	s.g = klondike.New(s.deal, 1)
	s.stopCascade()
	s.won = false
	s.persist()
	s.startDeal()
	s.SetState(nil)
}

// finish auto-plays the rest of the game to the foundations (shown only when
// s.g.CanAutoComplete()).
func (s *gameState) finish() {
	s.cancelInteraction()
	s.g.AutoComplete()
	s.maybeWin()
	s.persist()
	s.SetState(nil)
}

func (s *gameState) cancelInteraction() {
	s.dragging, s.snapping, s.dragCards, s.pressOK = false, false, nil, false
	s.snapCtrl.Jump(0)
}

// grab sets up the run being dragged from pile at idx, or returns false.
func (s *gameState) grab(pile klondike.Pile, idx int, p geom.Pt) bool {
	switch pile.Kind {
	case klondike.Waste:
		w := s.g.Waste()
		if len(w) == 0 {
			return false
		}
		s.dragPile, s.dragIdx = pile, len(w)-1
		s.dragCards = []klondike.Card{w[len(w)-1]}
		s.grabOff = p.Sub(s.board.Waste.Min)
		return true
	case klondike.Foundation:
		f := s.g.Foundation(pile.Index)
		if len(f) == 0 {
			return false
		}
		s.dragPile, s.dragIdx = pile, len(f)-1
		s.dragCards = []klondike.Card{f[len(f)-1]}
		s.grabOff = p.Sub(s.board.Foundations[pile.Index].Min)
		return true
	case klondike.Tableau:
		col := s.g.Tableau(pile.Index)
		if idx < 0 || idx >= len(col) || !col[idx].Up {
			return false
		}
		s.dragPile, s.dragIdx = pile, idx
		s.dragCards = append([]klondike.Card(nil), col[idx:]...)
		s.grabOff = p.Sub(s.board.Tableaus[pile.Index][idx].Min)
		return true
	}
	return false
}

// tryDrop lands the dragged run on the legal target it overlaps most and reports
// whether it moved (false → the caller snaps it back).
func (s *gameState) tryDrop() bool {
	topRect := geom.RectXYWH(s.pointer.X-s.grabOff.X, s.pointer.Y-s.grabOff.Y, s.board.CardW, s.board.CardH)
	best := -1
	var bestArea float32
	targets := s.board.DropTargets(s.g)
	for i, t := range targets {
		if !s.g.CanMove(s.dragPile, s.dragIdx, t.Pile) {
			continue
		}
		if a := overlapArea(topRect, t.Rect); a > bestArea {
			bestArea, best = a, i
		}
	}
	if best >= 0 && bestArea > 0 {
		return s.g.Move(s.dragPile, s.dragIdx, targets[best].Pile)
	}
	return false
}

// startSnapBack animates the dragged run from the release point back to where it
// was grabbed, then clears it (the game was never mutated).
func (s *gameState) startSnapBack() {
	s.snapFrom = geom.Pt{X: s.pointer.X - s.grabOff.X, Y: s.pointer.Y - s.grabOff.Y}
	s.snapTo = s.sourceTop()
	s.dragging, s.snapping = false, true
	s.snapCtrl.Jump(0)
	s.snapCtrl.Forward()
	s.ctx.Invalidate()
}

func (s *gameState) sourceTop() geom.Pt {
	switch s.dragPile.Kind {
	case klondike.Waste:
		return s.board.Waste.Min
	case klondike.Foundation:
		return s.board.Foundations[s.dragPile.Index].Min
	case klondike.Tableau:
		return s.board.Tableaus[s.dragPile.Index][s.dragIdx].Min
	}
	return geom.Pt{}
}

// hidingRun reports whether the source cards of the active run should be hidden
// (they are being dragged or snapped back and drawn as an overlay).
func (s *gameState) hidingRun() bool { return s.dragging || s.snapping }

func (s *gameState) draw(c paint.Canvas, size geom.Size) {
	s.board = Layout(size, s.g)
	s.size = size
	b := s.board
	// A subtle felt gradient (lighter top → darker bottom) for depth.
	c.FillRRectGradient(geom.RectXYWH(0, 0, size.W, size.H), 0, colFeltHi, colFeltLo, false)

	if len(s.g.Stock()) > 0 {
		drawCard(c, b.Stock, klondike.Card{})
	} else {
		drawEmpty(c, b.Stock)
	}
	w := s.g.Waste()
	if len(w) > 0 && !(s.hidingRun() && s.dragPile.Kind == klondike.Waste) {
		drawCard(c, b.Waste, w[len(w)-1])
	} else {
		drawEmpty(c, b.Waste)
	}
	for i := range 4 {
		f := s.g.Foundation(i)
		hiding := s.hidingRun() && s.dragPile.Kind == klondike.Foundation && s.dragPile.Index == i
		if len(f) > 0 && !hiding {
			drawCard(c, b.Foundations[i], f[len(f)-1])
		} else {
			drawEmpty(c, b.Foundations[i])
		}
	}
	total := 0
	for j := range 7 {
		total += len(s.g.Tableau(j))
	}
	di := 0
	for j := range 7 {
		col := s.g.Tableau(j)
		if len(col) == 0 {
			drawEmpty(c, b.Slot[j])
		}
		for k := range col {
			idx := di
			di++
			if s.hidingRun() && s.dragPile.Kind == klondike.Tableau && s.dragPile.Index == j && k >= s.dragIdx {
				break // being dragged / snapped
			}
			if s.dealing {
				if lt, flying := dealProgress(s.dealCtrl.Value(), idx, total); lt < 0 {
					continue // still in the deck (drawn as the stock back)
				} else if flying {
					x := b.Stock.Min.X + (b.Tableaus[j][k].Min.X-b.Stock.Min.X)*lt
					y := b.Stock.Min.Y + (b.Tableaus[j][k].Min.Y-b.Stock.Min.Y)*lt
					drawCard(c, geom.RectXYWH(x, y, b.CardW, b.CardH), col[k])
					continue
				}
			}
			// A card with another on top of it shows only a strip, so it is
			// drawn without the back's inset frame -- see drawCardFanned.
			if k < len(col)-1 {
				drawCardFanned(c, b.Tableaus[j][k], col[k])
			} else {
				drawCard(c, b.Tableaus[j][k], col[k])
			}
		}
	}

	// The active run (dragged, or gliding home), on top.
	if rx, ry, ok := s.runOrigin(); ok {
		fan := b.CardH * 0.30
		for i, card := range s.dragCards {
			drawCard(c, geom.RectXYWH(rx, ry+float32(i)*fan, b.CardW, b.CardH), card)
		}
	}
	// Win cascade: the trail streaks under the live bouncing cards.
	for _, st := range s.stamps {
		drawStamp(c, geom.RectXYWH(st.pos.X, st.pos.Y, b.CardW, b.CardH), st.card)
	}
	for _, fc := range s.cascade {
		drawCard(c, geom.RectXYWH(fc.pos.X, fc.pos.Y, b.CardW, b.CardH), fc.card)
	}

	// The banner lands once the cascade has played out.
	if s.won && !s.cascading {
		c.FillRRect(geom.RectXYWH(size.W*0.5-size.W*0.22, size.H*0.42, size.W*0.44, size.H*0.14), 16, colFeltHi)
		c.TextIn("bold", "You win!", geom.Pt{X: size.W*0.5 - size.W*0.16, Y: size.H * 0.52}, size.W*0.08, colFace)
	}
}

// dealProgress maps the global deal timeline t (0..1) to card idx's flight:
// lt < 0 means still in the deck, 0..1 means in flight, and flying is true only
// during that window (lt >= 1 means arrived — draw it at its final spot).
func dealProgress(t float32, idx, total int) (lt float32, flying bool) {
	if total < 1 {
		total = 1
	}
	const fly = 0.5
	lt = (t - float32(idx)*(0.5/float32(total))) / fly
	switch {
	case lt < 0:
		return lt, false
	case lt >= 1:
		return 1, false
	default:
		return lt, true
	}
}

// runOrigin returns the top-left of the active run and whether one is showing.
func (s *gameState) runOrigin() (float32, float32, bool) {
	switch {
	case s.dragging:
		return s.pointer.X - s.grabOff.X, s.pointer.Y - s.grabOff.Y, true
	case s.snapping:
		t := s.snapCtrl.Value()
		return s.snapFrom.X + (s.snapTo.X-s.snapFrom.X)*t, s.snapFrom.Y + (s.snapTo.Y-s.snapFrom.Y)*t, true
	}
	return 0, 0, false
}

store.go

package main

import (
	"github.com/doug/gophics/examples/solitaire/klondike"
	"github.com/doug/gophics/shell"
)

// store persists the current game between runs.
//
// There is no platform split here, and that is the point. This used to be two
// build-tagged files — localStorage on web, a JSON file under the user's config
// directory on desktop — which is precisely what shell.Preferences already is
// on every platform gophics runs on, mobile included. An app writing that split
// itself is reimplementing a capability the framework ships.
//
// makeStore is a var so tests can substitute an in-memory slot.
type store interface {
	save(data []byte)
	load() ([]byte, bool)
}

var makeStore = newPrefsStore

// prefKey names this game's save. The app name is part of the key because the
// shell's own prefix ("gophics.pref.") namespaces the framework, not the app,
// and every demo on gophics.com shares one origin and therefore one
// localStorage.
const prefKey = "solitaire.game"

// prefsStore autosaves the game through the Preferences capability.
type prefsStore struct{ p shell.Preferences }

// newPrefsStore returns nil where the platform has no preference store — a
// sandboxed browser context, say. Callers already treat a nil store as "do not
// persist", so a game that cannot be saved still deals and plays.
func newPrefsStore(p shell.Preferences) store {
	if p == nil {
		return nil
	}
	return prefsStore{p}
}

func (s prefsStore) save(data []byte) { _ = s.p.Set(prefKey, string(data)) }

func (s prefsStore) load() ([]byte, bool) {
	v, ok := s.p.Get(prefKey)
	if !ok {
		return nil, false
	}
	return []byte(v), true
}

// fullDeck reports whether g is a complete, non-corrupt game (52 cards) — used
// to reject a bad save and start fresh instead.
func fullDeck(g *klondike.Game) bool { return g.CardTotal() == 52 }