card-game.el/cg-president.el

426 lines
18 KiB
EmacsLisp
Raw Normal View History

;;; cg-president.el --- President (Scum), a climbing card game -*- lexical-binding: t; -*-
;; Copyright (C) 2026 Corwin Brust
;; Author: Corwin Brust <corwin@bru.st>
;; Maintainer: Corwin Brust <corwin@bru.st>
;; Version: 1.0.90
;; 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:
;; President (also Scum, Asshole, Daihinmin): a climbing/shedding game.
;; The leader plays one to four cards of a single rank; each player in turn
;; must beat it with the same number of a higher rank or pass. Once all but
;; one have passed, the pile clears and the last player to play leads again.
;; The first player out is President, the last is Scum; on the next deal the
;; Scum hands the President their two best cards and gets two junk cards back.
;;
;; You are the South player (seat 0); the rest are simple AI. Card power
;; runs 3 (low) .. K, A, then the Two (highest). Cards are the package
;; cons (SUIT . RANK) with RANK 0 (the Two) .. 12 (the Ace).
;;; Code:
(require 'cl-lib)
(require 'eieio)
(require 'cg-core)
(require 'cg-svg)
(defconst cg-pres-ranks
["2" "3" "4" "5" "6" "7" "8" "9" "10" "J" "Q" "K" "A"]
"Rank labels indexed 0 (Two) .. 12 (Ace).")
(defconst cg-pres-titles ["President" "Vice-President" "Citizen"
"Vice-Scum" "Scum"]
"Finishing titles from first out to last.")
(defcustom cg-president-players 4
"Number of players in President, including you (3-6)."
:type 'integer :group 'card-games)
(defun cg-pres--power (rank)
"Return the climbing power of RANK; the Two (RANK 0) is highest."
(if (= rank 0) 13 rank))
(defun cg-pres-card-string (card)
(if (null card) "·"
(concat (aref cg-pres-ranks (cdr card)) (cg-suit-glyph (car card)))))
(defsubst cg-pres-red-p (card) (and card (cg-red-suit-p (car card))))
(defun cg-pres--deck ()
(cg-shuffle (cl-loop for s below 4 append
(cl-loop for r below 13 collect (cons s r)))))
(defun cg-pres--sort (cards)
"Sort CARDS by climbing power then suit."
(sort (copy-sequence cards)
(lambda (a b) (if (= (cg-pres--power (cdr a)) (cg-pres--power (cdr b)))
(< (car a) (car b))
(< (cg-pres--power (cdr a)) (cg-pres--power (cdr b)))))))
(defclass cg-president-game (cg-game)
((vname :initform "President"))
"A game of President (Scum).")
(defsubst cg-pres--hand (game s) (aref (cg-get game :hands) s))
(defsubst cg-pres--set-hand (game s v) (aset (cg-get game :hands) s v))
(defsubst cg-pres--name (_game s)
(if (= s 0) "You" (format "Player %d" s)))
;;;; Dealing and the inter-game exchange
(cl-defmethod cg-pres--deal ((game cg-president-game))
(let* ((n (max 3 (min 6 cg-president-players)))
(deck (cg-pres--deck))
(hands (make-vector n nil))
(s 0))
(while deck
(push (pop deck) (aref hands (mod s n)))
(cl-incf s))
(dotimes (i n) (aset hands i (cg-pres--sort (aref hands i))))
(cg-put game :hands hands)
(cg-put game :nplayers n)
(cg-pres--exchange game) ; carry out roles from the last deal
(cg-put game :count 0)
(cg-put game :top -1)
(cg-put game :passed (make-vector n nil))
(cg-put game :out nil)
(cg-put game :last-player nil)
(cg-put game :turn 0)
(cg-put game :phase 'play)
(cg-put game :cursor 0)
(unless (cg-get game :games) (cg-put game :games 0))
(cg-put game :message
"Lead any rank; others beat it with a higher one or pass. p passes.")
game))
(defun cg-pres--best (hand k) "The K highest-power cards of HAND." (last (cg-pres--sort hand) k))
(defun cg-pres--worst (hand k) "The K lowest-power cards of HAND." (cl-subseq (cg-pres--sort hand) 0 k))
(cl-defmethod cg-pres--exchange ((game cg-president-game))
"Trade cards by rank from the previous deal's finishing order, if any."
(let ((order (cg-get game :order)) (n (cg-get game :nplayers)))
(when (and order (= (length order) n) (>= n 4))
(let* ((prez (nth 0 order)) (scum (nth (1- n) order))
(vp (nth 1 order)) (vice (nth (- n 2) order)))
(cg-pres--give game scum prez 2) ; scum's 2 best -> president
(cg-pres--give game prez scum 2 t) ; president's 2 worst -> scum
(cg-pres--give game vice vp 1)
(cg-pres--give game vp vice 1 t)))))
(defun cg-pres--give (game from to k &optional worst)
"Move K cards (best, or WORST) from seat FROM to seat TO."
(let* ((cards (if worst (cg-pres--worst (cg-pres--hand game from) k)
(cg-pres--best (cg-pres--hand game from) k))))
(cg-pres--set-hand game from
(cl-set-difference (cg-pres--hand game from) cards :test #'equal))
(cg-pres--set-hand game to
(cg-pres--sort (append (cg-pres--hand game to) cards)))))
;;;; Move logic
(defun cg-pres--rank-counts (game s)
"Return an alist (RANK . COUNT) for seat S's hand."
(let ((tbl nil))
(dolist (c (cg-pres--hand game s))
(setf (alist-get (cdr c) tbl 0) (1+ (alist-get (cdr c) tbl 0))))
tbl))
(defun cg-pres--legal-ranks (game s)
"Return the ranks seat S may legally play now."
(let ((cnt (cg-get game :count)) (top (cg-get game :top)))
(cl-loop for (r . c) in (cg-pres--rank-counts game s)
when (if (= cnt 0) t (and (>= c cnt) (> (cg-pres--power r) top)))
collect r)))
(defun cg-pres--remove-n (hand rank n)
"Remove N cards of RANK from HAND."
(let ((out nil) (left n))
(dolist (c hand) (if (and (> left 0) (= (cdr c) rank))
(cl-decf left) (push c out)))
(nreverse out)))
(defun cg-pres--in-game (game)
"Seats that still hold cards."
(cl-loop for s below (cg-get game :nplayers)
unless (memq s (cg-get game :out)) collect s))
(defun cg-pres--round-active (game)
"Seats that can still act on the current pile."
(cl-loop for s below (cg-get game :nplayers)
unless (or (memq s (cg-get game :out)) (aref (cg-get game :passed) s))
collect s))
(defun cg-pres--next (game from)
"Next seat after FROM that is still in the round."
(let ((n (cg-get game :nplayers)) (s from) (res nil))
(dotimes (_ n)
(setq s (mod (1+ s) n))
(when (and (not res)
(not (memq s (cg-get game :out)))
(not (aref (cg-get game :passed) s)))
(setq res s)))
(or res from)))
(defun cg-pres--clear (game)
"Clear the pile; the last player to play leads (or the next active seat)."
(cg-put game :count 0) (cg-put game :top -1)
(cg-put game :passed (make-vector (cg-get game :nplayers) nil))
(let ((last (cg-get game :last-player)))
(cg-put game :turn (if (and last (not (memq last (cg-get game :out)))) last
(cg-pres--next game (or last 0)))))
(cg-put game :message "Pile cleared."))
(defun cg-pres--check-finish (game)
"End the game when only one player still holds cards (the Scum)."
(let ((in (cg-pres--in-game game)))
(when (<= (length in) 1)
(when in (cg-put game :out (append (cg-get game :out) in)))
(cg-put game :order (cg-get game :out))
(cg-put game :games (1+ (or (cg-get game :games) 0)))
(cg-put game :phase 'game-over)
(cg-put game :message (cg-pres--result game))
t)))
(defun cg-pres--advance (game)
"Decide the next turn or clear the pile after a move."
(unless (cg-pres--check-finish game)
(let* ((active (cg-pres--round-active game))
(last (cg-get game :last-player))
(others (and last (cl-remove last active))))
(if (and (> (cg-get game :count) 0) (null others))
(cg-pres--clear game)
(cg-put game :turn (cg-pres--next game (cg-get game :turn)))))))
(defun cg-pres--play (game seat rank n)
"Seat SEAT plays N cards of RANK."
(cg-pres--set-hand game seat (cg-pres--remove-n (cg-pres--hand game seat) rank n))
(cg-put game :count n) (cg-put game :top (cg-pres--power rank))
(cg-put game :last-player seat)
(when (null (cg-pres--hand game seat))
(cg-put game :out (append (cg-get game :out) (list seat))))
(cg-put game :message (format "%s plays %d × %s" (cg-pres--name game seat)
n (aref cg-pres-ranks rank)))
(cg-pres--advance game))
(defun cg-pres--pass (game seat)
"Seat SEAT passes for the current pile."
(aset (cg-get game :passed) seat t)
(cg-put game :message (format "%s passes." (cg-pres--name game seat)))
(cg-pres--advance game))
(defun cg-pres--ai-move (game seat)
"Make seat SEAT's move: lead low, beat low, or pass."
(let* ((cnt (cg-get game :count)) (top (cg-get game :top))
(counts (cg-pres--rank-counts game seat)))
(if (= cnt 0)
(let ((r (caar (cl-sort counts #'< :key (lambda (x) (cg-pres--power (car x)))))))
(cg-pres--play game seat r 1))
(let ((cand (cl-loop for (r . c) in counts
when (and (>= c cnt) (> (cg-pres--power r) top)) collect r)))
(if cand
(cg-pres--play game seat
(car (cl-sort cand #'< :key #'cg-pres--power)) cnt)
(cg-pres--pass game seat))))))
(defun cg-pres--result (game)
"Return a finishing summary string."
(let* ((order (cg-get game :order)) (n (length order)) (parts nil))
(dotimes (i n)
(let ((title (cond ((= i 0) "President") ((= i (1- n)) "Scum")
((= i 1) "Vice-President") ((= i (- n 2)) "Vice-Scum")
(t "Citizen"))))
(push (format "%s: %s" title (cg-pres--name game (nth i order))) parts)))
(concat "Game over -- " (mapconcat #'identity (nreverse parts) ", ")
". Press n for the next deal.")))
;;;; UI
(defvar-local cg-pres--game nil "The President game in the current buffer.")
(defun cg-pres--run (game)
"Advance AI seats until it is the human's turn or the game ends."
(while (and (eq (cg-get game :phase) 'play) (/= (cg-get game :turn) 0))
(cg-pres--ai-move game (cg-get game :turn))))
(defun cg-pres--hand-ranks (game)
"Distinct ranks in seat 0's hand, ordered by power."
(let ((rs (delete-dups (mapcar #'cdr (cg-pres--hand game 0)))))
(cl-sort rs #'< :key #'cg-pres--power)))
(defun cg-pres-act (&optional count)
"Play the selected rank. With prefix COUNT, lead that many of it."
(interactive "P")
(let* ((game cg-pres--game)
(ranks (cg-pres--hand-ranks 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 ranks) (cg-put game :message "You are out."))
(t (let* ((rank (nth (min (cg-get game :cursor) (1- (length ranks))) ranks))
(have (cl-count rank (mapcar #'cdr (cg-pres--hand game 0))))
(need (cg-get game :count)))
(if (= need 0)
(let ((n (min have (max 1 (prefix-numeric-value (or count 1))))))
(cg-pres--play game 0 rank n)
(cg-put game :cursor 0)
(cg-pres--run game))
(if (and (>= have need) (> (cg-pres--power rank) (cg-get game :top)))
(progn (cg-pres--play game 0 rank need)
(cg-put game :cursor 0)
(cg-pres--run game))
(cg-put game :message
(format "Need %d of a rank higher than the pile." need)))))))
(cg-pres--redisplay)))
(defun cg-pres-pass ()
"Pass for the current pile."
(interactive)
(let ((game cg-pres--game))
(cond
((/= (cg-get game :turn) 0) (cg-put game :message "Not your turn."))
((= (cg-get game :count) 0) (cg-put game :message "You lead -- you must play."))
(t (cg-pres--pass game 0) (cg-pres--run game)))
(cg-pres--redisplay)))
(defun cg-pres-left () "Cursor left." (interactive)
(let* ((g cg-pres--game) (n (length (cg-pres--hand-ranks g))))
(when (> n 0) (cg-put g :cursor (mod (1- (cg-get g :cursor)) n))) (cg-pres--redisplay)))
(defun cg-pres-right () "Cursor right." (interactive)
(let* ((g cg-pres--game) (n (length (cg-pres--hand-ranks g))))
(when (> n 0) (cg-put g :cursor (mod (1+ (cg-get g :cursor)) n))) (cg-pres--redisplay)))
(defun cg-pres-new () "New deal." (interactive)
(cg-pres--deal cg-pres--game) (cg-pres--run cg-pres--game) (cg-pres--redisplay))
(defun cg-pres-redraw () "Redraw." (interactive) (cg-pres--redisplay))
(defun cg-pres-help () "Controls." (interactive)
(message "Arrows or click: choose/play RET: play (C-u N to lead N) p: pass +/-: size n: new"))
(defcustom cg-pres-svg-cards t
"When non-nil, draw the hand as SVG on a graphical display."
:type 'boolean :group 'card-games)
(defun cg-pres--svg (game)
"Return a propertized, clickable SVG row of the hand: one card per rank.
Each rank maps to a (hand . INDEX) region and a card-size slider sits below."
(let* ((w cg-svg-card-width) (h cg-svg-card-height) (pad 10)
(gap (+ cg-svg-card-gap 8)) (ranks (cg-pres--hand-ranks game))
(cur (cg-get game :cursor)) (hand (cg-pres--hand game 0))
(n (length ranks)) (lc (cg-color 'shadow :foreground "gray40"))
(sh (cg-svg-slider-height)) (slider-y (+ pad h 22))
(width (+ (* 2 pad) (max (+ w gap) (* n (+ w gap)) (cg-svg-slider-width))))
(height (+ slider-y sh pad)) (svg (svg-create width height))
(x pad) (i 0) (regions '()))
(dolist (r ranks)
(let* ((cnt (cl-count r (mapcar #'cdr hand)))
(suit (car (cl-find r hand :key #'cdr))))
(cg-svg-card svg x pad :rank (aref cg-pres-ranks r) :suit suit
:highlight (= i cur))
(svg-text svg (format "x%d" cnt) :x (+ x 3) :y (+ pad h 15)
:font-size 13 :fill lc :font-family cg-svg-font-family)
(push (cons (list x pad w h) (cons 'hand i)) regions))
(setq x (+ x w gap) i (1+ i)))
(setq regions (append (nreverse regions)
(cg-svg-slider-draw svg pad slider-y cg-card-scale)))
(propertize "*" 'display (cg-svg-image svg (cg-scale)) 'cg-regions regions)))
(cl-defmethod cg-render ((game cg-president-game))
"Return a propertized string depicting GAME for a text display."
(let* ((out (list)) (ranks (cg-pres--hand-ranks game))
(cur (cg-get game :cursor)))
(push (format " President\n\n") out)
(dotimes (s (cg-get game :nplayers))
(unless (= s 0)
(push (format " Player %d: %d cards%s\n" s (length (cg-pres--hand game s))
(if (memq s (cg-get game :out)) " (out)" "")) out)))
(push (format "\n Pile: %s\n\n"
(if (> (cg-get game :count) 0)
(format "%d × power-%d (last: %s)"
(cg-get game :count) (cg-get game :top)
(cg-pres--name game (cg-get game :last-player)))
"empty -- your lead"))
out)
(push " Your hand (by rank):\n " out)
(if (and cg-pres-svg-cards (display-graphic-p))
(push (cg-pres--svg game) out)
(let ((i 0))
(dolist (r ranks)
(let* ((cnt (cl-count r (mapcar #'cdr (cg-pres--hand game 0))))
(str (format "%s×%d" (aref cg-pres-ranks r) cnt))
(faces nil))
(when (= i cur) (push 'cg-cursor faces))
(push (propertize (format "%6s" str) 'face (or faces 'default)) out))
(cl-incf i))))
(push (format "\n\n %s\n" (cg-get game :message)) out)
(apply #'concat (nreverse out))))
(cl-defmethod cg-render-apply ((g cg-president-game) action)
"Apply a click ACTION on the rank row to GAME G (a click also plays)."
(pcase action
(`(hand . ,i)
(cg-put g :cursor i)
(when (and (eq (cg-get g :phase) 'play) (= (cg-get g :turn) 0))
(cg-pres-act)))
(_ (cl-call-next-method))))
(defun cg-pres--redisplay ()
(let ((game cg-pres--game) (inhibit-read-only t))
(setq cg-current-game game cg-redisplay-function #'cg-pres--redisplay)
(setq-local mode-line-process (format " [%s]" (cg-get game :phase)))
(erase-buffer) (insert (cg-render game)) (goto-char (point-min))))
(defvar cg-pres-mode-map
(let ((map (make-sparse-keymap)))
(define-key map [mouse-1] #'cg-card-click)
(define-key map "+" #'cg-card-zoom-in)
(define-key map "=" #'cg-card-zoom-in)
(define-key map "-" #'cg-card-zoom-out)
(define-key map "0" #'cg-card-zoom-reset)
(define-key map (kbd "<left>") #'cg-pres-left)
(define-key map (kbd "<right>") #'cg-pres-right)
(define-key map (kbd "RET") #'cg-pres-act)
(define-key map (kbd "SPC") #'cg-pres-act)
(define-key map "p" #'cg-pres-pass)
(define-key map "n" #'cg-pres-new)
(define-key map "g" #'cg-pres-redraw)
(define-key map "?" #'cg-pres-help)
map)
"Keymap for `cg-pres-mode'.")
(define-derived-mode cg-pres-mode special-mode "President"
"Major mode for President."
(setq-local truncate-lines t)
(setq-local cursor-type cg-cursor-type))
;;;###autoload
(defun cg-president ()
"Play President (Scum) against the computer."
(interactive)
(let ((buf (get-buffer-create "*President*")))
(with-current-buffer buf
(cg-pres-mode)
(setq cg-pres--game (cg-president-game))
(cg-pres--deal cg-pres--game)
(cg-pres--run cg-pres--game)
(cg-pres--redisplay))
(switch-to-buffer buf)))
(provide 'cg-president)
;;; cg-president.el ends here