;;; cg-eights.el --- Crazy Eights, a shedding card game -*- lexical-binding: t; -*- ;; Copyright (C) 2026 Corwin Brust ;; Author: Corwin Brust ;; Maintainer: Corwin Brust ;; Version: 1.0.60 ;; 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 . ;;; Commentary: ;; Crazy Eights: shed your whole hand by matching the suit or rank of the ;; card on top of the discard pile. Eights are wild -- play one any time ;; and name the suit that must follow. If you cannot play, draw a card. ;; You are the South player; the others are simple AI. This is the direct ;; ancestor of UNO, and the shedding engine generalises to climbing games ;; such as President. ;; ;; Cards are the package-standard cons (SUIT . RANK) with SUIT 0 spades, ;; 1 clubs, 2 diamonds, 3 hearts and RANK 0 (the Two) .. 12 (the Ace). ;;; Code: (require 'cl-lib) (require 'eieio) (require 'cg-core) (defconst cg-eights-ranks ["2" "3" "4" "5" "6" "7" "8" "9" "10" "J" "Q" "K" "A"] "Rank labels indexed 0 (Two) .. 12 (Ace).") (defconst cg-eights--wild 6 "Rank index of the wild Eight.") (defcustom cg-eights-players 3 "Number of players in Crazy Eights, including you (2-4)." :type '(choice (const 2) (const 3) (const 4)) :group 'card-games) (defun cg-eights-card-string (card) "Return a short string for CARD." (if (null card) "·" (concat (aref cg-eights-ranks (cdr card)) (cg-suit-glyph (car card))))) (defsubst cg-eights-red-p (card) (and card (cg-red-suit-p (car card)))) (defun cg-eights--value (card) "Return the scoring value of CARD held at the end of a hand." (cond ((= (cdr card) cg-eights--wild) 50) ((>= (cdr card) 9) 10) ; J Q K ((= (cdr card) 12) 1) ; (Ace handled above by >=9? no) (t (+ 2 (cdr card))))) (defun cg-eights--deck () "Return a fresh shuffled 52-card deck." (random t) (cg-shuffle (cl-loop for s below 4 append (cl-loop for r below 13 collect (cons s r))))) (defclass cg-eights-game (cg-game) ((vname :initform "Crazy Eights")) "A game of Crazy Eights.") (defsubst cg-eights--hand (game s) (aref (cg-get game :hands) s)) (defsubst cg-eights--set-hand (game s v) (aset (cg-get game :hands) s v)) (defsubst cg-eights--top (game) (car (cg-get game :discard))) (cl-defmethod cg-eights--deal ((game cg-eights-game)) "Deal a fresh Crazy Eights hand into GAME." (let* ((n (max 2 (min 4 cg-eights-players))) (deck (cg-eights--deck)) (per (if (= n 2) 7 5)) (hands (make-vector n nil))) (dotimes (s n) (aset hands s (cl-loop repeat per collect (pop deck)))) ;; turn up a starter that is not an eight (let ((start (pop deck))) (while (= (cdr start) cg-eights--wild) (setq deck (append deck (list start)) start (pop deck))) (cg-put game :discard (list start)) (cg-put game :suit (car start))) (cg-put game :stock deck) (cg-put game :hands hands) (cg-put game :nplayers n) (cg-put game :turn 0) (cg-put game :phase 'play) (cg-put game :passes 0) (cg-put game :cursor 0) (unless (cg-get game :scores) (cg-put game :scores (make-vector n 0))) (cg-put game :message "Match the suit or rank; eights are wild. d draws.") game)) (cl-defmethod cg-eights--legal-p ((game cg-eights-game) card) "Return non-nil when CARD may be played onto the discard now." (or (= (cdr card) cg-eights--wild) (= (car card) (cg-get game :suit)) (= (cdr card) (cdr (cg-eights--top game))))) (defun cg-eights--legal-moves (game s) "Return the cards in seat S's hand that may be played now." (cl-remove-if-not (lambda (c) (cg-eights--legal-p game c)) (cg-eights--hand game s))) (defun cg-eights--best-suit (game s) "Return the suit seat S holds most of (ignoring eights)." (let ((counts (make-vector 4 0))) (dolist (c (cg-eights--hand game s)) (unless (= (cdr c) cg-eights--wild) (aset counts (car c) (1+ (aref counts (car c)))))) (let ((best 0)) (dotimes (i 4) (when (> (aref counts i) (aref counts best)) (setq best i))) best))) (cl-defmethod cg-eights--play ((game cg-eights-game) s card &optional suit) "Have seat S play CARD; SUIT names the next suit for a wild eight." (cg-eights--set-hand game s (remove card (cg-eights--hand game s))) (cg-put game :discard (cons card (cg-get game :discard))) (cg-put game :suit (if (= (cdr card) cg-eights--wild) (or suit (cg-eights--best-suit game s)) (car card))) (cg-put game :passes 0) (if (null (cg-eights--hand game s)) (cg-eights--finish game s) (cg-put game :turn (mod (1+ s) (cg-get game :nplayers))))) (defun cg-eights--draw-card (game s) "Move one card from the stock to seat S's hand, recycling if needed. Return the drawn card, or nil when none is available." (when (and (null (cg-get game :stock)) (cdr (cg-get game :discard))) (let ((top (car (cg-get game :discard)))) (cg-put game :stock (cg-shuffle (cdr (cg-get game :discard)))) (cg-put game :discard (list top)))) (let ((stock (cg-get game :stock))) (when stock (let ((card (car stock))) (cg-put game :stock (cdr stock)) (cg-eights--set-hand game s (cons card (cg-eights--hand game s))) card)))) (cl-defmethod cg-eights--finish ((game cg-eights-game) winner) "Record WINNER going out and score the other hands against them." (let ((sum 0)) (dotimes (s (cg-get game :nplayers)) (unless (= s winner) (dolist (c (cg-eights--hand game s)) (setq sum (+ sum (cg-eights--value c)))))) (aset (cg-get game :scores) winner (+ (aref (cg-get game :scores) winner) sum)) (cg-put game :phase 'game-over) (cg-put game :winner winner) (cg-put game :message (format "%s goes out and scores %d. Press n for a new deal." (if (= winner 0) "You" (format "Player %d" winner)) sum)))) (cl-defmethod cg-eights--ai-turn ((game cg-eights-game) s) "Take seat S's whole turn: play if able, otherwise draw then play or pass." (let ((moves (cg-eights--legal-moves game s))) (unless moves ;; draw up to a small limit looking for a play (let ((tries 0)) (while (and (not moves) (< tries 60) (cg-eights--draw-card game s)) (setq moves (cg-eights--legal-moves game s) tries (1+ tries))))) (if moves ;; prefer a non-eight of lowest value; keep eights for later (let* ((non (cl-remove-if (lambda (c) (= (cdr c) cg-eights--wild)) moves)) (pick (car (sort (copy-sequence (or non moves)) (lambda (a b) (< (cg-eights--value a) (cg-eights--value b))))))) (cg-eights--play game s pick)) ;; truly stuck: pass (cg-put game :passes (1+ (cg-get game :passes))) (cg-put game :turn (mod (1+ s) (cg-get game :nplayers)))))) (defun cg-eights--run (game) "Advance AI seats until it is the human's turn or the hand ends." (while (and (eq (cg-get game :phase) 'play) (/= (cg-get game :turn) 0) (< (cg-get game :passes) (cg-get game :nplayers))) (cg-eights--ai-turn game (cg-get game :turn))) (when (>= (cg-get game :passes) (cg-get game :nplayers)) (cg-eights--deadlock game))) (cl-defmethod cg-eights--deadlock ((game cg-eights-game)) "End a hand in which everyone passed; lowest hand value wins." (let ((best 0) (bestv most-positive-fixnum)) (dotimes (s (cg-get game :nplayers)) (let ((v (apply #'+ (mapcar #'cg-eights--value (cg-eights--hand game s))))) (when (< v bestv) (setq bestv v best s)))) (cg-eights--finish game best))) ;;;; UI (defvar-local cg-eights--game nil "The Crazy Eights game in the current buffer.") (cl-defmethod cg-render ((game cg-eights-game)) "Return a propertized string depicting GAME for a text display." (let* ((out (list)) (top (cg-eights--top game)) (hand (cg-eights--hand game 0)) (cursor (cg-get game :cursor))) (push (format " Crazy Eights\n\n") out) (dotimes (s (cg-get game :nplayers)) (unless (= s 0) (push (format " Player %d: %d cards (score %d)\n" s (length (cg-eights--hand game s)) (aref (cg-get game :scores) s)) out))) (push (format "\n Discard top: %s Suit in play: %s Stock: %d\n\n" (let ((cs (cg-eights-card-string top))) (if (cg-eights-red-p top) (propertize cs 'face 'cg-red-suit) cs)) (cg-suit-glyph (cg-get game :suit)) (length (cg-get game :stock))) out) (push (format " Your hand (score %d):\n " (aref (cg-get game :scores) 0)) out) (let ((i 0)) (dolist (c hand) (let ((cs (cg-eights-card-string c)) (faces nil)) (when (cg-eights-red-p c) (push 'cg-red-suit faces)) (when (cg-eights--legal-p game c) (push 'cg-hint faces)) (when (= i cursor) (push 'cg-cursor faces)) (push (propertize (format "%4s" cs) 'face (or faces 'default)) out)) (setq i (1+ i)))) (push (format "\n\n %s\n" (cg-get game :message)) out) (apply #'concat (nreverse out)))) (defun cg-eights--redisplay () "Redraw the Crazy Eights buffer." (let ((game cg-eights--game) (inhibit-read-only t)) (setq-local mode-line-process (format " [%s]" (cg-get game :phase))) (erase-buffer) (insert (cg-render game)) (goto-char (point-min)))) (defun cg-eights--cursor-card (game) (nth (cg-get game :cursor) (cg-eights--hand game 0))) (defun cg-eights-left () "Move the hand cursor left." (interactive) (let* ((game cg-eights--game) (n (length (cg-eights--hand game 0)))) (when (> n 0) (cg-put game :cursor (mod (1- (cg-get game :cursor)) n))) (cg-eights--redisplay))) (defun cg-eights-right () "Move the hand cursor right." (interactive) (let* ((game cg-eights--game) (n (length (cg-eights--hand game 0)))) (when (> n 0) (cg-put game :cursor (mod (1+ (cg-get game :cursor)) n))) (cg-eights--redisplay))) (defun cg-eights--choose-suit (game) "Return a suit the human names for a wild eight." (if noninteractive (cg-eights--best-suit game 0) (let* ((names (mapcar (lambda (i) (cons (aref cg-suit-names i) i)) '(0 1 2 3))) (pick (completing-read "Name the suit: " (mapcar #'car names) nil t))) (cdr (assoc pick names))))) (defun cg-eights-act () "Play the selected card if it is legal." (interactive) (let* ((game cg-eights--game) (card (cg-eights--cursor-card game))) (cond ((not (eq (cg-get game :phase) 'play)) (cg-put game :message "Press n for a new deal.")) ((/= (cg-get game :turn) 0) (cg-put game :message "Not your turn.")) ((null card) (cg-put game :message "No card selected.")) ((not (cg-eights--legal-p game card)) (cg-put game :message "That card does not match — draw with d if stuck.")) (t (let ((suit (and (= (cdr card) cg-eights--wild) (cg-eights--choose-suit game)))) (cg-eights--play game 0 card suit) (cg-put game :cursor 0) (cg-eights--run game)))) (cg-eights--redisplay))) (defun cg-eights-draw () "Draw a card; if the stock cannot help, pass your turn." (interactive) (let ((game cg-eights--game)) (when (eq (cg-get game :phase) 'play) (if (= (cg-get game :turn) 0) (let ((card (cg-eights--draw-card game 0))) (if card (cg-put game :message (format "You drew %s." (cg-eights-card-string card))) (cg-put game :passes (1+ (cg-get game :passes))) (cg-put game :turn (mod 1 (cg-get game :nplayers))) (cg-put game :message "Nothing to draw — you pass.") (cg-eights--run game))) (cg-put game :message "Not your turn."))) (cg-eights--redisplay))) (defun cg-eights-pass () "Pass your turn (only sensible after drawing with an empty stock)." (interactive) (let ((game cg-eights--game)) (when (and (eq (cg-get game :phase) 'play) (= (cg-get game :turn) 0)) (cg-put game :passes (1+ (cg-get game :passes))) (cg-put game :turn (mod 1 (cg-get game :nplayers))) (cg-eights--run game)) (cg-eights--redisplay))) (defun cg-eights-new () "Deal a fresh hand." (interactive) (cg-eights--deal cg-eights--game) (cg-eights--run cg-eights--game) (cg-eights--redisplay)) (defun cg-eights-redraw () "Redraw." (interactive) (cg-eights--redisplay)) (defun cg-eights-help () "Describe the controls." (interactive) (message "Arrows: choose RET: play d: draw x: pass n: new deal g: redraw")) (defvar cg-eights-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "") #'cg-eights-left) (define-key map (kbd "") #'cg-eights-right) (define-key map (kbd "RET") #'cg-eights-act) (define-key map (kbd "SPC") #'cg-eights-act) (define-key map "d" #'cg-eights-draw) (define-key map "x" #'cg-eights-pass) (define-key map "n" #'cg-eights-new) (define-key map "g" #'cg-eights-redraw) (define-key map "?" #'cg-eights-help) map) "Keymap for `cg-eights-mode'.") (define-derived-mode cg-eights-mode special-mode "Crazy8" "Major mode for Crazy Eights." (setq-local truncate-lines t)) ;;;###autoload (defun cg-eights () "Play Crazy Eights against the computer." (interactive) (let ((buf (get-buffer-create "*Crazy Eights*"))) (with-current-buffer buf (cg-eights-mode) (setq cg-eights--game (cg-eights-game)) (cg-eights--deal cg-eights--game) (cg-eights--run cg-eights--game) (cg-eights--redisplay)) (switch-to-buffer buf))) (provide 'cg-eights) ;;; cg-eights.el ends here