diff --git a/Makefile b/Makefile index ecc7d0c..ea21149 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ EMACS ?= emacs PKG = card-games VERSION = 1.0.60 # Source files in dependency order (cg-core first). -EL = cg-core.el cg-svg.el cg-render.el cg-net.el cg-bid.el cg-gaps.el cg-bid-ui.el cg-bid-net.el cg-solitaire.el cg-trick.el cg-eights.el cg-patience.el cg-president.el card-games.el +EL = cg-core.el cg-svg.el cg-render.el cg-net.el cg-bid.el cg-gaps.el cg-bid-ui.el cg-bid-net.el cg-solitaire.el cg-trick.el cg-eights.el cg-patience.el cg-president.el cg-rummy.el cg-rum500.el cg-handfoot.el card-games.el ELC = $(EL:.el=.elc) PKGDESC = $(PKG)-pkg.el TARDIR = $(PKG)-$(VERSION) diff --git a/README.org b/README.org index 1fadacb..2108afb 100644 --- a/README.org +++ b/README.org @@ -60,6 +60,20 @@ with its command. beat it or pass; first out rules, last out scrubs, and the roles trade cards on the next deal. +** Rummy +- ~cg-gin~ -- Gin Rummy. A two-handed duel: draw or take the discard, + build sets and runs, and knock once your deadwood is ten or less, or go + gin with none; your opponent then lays off and may undercut you. First + to 100 wins. +- ~cg-rummy-basic~ -- Rummy. Meld sets and runs onto the table and lay + cards off onto them; empty your hand to go out and score the cards left + in the other hands. +- ~cg-rum500~ -- Rummy 500. As above, but you score the cards you lay + down and lose the cards left in your hand; first past 500 wins. +- ~cg-handfoot~ -- Hand & Foot. A partnership Canasta cousin: play a hand + and then a foot, build books of a rank with Twos and Jokers wild, and go + out once your side has completed two of them. + * TODO - [X] make the suit symbols customizable (~cg-symbols~) and obey them - [ ] a Texinfo manual diff --git a/card-games.el b/card-games.el index de17e2b..cb86611 100644 --- a/card-games.el +++ b/card-games.el @@ -49,6 +49,9 @@ (require 'cg-eights) (require 'cg-patience) (require 'cg-president) +(require 'cg-rummy) +(require 'cg-rum500) +(require 'cg-handfoot) (defvar card-games-list '(("500 (Bid)" cg-bid @@ -88,7 +91,15 @@ ("Oh Hell" cg-ohhell "Trick-taking: shrinking hands; bid the exact tricks you will take.") ("President" cg-president - "Climbing: shed your hand; first out rules, last out scrubs.")) + "Climbing: shed your hand; first out rules, last out scrubs.") + ("Gin Rummy" cg-gin + "Rummy: form melds, knock with little deadwood; head to head to 100.") + ("Rummy" cg-rummy-basic + "Rummy: meld your whole hand to the table to go out.") + ("Rummy 500" cg-rum500 + "Rummy: score the cards you lay down; race past 500.") + ("Hand & Foot" cg-handfoot + "Rummy: partnership Canasta cousin; build books from hand and foot.")) "Registry of playable games. Each entry is (NAME COMMAND DESCRIPTION); `card-game' lists them.") diff --git a/cg-handfoot.el b/cg-handfoot.el new file mode 100644 index 0000000..33e3a97 --- /dev/null +++ b/cg-handfoot.el @@ -0,0 +1,535 @@ +;;; cg-handfoot.el --- Hand and Foot, a partnership rummy -*- 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: + +;; Hand & Foot, a Canasta cousin played in partnerships. Each player is +;; dealt two packets: a "hand" played first and a "foot" taken up once the +;; hand is gone. Partners build *books* -- three or more cards of one +;; rank, suits ignored -- on the table; a book of seven is complete (a +;; "pile"), clean if it holds no wild card and dirty if it does. Twos and +;; Jokers are wild. You go out, ending the round, once your side owns at +;; least two complete books and you can empty your foot. +;; +;; You partner the North player against East and West, all three of them +;; computer opponents. Mark cards with SPC, meld them with m, lay off onto +;; a book with l, and discard with RET. +;; +;; This is a deliberately streamlined Hand & Foot: it omits picking up the +;; discard pile, the red-three bonus, and round-by-round minimum-meld +;; requirements, keeping the books, wilds, hand/foot, and partnership +;; scoring that give the game its character. Cards use the package cons +;; (SUIT . RANK), RANK 0 (Ace) .. 12 (King), with jokers as (joker . 0). + +;;; Code: + +(require 'cl-lib) +(require 'eieio) +(require 'cg-core) +(require 'cg-rummy) + +(defcustom cg-handfoot-target 5000 + "Points a partnership needs to win Hand & Foot." + :type 'integer :group 'card-games) + +(defconst cg-handfoot--names ["You" "West" "North" "East"] + "Seat labels; North is your partner.") + +(defclass cg-handfoot-game (cg-rummy-game) + ((vname :initform "Hand & Foot")) + "A game of Hand & Foot.") + +;;;; Cards + +(defun cg-hf--wild-p (card) + "Return non-nil when CARD is wild (a Joker or a Two)." + (or (cg-rummy-joker-p card) (= (cdr card) 1))) + +(defun cg-hf--three-p (card) + "Return non-nil when CARD is a three (never meldable)." + (and (not (cg-rummy-joker-p card)) (= (cdr card) 2))) + +(defun cg-hf-value (card) + "Return the Hand & Foot point value of CARD." + (cond ((cg-rummy-joker-p card) 50) + (t (let ((r (cdr card))) + (cond ((= r 1) 20) ; Two (wild) + ((= r 0) 20) ; Ace + ((= r 2) 5) ; Three + ((<= r 6) 5) ; 4 5 6 7 + (t 10)))))) ; 8 9 10 J Q K + +(defun cg-hf--book-rank (cards) + "Return the natural rank shared by CARDS, or nil if invalid." + (let ((nats (cl-remove-if #'cg-hf--wild-p cards))) + (and nats + (let ((r (cdr (car nats)))) + (and (cl-every (lambda (c) (= (cdr c) r)) nats) + (/= r 2) + r))))) + +(defun cg-hf--book-valid-p (cards) + "Return non-nil when CARDS form a legal book." + (let* ((nats (cl-remove-if #'cg-hf--wild-p cards)) + (wilds (cl-remove-if-not #'cg-hf--wild-p cards))) + (and (>= (length cards) 3) + (cg-hf--book-rank cards) + (>= (length nats) 2) + (<= (length wilds) 3) + (<= (length wilds) (length nats))))) + +(defun cg-hf--book-complete-p (cards) (>= (length cards) 7)) +(defun cg-hf--book-clean-p (cards) (not (cl-some #'cg-hf--wild-p cards))) + +;;;; Setup + +(defun cg-hf--team (game s) (mod s (cg-get game :nteams))) + +(cl-defmethod cg-hf--deal ((game cg-handfoot-game)) + "Deal a fresh round into GAME." + (let* ((n (cg-get game :nplayers)) + (decks (1+ n)) + (deck (cg-rummy-deck decks 2)) + (hands (make-vector n nil)) (feet (make-vector n nil)) + (stage (make-vector n 0))) + (dotimes (s n) + (aset hands s (cg-rummy-sort-hand (cl-loop repeat 11 collect (pop deck)))) + (aset feet s (cg-rummy-sort-hand (cl-loop repeat 11 collect (pop deck))))) + (cg-put game :hands hands) + (cg-put game :feet feet) + (cg-put game :stage stage) + (cg-put game :books (make-vector (cg-get game :nteams) nil)) + (cg-put game :discard (list (pop deck))) + (cg-put game :stock deck) + (cg-put game :turn 0) + (cg-put game :step 'draw) + (cg-put game :phase 'play) + (cg-put game :cursor 0) + (cg-put game :marks nil) + (cg-put game :message "Your turn: s draws two cards.") + game)) + +(defun cg-hf--books (game team) (aref (cg-get game :books) team)) +(defun cg-hf--set-books (game team v) (aset (cg-get game :books) team v)) + +;;;; Engine + +(defun cg-hf--draw2 (game s) + "Draw two stock cards into seat S's hand. Return nil if stock runs out." + (let ((ok t)) + (dotimes (_ 2) + (let ((stock (cg-get game :stock))) + (if (null stock) (setq ok nil) + (cg-rummy--set-hand game s (cg-rummy-sort-hand + (cons (car stock) (cg-rummy--hand game s)))) + (cg-put game :stock (cdr stock))))) + ok)) + +(defun cg-hf--meld (game s cards) + "Have seat S lay CARDS as a new book for their team. Return non-nil on win." + (when (and (cg-hf--book-valid-p cards) + (cl-subsetp cards (cg-rummy--hand game s) :test #'equal)) + (let ((team (cg-hf--team game s))) + (dolist (c cards) + (cg-rummy--set-hand game s (cg-rummy--remove1 c (cg-rummy--hand game s)))) + (cg-hf--set-books game team + (append (cg-hf--books game team) + (list (cg-rummy-sort-hand (copy-sequence cards))))) + t))) + +(defun cg-rummy--remove1 (card list) + "Return LIST with one copy of CARD (an `equal' match) removed." + (let ((seen nil)) + (cl-remove-if (lambda (c) (and (not seen) (equal c card) (setq seen t))) list))) + +(defun cg-hf--layoff (game s card) + "Lay CARD off onto a team book it fits. Return non-nil on success." + (let* ((team (cg-hf--team game s)) (books (cg-hf--books game team)) (done nil)) + (catch 'hit + (dolist (bk books) + (unless (cg-hf--book-complete-p bk) + (let ((cand (cons card bk))) + (when (cg-hf--book-valid-p cand) + (cg-rummy--set-hand game s (cg-rummy--remove1 card (cg-rummy--hand game s))) + (setcar (memq bk books) (cg-rummy-sort-hand cand)) + (setq done t) + (throw 'hit t)))))) + done)) + +(defun cg-hf--advance (game s) + "After a play, take up the foot or finish, then pass the turn." + (let ((stage (cg-get game :stage))) + (when (and (= (aref stage s) 0) (null (cg-rummy--hand game s))) + ;; hand exhausted: pick up the foot + (aset stage s 1) + (cg-rummy--set-hand game s (aref (cg-get game :feet) s))) + (if (and (= (aref stage s) 1) (null (cg-rummy--hand game s)) + (cg-hf--can-go-out-p game (cg-hf--team game s))) + (cg-hf--score-round game s) + (cg-put game :turn (mod (1+ s) (cg-get game :nplayers))) + (cg-put game :step 'draw)))) + +(defun cg-hf--can-go-out-p (game team) + "Return non-nil when TEAM owns at least two complete books." + (>= (cl-count-if #'cg-hf--book-complete-p (cg-hf--books game team)) 2)) + +(defun cg-hf--discard (game s card) + "Discard CARD from seat S and end the play portion of the turn." + (cg-rummy--set-hand game s (cg-rummy--remove1 card (cg-rummy--hand game s))) + (cg-put game :discard (cons card (cg-get game :discard))) + (cg-hf--advance game s)) + +(cl-defmethod cg-hf--score-round ((game cg-handfoot-game) outseat) + "Score the round (OUTSEAT went out, or nil if the stock ran dry)." + (let* ((nt (cg-get game :nteams)) (scores (cg-get game :scores))) + (dotimes (team nt) + (let ((pts 0)) + (dolist (bk (cg-hf--books game team)) + (dolist (c bk) (setq pts (+ pts (cg-hf-value c)))) + (when (cg-hf--book-complete-p bk) + (setq pts (+ pts (if (cg-hf--book-clean-p bk) 500 300))))) + (when (and outseat (= (cg-hf--team game outseat) team)) + (setq pts (+ pts 100))) ; going-out bonus + ;; subtract cards left in members' hands and feet + (dotimes (s (cg-get game :nplayers)) + (when (= (cg-hf--team game s) team) + (dolist (c (cg-rummy--hand game s)) (setq pts (- pts (cg-hf-value c)))) + (when (= (aref (cg-get game :stage) s) 0) + (dolist (c (aref (cg-get game :feet) s)) + (setq pts (- pts (cg-hf-value c))))))) + (aset scores team (+ (aref scores team) pts)))) + (let ((win nil) (best most-negative-fixnum)) + (dotimes (team nt) + (when (and (>= (aref scores team) cg-handfoot-target) + (> (aref scores team) best)) + (setq win team best (aref scores team)))) + (cg-put game :phase (if win 'game-over 'round-over)) + (cg-put game :winner (or win (and outseat (cg-hf--team game outseat)))) + (cg-put game :reveal t) + (cg-put game :message + (concat + (if outseat (format "%s goes out! " (aref cg-handfoot--names outseat)) + "Stock exhausted. ") + (if win (format "Team %d wins the game! (n: new game)" win) + (format "Round over. Scores: %s. (n: next round)" + (cg-hf--scores-string game)))))))) + +(defun cg-hf--scores-string (game) + "Return a compact per-team score line for GAME." + (let ((scores (cg-get game :scores)) (parts '())) + (dotimes (team (cg-get game :nteams)) + (push (format "Team %d %d" team (aref scores team)) parts)) + (mapconcat #'identity (nreverse parts) " · "))) + +;;;; AI + +(defun cg-hf--ai-meld (game s) + "Lay down and extend books for seat S as far as is easy." + ;; lay off naturals onto existing incomplete team books + (let ((again t)) + (while again + (setq again nil) + (let ((card (cl-find-if + (lambda (c) + (and (not (cg-hf--wild-p c)) (not (cg-hf--three-p c)) + (cl-find-if + (lambda (bk) (and (not (cg-hf--book-complete-p bk)) + (equal (cg-hf--book-rank bk) (cdr c)))) + (cg-hf--books game (cg-hf--team game s))))) + (cg-rummy--hand game s)))) + (when card (cg-hf--layoff game s card) (setq again t))))) + ;; start new books from ranks with >=3 naturals in hand + (let ((again t)) + (while again + (setq again nil) + (let* ((hand (cg-rummy--hand game s)) + (byrank (make-hash-table :test 'eql)) (target nil)) + (dolist (c hand) + (unless (or (cg-hf--wild-p c) (cg-hf--three-p c)) + (push c (gethash (cdr c) byrank)))) + (maphash (lambda (_r cs) (when (and (not target) (>= (length cs) 3)) + (setq target cs))) + byrank) + (when target + (cg-hf--meld game s (cl-subseq target 0 (min 7 (length target)))) + (setq again t))))) + ;; push a nearly-complete book to seven with a spare wild + (let ((again t)) + (while again + (setq again nil) + (let ((wild (cl-find-if #'cg-hf--wild-p (cg-rummy--hand game s))) + (team (cg-hf--team game s))) + (when wild + (let ((bk (cl-find-if + (lambda (b) + (and (not (cg-hf--book-complete-p b)) + (>= (length b) 5) + (> (cl-count-if-not #'cg-hf--wild-p b) + (cl-count-if #'cg-hf--wild-p b)) + (< (cl-count-if #'cg-hf--wild-p b) 3))) + (cg-hf--books game team)))) + (when bk + (cg-rummy--set-hand game s (cg-rummy--remove1 wild (cg-rummy--hand game s))) + (setcar (memq bk (cg-hf--books game team)) + (cg-rummy-sort-hand (cons wild bk))) + (setq again t)))))))) + +(defun cg-hf--ai-discard-card (game s) + "Return the card seat S should discard." + (let ((hand (cg-rummy--hand game s))) + (or (cl-find-if #'cg-hf--three-p hand) + ;; a high singleton, else the first card + (let ((byrank (make-hash-table :test 'eql)) (best (car hand)) (bestv -1)) + (dolist (c hand) (unless (cg-hf--wild-p c) + (push c (gethash (cdr c) byrank)))) + (maphash (lambda (_r cs) + (when (= (length cs) 1) + (let ((v (cg-hf-value (car cs)))) + (when (> v bestv) (setq best (car cs) bestv v))))) + byrank) + best)))) + +(cl-defmethod cg-hf--ai-turn ((game cg-handfoot-game) s) + "Play seat S's whole turn." + (if (not (cg-hf--draw2 game s)) + (cg-hf--score-round game nil) + (cg-hf--ai-meld game s) + (when (eq (cg-get game :phase) 'play) + ;; if the hand emptied through melding, pick up the foot and meld again + (when (and (= (aref (cg-get game :stage) s) 0) (null (cg-rummy--hand game s))) + (aset (cg-get game :stage) s 1) + (cg-rummy--set-hand game s (aref (cg-get game :feet) s)) + (cg-hf--ai-meld game s)) + (when (eq (cg-get game :phase) 'play) + (if (cg-rummy--hand game s) + (cg-hf--discard game s (cg-hf--ai-discard-card game s)) + (cg-hf--advance game s)))))) + +(defun cg-hf--run (game) + "Advance AI seats until it is your turn or the round ends." + (while (and (eq (cg-get game :phase) 'play) (/= (cg-get game :turn) 0)) + (cg-hf--ai-turn game (cg-get game :turn)))) + +;;;; UI + +(defvar-local cg-hf--game nil "The Hand & Foot game in the current buffer.") + +(cl-defmethod cg-render ((game cg-handfoot-game)) + "Return a propertized depiction of the Hand & Foot GAME." + (let* ((out '()) (scores (cg-get game :scores)) + (hand (cg-rummy--hand game 0)) (cursor (cg-get game :cursor))) + (push (format " Hand & Foot target %d\n\n" cg-handfoot-target) out) + (dotimes (team (cg-get game :nteams)) + (push (format " Team %d (score %d):\n" team (aref scores team)) out) + (let ((bks (cg-hf--books game team))) + (if bks + (dolist (bk bks) + (push (format " %s%s\n" + (mapconcat #'cg-rummy-card-string bk " ") + (cond ((cg-hf--book-complete-p bk) + (if (cg-hf--book-clean-p bk) " [clean pile]" " [dirty pile]")) + (t ""))) + out)) + (push " (no books yet)\n" out)))) + (push "\n" out) + (dotimes (s (cg-get game :nplayers)) + (unless (= s 0) + (push (format " %-6s %d in hand%s\n" (aref cg-handfoot--names s) + (length (cg-rummy--hand game s)) + (if (= (aref (cg-get game :stage) s) 1) " (on foot)" "")) + out))) + (push (format "\n Discard: %s Stock: %d\n\n" + (let ((cs (cg-rummy-card-string (cg-rummy--top game))) (tp (cg-rummy--top game))) + (if (and tp (not (cg-rummy-joker-p tp)) (cg-red-suit-p (car tp))) + (propertize cs 'face 'cg-red-suit) cs)) + (length (cg-get game :stock))) + out) + (push (format " Your %s:\n " (if (= (aref (cg-get game :stage) 0) 1) "foot" "hand")) out) + (push (cg-rummy--render-cards hand cursor (cg-get game :marks)) out) + (push (format "\n\n %s\n" (cg-get game :message)) out) + (apply #'concat (nreverse out)))) + +(defun cg-hf--redisplay () + "Redraw the Hand & Foot buffer." + (let ((game cg-hf--game) (inhibit-read-only t)) + (setq-local mode-line-process + (format " [%s]" (or (cg-get game :step) (cg-get game :phase)))) + (erase-buffer) (insert (cg-render game)) (goto-char (point-min)))) + +(defun cg-hf--clamp (g) + "Keep G's cursor in range and drop stale marks." + (let ((n (length (cg-rummy--hand g 0)))) + (cg-put g :cursor (if (> n 0) (min (cg-get g :cursor) (1- n)) 0)) + (cg-put g :marks (cl-remove-if (lambda (i) (>= i n)) (cg-get g :marks))))) + +(defun cg-hf--my-turn-p (g) + (and (eq (cg-get g :phase) 'play) (= (cg-get g :turn) 0))) + +(defun cg-hf-left () + "Move the hand cursor left." + (interactive) + (let* ((g cg-hf--game) (n (length (cg-rummy--hand g 0)))) + (when (> n 0) (cg-put g :cursor (mod (1- (cg-get g :cursor)) n))) + (cg-hf--redisplay))) + +(defun cg-hf-right () + "Move the hand cursor right." + (interactive) + (let* ((g cg-hf--game) (n (length (cg-rummy--hand g 0)))) + (when (> n 0) (cg-put g :cursor (mod (1+ (cg-get g :cursor)) n))) + (cg-hf--redisplay))) + +(defun cg-hf-mark () + "Toggle a mark on the card under the cursor." + (interactive) + (let* ((g cg-hf--game) (i (cg-get g :cursor)) (marks (cg-get g :marks))) + (cg-put g :marks (if (memq i marks) (delq i marks) (cons i marks))) + (cg-hf--redisplay))) + +(defun cg-hf--marked (g) + "Return the marked cards in G's hand." + (let ((hand (cg-rummy--hand g 0))) + (mapcar (lambda (i) (nth i hand)) (sort (copy-sequence (cg-get g :marks)) #'<)))) + +(defun cg-hf-meld () + "Meld the marked cards as a new book." + (interactive) + (let* ((g cg-hf--game) (cards (cg-hf--marked g))) + (cond + ((not (cg-hf--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((eq (cg-get g :step) 'draw) (cg-put g :message "Draw first (s).")) + ((not (cg-hf--book-valid-p cards)) + (cg-put g :message "Not a legal book: 3+ of a rank, 2+ natural, wilds <= naturals.")) + ((cg-hf--meld g 0 cards) + (cg-put g :marks nil) (cg-hf--clamp g) + (when (and (= (aref (cg-get g :stage) 0) 0) (null (cg-rummy--hand g 0))) + (aset (cg-get g :stage) 0 1) + (cg-rummy--set-hand g 0 (aref (cg-get g :feet) 0)) + (cg-hf--clamp g) + (cg-put g :message "Hand cleared -- foot picked up! Keep melding or discard (RET).")) + (unless (and (= (aref (cg-get g :stage) 0) 1) (null (cg-rummy--hand g 0))) + (cg-put g :message "Booked. Meld more, lay off (l), or discard (RET)."))) + (t (cg-put g :message "Could not meld those cards."))) + (cg-hf--redisplay))) + +(defun cg-hf-layoff () + "Lay the cursor card (or marked cards) off onto a team book." + (interactive) + (let* ((g cg-hf--game) (marks (cg-hf--marked g))) + (cond + ((not (cg-hf--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((eq (cg-get g :step) 'draw) (cg-put g :message "Draw first (s).")) + (t (let ((cards (or marks (list (nth (cg-get g :cursor) (cg-rummy--hand g 0))))) + (any nil)) + (dolist (c cards) (when (and c (cg-hf--layoff g 0 c)) (setq any t))) + (cg-put g :marks nil) (cg-hf--clamp g) + (cg-put g :message (if any "Laid off." "That card fits none of your books."))))) + (cg-hf--redisplay))) + +(defun cg-hf-draw () + "Draw two cards from the stock." + (interactive) + (let ((g cg-hf--game)) + (cond + ((not (cg-hf--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((not (eq (cg-get g :step) 'draw)) (cg-put g :message "You already drew.")) + ((cg-hf--draw2 g 0) + (cg-put g :step 'play) (cg-hf--clamp g) + (cg-put g :message "Meld (m), lay off (l), then discard (RET).")) + (t (cg-hf--score-round g nil))) + (cg-hf--redisplay))) + +(defun cg-hf-discard () + "Discard the cursor card and end your turn." + (interactive) + (let* ((g cg-hf--game) (card (nth (cg-get g :cursor) (cg-rummy--hand g 0)))) + (cond + ((not (cg-hf--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((eq (cg-get g :step) 'draw) (cg-put g :message "Draw first (s).")) + ((null card) (cg-put g :message "No card selected.")) + (t (cg-hf--discard g 0 card) + (cg-put g :marks nil) + (when (memq (cg-get g :phase) '(play)) + (cg-put g :message "You discarded.") + (cg-hf--run g)))) + (cg-hf--redisplay))) + +(defun cg-hf-new () + "Deal a fresh round, or a new game when one is over." + (interactive) + (let ((g cg-hf--game)) + (when (eq (cg-get g :phase) 'game-over) + (cg-put g :scores (make-vector (cg-get g :nteams) 0))) + (cg-put g :reveal nil) + (cg-hf--deal g) + (cg-hf--run g) + (cg-hf--redisplay))) + +(defun cg-hf-redraw () "Redraw the board." (interactive) (cg-hf--redisplay)) + +(defun cg-hf-help () + "Describe the Hand & Foot controls." + (interactive) + (message "Arrows: choose SPC: mark m: meld l: lay off s: draw 2 RET: discard n: new")) + +(defvar cg-handfoot-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "") #'cg-hf-left) + (define-key map (kbd "") #'cg-hf-right) + (define-key map (kbd "SPC") #'cg-hf-mark) + (define-key map "m" #'cg-hf-meld) + (define-key map "l" #'cg-hf-layoff) + (define-key map "s" #'cg-hf-draw) + (define-key map (kbd "RET") #'cg-hf-discard) + (define-key map "n" #'cg-hf-new) + (define-key map "g" #'cg-hf-redraw) + (define-key map "?" #'cg-hf-help) + map) + "Keymap for `cg-handfoot-mode'.") + +(define-derived-mode cg-handfoot-mode special-mode "Hand&Foot" + "Major mode for Hand & Foot." + (setq-local truncate-lines t)) + +;;;###autoload +(defun cg-handfoot () + "Play Hand & Foot, partnering North against two AI opponents." + (interactive) + (let ((buf (get-buffer-create "*Hand & Foot*"))) + (with-current-buffer buf + (cg-handfoot-mode) + (setq cg-hf--game (cg-handfoot-game)) + (cg-put cg-hf--game :nplayers 4) + (cg-put cg-hf--game :nteams 2) + (cg-put cg-hf--game :scores (make-vector 2 0)) + (cg-hf--deal cg-hf--game) + (cg-hf--run cg-hf--game) + (cg-hf--redisplay)) + (switch-to-buffer buf))) + +;;;###autoload +(defalias 'cg-hand-and-foot #'cg-handfoot) + +(provide 'cg-handfoot) +;;; cg-handfoot.el ends here diff --git a/cg-rum500.el b/cg-rum500.el new file mode 100644 index 0000000..b96baf6 --- /dev/null +++ b/cg-rum500.el @@ -0,0 +1,519 @@ +;;; cg-rum500.el --- Basic Rummy and Rummy 500 -*- 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: + +;; Two table-meld rummy games sharing one engine, built on the meld +;; finder in cg-rummy.el. +;; +;; In a table-meld game you draw a card, lay melds face-up on the table, +;; lay single cards off onto melds already there, and end your turn by +;; discarding. Empty your hand to go out. +;; +;; `cg-rummy-basic' -- plain Rummy: the first player to meld their whole +;; hand wins the deal and scores the cards left in the others' hands. +;; `cg-rum500' -- Rummy 500: you score the cards you lay down and lose +;; the cards left in your hand; first past 500 wins. +;; +;; You are the South player (seat 0); the rest are simple AI. To meld, +;; mark cards with SPC and press m; to lay a card off, put the cursor on +;; it and press l. + +;;; Code: + +(require 'cl-lib) +(require 'eieio) +(require 'cg-core) +(require 'cg-rummy) + +(defclass cg-tablemeld-game (cg-rummy-game) + ((nplayers :initarg :nplayers :initform 2) + (hand-size :initarg :hand-size :initform 10) + (ace-15 :initarg :ace-15 :initform nil) + (ace-high :initarg :ace-high :initform nil) + (target :initarg :target :initform 100) + (score-style :initarg :score-style :initform 'go-out)) + "Abstract base for table-meld rummy games (Basic Rummy, Rummy 500)." + :abstract t) + +;;;; Engine + +(cl-defmethod cg-tm--deal ((game cg-tablemeld-game)) + "Deal a fresh hand into GAME." + (let* ((n (oref game nplayers)) + (deck (cg-rummy-deck)) + (per (oref game hand-size)) + (hands (make-vector n nil))) + (dotimes (s n) + (aset hands s (cg-rummy-sort-hand (cl-loop repeat per collect (pop deck))))) + (cg-put game :hands hands) + (cg-put game :nplayers n) + (cg-put game :discard (list (pop deck))) + (cg-put game :stock deck) + (cg-put game :table nil) + (cg-put game :laid (make-vector n 0)) + (cg-put game :turn 0) + (cg-put game :step 'draw) + (cg-put game :phase 'play) + (cg-put game :cursor 0) + (cg-put game :marks nil) + (unless (cg-get game :scores) (cg-put game :scores (make-vector n 0))) + (cg-put game :message "Your turn: s draws from stock, t takes the discard.") + game)) + +(defun cg-tm--ace-high (game) (oref game ace-high)) +(defun cg-tm--ace-15 (game) (oref game ace-15)) + +(defun cg-tm--draw (game s) + "Move one stock card to seat S's hand; return it or nil if stock empty." + (let ((stock (cg-get game :stock))) + (when stock + (let ((c (pop stock))) + (cg-put game :stock stock) + (cg-rummy--set-hand game s (cg-rummy-sort-hand + (cons c (cg-rummy--hand game s)))) + c)))) + +(defun cg-tm--take-top (game s) + "Move the discard top to seat S's hand and return it." + (let ((c (cg-rummy--top game))) + (when c + (cg-put game :discard (cdr (cg-get game :discard))) + (cg-rummy--set-hand game s (cg-rummy-sort-hand + (cons c (cg-rummy--hand game s)))) + c))) + +(defun cg-tm--meld-value (game cards) + "Return the total point value of CARDS for GAME's scoring." + (apply #'+ (mapcar (lambda (c) (cg-rummy-value c (cg-tm--ace-15 game))) cards))) + +(cl-defmethod cg-tm--meld ((game cg-tablemeld-game) s cards) + "Have seat S meld CARDS onto the table. Return non-nil on success." + (when (cg-rummy-meld-p cards :min 3 :ace-high (cg-tm--ace-high game) + :distinct-suits t) + (dolist (c cards) (cg-rummy--set-hand game s (remove c (cg-rummy--hand game s)))) + (cg-put game :table (append (cg-get game :table) + (list (cons s (copy-sequence cards))))) + (let ((laid (cg-get game :laid))) + (aset laid s (+ (aref laid s) (cg-tm--meld-value game cards)))) + t)) + +(cl-defmethod cg-tm--layoff ((game cg-tablemeld-game) s card) + "Have seat S lay CARD off onto a matching table meld. Return non-nil on success." + (let ((rec (cl-find-if + (lambda (r) (cg-rummy-meld-p (cons card (cdr r)) :min 3 + :ace-high (cg-tm--ace-high game))) + (cg-get game :table)))) + (when rec + (cg-rummy--set-hand game s (remove card (cg-rummy--hand game s))) + (setcdr rec (cg-rummy-sort-hand (cons card (cdr rec)))) + (let ((laid (cg-get game :laid))) + (aset laid s (+ (aref laid s) (cg-rummy-value card (cg-tm--ace-15 game))))) + t))) + +(cl-defmethod cg-tm--score-hand ((game cg-tablemeld-game) outseat) + "Score the hand ended by OUTSEAT (or nil for a washed-out hand)." + (let* ((n (cg-get game :nplayers)) (scores (cg-get game :scores)) + (style (oref game score-style))) + (cond + ((eq style 'go-out) + (when outseat + (let ((sum 0)) + (dotimes (s n) + (unless (= s outseat) + (dolist (c (cg-rummy--hand game s)) + (setq sum (+ sum (cg-rummy-value c)))))) + (aset scores outseat (+ (aref scores outseat) sum))))) + ((eq style 'meld-points) + (let ((laid (cg-get game :laid))) + (dotimes (s n) + (let ((rem (apply #'+ (mapcar (lambda (c) + (cg-rummy-value c (cg-tm--ace-15 game))) + (cg-rummy--hand game s))))) + (aset scores s (+ (aref scores s) (- (aref laid s) rem)))))))) + ;; decide if the game is over + (let ((win nil) (best most-negative-fixnum)) + (dotimes (s n) + (when (and (>= (aref scores s) (oref game target)) + (> (aref scores s) best)) + (setq win s best (aref scores s)))) + (cg-put game :phase (if win 'game-over 'hand-over)) + (cg-put game :winner (or win outseat)) + (cg-put game :reveal t) + (cg-put game :message + (if win + (format "%s wins the game with %d! (n: new game)" + (cg-tm--who win) (aref scores win)) + (concat (if outseat (format "%s goes out. " (cg-tm--who outseat)) + "Stock exhausted. ") + (format "Scores: %s. (n: next hand)" + (cg-tm--scores-string game)))))))) + +(defun cg-tm--who (s) (if (= s 0) "You" (format "Player %d" s))) + +(defun cg-tm--scores-string (game) + "Return a compact \"You N · P1 N ...\" score line for GAME." + (let ((scores (cg-get game :scores)) (parts '())) + (dotimes (s (cg-get game :nplayers)) + (push (format "%s %d" (if (= s 0) "You" (format "P%d" s)) (aref scores s)) + parts)) + (mapconcat #'identity (nreverse parts) " · "))) + +(cl-defmethod cg-tm--end-turn ((game cg-tablemeld-game) s) + "Finish seat S's turn: go out if the hand is empty, else advance." + (if (null (cg-rummy--hand game s)) + (cg-tm--score-hand game s) + (cg-put game :turn (mod (1+ s) (cg-get game :nplayers))) + (cg-put game :step 'draw))) + +(cl-defmethod cg-tm--discard ((game cg-tablemeld-game) s card) + "Discard CARD from seat S and finish the turn." + (cg-rummy--set-hand game s (remove card (cg-rummy--hand game s))) + (cg-put game :discard (cons card (cg-get game :discard))) + (cg-tm--end-turn game s)) + +;;;; AI + +(defun cg-tm--ai-melds (game s) + "Lay down every meld seat S can, keeping a card back to discard. +Return non-nil if any meld was laid." + (let ((did nil) (again t)) + (while again + (setq again nil) + (let* ((hand (cg-rummy--hand game s)) + (p (cg-rummy-best-partition hand :ace-high (cg-tm--ace-high game) + :ace-15 (cg-tm--ace-15 game))) + (melds (plist-get p :melds)) + ;; keep one card to discard: skip a meld if it would empty the hand + (melded (apply #'+ (mapcar #'length melds)))) + (when (and melds (= melded (length hand))) + (setq melds (cdr (sort melds (lambda (a b) (< (length a) (length b))))))) + (when melds + (cg-tm--meld game s (car melds)) + (setq did t again t)))) + did)) + +(defun cg-tm--ai-layoffs (game s) + "Lay off every fitting card from seat S, keeping a card back to discard." + (let ((again t)) + (while again + (setq again nil) + (when (> (length (cg-rummy--hand game s)) 1) + (let ((card (cl-find-if + (lambda (c) + (cl-find-if + (lambda (r) (cg-rummy-meld-p (cons c (cdr r)) :min 3 + :ace-high (cg-tm--ace-high game))) + (cg-get game :table))) + (cg-rummy--hand game s)))) + (when card (cg-tm--layoff game s card) (setq again t))))))) + +(defun cg-tm--ai-discard-card (game s) + "Return the best card for seat S to discard (highest deadwood)." + (let* ((hand (cg-rummy--hand game s)) + (p (cg-rummy-best-partition hand :ace-high (cg-tm--ace-high game) + :ace-15 (cg-tm--ace-15 game))) + (dead (or (plist-get p :deadwood) hand)) + (best (car dead)) (bestv -1)) + (dolist (c dead best) + (let ((v (cg-rummy-value c (cg-tm--ace-15 game)))) + (when (> v bestv) (setq best c bestv v)))))) + +(cl-defmethod cg-tm--ai-turn ((game cg-tablemeld-game) s) + "Play seat S's whole turn." + (let* ((hand (cg-rummy--hand game s)) + (up (cg-rummy--top game)) + (cur (cg-rummy-deadwood hand (cg-tm--ace-high game) (cg-tm--ace-15 game))) + (with (and up (cg-rummy-deadwood (cons up hand) + (cg-tm--ace-high game) (cg-tm--ace-15 game)))) + (drew (if (and up with (< with cur)) + (cg-tm--take-top game s) + (cg-tm--draw game s)))) + (if (not drew) + (cg-tm--score-hand game nil) + (cg-tm--ai-melds game s) + (cg-tm--ai-layoffs game s) + (when (eq (cg-get game :phase) 'play) + (if (null (cg-rummy--hand game s)) + (cg-tm--end-turn game s) ; melded out, no discard needed + (cg-tm--discard game s (cg-tm--ai-discard-card game s))))))) + +(defun cg-tm--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-tm--ai-turn game (cg-get game :turn)))) + +;;;; UI + +(defvar-local cg-tm--game nil "The table-meld game in the current buffer.") + +(defun cg-tm--layoff-hint (game) + "Return a predicate marking cards that can be laid off in GAME now." + (lambda (c) + (cl-find-if (lambda (r) (cg-rummy-meld-p (cons c (cdr r)) :min 3 + :ace-high (cg-tm--ace-high game))) + (cg-get game :table)))) + +(cl-defmethod cg-render ((game cg-tablemeld-game)) + "Return a propertized depiction of the table-meld GAME." + (let* ((out '()) (scores (cg-get game :scores)) + (laid (cg-get game :laid)) (meldp (oref game score-style)) + (hand (cg-rummy--hand game 0)) (cursor (cg-get game :cursor))) + (push (format " %s target %d\n\n" (oref game vname) (oref game target)) out) + (dotimes (s (cg-get game :nplayers)) + (unless (= s 0) + (push (format " Player %d: %d cards score %d%s\n" + s (length (cg-rummy--hand game s)) (aref scores s) + (if (eq meldp 'meld-points) + (format " (laid %d)" (aref laid s)) "")) + out))) + (push "\n Table:\n" out) + (if (cg-get game :table) + (dolist (rec (cg-get game :table)) + (push (format " [%s] %s\n" (if (= (car rec) 0) "you" (format "P%d" (car rec))) + (mapconcat #'cg-rummy-card-string (cdr rec) " ")) + out)) + (push " (empty)\n" out)) + (push (format "\n Discard: %s Stock: %d\n\n" + (let ((cs (cg-rummy-card-string (cg-rummy--top game))) (tp (cg-rummy--top game))) + (if (and tp (cg-red-suit-p (car tp))) (propertize cs 'face 'cg-red-suit) cs)) + (length (cg-get game :stock))) + out) + (push (format " Your hand%s:\n " + (if (eq meldp 'meld-points) (format " (laid %d, score %d)" + (aref laid 0) (aref scores 0)) + (format " (score %d)" (aref scores 0)))) + out) + (push (cg-rummy--render-cards hand cursor (cg-get game :marks) + (cg-tm--layoff-hint game)) + out) + (push (format "\n\n %s\n" (cg-get game :message)) out) + (apply #'concat (nreverse out)))) + +(defun cg-tm--redisplay () + "Redraw the table-meld buffer." + (let ((game cg-tm--game) (inhibit-read-only t)) + (setq-local mode-line-process + (format " [%s]" (or (cg-get game :step) (cg-get game :phase)))) + (erase-buffer) (insert (cg-render game)) (goto-char (point-min)))) + +(defun cg-tm--clamp-cursor (g) + "Keep G's cursor within the hand and drop stale marks." + (let ((n (length (cg-rummy--hand g 0)))) + (cg-put g :cursor (if (> n 0) (min (cg-get g :cursor) (1- n)) 0)) + (cg-put g :marks (cl-remove-if (lambda (i) (>= i n)) (cg-get g :marks))))) + +(defun cg-tm--my-turn-p (g) + (and (eq (cg-get g :phase) 'play) (= (cg-get g :turn) 0))) + +(defun cg-tm-left () + "Move the hand cursor left." + (interactive) + (let* ((g cg-tm--game) (n (length (cg-rummy--hand g 0)))) + (when (> n 0) (cg-put g :cursor (mod (1- (cg-get g :cursor)) n))) + (cg-tm--redisplay))) + +(defun cg-tm-right () + "Move the hand cursor right." + (interactive) + (let* ((g cg-tm--game) (n (length (cg-rummy--hand g 0)))) + (when (> n 0) (cg-put g :cursor (mod (1+ (cg-get g :cursor)) n))) + (cg-tm--redisplay))) + +(defun cg-tm-mark () + "Toggle a mark on the card under the cursor (for melding)." + (interactive) + (let* ((g cg-tm--game) (i (cg-get g :cursor)) (marks (cg-get g :marks))) + (cg-put g :marks (if (memq i marks) (delq i marks) (cons i marks))) + (cg-tm--redisplay))) + +(defun cg-tm--marked-cards (g) + "Return the cards currently marked in G's hand." + (let ((hand (cg-rummy--hand g 0))) + (mapcar (lambda (i) (nth i hand)) (sort (copy-sequence (cg-get g :marks)) #'<)))) + +(defun cg-tm-meld () + "Meld the marked cards onto the table." + (interactive) + (let* ((g cg-tm--game) (cards (cg-tm--marked-cards g))) + (cond + ((not (cg-tm--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((eq (cg-get g :step) 'draw) (cg-put g :message "Draw first (s or t).")) + ((< (length cards) 3) (cg-put g :message "Mark at least three cards (SPC), then m.")) + ((cg-tm--meld g 0 cards) + (cg-put g :marks nil) (cg-tm--clamp-cursor g) + (cg-put g :message "Melded. Lay off with l, meld more, or discard (RET).")) + (t (cg-put g :message "Those cards are not a valid set or run."))) + (cg-tm--redisplay))) + +(defun cg-tm-layoff () + "Lay the cursor card (or marked cards) off onto a table meld." + (interactive) + (let* ((g cg-tm--game) (marks (cg-tm--marked-cards g))) + (cond + ((not (cg-tm--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((eq (cg-get g :step) 'draw) (cg-put g :message "Draw first (s or t).")) + (t (let ((cards (or marks (list (nth (cg-get g :cursor) (cg-rummy--hand g 0))))) + (any nil)) + (dolist (c cards) (when (and c (cg-tm--layoff g 0 c)) (setq any t))) + (cg-put g :marks nil) (cg-tm--clamp-cursor g) + (cg-put g :message (if any "Laid off." "That card fits no meld on the table."))))) + (cg-tm--redisplay))) + +(defun cg-tm-draw-stock () + "Draw the top stock card." + (interactive) + (let ((g cg-tm--game)) + (cond + ((not (cg-tm--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((not (eq (cg-get g :step) 'draw)) (cg-put g :message "You already drew.")) + ((cg-tm--draw g 0) + (cg-put g :step 'play) (cg-tm--clamp-cursor g) + (cg-put g :message "Meld (m), lay off (l), then discard (RET).")) + (t (cg-tm--score-hand g nil))) + (cg-tm--redisplay))) + +(defun cg-tm-take () + "Take the discard top into your hand." + (interactive) + (let ((g cg-tm--game)) + (cond + ((not (cg-tm--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((not (eq (cg-get g :step) 'draw)) (cg-put g :message "You already drew.")) + ((null (cg-rummy--top g)) (cg-put g :message "The discard pile is empty.")) + (t (let ((c (cg-tm--take-top g 0))) + (cg-put g :step 'play) (cg-tm--clamp-cursor g) + (cg-put g :message (format "Took %s. Meld (m), lay off (l), discard (RET)." + (cg-rummy-card-string c)))))) + (cg-tm--redisplay))) + +(defun cg-tm-discard () + "Discard the cursor card and end your turn." + (interactive) + (let* ((g cg-tm--game) (card (nth (cg-get g :cursor) (cg-rummy--hand g 0)))) + (cond + ((not (cg-tm--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((eq (cg-get g :step) 'draw) (cg-put g :message "Draw first (s or t).")) + ((null card) (cg-put g :message "No card selected.")) + (t (cg-tm--discard g 0 card) + (cg-put g :marks nil) + (when (eq (cg-get g :phase) 'play) + (cg-put g :message "You discarded.") + (cg-tm--run g)))) + (cg-tm--redisplay))) + +(defun cg-tm-new () + "Deal a fresh hand, or a new game when one is over." + (interactive) + (let ((g cg-tm--game)) + (when (eq (cg-get g :phase) 'game-over) + (cg-put g :scores (make-vector (oref g nplayers) 0))) + (cg-put g :reveal nil) + (cg-tm--deal g) + (cg-tm--run g) + (cg-tm--redisplay))) + +(defun cg-tm-redraw () "Redraw the board." (interactive) (cg-tm--redisplay)) + +(defun cg-tm-help () + "Describe the table-meld controls." + (interactive) + (message "Arrows: choose SPC: mark m: meld l: lay off s: draw t: take RET: discard n: new")) + +(defvar cg-tm-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "") #'cg-tm-left) + (define-key map (kbd "") #'cg-tm-right) + (define-key map (kbd "SPC") #'cg-tm-mark) + (define-key map "m" #'cg-tm-meld) + (define-key map "l" #'cg-tm-layoff) + (define-key map "s" #'cg-tm-draw-stock) + (define-key map "t" #'cg-tm-take) + (define-key map (kbd "RET") #'cg-tm-discard) + (define-key map "n" #'cg-tm-new) + (define-key map "g" #'cg-tm-redraw) + (define-key map "?" #'cg-tm-help) + map) + "Keymap for `cg-tm-mode'.") + +(define-derived-mode cg-tm-mode special-mode "Rummy" + "Major mode for the table-meld rummy games." + (setq-local truncate-lines t)) + +(defun cg-tm--start (game buffer-name) + "Start GAME in a buffer named BUFFER-NAME." + (let ((buf (get-buffer-create buffer-name))) + (with-current-buffer buf + (cg-tm-mode) + (setq cg-tm--game game) + (cg-tm--deal game) + (cg-tm--run game) + (cg-tm--redisplay)) + (switch-to-buffer buf))) + +;;;; The two games + +(defcustom cg-rummy-basic-players 2 + "Number of players in Basic Rummy, including you (2-4)." + :type '(choice (const 2) (const 3) (const 4)) :group 'card-games) + +(defclass cg-rummy-basic-game (cg-tablemeld-game) + ((vname :initform "Rummy") + (score-style :initform 'go-out) + (target :initform 100)) + "A game of plain Rummy.") + +;;;###autoload +(defun cg-rummy-basic () + "Play Basic Rummy against the computer." + (interactive) + (let ((n (max 2 (min 4 cg-rummy-basic-players)))) + (cg-tm--start (cg-rummy-basic-game :nplayers n :hand-size (if (= n 2) 10 7)) + "*Rummy*"))) + +(defcustom cg-rum500-players 3 + "Number of players in Rummy 500, including you (2-4)." + :type '(choice (const 2) (const 3) (const 4)) :group 'card-games) + +(defclass cg-rum500-game (cg-tablemeld-game) + ((vname :initform "Rummy 500") + (score-style :initform 'meld-points) + (ace-15 :initform t) + (ace-high :initform t) + (target :initform 500)) + "A game of Rummy 500.") + +;;;###autoload +(defun cg-rum500 () + "Play Rummy 500 against the computer." + (interactive) + (let ((n (max 2 (min 4 cg-rum500-players)))) + (cg-tm--start (cg-rum500-game :nplayers n :hand-size (if (= n 2) 13 7)) + "*Rummy 500*"))) + +;;;###autoload +(defalias 'cg-rummy-500 #'cg-rum500) + +(provide 'cg-rum500) +;;; \ No newline at end of file diff --git a/cg-rummy.el b/cg-rummy.el new file mode 100644 index 0000000..bf9ba0e --- /dev/null +++ b/cg-rummy.el @@ -0,0 +1,617 @@ +;;; cg-rummy.el --- Rummy meld engine and Gin Rummy -*- 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: + +;; The shared meld engine for the rummy family, plus Gin Rummy. +;; +;; A "meld" is a list of cards that is either a *set* (three or four cards +;; of the same rank) or a *run* (three or more cards of the same suit in +;; consecutive rank). The engine validates melds, enumerates the melds +;; latent in a hand, and -- the hard part -- finds the layout that leaves +;; the least deadwood, which drives both knock detection and the AI. +;; +;; Cards use the package-standard cons (SUIT . RANK) with SUIT 0 spades, +;; 1 clubs, 2 diamonds, 3 hearts, and RANK 0 (Ace) .. 12 (King). A joker, +;; used only by Hand & Foot, is the cons (joker . 0). +;; +;; Gin Rummy: a two-handed game of ten-card hands. Draw from the stock or +;; take the discard, then discard one card. Knock when your deadwood is +;; ten or less, or go gin with none; your opponent then lays off onto your +;; melds. First to 100 points wins. This file also provides the abstract +;; `cg-rummy-game' base and the rendering helpers reused by the other +;; rummy games (cg-rum500.el, cg-handfoot.el). + +;;; Code: + +(require 'cl-lib) +(require 'eieio) +(require 'cg-core) + +(defconst cg-rummy-ranks + ["A" "2" "3" "4" "5" "6" "7" "8" "9" "10" "J" "Q" "K"] + "Rank labels indexed 0 (Ace) .. 12 (King).") + +(defface cg-rummy-mark '((t :background "steelblue" :foreground "white")) + "Face for a card the player has marked toward a meld." + :group 'card-games) + +(defun cg-rummy-card-string (card) + "Return a short label for CARD, or a dot for nil." + (cond ((null card) "·") + ((eq (car card) 'joker) (cg-suit-glyph 'joker)) + (t (concat (aref cg-rummy-ranks (cdr card)) (cg-suit-glyph (car card)))))) + +(defun cg-rummy-joker-p (card) + "Return non-nil when CARD is a joker." + (and card (eq (car card) 'joker))) + +(defun cg-rummy-value (card &optional ace-15) + "Return the point value of CARD. +Faces and tens are 10, an Ace is 1 (or 15 when ACE-15 is non-nil), and +other cards their pip value. A joker is worth 0 here; games that use +jokers value them separately." + (cond ((or (null card) (cg-rummy-joker-p card)) 0) + (t (let ((r (cdr card))) + (cond ((= r 0) (if ace-15 15 1)) + ((<= r 8) (1+ r)) + (t 10)))))) + + +;;;; Meld predicates + +(defun cg-rummy--consec-p (ranks) + "Return non-nil when sorted RANKS rise by exactly one with no repeats." + (let ((ok t) (prev nil)) + (dolist (r ranks ok) + (when (and prev (/= r (1+ prev))) (setq ok nil)) + (setq prev r)))) + +(cl-defun cg-rummy-set-p (cards &key (min 3) distinct-suits) + "Return non-nil when CARDS form a set (>= MIN cards of one rank). +With DISTINCT-SUITS, every suit must differ (a single-deck rule)." + (and (>= (length cards) min) + (not (cl-some #'cg-rummy-joker-p cards)) + (let ((r (cdr (car cards)))) + (cl-every (lambda (c) (= (cdr c) r)) cards)) + (or (not distinct-suits) + (let ((suits (mapcar #'car cards))) + (= (length suits) + (length (delete-dups (copy-sequence suits)))))))) + +(cl-defun cg-rummy-run-p (cards &key (min 3) ace-high) + "Return non-nil when CARDS form a run (>= MIN cards, one suit, in sequence). +With ACE-HIGH, an Ace may sit above the King (Q-K-A)." + (and (>= (length cards) min) + (not (cl-some #'cg-rummy-joker-p cards)) + (let ((suit (car (car cards))) + (ranks (sort (mapcar #'cdr cards) #'<))) + (and (cl-every (lambda (c) (= (car c) suit)) cards) + (= (length ranks) (length (delete-dups (copy-sequence ranks)))) + (or (cg-rummy--consec-p ranks) + (and ace-high (eql (car ranks) 0) + (cg-rummy--consec-p (sort (cons 13 (cdr ranks)) #'<)))))))) + +(cl-defun cg-rummy-meld-p (cards &key (min 3) ace-high distinct-suits) + "Return non-nil when CARDS form a valid set or run. +MIN, ACE-HIGH and DISTINCT-SUITS are passed through to the predicates." + (or (cg-rummy-set-p cards :min min :distinct-suits distinct-suits) + (cg-rummy-run-p cards :min min :ace-high ace-high))) + + +;;;; Candidate melds and best partition + +(defun cg-rummy--combinations (lst k) + "Return all K-element combinations of LST, each as a list." + (cond ((= k 0) (list nil)) + ((null lst) nil) + (t (append + (mapcar (lambda (c) (cons (car lst) c)) + (cg-rummy--combinations (cdr lst) (1- k))) + (cg-rummy--combinations (cdr lst) k))))) + +(defun cg-rummy--runs-in (pairs) + "Return index-lists of runs (length >= 3) within PAIRS. +PAIRS is a list of (RANK . INDEX) sorted ascending by RANK." + (let ((res '()) (vec (vconcat pairs))) + (let ((n (length vec))) + (dotimes (i n) + (let ((idxs (list (cdr (aref vec i)))) + (last (car (aref vec i))) + (j (1+ i))) + (while (and (< j n) (= (car (aref vec j)) (1+ last))) + (setq last (car (aref vec j))) + (setq idxs (cons (cdr (aref vec j)) idxs)) + (when (>= (length idxs) 3) + (push (reverse idxs) res)) + (setq j (1+ j)))))) + res)) + +(cl-defun cg-rummy--candidate-melds (cards &key ace-high) + "Return candidate melds for CARDS as a list of index-lists. +Each index-list refers to positions in CARDS. With ACE-HIGH, high runs +ending in an Ace are also generated." + (let* ((vec (vconcat cards)) (n (length vec)) (melds '())) + ;; sets, grouped by rank + (let ((byrank (make-hash-table :test 'eql))) + (dotimes (i n) + (let ((c (aref vec i))) + (unless (cg-rummy-joker-p c) + (push i (gethash (cdr c) byrank))))) + (maphash + (lambda (_r idxs) + (when (>= (length idxs) 3) + (dolist (k '(3 4)) + (dolist (combo (cg-rummy--combinations idxs k)) + (when (cg-rummy-set-p (mapcar (lambda (i) (aref vec i)) combo) + :distinct-suits t) + (push (sort (copy-sequence combo) #'<) melds)))))) + byrank)) + ;; runs, grouped by suit + (let ((bysuit (make-hash-table :test 'eql))) + (dotimes (i n) + (let ((c (aref vec i))) + (unless (cg-rummy-joker-p c) + (push (cons (cdr c) i) (gethash (car c) bysuit))))) + (maphash + (lambda (_s pairs) + (let ((sorted (sort (copy-sequence pairs) + (lambda (a b) (< (car a) (car b)))))) + (dolist (m (cg-rummy--runs-in sorted)) (push (sort m #'<) melds))) + (when (and ace-high (assq 0 pairs)) + (let* ((hi (mapcar (lambda (p) (if (= (car p) 0) (cons 13 (cdr p)) p)) + pairs)) + (sh (sort hi (lambda (a b) (< (car a) (car b)))))) + (dolist (m (cg-rummy--runs-in sh)) (push (sort m #'<) melds))))) + bysuit)) + (delete-dups melds))) + +(cl-defun cg-rummy-best-partition (cards &key ace-high ace-15) + "Return the lowest-deadwood layout of CARDS. +The result is a plist (:melds MELDS :deadwood CARDS :count N) where MELDS +is a list of card-lists, DEADWOOD the unmelded cards and N their value +sum. ACE-HIGH allows Q-K-A runs; ACE-15 scores Aces at 15." + (let* ((vec (vconcat cards)) (n (length vec)) + (cand (cg-rummy--candidate-melds cards :ace-high ace-high)) + (mmasks (mapcar (lambda (m) + (let ((b 0)) (dolist (i m) (setq b (logior b (ash 1 i)))) + b)) + cand)) + (memo (make-hash-table :test 'eql)) + (full (1- (ash 1 n)))) + (cl-labels + ((cval (i) (cg-rummy-value (aref vec i) ace-15)) + (lowbit (avail) + (let ((i 0)) (while (zerop (logand avail (ash 1 i))) (setq i (1+ i))) i)) + (solve (avail) + (if (zerop avail) (cons 0 '()) + (or (gethash avail memo) + (let* ((i (lowbit avail)) + (ibit (ash 1 i)) + (sub0 (solve (logxor avail ibit))) + (best (cons (+ (cval i) (car sub0)) (cdr sub0)))) + (dolist (mask mmasks) + (when (and (/= 0 (logand mask ibit)) + (= mask (logand mask avail))) + (let ((sub (solve (logand avail (lognot mask))))) + (when (< (car sub) (car best)) + (setq best (cons (car sub) (cons mask (cdr sub)))))))) + (puthash avail best memo) + best))))) + (let* ((res (solve full)) (maskmelds (cdr res)) (used 0)) + (dolist (m maskmelds) (setq used (logior used m))) + (list :melds + (mapcar (lambda (mask) + (let ((cl '())) + (dotimes (i n) + (when (/= 0 (logand mask (ash 1 i))) + (push (aref vec i) cl))) + (nreverse cl))) + maskmelds) + :deadwood + (let ((dl '())) + (dotimes (i n) + (when (= 0 (logand used (ash 1 i))) (push (aref vec i) dl))) + (nreverse dl)) + :count (car res)))))) + +(defun cg-rummy-deadwood (cards &optional ace-high ace-15) + "Return the minimum deadwood value of CARDS. +ACE-HIGH and ACE-15 are passed to `cg-rummy-best-partition'." + (plist-get (cg-rummy-best-partition cards :ace-high ace-high :ace-15 ace-15) + :count)) + +(defun cg-rummy-layoff-p (card melds &optional ace-high) + "Return the first meld in MELDS that CARD extends, or nil. +ACE-HIGH allows extending a run with a high Ace." + (cl-find-if (lambda (m) + (cg-rummy-meld-p (cons card m) :min 3 :ace-high ace-high)) + melds)) + + +;;;; Shared deck and base game + +(defun cg-rummy-deck (&optional ndecks jokers) + "Return a shuffled deck of NDECKS standard packs plus JOKERS jokers each. +NDECKS defaults to 1 and JOKERS to 0." + (random t) + (let ((cards '())) + (dotimes (_ (or ndecks 1)) + (dotimes (s 4) (dotimes (r 13) (push (cons s r) cards))) + (dotimes (_ (or jokers 0)) (push (cons 'joker 0) cards))) + (cg-shuffle cards))) + +(defclass cg-rummy-game (cg-game) () + "Abstract base for rummy-style draw-and-discard games." + :abstract t) + +(defsubst cg-rummy--hand (game s) (aref (cg-get game :hands) s)) +(defsubst cg-rummy--set-hand (game s v) (aset (cg-get game :hands) s v)) +(defsubst cg-rummy--top (game) (car (cg-get game :discard))) + +(defun cg-rummy-sort-hand (cards) + "Return CARDS sorted by suit then rank for display, jokers last." + (sort (copy-sequence cards) + (lambda (a b) + (let ((sa (if (cg-rummy-joker-p a) 99 (car a))) + (sb (if (cg-rummy-joker-p b) 99 (car b)))) + (if (= sa sb) (< (cdr a) (cdr b)) (< sa sb)))))) + +(defun cg-rummy--render-cards (cards cursor marks &optional hint-fn) + "Return a propertized row of CARDS. +CURSOR is the highlighted index, MARKS a list of marked indices, and +HINT-FN an optional predicate marking playable cards." + (let ((i 0) (out '())) + (dolist (c cards) + (let ((cs (cg-rummy-card-string c)) (faces nil)) + (when (and (not (cg-rummy-joker-p c)) (cg-red-suit-p (car c))) + (push 'cg-red-suit faces)) + (when (and hint-fn (funcall hint-fn c)) (push 'cg-hint faces)) + (when (memq i marks) (push 'cg-rummy-mark faces)) + (when (eql i cursor) (push 'cg-cursor faces)) + (push (propertize (format "%4s" cs) 'face (or faces 'default)) out)) + (setq i (1+ i))) + (apply #'concat (nreverse out)))) + +(defun cg-rummy--melds-string (melds) + "Return a one-line depiction of MELDS (a list of card-lists)." + (mapconcat (lambda (m) (mapconcat #'cg-rummy-card-string m " ")) + melds " | ")) + + +;;;; Gin Rummy + +(defcustom cg-gin-target 100 + "Points needed to win a game of Gin Rummy." + :type 'integer :group 'card-games) + +(defclass cg-gin-game (cg-rummy-game) + ((vname :initform "Gin Rummy")) + "A two-handed game of Gin Rummy.") + +(cl-defmethod cg-gin--deal ((game cg-gin-game)) + "Deal a fresh Gin hand into GAME." + (let ((deck (cg-rummy-deck)) (hands (make-vector 2 nil))) + (dotimes (s 2) + (aset hands s (cg-rummy-sort-hand (cl-loop repeat 10 collect (pop deck))))) + (cg-put game :hands hands) + (cg-put game :discard (list (pop deck))) + (cg-put game :stock deck) + (cg-put game :turn 0) + (cg-put game :step 'draw) + (cg-put game :phase 'play) + (cg-put game :cursor 0) + (unless (cg-get game :scores) (cg-put game :scores (make-vector 2 0))) + (cg-put game :message + "Your turn: s draws from stock, t takes the discard.") + game)) + +(defun cg-gin--deadwood (cards) (cg-rummy-deadwood cards)) + +(defun cg-gin--draw (game s) + "Move one stock card to seat S's hand; return it or nil if stock empty." + (let ((stock (cg-get game :stock))) + (when stock + (let ((c (pop stock))) + (cg-put game :stock stock) + (cg-rummy--set-hand game s (cons c (cg-rummy--hand game s))) + c)))) + +(defun cg-gin--take (game s) + "Move the discard top to seat S's hand and return it." + (let ((c (cg-rummy--top game))) + (cg-put game :discard (cdr (cg-get game :discard))) + (cg-rummy--set-hand game s (cons c (cg-rummy--hand game s))) + c)) + +(defun cg-gin--discard (game s card) + "Discard CARD from seat S's hand onto the pile." + (cg-rummy--set-hand game s (cg-rummy-sort-hand + (remove card (cg-rummy--hand game s)))) + (cg-put game :discard (cons card (cg-get game :discard)))) + +(defun cg-gin--ai-best-discard (game s) + "Return the card seat S should discard to minimise its deadwood." + (let ((hand (cg-rummy--hand game s)) (best nil) (bestv most-positive-fixnum)) + (dolist (c hand best) + (let ((d (cg-gin--deadwood (remove c hand)))) + (when (or (< d bestv) + (and (= d bestv) best + (> (cg-rummy-value c) (cg-rummy-value best)))) + (setq best c bestv d)))))) + +(cl-defmethod cg-gin--ai-turn ((game cg-gin-game) s) + "Play seat S's whole turn, then hand control back." + (let* ((hand (cg-rummy--hand game s)) + (cur (cg-gin--deadwood hand)) + (up (cg-rummy--top game)) + (with (and up (cg-gin--deadwood (cons up hand)))) + ;; take the discard only when it strictly helps + (drew (if (and up with (< with cur)) + (cg-gin--take game s) + (cg-gin--draw game s)))) + (if (not drew) + (cg-gin--exhaust game) ; stock exhausted: wash the hand + (let ((card (cg-gin--ai-best-discard game s))) + (cg-gin--discard game s card) + (let ((dw (cg-gin--deadwood (cg-rummy--hand game s)))) + (if (<= dw 10) + (cg-gin--knock game s) + (cg-put game :turn 0) + (cg-put game :step 'draw) + (cg-put game :message + (format "Opponent discarded %s. s draws, t takes." + (cg-rummy-card-string card))))))))) + +(cl-defmethod cg-gin--knock ((game cg-gin-game) knocker) + "Resolve the hand when KNOCKER knocks; score and end the hand." + (let* ((kpart (cg-rummy-best-partition (cg-rummy--hand game knocker))) + (kmelds (plist-get kpart :melds)) + (kdw (plist-get kpart :count)) + (opp (- 1 knocker)) + (opart (cg-rummy-best-partition (cg-rummy--hand game opp))) + (odead (plist-get opart :deadwood)) + (gin (= kdw 0))) + ;; opponent lays off onto the knocker's melds (not allowed on gin) + (unless gin + (let (remaining) + (dolist (c odead) + (let ((m (cg-rummy-layoff-p c kmelds))) + (if m (setcdr (last m) (list c)) ; extend that meld in place + (push c remaining)))) + (setq odead (nreverse remaining)))) + (let* ((odw (apply #'+ (mapcar #'cg-rummy-value odead))) + (scores (cg-get game :scores)) + (winner knocker) (pts 0) (note "")) + (cond + (gin (setq pts (+ 25 odw) note "Gin!")) + ((< kdw odw) (setq pts (- odw kdw) note "Knock.")) + (t ;; undercut: defender wins + (setq winner opp pts (+ 25 (- kdw odw)) note "Undercut!"))) + (aset scores winner (+ (aref scores winner) pts)) + (cg-put game :phase 'hand-over) + (cg-put game :reveal t) + (cg-put game :winner winner) + (cg-put game :message + (format "%s %s scores %d. %s (n: next hand)" + note + (if (= winner 0) "You" "Opponent") pts + (if (>= (aref scores winner) cg-gin-target) + (progn (cg-put game :phase 'game-over) + (format "%s wins the game!" + (if (= winner 0) "You" "Opponent"))) + (format "Score %d-%d." + (aref scores 0) (aref scores 1)))))))) + +(cl-defmethod cg-gin--exhaust ((game cg-gin-game)) + "End a hand washed out because the stock ran dry." + (cg-put game :phase 'hand-over) + (cg-put game :reveal t) + (cg-put game :message "Stock exhausted -- the hand is a wash. n: next hand.")) + +;;;; Gin UI + +(defvar-local cg-gin--game nil "The Gin Rummy game in the current buffer.") + +(cl-defmethod cg-render ((game cg-gin-game)) + "Return a propertized depiction of the Gin GAME for a text display." + (let* ((out '()) (scores (cg-get game :scores)) + (reveal (cg-get game :reveal)) + (hand (cg-rummy--hand game 0)) (cursor (cg-get game :cursor))) + (push (format " Gin Rummy first to %d\n\n" cg-gin-target) out) + (push (format " Opponent: %d cards score %d\n" + (length (cg-rummy--hand game 1)) (aref scores 1)) out) + (when reveal + (let ((p (cg-rummy-best-partition (cg-rummy--hand game 1)))) + (push (format " melds: %s\n deadwood: %s (%d)\n" + (cg-rummy--melds-string (plist-get p :melds)) + (mapconcat #'cg-rummy-card-string (plist-get p :deadwood) " ") + (plist-get p :count)) + out))) + (push (format "\n Discard: %s Stock: %d\n\n" + (let ((cs (cg-rummy-card-string (cg-rummy--top game)))) + (if (and (cg-rummy--top game) (cg-red-suit-p (car (cg-rummy--top game)))) + (propertize cs 'face 'cg-red-suit) cs)) + (length (cg-get game :stock))) + out) + (push (format " Your hand (deadwood %d, score %d):\n " + (cg-gin--deadwood hand) (aref scores 0)) out) + (push (cg-rummy--render-cards hand cursor nil) out) + (when reveal + (let ((p (cg-rummy-best-partition hand))) + (push (format "\n melds: %s\n deadwood: %s" + (cg-rummy--melds-string (plist-get p :melds)) + (mapconcat #'cg-rummy-card-string (plist-get p :deadwood) " ")) + out))) + (push (format "\n\n %s\n" (cg-get game :message)) out) + (apply #'concat (nreverse out)))) + +(defun cg-gin--redisplay () + "Redraw the Gin Rummy buffer." + (let ((game cg-gin--game) (inhibit-read-only t)) + (setq-local mode-line-process + (format " [%s]" (or (cg-get game :step) (cg-get game :phase)))) + (erase-buffer) (insert (cg-render game)) (goto-char (point-min)))) + +(defun cg-gin--cursor-card () + "Return the card under the cursor in your hand." + (nth (cg-get cg-gin--game :cursor) (cg-rummy--hand cg-gin--game 0))) + +(defun cg-gin-left () + "Move the hand cursor left." + (interactive) + (let* ((g cg-gin--game) (n (length (cg-rummy--hand g 0)))) + (when (> n 0) (cg-put g :cursor (mod (1- (cg-get g :cursor)) n))) + (cg-gin--redisplay))) + +(defun cg-gin-right () + "Move the hand cursor right." + (interactive) + (let* ((g cg-gin--game) (n (length (cg-rummy--hand g 0)))) + (when (> n 0) (cg-put g :cursor (mod (1+ (cg-get g :cursor)) n))) + (cg-gin--redisplay))) + +(defun cg-gin--my-turn-p (g) + "Return non-nil when it is your turn to act in G." + (and (eq (cg-get g :phase) 'play) (= (cg-get g :turn) 0))) + +(defun cg-gin-draw-stock () + "Draw the top stock card into your hand." + (interactive) + (let ((g cg-gin--game)) + (cond + ((not (cg-gin--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((not (eq (cg-get g :step) 'draw)) (cg-put g :message "Discard first (RET).")) + ((cg-gin--draw g 0) + (cg-put g :step 'discard) + (cg-put g :cursor 0) + (cg-put g :message "Choose a card and discard with RET, or knock with k.")) + (t (cg-gin--exhaust g))) + (cg-gin--redisplay))) + +(defun cg-gin-take-discard () + "Take the discard top into your hand." + (interactive) + (let ((g cg-gin--game)) + (cond + ((not (cg-gin--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((not (eq (cg-get g :step) 'draw)) (cg-put g :message "Discard first (RET).")) + ((null (cg-rummy--top g)) (cg-put g :message "The discard pile is empty.")) + (t (let ((c (cg-gin--take g 0))) + (cg-put g :step 'discard) + (cg-put g :cursor 0) + (cg-put g :message + (format "Took %s. Discard with RET, or knock with k." + (cg-rummy-card-string c)))))) + (cg-gin--redisplay))) + +(defun cg-gin--after-discard (g) + "Hand control to the opponent after you discard in G." + (cg-put g :turn 1) + (cg-put g :step 'draw) + (cg-gin--ai-turn g 1) + (cg-gin--redisplay)) + +(defun cg-gin-discard () + "Discard the selected card and end your turn." + (interactive) + (let* ((g cg-gin--game) (card (cg-gin--cursor-card))) + (cond + ((not (cg-gin--my-turn-p g)) (cg-put g :message "Not your turn.") + (cg-gin--redisplay)) + ((not (eq (cg-get g :step) 'discard)) + (cg-put g :message "Draw first (s or t).") (cg-gin--redisplay)) + ((null card) (cg-put g :message "No card selected.") (cg-gin--redisplay)) + (t (cg-gin--discard g 0 card) + (cg-put g :message (format "You discarded %s." (cg-rummy-card-string card))) + (cg-gin--after-discard g))))) + +(defun cg-gin-knock () + "Knock, discarding the selected card, if your deadwood allows." + (interactive) + (let* ((g cg-gin--game) (card (cg-gin--cursor-card))) + (cond + ((not (cg-gin--my-turn-p g)) (cg-put g :message "Not your turn.")) + ((not (eq (cg-get g :step) 'discard)) + (cg-put g :message "Draw first (s or t).")) + ((null card) (cg-put g :message "No card selected.")) + (t (let ((dw (cg-gin--deadwood (remove card (cg-rummy--hand g 0))))) + (if (> dw 10) + (cg-put g :message + (format "Can't knock: that leaves %d deadwood (need <= 10)." dw)) + (cg-gin--discard g 0 card) + (cg-gin--knock g 0))))) + (cg-gin--redisplay))) + +(defun cg-gin-new () + "Deal a fresh hand (or a new game when one is over)." + (interactive) + (let ((g cg-gin--game)) + (when (eq (cg-get g :phase) 'game-over) (cg-put g :scores (make-vector 2 0))) + (cg-put g :reveal nil) + (cg-gin--deal g) + (cg-gin--redisplay))) + +(defun cg-gin-redraw () "Redraw the board." (interactive) (cg-gin--redisplay)) + +(defun cg-gin-help () + "Describe the Gin Rummy controls." + (interactive) + (message "Arrows: choose s: draw stock t: take discard RET: discard k: knock n: new g: redraw")) + +(defvar cg-gin-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "") #'cg-gin-left) + (define-key map (kbd "") #'cg-gin-right) + (define-key map "s" #'cg-gin-draw-stock) + (define-key map "t" #'cg-gin-take-discard) + (define-key map (kbd "RET") #'cg-gin-discard) + (define-key map "k" #'cg-gin-knock) + (define-key map "n" #'cg-gin-new) + (define-key map "g" #'cg-gin-redraw) + (define-key map "?" #'cg-gin-help) + map) + "Keymap for `cg-gin-mode'.") + +(define-derived-mode cg-gin-mode special-mode "Gin" + "Major mode for Gin Rummy." + (setq-local truncate-lines t)) + +;;;###autoload +(defun cg-gin () + "Play Gin Rummy against the computer." + (interactive) + (let ((buf (get-buffer-create "*Gin Rummy*"))) + (with-current-buffer buf + (cg-gin-mode) + (setq cg-gin--game (cg-gin-game)) + (cg-gin--deal cg-gin--game) + (cg-gin--redisplay)) + (switch-to-buffer buf))) + +;;;###autoload +(defalias 'cg-gin-rummy #'cg-gin) + +(provide 'cg-rummy) +;;; cg-rummy.el ends here diff --git a/runemacs.sh b/runemacs.sh new file mode 100644 index 0000000..534b111 --- /dev/null +++ b/runemacs.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Load the card-games package from this directory and open the launcher. +dir="$(cd "$(dirname "$0")" && pwd)" +exec emacs -L "$dir" -l card-games -f card-game "$@" diff --git a/test/card-games-tests.el b/test/card-games-tests.el index 92cf653..b1de5ce 100644 --- a/test/card-games-tests.el +++ b/test/card-games-tests.el @@ -828,3 +828,83 @@ (ert-deftest cgt-pres-render () (let ((g (cg-pres--deal (cg-president-game)))) (should (stringp (cg-render g))))) +;;;; Rummy family + +(ert-deftest cgt-rummy-set-run () + (should (cg-rummy-set-p '((0 . 5) (1 . 5) (2 . 5)) :distinct-suits t)) + (should-not (cg-rummy-set-p '((0 . 5) (0 . 5) (2 . 5)) :distinct-suits t)) + (should (cg-rummy-run-p '((0 . 3) (0 . 4) (0 . 5)))) + (should-not (cg-rummy-run-p '((0 . 3) (1 . 4) (0 . 5)))) + (should (cg-rummy-run-p '((0 . 10) (0 . 11) (0 . 12) (0 . 0)) :ace-high t)) + (should-not (cg-rummy-run-p '((0 . 11) (0 . 12) (0 . 0)))) + (should (cg-rummy-run-p '((0 . 0) (0 . 1) (0 . 2))))) + +(ert-deftest cgt-rummy-best-partition () + (let* ((hand '((0 . 2)(0 . 3)(0 . 4) (0 . 6)(1 . 6)(2 . 6) + (3 . 8)(3 . 9)(3 . 10) (3 . 12))) + (p (cg-rummy-best-partition hand))) + (should (= 3 (length (plist-get p :melds)))) + (should (= 10 (plist-get p :count)))) + (should (= 0 (cg-rummy-deadwood + '((0 . 0)(0 . 1)(0 . 2) (1 . 4)(1 . 5)(1 . 6) + (2 . 8)(2 . 9)(2 . 10)(2 . 11)))))) + +(ert-deftest cgt-rummy-layoff () + (should (cg-rummy-layoff-p '(3 . 11) '(((3 . 8)(3 . 9)(3 . 10))))) + (should-not (cg-rummy-layoff-p '(0 . 4) '(((3 . 8)(3 . 9)(3 . 10)))))) + +(ert-deftest cgt-gin-full-game () + (let ((g (cg-gin-game)) (turns 0)) + (cg-gin--deal g) + (while (and (not (eq (cg-get g :phase) 'game-over)) (< turns 100000)) + (if (eq (cg-get g :phase) 'hand-over) + (progn (cg-put g :reveal nil) (cg-gin--deal g)) + (cg-gin--ai-turn g (cg-get g :turn)) (cl-incf turns))) + (should (eq (cg-get g :phase) 'game-over)) + (should (>= (apply #'max (append (cg-get g :scores) nil)) cg-gin-target)))) + +(ert-deftest cgt-gin-render () (let ((g (cg-gin--deal (cg-gin-game)))) + (should (stringp (cg-render g))))) + +(ert-deftest cgt-tablemeld-games () + (dolist (mk (list (lambda () (cg-rummy-basic-game :nplayers 3 :hand-size 7)) + (lambda () (cg-rum500-game :nplayers 3 :hand-size 7)))) + (let ((g (funcall mk)) (turns 0)) + (cg-tm--deal g) + (while (and (not (eq (cg-get g :phase) 'game-over)) (< turns 200000)) + (if (eq (cg-get g :phase) 'hand-over) + (progn (cg-put g :reveal nil) (cg-tm--deal g)) + (cg-tm--ai-turn g (cg-get g :turn)) (cl-incf turns))) + (should (eq (cg-get g :phase) 'game-over)) + (should (stringp (cg-render g)))))) + +(ert-deftest cgt-tablemeld-conservation () + (let ((g (cg-rum500-game :nplayers 3 :hand-size 7))) + (cg-tm--deal g) + (dotimes (_ 30) (when (eq (cg-get g :phase) 'play) + (cg-tm--ai-turn g (cg-get g :turn)))) + (let ((tot (+ (length (cg-get g :stock)) (length (cg-get g :discard)) + (apply #'+ (mapcar (lambda (r) (length (cdr r))) (cg-get g :table)))))) + (dotimes (s 3) (setq tot (+ tot (length (cg-rummy--hand g s))))) + (should (= tot 52))))) + +(ert-deftest cgt-handfoot-book () + (should (cg-hf--book-valid-p '((0 . 7)(1 . 7)(2 . 7)))) + (should (cg-hf--book-valid-p '((0 . 7)(1 . 7)(joker . 0)))) ; dirty + (should-not (cg-hf--book-valid-p '((0 . 7)(joker . 0)(joker . 0)))) ; wilds > nats + (should-not (cg-hf--book-valid-p '((0 . 2)(1 . 2)(2 . 2)))) ; threes + (should (cg-hf--book-complete-p '(a b c d e f g))) + (should (cg-hf--book-clean-p '((0 . 7)(1 . 7)(2 . 7)))) + (should-not (cg-hf--book-clean-p '((0 . 7)(1 . 7)(joker . 0))))) + +(ert-deftest cgt-handfoot-full-game () + (let ((g (cg-handfoot-game)) (rounds 0) (turns 0) (expect (* 5 54))) + (cg-put g :nplayers 4) (cg-put g :nteams 2) (cg-put g :scores (make-vector 2 0)) + (cg-hf--deal g) + (while (and (not (eq (cg-get g :phase) 'game-over)) (< rounds 60)) + (cond ((eq (cg-get g :phase) 'round-over) + (cg-put g :reveal nil) (cg-hf--deal g) (cl-incf rounds)) + (t (cg-hf--ai-turn g (cg-get g :turn)) (cl-incf turns) + (when (> turns 500000) (error "runaway"))))) + (should (eq (cg-get g :phase) 'game-over)) + (should (stringp (cg-render g)))))