;;; card-games-spite.el --- Spite and Malice, a competitive patience -*- lexical-binding: t; -*- ;; Copyright (C) 2026 Corwin Brust ;; Author: Corwin Brust ;; Maintainer: Corwin Brust ;; Version: 1.0.91 ;; 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 . ;;; Commentary: ;; Spite & Malice (also called Cat & Mouse): a race between you and the ;; computer to empty a face-down goal pile. Play cards onto up to four ;; shared centre piles, which build up from Ace to Queen regardless of ;; suit; a pile that reaches a Queen is cleared away. Kings are wild and ;; stand for whatever rank a pile needs next. ;; ;; On your turn, draw your hand up to five, then play from the top of your ;; goal pile, your hand, or the tops of your four discard piles. Playing ;; your goal card is how you win, so take every chance to. End your turn ;; by discarding one card to a discard pile. ;; ;; Targets are chosen automatically (the first centre pile a card fits). ;; Cards use the package cons (SUIT . RANK), RANK 0 (Ace) .. 12 (King); ;; the build order runs Ace(0) up to Queen(11), and the King(12) is wild. ;;; Code: (require 'cl-lib) (require 'eieio) (require 'card-games-core) (require 'card-games-rummy) (defcustom card-games-spite-goal-size 20 "Number of cards in each player's goal pile." :type 'integer :group 'card-games) (defclass card-games-spite-game (card-games-game) ((vname :initform "Spite & Malice")) "A game of Spite & Malice.") (defun card-games-spite--wild-p (card) "Return non-nil when CARD (a King) is wild." (= (cdr card) 12)) (defun card-games-spite--nat (card) "Return CARD's natural build rank, or nil if wild." (if (card-games-spite--wild-p card) nil (cdr card))) (defun card-games-spite--deck () "Return two shuffled standard decks (104 cards)." (random t) (card-games-shuffle (cl-loop repeat 2 append (cl-loop for s below 4 append (cl-loop for r below 13 collect (cons s r)))))) ;;;; Accessors (defsubst card-games-spite--goal (game s) "Return seat S's goal pile in GAME." (aref (card-games-get game :goal) s)) (defsubst card-games-spite--set-goal (game s v) "Set seat S's goal pile in GAME to V." (aset (card-games-get game :goal) s v)) (defsubst card-games-spite--hand (game s) "Return seat S's hand in GAME." (aref (card-games-get game :hand) s)) (defsubst card-games-spite--set-hand (game s v) "Set seat S's hand in GAME to V." (aset (card-games-get game :hand) s v)) (defsubst card-games-spite--disc (game s) "Return seat S's discard piles in GAME." (aref (card-games-get game :disc) s)) ; vector of 4 lists (defun card-games-spite--who (s) "Return the display name of seat S." (if (= s 0) "You" "Computer")) (cl-defmethod card-games-spite--deal ((game card-games-spite-game)) "Deal a fresh Spite & Malice game into GAME." (let ((deck (card-games-spite--deck)) (goal (make-vector 2 nil)) (hand (make-vector 2 nil)) (disc (vector nil nil))) (dotimes (s 2) (aset goal s (cl-loop repeat card-games-spite-goal-size collect (pop deck))) (aset hand s (card-games-rummy-sort-hand (cl-loop repeat 5 collect (pop deck)))) (aset disc s (make-vector 4 nil))) (card-games-put game :goal goal) (card-games-put game :hand hand) (card-games-put game :disc disc) (card-games-put game :center (make-vector 4 nil)) ; each nil or (TOPRANK . CARDS) (card-games-put game :muck nil) (card-games-put game :stock deck) (card-games-put game :turn 0) (card-games-put game :phase 'play) (card-games-put game :cursor 0) (card-games-put game :message "Your turn. RET plays a hand card; G plays your goal card.") game)) ;;;; Stock and centre piles (defun card-games-spite--draw-stock (game) "Pop one card from GAME's stock, recycling the muck when it is empty." (when (and (null (card-games-get game :stock)) (card-games-get game :muck)) (card-games-put game :stock (card-games-shuffle (card-games-get game :muck))) (card-games-put game :muck nil)) (let ((stock (card-games-get game :stock))) (when stock (card-games-put game :stock (cdr stock)) (car stock)))) (defun card-games-spite--refill (game s) "Draw GAME seat S's hand back up to five cards." (while (and (< (length (card-games-spite--hand game s)) 5) (or (card-games-get game :stock) (card-games-get game :muck))) (let ((c (card-games-spite--draw-stock game))) (when c (card-games-spite--set-hand game s (card-games-rummy-sort-hand (cons c (card-games-spite--hand game s)))))))) (defun card-games-spite--needed (game i) "Return the rank GAME centre pile I needs next (0 for an empty slot)." (let ((p (aref (card-games-get game :center) i))) (if p (1+ (car p)) 0))) (defun card-games-spite--legal-center (game card) "Return the index of the first GAME centre pile CARD may be played on, or nil." (let ((found nil)) (dotimes (i 4) (let ((need (card-games-spite--needed game i))) (when (and (null found) (<= need 11) (or (card-games-spite--wild-p card) (eql (card-games-spite--nat card) need))) (setq found i)))) found)) (defun card-games-spite--put-center (game card i) "Place CARD on GAME centre pile I; clear the pile if it reaches a Queen." (let* ((need (card-games-spite--needed game i)) (p (aref (card-games-get game :center) i)) (cards (cons card (and p (cdr p))))) (if (= need 11) ; completed Ace..Queen (progn (card-games-put game :muck (append cards (card-games-get game :muck))) (aset (card-games-get game :center) i nil)) (aset (card-games-get game :center) i (cons need cards))))) ;;;; Plays (defun card-games-spite--play-hand (game s card i) "Have GAME seat S play hand CARD onto centre pile I." (card-games-spite--set-hand game s (cl-remove card (card-games-spite--hand game s) :test #'equal :count 1)) (card-games-spite--put-center game card i) (when (null (card-games-spite--hand game s)) (card-games-spite--refill game s))) (defun card-games-spite--play-goal (game s i) "Have GAME seat S play the top of their goal pile onto centre pile I." (let ((card (car (card-games-spite--goal game s)))) (card-games-spite--set-goal game s (cdr (card-games-spite--goal game s))) (card-games-spite--put-center game card i) (when (null (card-games-spite--goal game s)) (card-games-put game :phase 'game-over) (card-games-put game :winner s)))) (defun card-games-spite--play-disc (game s d i) "Have GAME seat S play the top of discard pile D onto centre pile I." (let* ((pile (aref (card-games-spite--disc game s) d)) (card (car pile))) (aset (card-games-spite--disc game s) d (cdr pile)) (card-games-spite--put-center game card i))) (defun card-games-spite--discard (game s card d) "Have GAME seat S discard CARD from hand onto pile D, ending the turn." (card-games-spite--set-hand game s (cl-remove card (card-games-spite--hand game s) :test #'equal :count 1)) (aset (card-games-spite--disc game s) d (cons card (aref (card-games-spite--disc game s) d))) (card-games-put game :turn (- 1 s))) ;;;; AI (defun card-games-spite--ai-one (game s) "Make one beneficial play for GAME seat S; return non-nil if one was made." (let ((goal (car (card-games-spite--goal game s))) (done nil)) (cond ;; 1. advance the goal card (a wild goal card plays anywhere) ((and goal (card-games-spite--legal-center game goal)) (card-games-spite--play-goal game s (card-games-spite--legal-center game goal)) (setq done t)) ;; 2. a non-wild hand card that fits ((cl-find-if (lambda (c) (and (not (card-games-spite--wild-p c)) (card-games-spite--legal-center game c))) (card-games-spite--hand game s)) (let ((card (cl-find-if (lambda (c) (and (not (card-games-spite--wild-p c)) (card-games-spite--legal-center game c))) (card-games-spite--hand game s)))) (card-games-spite--play-hand game s card (card-games-spite--legal-center game card)) (setq done t))) (t ;; 3. a non-wild discard top that fits (catch 'hit (dotimes (d 4) (let ((top (car (aref (card-games-spite--disc game s) d)))) (when (and top (not (card-games-spite--wild-p top)) (card-games-spite--legal-center game top)) (card-games-spite--play-disc game s d (card-games-spite--legal-center game top)) (setq done t) (throw 'hit t)))) ;; 4. use a wild King: bridge to the goal card if possible, else ;; advance the most-built pile to keep cards flowing (let ((king (cl-find-if #'card-games-spite--wild-p (card-games-spite--hand game s)))) (when king (let* ((gr (and goal (card-games-spite--nat goal))) (target nil)) (when gr (dotimes (i 4) (let ((need (card-games-spite--needed game i))) (when (and (null target) (<= need 11) (= need (1- gr))) (setq target i))))) (unless target (let ((bestneed -1)) (dotimes (i 4) (let ((need (card-games-spite--needed game i))) (when (and (<= need 11) (> need bestneed)) (setq bestneed need target i)))))) (when target (card-games-spite--play-hand game s king target) (setq done t)))))))) done)) (defun card-games-spite--ai-turn (game s) "Take GAME seat S's whole AI turn: play what helps, then discard." (card-games-spite--refill game s) (let ((guard 0)) (while (and (eq (card-games-get game :phase) 'play) (< guard 300) (card-games-spite--ai-one game s)) (setq guard (1+ guard)))) (when (eq (card-games-get game :phase) 'play) (let ((hand (card-games-spite--hand game s))) (if (null hand) (card-games-put game :turn (- 1 s)) ; played out, nothing to discard ;; discard the highest non-wild card; keep Kings (wild) (let* ((nonk (cl-remove-if #'card-games-spite--wild-p hand)) (card (car (sort (copy-sequence (or nonk hand)) (lambda (a b) (> (cdr a) (cdr b)))))) (d (card-games-spite--ai-disc-pile game s card))) (card-games-spite--discard game s card d)))))) (defun card-games-spite--ai-disc-pile (game s card) "Choose GAME seat S's discard pile for CARD (empty, else topped just above)." (let ((disc (card-games-spite--disc game s)) (empty nil) (best nil) (bestv 99)) (dotimes (d 4) (let ((top (car (aref disc d)))) (cond ((null top) (unless empty (setq empty d))) ((and (not (card-games-spite--wild-p top)) (>= (cdr top) (cdr card)) (< (- (cdr top) (cdr card)) bestv)) (setq best d bestv (- (cdr top) (cdr card))))))) (or best empty 0))) (defun card-games-spite--run (game) "Let the computer (seat 1) act in GAME until your turn or the game ends." (let ((guard 0)) (while (and (eq (card-games-get game :phase) 'play) (= (card-games-get game :turn) 1) (< guard 200)) (setq guard (1+ guard)) (card-games-spite--ai-turn game 1)))) ;;;; UI (defvar-local card-games-spite--game nil "The Spite & Malice game in the current buffer.") (defun card-games-spite--center-string (game) "Return a one-line depiction of GAME's centre piles." (let ((parts '())) (dotimes (i 4) (let ((p (aref (card-games-get game :center) i))) (push (if p (format "[%s->%s]" (length (cdr p)) (aref card-games-rummy-ranks (car p))) "[ -- ]") parts))) (mapconcat #'identity (nreverse parts) " "))) (defun card-games-spite--disc-string (game s) "Return a depiction of GAME seat S's four discard-pile tops." (let ((parts '())) (dotimes (d 4) (let ((top (car (aref (card-games-spite--disc game s) d)))) (push (format "%d:%s" (1+ d) (if top (card-games-rummy-card-string top) "--")) parts))) (mapconcat #'identity (nreverse parts) " "))) (defun card-games-spite--board-svg (game) "Return an SVG board for the Spite & Malice GAME." (let* ((w card-games-svg-card-width) (h card-games-svg-card-height) (gap card-games-svg-card-gap) (pad 16) (hand (card-games-spite--hand game 0)) (n (length hand)) (cursor (card-games-get game :cursor)) (center (card-games-get game :center)) (overlap (cond ((> n 11) (- w 24)) ((> n 8) 20) (t 0))) (step (max 14 (- (+ w gap) overlap))) (fanw (if (> n 0) (+ (* (1- n) step) w) w)) (colstep (+ w 14)) (y-title 6) (y-opp 26) (y-center (+ y-opp 16)) (y-sm (+ y-center h 16)) (y-ylabel (+ y-sm 12)) (y-yours (+ y-ylabel 6)) (y-hand (+ y-yours h 42)) (height (+ y-hand h 30)) (width (max (+ fanw (* 2 pad)) (+ (* 5 colstep) (* 2 pad)) 620)) (svg (svg-create width height)) (lc (card-games-color 'shadow :foreground "gray50")) (regions '())) (cl-labels ((txt (str x y &optional sz bold) (apply #'svg-text svg str :x x :y y :font-size (or sz 12) :fill lc :font-family card-games-svg-font-family (and bold '(:font-weight "bold")))) (pilecard (spec x y) (if spec (card-games-svg-card svg x y :rank (car spec) :suit (cdr spec)) (card-games-svg-card svg x y :gap t)))) (txt (format "Spite & Malice (goal %d)" card-games-spite-goal-size) pad (+ y-title 12) 13 t) (txt (format "Computer: goal %d left hand %d discards %s" (length (card-games-spite--goal game 1)) (length (card-games-spite--hand game 1)) (card-games-spite--disc-string game 1)) pad (+ y-opp 4) 12) (txt "Centre (build A..Q; King is wild)" pad (- y-center 4) 11) (dotimes (i 4) (let* ((x (+ pad (* i colstep))) (pp (aref center i)) (spec (and pp (cons (aref card-games-rummy-ranks (car pp)) (car (cadr pp)))))) (pilecard spec x y-center))) (txt (format "Stock %d Muck %d" (length (card-games-get game :stock)) (length (card-games-get game :muck))) pad y-sm 11) (let* ((gtop (car (card-games-spite--goal game 0))) (gspec (and gtop (card-games-rummy--card-spec gtop)))) (txt (format "Your goal (%d left)" (length (card-games-spite--goal game 0))) pad y-ylabel 11) (txt "Discards" (+ pad colstep) y-ylabel 11) (pilecard gspec pad y-yours) (dotimes (d 4) (let* ((x (+ pad colstep (* d colstep))) (dtop (car (aref (card-games-spite--disc game 0) d))) (dspec (and dtop (card-games-rummy--card-spec dtop)))) (pilecard dspec x y-yours)))) (txt "Your hand" pad (- y-hand 6) 11) (let ((x (max pad (- (/ width 2) (/ fanw 2)))) (i 0)) (dolist (c hand) (let ((sp (card-games-rummy--card-spec c)) (curp (= i cursor))) (card-games-svg-card svg x y-hand :rank (car sp) :suit (cdr sp) :highlight curp) (push (cons (list x y-hand (if (= i (1- n)) w step) h) (cons 'hand i)) regions)) (setq x (+ x step) i (1+ i)))) (txt (or (card-games-get game :message) "") pad (- height 8) 12)) (propertize "*" 'display (card-games-svg-image svg (card-games-scale)) 'card-games-regions (nreverse regions)))) (cl-defmethod card-games-render ((game card-games-spite-game)) "Return a depiction of the GAME: an SVG board if graphical, else text." (if (and card-games-rummy-svg-cards (display-graphic-p)) (card-games-spite--board-svg game) (card-games-spite--render-text game))) (defun card-games-spite--render-text (game) "Return a plain-text depiction of the Spite & Malice GAME." (let* ((out '()) (cursor (card-games-get game :cursor))) (push " Spite & Malice\n\n" out) (push (format " Computer goal: %d left hand: %d discards: %s\n\n" (length (card-games-spite--goal game 1)) (length (card-games-spite--hand game 1)) (card-games-spite--disc-string game 1)) out) (push (format " Centre: %s\n" (card-games-spite--center-string game)) out) (push (format " Stock: %d Muck: %d\n\n" (length (card-games-get game :stock)) (length (card-games-get game :muck))) out) (push (format " Your goal: %s (%d left)\n" (let ((g (car (card-games-spite--goal game 0)))) (if g (card-games-rummy-card-string g) "--")) (length (card-games-spite--goal game 0))) out) (push (format " Your discards: %s\n\n" (card-games-spite--disc-string game 0)) out) (push " Your hand:\n " out) (push (card-games-rummy--render-cards (card-games-spite--hand game 0) cursor nil nil 'hand) out) (push (format "\n\n %s\n" (card-games-get game :message)) out) (apply #'concat (nreverse out)))) (cl-defmethod card-games-render-apply ((g card-games-spite-game) action) "Apply a click ACTION on the hand to GAME G." (pcase action (`(hand . ,i) (card-games-put g :cursor i) (card-games-spite-play)) (_ (cl-call-next-method)))) (defun card-games-spite--redisplay () "Redraw the current Spite & Malice buffer." (let ((game card-games-spite--game) (inhibit-read-only t)) (setq card-games-current-game game card-games-redisplay-function #'card-games-spite--redisplay) (setq-local mode-line-process (format " [%s]" (card-games-get game :phase))) (erase-buffer) (insert (card-games-render game)) (goto-char (point-min)))) (defun card-games-spite--my-turn-p (g) "Return non-nil when it is your turn to play in G." (and (eq (card-games-get g :phase) 'play) (= (card-games-get g :turn) 0))) (defun card-games-spite-left () "Move the hand cursor left." (interactive) (let* ((g card-games-spite--game) (n (length (card-games-spite--hand g 0)))) (when (> n 0) (card-games-put g :cursor (mod (1- (card-games-get g :cursor)) n))) (card-games-spite--redisplay))) (defun card-games-spite-right () "Move the hand cursor right." (interactive) (let* ((g card-games-spite--game) (n (length (card-games-spite--hand g 0)))) (when (> n 0) (card-games-put g :cursor (mod (1+ (card-games-get g :cursor)) n))) (card-games-spite--redisplay))) (defun card-games-spite--ensure-hand (g) "Draw your hand in G up to five at the start of your turn." (card-games-spite--refill g 0)) (defun card-games-spite-play () "Play the cursor hand card onto the first centre pile it fits." (interactive) (let* ((g card-games-spite--game) (card (nth (card-games-get g :cursor) (card-games-spite--hand g 0)))) (cond ((not (card-games-spite--my-turn-p g)) (card-games-put g :message "Not your turn.")) ((null card) (card-games-put g :message "No card selected.")) (t (let ((i (card-games-spite--legal-center g card))) (if (null i) (card-games-put g :message "That card fits no centre pile.") (card-games-spite--play-hand g 0 card i) (card-games-put g :cursor 0) (card-games-put g :message "Played. Keep going, or d to discard and end turn."))))) (card-games-spite--redisplay))) (defun card-games-spite-goal () "Play your goal-pile top onto the first centre pile it fits." (interactive) (let* ((g card-games-spite--game) (card (car (card-games-spite--goal g 0)))) (cond ((not (card-games-spite--my-turn-p g)) (card-games-put g :message "Not your turn.")) ((null card) (card-games-put g :message "Your goal pile is empty.")) (t (let ((i (card-games-spite--legal-center g card))) (if (null i) (card-games-put g :message "Your goal card fits no centre pile.") (card-games-spite--play-goal g 0 i) (if (eq (card-games-get g :phase) 'game-over) (card-games-put g :message "You emptied your goal -- you win! (n: new game)") (card-games-put g :message "Goal card played!")))))) (card-games-spite--redisplay))) (defun card-games-spite-play-disc () "Play the top of the discard pile whose number you pressed." (interactive) (let* ((g card-games-spite--game) (d (- last-command-event ?1)) (top (and (>= d 0) (< d 4) (car (aref (card-games-spite--disc g 0) d))))) (cond ((not (card-games-spite--my-turn-p g)) (card-games-put g :message "Not your turn.")) ((null top) (card-games-put g :message "That discard pile is empty.")) (t (let ((i (card-games-spite--legal-center g top))) (if (null i) (card-games-put g :message "That card fits no centre pile.") (card-games-spite--play-disc g 0 d i) (card-games-put g :message "Played from a discard pile."))))) (card-games-spite--redisplay))) (defun card-games-spite-discard () "Discard the cursor card to a discard pile and end your turn." (interactive) (let* ((g card-games-spite--game) (card (nth (card-games-get g :cursor) (card-games-spite--hand g 0)))) (cond ((not (card-games-spite--my-turn-p g)) (card-games-put g :message "Not your turn.")) ((null card) (card-games-put g :message "No card to discard.")) (t (card-games-spite--discard g 0 card (card-games-spite--ai-disc-pile g 0 card)) (card-games-put g :cursor 0) (card-games-spite--run g) (when (eq (card-games-get g :phase) 'play) (card-games-spite--ensure-hand g) (card-games-put g :message "Your turn.")))) (card-games-spite--redisplay))) (defun card-games-spite-new () "Deal a fresh game." (interactive) (card-games-spite--deal card-games-spite--game) (card-games-spite--redisplay)) (defun card-games-spite-redraw () "Redraw." (interactive) (card-games-spite--redisplay)) (defun card-games-spite-help () "Describe the controls." (interactive) (message "Arrows: choose RET: play hand card G: play goal 1-4: play discard top d: discard/end n: new")) (defvar card-games-spite-mode-map (let ((map (make-sparse-keymap))) (define-key map [mouse-1] #'card-games-card-click) (define-key map "+" #'card-games-card-zoom-in) (define-key map "=" #'card-games-card-zoom-in) (define-key map "-" #'card-games-card-zoom-out) (define-key map "0" #'card-games-card-zoom-reset) (define-key map (kbd "") #'card-games-spite-left) (define-key map (kbd "") #'card-games-spite-right) (define-key map (kbd "RET") #'card-games-spite-play) (define-key map "G" #'card-games-spite-goal) (dolist (k '("1" "2" "3" "4")) (define-key map k #'card-games-spite-play-disc)) (define-key map "d" #'card-games-spite-discard) (define-key map "n" #'card-games-spite-new) (define-key map "g" #'card-games-spite-redraw) (define-key map "?" #'card-games-spite-help) (define-key map "q" #'card-games-quit-to-menu) map) "Keymap for `card-games-spite-mode'.") (define-derived-mode card-games-spite-mode special-mode "Spite" "Major mode for Spite & Malice." (setq-local truncate-lines t) (setq-local cursor-type card-games-cursor-type)) ;;;###autoload (defun card-games-spite () "Play Spite & Malice against the computer." (interactive) (let ((buf (get-buffer-create "*Spite & Malice*"))) (with-current-buffer buf (card-games-spite-mode) (setq card-games-spite--game (card-games-spite-game)) (card-games-spite--deal card-games-spite--game) (card-games-spite--redisplay)) (switch-to-buffer buf))) ;;;###autoload (defalias 'card-games-cat-and-mouse #'card-games-spite) (provide 'card-games-spite) ;;; card-games-spite.el ends here