;;; cg-solitaire.el --- Tableau solitaires (Klondike, FreeCell, Spider, Yukon)  -*- lexical-binding: t; -*-

;; Copyright (C) 2026  Corwin Brust

;; Author: Corwin Brust <corwin@bru.st>
;; Maintainer: Corwin Brust <corwin@bru.st>
;; Package-Requires: ((emacs "26.1"))
;; Keywords: games
;; URL: https://code.bru.st/corwin/card-game.el

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; A shared engine for tableau solitaires, with four games built on it:
;;
;;   `cg-klondike' -- the classic "Solitaire": seven columns, a stock and
;;                    waste, build the foundations up by suit from the Ace.
;;   `cg-freecell' -- all cards dealt face up, four free cells, no stock;
;;                    a game of nearly pure skill.
;;   `cg-spider'   -- two decks, ten columns; build down regardless of suit
;;                    but only same-suit runs move; clear eight K..A runs.
;;   `cg-yukon'    -- Klondike's layout, all face up, move any buried group.
;;
;; Cards are the package-standard cons (SUIT . RANK) with SUIT 0 spades,
;; 1 clubs, 2 diamonds, 3 hearts and RANK 0 Ace .. 12 King.  Each tableau
;; column is a list ordered bottom (screen top) to top (the accessible
;; card); a per-column face-down count tracks the hidden prefix.
;;
;; Play is by keyboard: move the cursor between piles with the arrow keys
;; and press RET to pick up the movable run from a pile, then RET again on
;; a destination to drop it.  `f' sends a card to a foundation, `a' auto-
;; plays everything it can, and the stock pile deals or recycles on RET.

;;; Code:

(require 'cl-lib)
(require 'eieio)
(require 'cg-core)
(require 'cg-svg)
(require 'cg-render)

;;;; Cards

(defconst cg-sol-ranks
  ["A" "2" "3" "4" "5" "6" "7" "8" "9" "10" "J" "Q" "K"]
  "Rank labels indexed 0..12 (Ace through King).")

(defun cg-sol-card-string (card &optional down)
  "Return a short string for CARD.
With DOWN non-nil, draw a face-down back instead.  A nil CARD draws an
empty-slot dot."
  (cond (down "##")
        ((null card) "·")
        (t (concat (aref cg-sol-ranks (cdr card)) (cg-suit-glyph (car card))))))

(defsubst cg-sol-red-p (card)
  "Return non-nil when CARD is a red suit."
  (and card (cg-red-suit-p (car card))))

(defun cg-sol--make-deck (ndecks)
  "Return a shuffled list of NDECKS standard 52-card decks."
  (random t)
  (let ((cards nil))
    (dotimes (_ ndecks)
      (dotimes (s 4)
        (dotimes (r 13)
          (push (cons s r) cards))))
    (cg-shuffle cards)))

;;;; Game classes

(defclass cg-solitaire-game (cg-game)
  ((ncols     :initform 7   :documentation "Number of tableau columns.")
   (ndecks    :initform 1   :documentation "Number of 52-card decks used.")
   (nfound    :initform 4   :documentation "Number of foundation piles.")
   (nfree     :initform 0   :documentation "Number of free cells.")
   (has-stock :initform nil :documentation "Whether a stock pile exists.")
   (has-waste :initform nil :documentation "Whether a waste pile exists.")
   (build     :initform 'alt :documentation "Tableau placement rule: alt, suit, any.")
   (run-rule  :initform 'alt :documentation "Movable-run cohesion: alt, suit, any.")
   (empty-rule :initform 'king :documentation "Empty-column rule: king or any.")
   (redeal    :initform t   :documentation "Whether an empty stock recycles the waste.")
   (draw      :initform 1   :documentation "Cards turned from the stock to the waste.")
   (target-sets :initform 8 :documentation "Completed runs to win when NFOUND is 0.")
   (base      :initform 0   :documentation "Foundation base rank (0 = Ace).")
   (wrap      :initform nil :documentation "Whether foundations wrap King to Ace.")
   (has-reserve :initform nil :documentation "Whether a reserve pile exists (Canfield).")
   (vname     :initform "Solitaire" :documentation "Display name."))
  "Abstract base for tableau solitaires."
  :abstract t)

(defclass cg-klondike-game (cg-solitaire-game)
  ((has-stock :initform t) (has-waste :initform t)
   (vname :initform "Klondike"))
  "Klondike: seven columns, stock and waste, foundations up by suit.")

(defclass cg-freecell-game (cg-solitaire-game)
  ((ncols :initform 8) (nfree :initform 4) (empty-rule :initform 'any)
   (vname :initform "FreeCell"))
  "FreeCell: eight columns dealt face up, four free cells, no stock.")

(defclass cg-yukon-game (cg-solitaire-game)
  ((run-rule :initform 'any) (vname :initform "Yukon"))
  "Yukon: Klondike layout dealt mostly face up; move any buried group.")

(defclass cg-spider-game (cg-solitaire-game)
  ((ncols :initform 10) (ndecks :initform 2) (nfound :initform 0)
   (has-stock :initform t) (build :initform 'any) (run-rule :initform 'suit)
   (empty-rule :initform 'any) (vname :initform "Spider"))
  "Spider: two decks, ten columns; clear eight K..A same-suit runs.")

;;;; Rules (predicates)

(defun cg-sol--diff-color-p (a b)
  "Return non-nil when cards A and B are of opposite colours."
  (not (eq (cg-red-suit-p (car a)) (cg-red-suit-p (car b)))))

(cl-defmethod cg-sol--link-p ((game cg-solitaire-game) upper lower)
  "Return non-nil when LOWER may rest directly on UPPER within a run."
  (pcase (oref game run-rule)
    ('any t)
    ('suit (and (= (cdr lower) (1- (cdr upper))) (= (car lower) (car upper))))
    (_     (and (= (cdr lower) (1- (cdr upper))) (cg-sol--diff-color-p upper lower)))))

(cl-defmethod cg-sol--place-p ((game cg-solitaire-game) top card)
  "Return non-nil when CARD may be placed on a column whose top is TOP."
  (pcase (oref game build)
    ('any  (= (cdr card) (1- (cdr top))))
    ('suit (and (= (cdr card) (1- (cdr top))) (= (car card) (car top))))
    (_     (and (= (cdr card) (1- (cdr top))) (cg-sol--diff-color-p top card)))))

(cl-defmethod cg-sol--empty-accepts ((game cg-solitaire-game) card)
  "Return non-nil when CARD may be placed on an empty column."
  (pcase (oref game empty-rule)
    ('king (= (cdr card) 12))
    (_ t)))

;;;; Layout and dealing

(cl-defgeneric cg-sol--layout (game)
  "Return a list of (DOWN . UP) card counts, one per tableau column.")

(cl-defmethod cg-sol--layout ((_ cg-klondike-game))
  (cl-loop for i below 7 collect (cons i 1)))
(cl-defmethod cg-sol--layout ((_ cg-yukon-game))
  (cons (cons 0 1) (cl-loop for i from 1 below 7 collect (cons i 5))))
(cl-defmethod cg-sol--layout ((_ cg-freecell-game))
  (append (make-list 4 (cons 0 7)) (make-list 4 (cons 0 6))))
(cl-defmethod cg-sol--layout ((_ cg-spider-game))
  (append (make-list 4 (cons 5 1)) (make-list 6 (cons 4 1))))

(cl-defmethod cg-sol--deal ((game cg-solitaire-game))
  "Deal a fresh layout into GAME and initialise its environment."
  (let* ((deck (cg-sol--make-deck (oref game ndecks)))
         (nc (oref game ncols))
         (layout (cg-sol--layout game))
         (tableau (make-vector nc nil))
         (down (make-vector nc 0)))
    (dotimes (c nc)
      (let* ((spec (nth c layout))
             (col nil))
        (dotimes (_ (+ (car spec) (cdr spec)))
          (push (pop deck) col))
        (aset tableau c (nreverse col))
        (aset down c (car spec))))
    (cg-put game :tableau tableau)
    (cg-put game :down down)
    (cg-put game :found (make-vector (oref game nfound) nil))
    (cg-put game :free (make-vector (oref game nfree) nil))
    (cg-put game :stock deck)
    (cg-put game :waste nil)
    (cg-put game :sets 0)
    (cg-put game :moves 0)
    (cg-put game :cursor 0)
    (cg-put game :sel nil)
    (cg-put game :sel-n 0)
    (cg-put game :history nil)
    (cg-put game :message
            (format "%s.  Arrows move; RET picks up/drops; f=foundation; a=auto; ?=help."
                    (oref game vname)))
    game))

;;;; Spots (the cursor visits piles)

(cl-defmethod cg-sol--spots ((game cg-solitaire-game))
  "Return the ordered list of (TYPE . INDEX) piles the cursor can visit."
  (append
   (when (oref game has-stock) '((stock . 0)))
   (when (oref game has-waste) '((waste . 0)))
   (when (oref game has-reserve) '((reserve . 0)))
   (cl-loop for i below (oref game nfree) collect (cons 'free i))
   (cl-loop for i below (oref game nfound) collect (cons 'found i))
   (cl-loop for i below (oref game ncols) collect (cons 'col i))))

(defun cg-sol--cur-spot (game)
  "Return the (TYPE . INDEX) spot currently under the cursor."
  (nth (cg-get game :cursor) (cg-sol--spots game)))

;;;; Pile access helpers

(defun cg-sol--col (game c) "Column C of GAME (a list)." (aref (cg-get game :tableau) c))
(defun cg-sol--set-col (game c v) (aset (cg-get game :tableau) c v))
(defun cg-sol--down (game c) "Face-down count of column C." (aref (cg-get game :down) c))
(defun cg-sol--set-down (game c v) (aset (cg-get game :down) c v))

(defun cg-sol--col-top (game c)
  "Return the top (accessible) card of column C, or nil."
  (car (last (cg-sol--col game c))))

(defun cg-sol--exposed (game c)
  "Return the face-up cards of column C (bottom..top order)."
  (nthcdr (cg-sol--down game c) (cg-sol--col game c)))

(cl-defmethod cg-sol--top-run ((game cg-solitaire-game) c)
  "Return the longest movable run from the top of column C (bottom..top)."
  (let ((top->bottom (reverse (cg-sol--exposed game c))))
    (if (null top->bottom)
        nil
      (let ((run (list (car top->bottom)))
            (prev (car top->bottom)))
        (catch 'done
          (dolist (card (cdr top->bottom))
            (if (cg-sol--link-p game card prev)
                (progn (push card run) (setq prev card))
              (throw 'done nil))))
        run))))

(defun cg-sol--spot-top (game spot)
  "Return the top card available at SPOT, or nil."
  (pcase (car spot)
    ('col   (cg-sol--col-top game (cdr spot)))
    ('waste (car (last (cg-get game :waste))))
    ('free  (aref (cg-get game :free) (cdr spot)))
    ('found (car (last (aref (cg-get game :found) (cdr spot)))))
    ('reserve (car (last (cg-get game :reserve))))
    (_ nil)))

;;;; Foundations

(defun cg-sol--found-accepts (game i card)
  "Return non-nil when CARD may go onto foundation I of GAME."
  (and card
       (let ((f (aref (cg-get game :found) i)))
         (if (null f)
             (= (cdr card) (oref game base))   ; empty foundation takes the base rank
           (let* ((top (car (last f)))
                  (need (if (oref game wrap) (mod (1+ (cdr top)) 13) (1+ (cdr top)))))
             (and (= (car card) (car top))
                  (= (cdr card) need)))))))

(defun cg-sol--found-for (game card)
  "Return the index of a foundation that would accept CARD, or nil."
  (cl-loop for i below (oref game nfound)
           when (cg-sol--found-accepts game i card) return i))

;;;; Move primitives

(defun cg-sol--snapshot (game)
  "Push a deep-ish copy of GAME's mutable state onto the undo history."
  (let ((tab (cg-get game :tableau))
        (frv (cg-get game :found))
        (fre (cg-get game :free)))
    (cg-put game :history
            (cons (list (vconcat (mapcar #'copy-sequence tab))
                        (copy-sequence (cg-get game :down))
                        (vconcat (mapcar #'copy-sequence frv))
                        (copy-sequence fre)
                        (copy-sequence (cg-get game :stock))
                        (copy-sequence (cg-get game :waste))
                        (cg-get game :sets)
                        (cg-get game :moves)
                        (copy-sequence (cg-get game :reserve)))
                  (cg-get game :history)))))

(defun cg-sol--restore (game)
  "Pop and restore the most recent undo snapshot of GAME, if any."
  (let ((h (cg-get game :history)))
    (when h
      (cl-destructuring-bind (tab down frv fre stock waste sets moves reserve) (car h)
        (cg-put game :tableau tab)
        (cg-put game :down down)
        (cg-put game :found frv)
        (cg-put game :free fre)
        (cg-put game :stock stock)
        (cg-put game :waste waste)
        (cg-put game :sets sets)
        (cg-put game :moves moves)
        (cg-put game :reserve reserve))
      (cg-put game :history (cdr h))
      (cg-put game :sel nil)
      t)))

(defun cg-sol--flip (game c)
  "Flip the top of column C face up if it is face down."
  (let ((len (length (cg-sol--col game c)))
        (d (cg-sol--down game c)))
    (when (and (> len 0) (>= d len))
      (cg-sol--set-down game c (1- len)))))

(defun cg-sol--take (game spot n)
  "Remove and return the top N cards (bottom..top order) from SPOT."
  (pcase (car spot)
    ('col (let* ((c (cdr spot)) (col (cg-sol--col game c))
                 (run (last col n)))
            (cg-sol--set-col game c (butlast col n))
            (let ((len (length (cg-sol--col game c))))
              (when (> (cg-sol--down game c) len)
                (cg-sol--set-down game c len)))
            (cg-sol--flip game c)
            run))
    ('waste (let ((w (cg-get game :waste)))
              (cg-put game :waste (butlast w 1)) (last w 1)))
    ('free (let ((card (aref (cg-get game :free) (cdr spot))))
             (aset (cg-get game :free) (cdr spot) nil) (list card)))
    ('found (let* ((i (cdr spot)) (f (aref (cg-get game :found) i)))
              (aset (cg-get game :found) i (butlast f 1)) (last f 1)))
    ('reserve (let ((r (cg-get game :reserve)))
                (cg-put game :reserve (butlast r 1)) (last r 1)))
    (_ nil)))

(defun cg-sol--can-drop (game spot cards)
  "Return non-nil when the run CARDS (bottom..top) may drop on SPOT."
  (and cards
       (pcase (car spot)
         ('col (let* ((c (cdr spot)) (top (cg-sol--col-top game c)))
                 (if top
                     (cg-sol--place-p game top (car cards))
                   (cg-sol--empty-accepts game (car cards)))))
         ('found (and (= 1 (length cards))
                      (cg-sol--found-accepts game (cdr spot) (car cards))))
         ('free (and (= 1 (length cards))
                     (null (aref (cg-get game :free) (cdr spot)))))
         (_ nil))))

(defun cg-sol--drop (game spot cards)
  "Place the run CARDS (bottom..top) onto SPOT."
  (pcase (car spot)
    ('col (let ((c (cdr spot)))
            (cg-sol--set-col game c (append (cg-sol--col game c) cards))))
    ('found (let ((i (cdr spot)))
              (aset (cg-get game :found) i
                    (append (aref (cg-get game :found) i) cards))))
    ('free (aset (cg-get game :free) (cdr spot) (car cards)))))

;;;; Spider: complete-run removal

(cl-defmethod cg-sol--harvest ((game cg-solitaire-game))
  "Remove any complete K..A same-suit run from a column top; bump :sets.
Only games without foundations (NFOUND 0: Spider, Scorpion) harvest runs."
  (when (= 0 (oref game nfound))
   (dotimes (c (oref game ncols))
    (let* ((col (cg-sol--col game c))
           (exp (cg-sol--exposed game c)))
      (when (>= (length exp) 13)
        (let ((run (last exp 13)) (ok t) (suit (car (nth 0 (last exp 13)))))
          (cl-loop for k below 13
                   for card = (nth k run)
                   unless (and (= (car card) suit) (= (cdr card) (- 12 k)))
                   do (setq ok nil))
          (when ok
            (cg-sol--set-col game c (butlast col 13))
            (let ((len (length (cg-sol--col game c))))
              (when (> (cg-sol--down game c) len) (cg-sol--set-down game c len)))
            (cg-sol--flip game c)
            (cg-put game :sets (1+ (cg-get game :sets))))))))))

;;;; Stock action

(defcustom cg-sol-klondike-draw 1
  "Number of cards turned from the stock to the waste in Klondike."
  :type '(choice (const :tag "Draw one" 1) (const :tag "Draw three" 3))
  :group 'card-games)

(cl-defmethod cg-sol--stock-action ((game cg-solitaire-game))
  "Deal `draw' cards to the waste, recycling the waste when `redeal'."
  (if (not (oref game has-waste))
      (cg-put game :message "No stock to deal.")
    (cg-sol--snapshot game)
    (let ((stock (cg-get game :stock)) (waste (cg-get game :waste)))
      (if stock
          (let ((n (min (oref game draw) (length stock))))
            (dotimes (_ n)
              (setq waste (append waste (last stock 1)))
              (setq stock (butlast stock 1)))
            (cg-put game :stock stock) (cg-put game :waste waste)
            (cg-put game :message "Dealt from stock."))
        (if (and (oref game redeal) waste)
            (progn (cg-put game :stock (reverse waste)) (cg-put game :waste nil)
                   (cg-put game :message "Recycled the waste into the stock."))
          (cg-put game :message "The stock is empty."))))))

(cl-defmethod cg-sol--stock-action ((game cg-klondike-game))
  (cg-sol--snapshot game)
  (let ((stock (cg-get game :stock)) (waste (cg-get game :waste)))
    (if stock
        (let ((n (min cg-sol-klondike-draw (length stock))))
          (dotimes (_ n)
            (setq waste (append waste (last stock 1)))
            (setq stock (butlast stock 1)))
          (cg-put game :stock stock) (cg-put game :waste waste)
          (cg-put game :message "Dealt from stock."))
      (if waste
          (progn (cg-put game :stock (reverse waste)) (cg-put game :waste nil)
                 (cg-put game :message "Recycled the waste into the stock."))
        (cg-put game :message "Stock and waste are both empty.")))))

(cl-defmethod cg-sol--stock-action ((game cg-spider-game))
  (let ((stock (cg-get game :stock)))
    (cond
     ((null stock) (cg-put game :message "The stock is empty."))
     ((cl-loop for c below (oref game ncols)
               thereis (null (cg-sol--col game c)))
      (cg-put game :message "Fill every column before dealing from the stock."))
     (t (cg-sol--snapshot game)
        (dotimes (c (oref game ncols))
          (cg-sol--set-col game c (append (cg-sol--col game c) (last stock 1)))
          (setq stock (butlast stock 1)))
        (cg-put game :stock stock)
        (cg-sol--harvest game)
        (cg-put game :message "Dealt a row from the stock.")))))

;;;; Win

(cl-defmethod cg-won-p ((game cg-solitaire-game))
  "Return non-nil when GAME is solved."
  (if (= 0 (oref game nfound))
      (>= (cg-get game :sets) (oref game target-sets))
    (cl-every (lambda (f) (= 13 (length f)))
              (append (cg-get game :found) nil))))

;;;; Interaction

(defvar-local cg-sol--game nil "The solitaire game in the current buffer.")

(cl-defmethod cg-sol--selectable ((game cg-solitaire-game) spot)
  "Return the run (bottom..top) GAME would pick up from SPOT, or nil."
  (pcase (car spot)
    ('col (cg-sol--top-run game (cdr spot)))
    ('waste (let ((c (cg-sol--spot-top game spot))) (and c (list c))))
    ('free (let ((c (cg-sol--spot-top game spot))) (and c (list c))))
    ('found (let ((c (cg-sol--spot-top game spot))) (and c (list c))))
    ('reserve (let ((c (cg-sol--spot-top game spot))) (and c (list c))))
    (_ nil)))

(defun cg-sol-act (&optional count)
  "Pick up from, or drop onto, the pile under the cursor.
With prefix COUNT, pick up exactly COUNT cards from a column."
  (interactive "P")
  (let* ((game cg-sol--game)
         (spot (cg-sol--cur-spot game))
         (sel (cg-get game :sel)))
    (cond
     ((eq (car spot) 'stock)
      (cg-put game :sel nil)
      (cg-sol--stock-action game))
     ((null sel)
      (let ((run (cg-sol--selectable game spot)))
        (cond
         ((null run) (cg-put game :message "Nothing to pick up there."))
         (t (when (and count (eq (car spot) 'col))
              (setq run (last run (min (prefix-numeric-value count) (length run)))))
            (cg-put game :sel spot)
            (cg-put game :sel-n (length run))
            (cg-put game :message
                    (format "Picked up %d card%s.  RET on a destination."
                            (length run) (if (= 1 (length run)) "" "s")))))))
     ((equal sel spot)
      (cg-put game :sel nil) (cg-put game :message "Cancelled."))
     (t
      (let* ((n (cg-get game :sel-n))
             (cards (last (pcase (car sel)
                            ('col (cg-sol--col game (cdr sel)))
                            ('waste (cg-get game :waste))
                            ('found (aref (cg-get game :found) (cdr sel)))
                            ('free (list (aref (cg-get game :free) (cdr sel)))))
                          n)))
        (if (cg-sol--can-drop game spot cards)
            (progn (cg-sol--snapshot game)
                   (cg-sol--take game sel n)
                   (cg-sol--drop game spot cards)
                   (cg-put game :moves (1+ (cg-get game :moves)))
                   (cg-sol--harvest game)
                   (cg-put game :sel nil)
                   (cg-put game :message "Moved."))
          (cg-put game :sel nil)
          (cg-put game :message "That move is not allowed."))))))
  (cg-sol--after cg-sol--game))

(defun cg-sol-to-foundation ()
  "Send the top card of the pile under the cursor to a foundation."
  (interactive)
  (let* ((game cg-sol--game)
         (spot (cg-sol--cur-spot game))
         (card (cg-sol--spot-top game spot)))
    (if (and card (memq (car spot) '(col waste free reserve)))
        (let ((i (cg-sol--found-for game card)))
          (if i
              (progn (cg-sol--snapshot game)
                     (cg-sol--take game spot 1)
                     (cg-sol--drop game (cons 'found i) (list card))
                     (cg-put game :moves (1+ (cg-get game :moves)))
                     (cg-put game :sel nil)
                     (cg-put game :message "To the foundation."))
            (cg-put game :message "No foundation will take that card.")))
      (cg-put game :message "Nothing to send to a foundation.")))
  (cg-sol--after cg-sol--game))

(defun cg-sol-auto ()
  "Repeatedly send any eligible card to the foundations."
  (interactive)
  (let ((game cg-sol--game) (moved 0))
    (when (> (oref game nfound) 0)
      (cg-sol--snapshot game)
      (let (again)
        (cl-loop
         do (setq again nil)
         (dolist (spot (cg-sol--spots game))
           (when (memq (car spot) '(col waste free reserve))
             (let* ((card (cg-sol--spot-top game spot))
                    (i (and card (cg-sol--found-for game card))))
               (when i
                 (cg-sol--take game spot 1)
                 (cg-sol--drop game (cons 'found i) (list card))
                 (setq moved (1+ moved) again t)))))
         while again))
      (if (> moved 0)
          (progn (cg-put game :moves (+ moved (cg-get game :moves)))
                 (cg-put game :sel nil)
                 (cg-put game :message (format "Auto-played %d card%s."
                                               moved (if (= 1 moved) "" "s"))))
        (cg-put game :history (cdr (cg-get game :history)))
        (cg-put game :message "Nothing to auto-play."))))
  (cg-sol--after cg-sol--game))

(defun cg-sol-undo ()
  "Undo the last move."
  (interactive)
  (let ((game cg-sol--game))
    (if (cg-sol--restore game)
        (cg-put game :message "Undid a move.")
      (cg-put game :message "Nothing to undo."))
    (cg-sol--redisplay)))

(defun cg-sol--move (delta)
  "Move the cursor by DELTA spots."
  (let* ((game cg-sol--game)
         (n (length (cg-sol--spots game)))
         (cur (cg-get game :cursor)))
    (cg-put game :cursor (mod (+ cur delta) n))
    (cg-sol--redisplay)))

(defun cg-sol-left () "Move cursor left." (interactive) (cg-sol--move -1))
(defun cg-sol-right () "Move cursor right." (interactive) (cg-sol--move 1))
(defun cg-sol-up () "Move cursor left (previous pile)." (interactive) (cg-sol--move -1))
(defun cg-sol-down () "Move cursor right (next pile)." (interactive) (cg-sol--move 1))

(defun cg-sol--after (game)
  "Fill empty columns from the reserve, redisplay GAME, and announce a win."
  (cg-sol--autofill game)
  (cg-sol--redisplay)
  (when (cg-won-p game)
    (cg-put game :message "You won!  Press n for a new game.")
    (cg-sol--redisplay)
    (message "Solved!  Well played.")))

(defun cg-sol-new ()
  "Start a fresh deal of the same game."
  (interactive)
  (let ((game cg-sol--game))
    (cg-sol--deal game)
    (cg-sol--redisplay)))

(defun cg-sol-help ()
  "Describe the controls."
  (interactive)
  (message "Arrows: move  RET: pick up/drop  f: to foundation  a: auto  u: undo  n: new  g: redraw"))

(defun cg-sol-redraw () "Redraw the board." (interactive) (cg-sol--redisplay))

;;;; Rendering (console)

(defun cg-sol--render-card (card down sel cursor)
  "Return a propertized 3-column cell for CARD (DOWN, SEL, CURSOR flags)."
  (let* ((s (cg-sol-card-string card down))
         (faces nil))
    (when (and card (not down) (cg-sol-red-p card)) (push 'cg-red-suit faces))
    (when down (push 'cg-gap faces))
    (when sel (push 'cg-hint faces))
    (when cursor (push 'cg-cursor faces))
    (propertize (format "%3s " s) 'face (or faces 'default))))

(defcustom cg-sol-svg-cards t
  "When non-nil, draw the solitaire board as SVG on a graphical display."
  :type 'boolean :group 'card-games)

(defun cg-sol--spec (card)
  "Return the cg-svg display spec (RANK-STRING . SUIT) for CARD, or nil."
  (and card (cons (aref cg-sol-ranks (cdr card)) (car card))))

(defun cg-sol--svg (game)
  "Return (DISPLAY . REGIONS) for an SVG board of solitaire GAME.
DISPLAY is a propertized one-image string; REGIONS is a click map of
\(RECT . SPOT) entries, RECT being (X Y W H) in unscaled image pixels."
  (let* ((w cg-svg-card-width) (h cg-svg-card-height)
         (pad 12) (gap cg-svg-card-gap) (colgap 8) (vdown 12) (vup 26)
         (ncols (oref game ncols))
         (cur-spot (cg-sol--cur-spot game))
         (sel (cg-get game :sel)) (sel-n (or (cg-get game :sel-n) 0))
         (lc (cg-color 'shadow :foreground "gray40"))
         (regions '()) (slots '()))
    (when (oref game has-stock)
      (push (list (format "Stock(%d)" (length (cg-get game :stock)))
                  nil (and (cg-get game :stock) t) (equal cur-spot '(stock . 0))
                  '(stock . 0)) slots))
    (when (oref game has-waste)
      (push (list "Waste" (cg-sol--spec (car (last (cg-get game :waste)))) nil
                  (equal cur-spot '(waste . 0)) '(waste . 0)) slots))
    (when (oref game has-reserve)
      (push (list (format "Resv(%d)" (length (cg-get game :reserve)))
                  (cg-sol--spec (car (last (cg-get game :reserve)))) nil
                  (equal cur-spot '(reserve . 0)) '(reserve . 0)) slots))
    (dotimes (i (oref game nfree))
      (push (list (format "F%d" (1+ i)) (cg-sol--spec (aref (cg-get game :free) i)) nil
                  (equal cur-spot (cons 'free i)) (cons 'free i)) slots))
    (dotimes (i (oref game nfound))
      (push (list (format "%d" (1+ i))
                  (cg-sol--spec (car (last (aref (cg-get game :found) i)))) nil
                  (equal cur-spot (cons 'found i)) (cons 'found i)) slots))
    (setq slots (nreverse slots))
    (let* ((ntop (length slots))
           (topw (+ (* 2 pad) (* ntop (+ w gap))))
           (colsw (+ (* 2 pad) (* ncols (+ w colgap))))
           (width (max topw colsw))
           (top-y (+ pad 14)) (col-label-y (+ top-y h 18)) (col-y (+ col-label-y 6))
           (tab (cg-get game :tableau))
           (maxext (let ((m h))
                     (dotimes (c ncols)
                       (let* ((col (aref tab c)) (d (cg-sol--down game c))
                              (nu (- (length col) d))
                              (ext (+ (* d vdown) (* (max 0 (1- nu)) vup) h)))
                         (setq m (max m ext))))
                     m))
           (height (+ col-y maxext pad))
           (svg (svg-create width height)))
      (let ((x pad))
        (dolist (sl slots)
          (cl-destructuring-bind (label spec downp cursorp spot) sl
            (svg-text svg label :x (+ x 1) :y (- top-y 3) :font-size 11 :fill lc
                      :font-family cg-svg-font-family)
            (cond (downp (cg-svg-card svg x top-y :down t :highlight cursorp))
                  (spec (cg-svg-card svg x top-y :rank (car spec) :suit (cdr spec)
                                     :highlight cursorp))
                  (t (cg-svg-card svg x top-y :gap t :highlight cursorp)))
            (push (cons (list x top-y w h) spot) regions))
          (setq x (+ x w gap))))
      (dotimes (c ncols)
        (let* ((x (+ pad (* c (+ w colgap)))) (col (aref tab c)) (len (length col))
               (d (cg-sol--down game c)) (y col-y) (r 0)
               (cursorp (equal cur-spot (cons 'col c))))
          (svg-text svg (format "%d" (1+ c)) :x (+ x 1) :y col-label-y
                    :font-size 11 :fill lc :font-family cg-svg-font-family)
          (push (cons (list x col-y w maxext) (cons 'col c)) regions)
          (if (= len 0)
              (cg-svg-card svg x y :gap t :highlight cursorp)
            (dolist (card col)
              (let* ((downp (< r d)) (top-card (= r (1- len)))
                     (selp (and (equal sel (cons 'col c)) (>= r (- len sel-n)))))
                (if downp (cg-svg-card svg x y :down t)
                  (cg-svg-card svg x y :rank (car (cg-sol--spec card))
                               :suit (cdr (cg-sol--spec card))
                               :highlight (and top-card cursorp) :hint selp))
                (setq y (+ y (if downp vdown vup)) r (1+ r)))))))
      (cons (propertize "*" 'display (cg-svg-image svg (cg-scale)))
            (nreverse regions)))))

(cl-defmethod cg-render ((game cg-solitaire-game))
  "Return a propertized depiction of GAME (SVG on a graphical display)."
  (if (and cg-sol-svg-cards (display-graphic-p))
      (car (cg-sol--svg game))
    (cg-sol--render-text game)))

(cl-defmethod cg-render-text ((game cg-solitaire-game))
  (cg-sol--render-text game))

(cl-defmethod cg-render-svg ((game cg-solitaire-game))
  (if cg-sol-svg-cards (cg-sol--svg game)
    (cons (cg-sol--render-text game) nil)))

(cl-defmethod cg-render-apply ((game cg-solitaire-game) action)
  "Apply a click ACTION (a cursor spot) by selecting it and acting."
  (let ((idx (cl-position action (cg-sol--spots game) :test #'equal)))
    (when idx (cg-put game :cursor idx) (cg-sol-act))))

(defun cg-sol--render-text (game)
  "Return a plain-text depiction of solitaire GAME."
  (let* ((spots (cg-sol--spots game))
         (cur (cg-get game :cursor))
         (cur-spot (nth cur spots))
         (sel (cg-get game :sel))
         (sel-n (cg-get game :sel-n))
         (out (list)))
    (push (format "  %s   Moves: %d%s\n\n"
                  (oref game vname) (cg-get game :moves)
                  (if (> (oref game nfound) 0) "" 
                    (format "   Sets: %d/%d" (cg-get game :sets) (oref game target-sets))))
          out)
    ;; Top line: stock / waste / free cells / foundations.
    (let ((line "  "))
      (when (oref game has-stock)
        (let ((on (equal cur-spot '(stock . 0))))
          (setq line (concat line "Stock:"
                             (propertize (format "%-4s"
                                                 (if (cg-get game :stock) "##" "·"))
                                         'face (if on 'cg-cursor 'default))
                             (format "(%d) " (length (cg-get game :stock))))) ))
      (when (oref game has-waste)
        (let ((on (equal cur-spot '(waste . 0))) (w (car (last (cg-get game :waste)))))
          (setq line (concat line "Waste:"
                             (cg-sol--render-card w nil nil on)))))
      (when (oref game has-reserve)
        (let ((on (equal cur-spot '(reserve . 0))) (r (car (last (cg-get game :reserve)))))
          (setq line (concat line "Reserve:"
                             (cg-sol--render-card r nil nil on)
                             (format "(%d) " (length (cg-get game :reserve)))))))
      (dotimes (i (oref game nfree))
        (let ((on (equal cur-spot (cons 'free i))) (c (aref (cg-get game :free) i)))
          (setq line (concat line (format "F%d:" (1+ i))
                             (cg-sol--render-card c nil nil on)))))
      (dotimes (i (oref game nfound))
        (let ((on (equal cur-spot (cons 'found i)))
              (c (car (last (aref (cg-get game :found) i)))))
          (setq line (concat line (format "%d:" (1+ i))
                             (cg-sol--render-card c nil nil on)))))
      (push (concat line "\n\n") out))
    ;; Column headers.
    (let ((hdr "   "))
      (dotimes (c (oref game ncols))
        (let ((on (equal cur-spot (cons 'col c))))
          (setq hdr (concat hdr (propertize (format "%2d  " (1+ c))
                                            'face (if on 'cg-cursor 'default))))))
      (push (concat hdr "\n") out))
    ;; Column bodies, row by row.
    (let* ((tab (cg-get game :tableau))
           (maxlen (apply #'max 1 (mapcar #'length (append tab nil)))))
      (dotimes (r maxlen)
        (let ((row "   "))
          (dotimes (c (oref game ncols))
            (let* ((col (aref tab c))
                   (len (length col))
                   (card (and (< r len) (nth r col)))
                   (down (and card (< r (cg-sol--down game c))))
                   (selp (and (equal sel (cons 'col c))
                              card (>= r (- len sel-n))))
                   (cursorp (and (equal cur-spot (cons 'col c)) (= r (1- len)))))
              (setq row (concat row
                                (if (< r len)
                                    (cg-sol--render-card card down selp cursorp)
                                  "    ")))))
          (push (concat row "\n") out))))
    (push (format "\n  %s\n" (cg-get game :message)) out)
    (apply #'concat (nreverse out))))

(defun cg-sol--redisplay ()
  "Redraw the current solitaire buffer."
  (let ((game cg-sol--game)
        (inhibit-read-only t))
    (setq-local mode-line-process
                (format " [%s]" (if (cg-won-p game) "solved"
                                  (let ((s (cg-get game :sel)))
                                    (if s "carrying" "playing")))))
    (erase-buffer)
    (cg-render-game game)
    (insert "\n")
    (cg-insert-legend
     "arrows move · RET pick up/drop · f foundation · a auto · u undo · n new · q menu · ? help")
    (goto-char (point-min))))

;;;; Mode and commands

(defun cg-sol-mouse (event)
  "Handle a mouse click on the solitaire board: select that pile and act."
  (interactive "e")
  (let* ((game cg-sol--game)
         (r (and game (oref game renderer)))
         (action (and r (cg-renderer-hit r game (event-start event)))))
    (when action (cg-render-apply game action))))

(defvar cg-sol-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map [mouse-1] #'cg-sol-mouse)
    (define-key map (kbd "<left>")  #'cg-sol-left)
    (define-key map (kbd "<right>") #'cg-sol-right)
    (define-key map (kbd "<up>")    #'cg-sol-up)
    (define-key map (kbd "<down>")  #'cg-sol-down)
    (define-key map (kbd "RET") #'cg-sol-act)
    (define-key map (kbd "SPC") #'cg-sol-act)
    (define-key map "f" #'cg-sol-to-foundation)
    (define-key map "a" #'cg-sol-auto)
    (define-key map "u" #'cg-sol-undo)
    (define-key map "n" #'cg-sol-new)
    (define-key map "g" #'cg-sol-redraw)
    (define-key map "?" #'cg-sol-help)
    (define-key map "q" #'cg-quit-to-menu)
    map)
  "Keymap for `cg-sol-mode'.")

(defun cg-sol--classic-keymap ()
  "Return a copy of `cg-sol-mode-map' with vi-style hjkl added."
  (let ((map (copy-keymap cg-sol-mode-map)))
    (define-key map "h" #'cg-sol-left)
    (define-key map "l" #'cg-sol-right)
    (define-key map "j" #'cg-sol-down)
    (define-key map "k" #'cg-sol-up)
    map))

(define-derived-mode cg-sol-mode special-mode "Solitaire"
  "Major mode for the tableau solitaires."
  (setq-local truncate-lines t)
  (setq-local cursor-type cg-cursor-type)
  (when (eq cg-keys 'classic)
    (use-local-map (cg-sol--classic-keymap))))

(defun cg-sol--play (class)
  "Start a solitaire game of CLASS in its own buffer."
  (let* ((game (cg-sol--deal (make-instance class)))
         (buf (get-buffer-create (format "*%s*" (oref game vname)))))
    (with-current-buffer buf
      (cg-sol-mode)
      (setq cg-sol--game game)
      (cg-sol--redisplay))
    (switch-to-buffer buf)))

;;;###autoload
(defun cg-klondike ()
  "Play Klondike, the classic solitaire."
  (interactive) (cg-sol--play 'cg-klondike-game))

;;;###autoload
(defun cg-freecell ()
  "Play FreeCell solitaire."
  (interactive) (cg-sol--play 'cg-freecell-game))

;;;###autoload
(defun cg-spider ()
  "Play Spider solitaire (two decks)."
  (interactive) (cg-sol--play 'cg-spider-game))

;;;###autoload
(defun cg-yukon ()
  "Play Yukon solitaire."
  (interactive) (cg-sol--play 'cg-yukon-game))


;;;; More games: Forty Thieves, Scorpion, Canfield

(defclass cg-forty-game (cg-solitaire-game)
  ((ncols :initform 10) (ndecks :initform 2) (nfound :initform 8)
   (has-stock :initform t) (has-waste :initform t) (redeal :initform nil)
   (build :initform 'suit) (run-rule :initform 'suit) (empty-rule :initform 'any)
   (vname :initform "Forty Thieves"))
  "Forty Thieves: two decks, ten columns, eight foundations, no redeal.")

(cl-defmethod cg-sol--layout ((_ cg-forty-game))
  (make-list 10 (cons 0 4)))

(defclass cg-scorpion-game (cg-solitaire-game)
  ((ncols :initform 7) (nfound :initform 0) (has-stock :initform t)
   (build :initform 'suit) (run-rule :initform 'any) (empty-rule :initform 'king)
   (target-sets :initform 4) (vname :initform "Scorpion"))
  "Scorpion: build down by suit, move any buried group, clear four runs.")

(cl-defmethod cg-sol--layout ((_ cg-scorpion-game))
  (append (make-list 4 (cons 3 4)) (make-list 3 (cons 0 7))))

(cl-defmethod cg-sol--stock-action ((game cg-scorpion-game))
  "Deal the three stock cards onto the first three columns."
  (let ((stock (cg-get game :stock)))
    (if (null stock)
        (cg-put game :message "The stock is empty.")
      (cg-sol--snapshot game)
      (dotimes (c (min 3 (length stock)))
        (cg-sol--set-col game c (append (cg-sol--col game c) (last stock 1)))
        (setq stock (butlast stock 1)))
      (cg-put game :stock stock)
      (cg-sol--harvest game)
      (cg-put game :message "Dealt the stock onto the first columns."))))

(defclass cg-canfield-game (cg-solitaire-game)
  ((ncols :initform 4) (nfound :initform 4) (has-stock :initform t)
   (has-waste :initform t) (has-reserve :initform t) (draw :initform 3)
   (redeal :initform t) (build :initform 'alt) (run-rule :initform 'alt)
   (empty-rule :initform 'any) (wrap :initform t) (vname :initform "Canfield"))
  "Canfield: a 13-card reserve and a variable foundation base rank.")

(cl-defmethod cg-sol--deal ((game cg-canfield-game))
  "Deal a Canfield layout: reserve, base foundation, four columns, stock."
  (let* ((deck (cg-sol--make-deck 1))
         (reserve (cl-loop repeat 13 collect (pop deck)))
         (first (pop deck))
         (found (make-vector 4 nil))
         (tableau (make-vector 4 nil))
         (down (make-vector 4 0)))
    (oset game base (cdr first))
    (aset found 0 (list first))
    (dotimes (c 4) (aset tableau c (list (pop deck))))
    (cg-put game :reserve reserve)
    (cg-put game :tableau tableau)
    (cg-put game :down down)
    (cg-put game :found found)
    (cg-put game :free (make-vector 0 nil))
    (cg-put game :stock deck)
    (cg-put game :waste nil)
    (cg-put game :sets 0)
    (cg-put game :moves 0)
    (cg-put game :cursor 0)
    (cg-put game :sel nil)
    (cg-put game :sel-n 0)
    (cg-put game :history nil)
    (cg-put game :message
            (format "Canfield.  Foundations build up from %s (wrapping).  RET deals three."
                    (aref cg-sol-ranks (cdr first))))
    game))

(cl-defmethod cg-sol--autofill ((_ cg-solitaire-game)) nil)
(cl-defmethod cg-sol--autofill ((game cg-canfield-game))
  "Fill empty columns from the reserve, as Canfield requires."
  (dotimes (c (oref game ncols))
    (when (and (null (cg-sol--col game c)) (cg-get game :reserve))
      (let ((card (car (last (cg-get game :reserve)))))
        (cg-put game :reserve (butlast (cg-get game :reserve) 1))
        (cg-sol--set-col game c (list card))))))


;;;; Russian Bank (single-player patience)

;; A one-deck patience in the spirit of Russian Bank (Crapette): eight
;; "houses" build down in alternating colours, the four foundations build
;; up by suit from the Ace, and a thirteen-card reserve feeds the houses.
;; The competitive two-player game (with its "stops" and loading rules)
;; is a separate, larger build; this is the solitaire adaptation.

(defclass cg-russian-bank-game (cg-solitaire-game)
  ((ncols :initform 8) (nfound :initform 4) (has-stock :initform t)
   (has-waste :initform t) (has-reserve :initform t) (draw :initform 1)
   (redeal :initform t) (build :initform 'alt) (run-rule :initform 'alt)
   (empty-rule :initform 'any) (base :initform 0) (wrap :initform nil)
   (vname :initform "Russian Bank"))
  "Russian Bank patience: eight houses down by alternating colour, four
foundations up by suit from the Ace, and a thirteen-card reserve.")

(cl-defmethod cg-sol--deal ((game cg-russian-bank-game))
  "Deal a Russian Bank patience layout: reserve, eight houses, and a stock."
  (let* ((deck (cg-sol--make-deck 1))
         (reserve (cl-loop repeat 13 collect (pop deck)))
         (tableau (make-vector 8 nil))
         (down (make-vector 8 0)))
    (dotimes (c 8) (aset tableau c (list (pop deck))))
    (cg-put game :reserve reserve)
    (cg-put game :tableau tableau)
    (cg-put game :down down)
    (cg-put game :found (make-vector 4 nil))
    (cg-put game :free (make-vector 0 nil))
    (cg-put game :stock deck)
    (cg-put game :waste nil)
    (cg-put game :sets 0)
    (cg-put game :moves 0)
    (cg-put game :cursor 0)
    (cg-put game :sel nil)
    (cg-put game :sel-n 0)
    (cg-put game :history nil)
    (cg-put game :message
            "Russian Bank.  Houses build down in alternating colours; foundations up by suit from the Ace.")
    game))

(cl-defmethod cg-sol--autofill ((game cg-russian-bank-game))
  "Fill an empty house from the reserve, as Russian Bank does."
  (dotimes (c (oref game ncols))
    (when (and (null (cg-sol--col game c)) (cg-get game :reserve))
      (let ((card (car (last (cg-get game :reserve)))))
        (cg-put game :reserve (butlast (cg-get game :reserve) 1))
        (cg-sol--set-col game c (list card))))))

;;;###autoload
(defun cg-forty-thieves ()
  "Play Forty Thieves solitaire (two decks)."
  (interactive) (cg-sol--play 'cg-forty-game))

;;;###autoload
(defun cg-scorpion ()
  "Play Scorpion solitaire."
  (interactive) (cg-sol--play 'cg-scorpion-game))

;;;###autoload
(defun cg-canfield ()
  "Play Canfield solitaire."
  (interactive) (cg-sol--play 'cg-canfield-game))

;;;###autoload
(defun cg-russian-bank ()
  "Play Russian Bank, the single-player patience adaptation."
  (interactive) (cg-sol--play 'cg-russian-bank-game))

(provide 'cg-solitaire)
;;; cg-solitaire.el ends here
