From 2345f7e1a6e01ca1dd073a5e628b6c04f8391542 Mon Sep 17 00:00:00 2001 From: Corwin Brust Date: Tue, 23 Jun 2026 23:34:48 -0500 Subject: [PATCH] Add live multiplayer 500 over cg-net (cg-bid-net.el) Host-authoritative networked 500: host sits South, joiners take W/N/E, open seats filled by AI. Per-seat rotated state hides other hands and the kitty; clients reuse the single-player renderer/commands via :around advice, so cg-bid.el/cg-bid-ui.el are untouched. Adds cg-bid-host/cg-bid-join, a start-now lobby with auto-start at four players, and cg-bid-shuffle-partners. cg-net.el gains cg-net-connect-functions. Verified: clean byte-compile, checkdoc, 34/34 ERT (incl. 3 new net tests) and a two-process TCP game. --- Makefile | 2 +- card-games.el | 1 + cg-bid-net.el | 417 +++++++++++++++++++++++++++++++++++++++ cg-net.el | 26 ++- test/card-games-tests.el | 66 ++++++- 5 files changed, 502 insertions(+), 10 deletions(-) create mode 100644 cg-bid-net.el diff --git a/Makefile b/Makefile index d7d0f41..f085770 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ EMACS ?= emacs PKG = card-games VERSION = 1.0.50 # 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 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 card-games.el ELC = $(EL:.el=.elc) PKGDESC = $(PKG)-pkg.el TARDIR = $(PKG)-$(VERSION) diff --git a/card-games.el b/card-games.el index cdae9bc..4f17cb3 100644 --- a/card-games.el +++ b/card-games.el @@ -43,6 +43,7 @@ (require 'cg-net) (require 'cg-gaps) (require 'cg-bid-ui) +(require 'cg-bid-net) (defvar card-games-list '(("500 (Bid)" cg-bid diff --git a/cg-bid-net.el b/cg-bid-net.el new file mode 100644 index 0000000..bf3e145 --- /dev/null +++ b/cg-bid-net.el @@ -0,0 +1,417 @@ +;;; cg-bid-net.el --- Networked live 500 (Bid) -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Corwin Brust + +;; Author: Corwin Brust +;; Maintainer: Corwin Brust +;; Version: 1.0.50 +;; 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: + +;; Live multiplayer 500 over `cg-net'. One Emacs hosts with +;; `cg-bid-host'; up to three others join with `cg-bid-join'. The host +;; owns the canonical game and sits South (seat 0); joining players take +;; seats West, North and East in turn. Any seat left open when play +;; begins is driven by the existing AI, so a table of one human and +;; three robots, or four humans, or anything between, all work. +;; +;; The host is authoritative: a client sends a move "intent" (bid, pass, +;; discard or play); the host validates it, applies it to the canonical +;; game, lets the AI take any open seats, then broadcasts a fresh view +;; to every client. Each client receives a per-seat view rotated so the +;; recipient sits South: it sees only its own cards, opponents collapse +;; to face-down counts, and the kitty stays hidden until won. Because a +;; client's view places itself at seat 0, the ordinary single-player +;; commands and renderer work unchanged for everyone. + +;;; Code: + +(require 'cl-lib) +(require 'cg-core) +(require 'cg-net) +(require 'cg-bid) +(require 'cg-bid-ui) + +(defcustom cg-bid-shuffle-partners nil + "When non-nil, randomize seating when a hosted game starts. +The host keeps South; joined players are shuffled among West, North and +East, so it is chance, not arrival order, that decides who partners whom." + :type 'boolean :group 'cg-net) + +(defvar cg-bid--net-role nil + "Role of this Emacs in a live game: `host', `client', or nil (solo).") + +(defvar cg-bid--net-seat 0 + "This player's absolute seat in a live game (the host is always 0).") + +(defvar cg-bid--applying-remote nil + "Bound non-nil while the host applies a remote player's move. +While set, prompts that would block the host (such as nominating a suit +for a Joker lead) fall back to an automatic choice.") + +;;;; Per-seat state filter (host -> client) + +(defun cg-bid--rot (x seat) + "Rotate absolute seat X into SEAT's frame so SEAT becomes 0. +Return nil when X is nil." + (and x (mod (- x seat) 4))) + +(defun cg-bid--rot-team (team seat) + "Rotate TEAM index (0 or 1) into SEAT's frame. +Return nil when TEAM is nil." + (and team (if (cl-oddp seat) (- 1 team) team))) + +(defun cg-bid--rotate-vec4 (vec seat) + "Return a fresh 4-vector whose element I is VEC element (I+SEAT) mod 4." + (let ((v (make-vector 4 nil))) + (dotimes (i 4) (aset v i (aref vec (mod (+ i seat) 4)))) + v)) + +(cl-defmethod cg-net-game-state ((game cg-bid-game) &optional seat) + "Return GAME's shared state for SEAT, rotated so SEAT sits South. +Other players' hands collapse to face-down counts, the kitty is hidden, +and a hand exposed by an open misère is revealed to everyone." + (let ((seat (or seat 0))) + (if (null (cg-get game :hands)) + ;; Lobby: nothing dealt yet. + (list :phase (or (cg-get game :phase) 'lobby) + :message (or (cg-get game :message) "Waiting for players…") + :hand-no (or (cg-get game :hand-no) 0)) + (let* ((hands (cg-get game :hands)) + (exposed (cg-get game :exposed)) + (rhands (make-vector 4 nil)) + (scores (cg-get game :scores))) + (dotimes (i 4) + (let* ((abs (mod (+ i seat) 4)) + (cards (aref hands abs))) + (aset rhands i + (if (or (= abs seat) (eql abs exposed)) + (copy-sequence cards) + (make-list (length cards) (cons 0 0)))))) + (list + :hands rhands + :kitty nil + :phase (cg-get game :phase) + :contract (cg-get game :contract) + :contractor (cg-bid--rot (cg-get game :contractor) seat) + :high-bid (cg-get game :high-bid) + :high-bidder (cg-bid--rot (cg-get game :high-bidder) seat) + :bidder (cg-bid--rot (cg-get game :bidder) seat) + :dealer (cg-bid--rot (cg-get game :dealer) seat) + :passed (cg-bid--rotate-vec4 (cg-get game :passed) seat) + :turn (cg-bid--rot (cg-get game :turn) seat) + :leader (cg-bid--rot (cg-get game :leader) seat) + :led (cg-get game :led) + :trick (mapcar (lambda (p) (cons (cg-bid--rot (car p) seat) (cdr p))) + (cg-get game :trick)) + :last-trick (mapcar (lambda (p) (cons (cg-bid--rot (car p) seat) (cdr p))) + (cg-get game :last-trick)) + :tricks (cg-bid--rotate-vec4 (cg-get game :tricks) seat) + :ntricks (cg-get game :ntricks) + :exposed (cg-bid--rot exposed seat) + :scores (if (cl-oddp seat) (cons (cdr scores) (car scores)) scores) + :game-over (cg-bid--rot-team (cg-get game :game-over) seat) + :hand-no (cg-get game :hand-no) + :hand-result (cg-get game :hand-result) + :message (cg-get game :message) + :log (cg-get game :log) + :log-scroll (cg-get game :log-scroll) + :you seat))))) + +(cl-defmethod cg-net-set-game-state ((game cg-bid-game) state) + "Install host STATE into GAME on a client, keeping the local cursor and scroll." + (let ((old (oref game env))) + (oset game env state) + (dolist (k '(:cursor :marks :log-scroll)) + (cg-put game k (and (plist-member old k) (plist-get old k)))))) + +;;;; Apply a move on the host + +(cl-defmethod cg-net-apply-move ((game cg-bid-game) seat move) + "Apply MOVE made by absolute SEAT to the host's 500 GAME. +MOVE is (bid BID), (pass), (discard CARD...) or (play CARD). Return +non-nil when the move was legal and applied, so the host broadcasts." + (let ((phase (cg-get game :phase)) (ok nil)) + (pcase move + (`(bid ,bid) + (when (and (eq phase 'auction) (eql (cg-get game :bidder) seat)) + (cg-bid--auction-act game seat bid) (setq ok t))) + (`(pass) + (when (and (eq phase 'auction) (eql (cg-get game :bidder) seat)) + (cg-bid--auction-act game seat nil) (setq ok t))) + (`(discard . ,cards) + (when (and (eq phase 'kitty) (eql (cg-get game :contractor) seat) + (= (length cards) 5)) + (cg-bid--discard game seat cards) (setq ok t))) + (`(play ,card) + (when (and (eq phase 'play) (eql (cg-get game :turn) seat) + (member card (cg-bid-legal-cards + (cg-bid--hand game seat) + (cg-get game :led) + (cg-bid-trump (cg-get game :contract))))) + (let ((cg-bid--applying-remote t)) (cg-bid--play game seat card)) + (setq ok t)))) + (when ok + (let ((cg-bid--applying-remote t)) (cg-bid--run game)) + (cg-bid--net-host-refresh)) + ok)) + +(defun cg-bid--net-nominate-advice (orig game seat) + "Around advice for `cg-bid--nominate-suit'. +While the host applies a remote move (ORIG GAME SEAT), pick the longest +suit automatically instead of prompting." + (if cg-bid--applying-remote + (let ((counts (make-vector 4 0)) (best 0)) + (dolist (c (cg-bid--hand game seat)) + (unless (cg-bid-joker-p c) (cl-incf (aref counts (car c))))) + (dotimes (s 4) (when (> (aref counts s) (aref counts best)) (setq best s))) + best) + (funcall orig game seat))) +(advice-add 'cg-bid--nominate-suit :around #'cg-bid--net-nominate-advice) + +;;;; Host bookkeeping and display + +(defun cg-bid--net-host-refresh () + "Redraw the host's own table buffer." + (let ((buf (get-buffer "*500 Bid*"))) + (when (buffer-live-p buf) + (with-current-buffer buf (cg-bid--redisplay))))) + +(defun cg-bid--net-broadcast-advice (&rest _) + "After advice on `cg-bid--refresh' that broadcasts when hosting." + (when (and (eq cg-bid--net-role 'host) (cg-net-hosting-p)) + (cg-net-host-broadcast))) +(advice-add 'cg-bid--refresh :after #'cg-bid--net-broadcast-advice) + +(defun cg-bid--net-lobby-display () + "Show the host's pre-game lobby of seats." + (let ((buf (get-buffer "*500 Bid*"))) + (when (buffer-live-p buf) + (with-current-buffer buf + (let ((inhibit-read-only t) + (seats (cl-remove-duplicates cg-bid--human-seats))) + (erase-buffer) + (insert "\n 500 — LIVE TABLE (hosting)\n\n") + (dotimes (s 4) + (insert (format " %-6s %s\n" (aref cg-bid-seat-names s) + (cond ((= s 0) "you (host)") + ((memq s seats) "joined") + (t "open — AI will fill"))))) + (insert "\n Press s to start now") + (when cg-bid-shuffle-partners (insert " (partners shuffled)")) + (insert ".\n") + (goto-char (point-min))))))) + +(defun cg-bid--net-shuffle-seats () + "Randomly reassign joined clients among seats 1, 2 and 3. +The host keeps South (seat 0)." + (let ((clients (and cg-net--host + (cl-remove-if-not #'process-live-p + (cg-net-host-clients cg-net--host)))) + (seats (cg-shuffle (list 1 2 3)))) + (dolist (p clients) (process-put p 'cg-net-seat (pop seats))) + (setq cg-bid--human-seats + (cons 0 (mapcar (lambda (p) (process-get p 'cg-net-seat)) clients))))) + +(defun cg-bid--net-start () + "Deal and begin the hosted game, AI filling any open seat." + (let ((game cg-bid--game)) + (when cg-bid-shuffle-partners (cg-bid--net-shuffle-seats)) + (setq cg-bid--human-seats (cl-remove-duplicates cg-bid--human-seats)) + (cg-bid--deal game 3) + (let ((cg-bid--applying-remote t)) (cg-bid--run game)) + (cg-bid--net-host-refresh) + (cg-net-host-broadcast))) + +(defun cg-bid--net-on-connect (host seat) + "Host hook: a client has joined at SEAT (HOST is the server struct)." + (when (eq cg-bid--net-role 'host) + (if (> seat 3) + ;; Table full: turn the latecomer away. + (let ((p (cl-find seat (cg-net-host-clients host) + :key (lambda (q) (process-get q 'cg-net-seat))))) + (when p (cg-net--send p '(:type full)) (delete-process p))) + (cl-pushnew seat cg-bid--human-seats) + (cg-bid--net-lobby-display) + (message "Player joined at %s." (aref cg-bid-seat-names seat)) + (when (= (length (cl-remove-duplicates cg-bid--human-seats)) 4) + (cg-bid--net-start))))) + +;;;; Client display + +(defun cg-bid--net-client-update (game) + "Redraw the client's buffer after the host sends new state for GAME." + (let ((buf (get-buffer "*500 Bid*"))) + (when (buffer-live-p buf) + (with-current-buffer buf + (if (memq (cg-get game :phase) '(lobby nil)) + (let ((inhibit-read-only t)) + (erase-buffer) + (insert "\n 500 — connected to host.\n\n " + (or (cg-get game :message) + "Waiting for the host to start…") + "\n") + (goto-char (point-min))) + (cg-bid--redisplay)))))) + +;;;; Client move interception + +(defun cg-bid--net-client-bid-advice (orig) + "Around advice on `cg-bid-make-bid' (ORIG): send the bid, do not apply it." + (if (eq cg-bid--net-role 'client) + (let ((game cg-bid--game)) + (if (or (not (eq (cg-get game :phase) 'auction)) + (/= (cg-get game :bidder) 0)) + (progn (cg-put game :message "Not your turn to bid.") + (cg-bid--redisplay)) + (let* ((legal (cg-bid--legal-bids game)) + (completion-ignore-case t) + (choices (append + (mapcar (lambda (b) + (cons (format "%-4s %s (%d)" + (cg-bid--code b) + (cg-bid-name b) + (cg-bid-value b)) + b)) + legal) + '(("Pass" . pass)))) + (pick (completing-read + "Your bid (e.g. 7H, 8NT, NL; or Pass): " + (mapcar #'car choices) nil t)) + (sel (cdr (assoc pick choices)))) + (cg-net-send-move (if (eq sel 'pass) '(pass) (list 'bid sel))) + (cg-put game :message "Bid sent — waiting…") + (cg-bid--redisplay)))) + (funcall orig))) +(advice-add 'cg-bid-make-bid :around #'cg-bid--net-client-bid-advice) + +(defun cg-bid--net-client-pass-advice (orig) + "Around advice on `cg-bid-pass' (ORIG): send a pass, do not apply it." + (if (eq cg-bid--net-role 'client) + (let ((game cg-bid--game)) + (if (or (not (eq (cg-get game :phase) 'auction)) + (/= (cg-get game :bidder) 0)) + (progn (cg-put game :message "Not your turn to bid.") + (cg-bid--redisplay)) + (cg-net-send-move '(pass)) + (cg-put game :message "Pass sent — waiting…") + (cg-bid--redisplay))) + (funcall orig))) +(advice-add 'cg-bid-pass :around #'cg-bid--net-client-pass-advice) + +(defun cg-bid--net-client-select-advice (orig) + "Around advice on `cg-bid-select' (ORIG): send a play, or mark locally." + (if (eq cg-bid--net-role 'client) + (let* ((game cg-bid--game) + (phase (cg-get game :phase)) + (card (cg-bid--current-card))) + (pcase phase + ('kitty + (when (eql (cg-get game :contractor) 0) + (let ((marks (cg-get game :marks))) + (cg-put game :marks (if (member card marks) + (remove card marks) + (cons card marks))) + (cg-put game :message + (format "%d of 5 marked for discard." + (length (cg-get game :marks)))) + (cg-bid--redisplay)))) + ('play + (cond + ((/= (cg-get game :turn) 0) + (cg-put game :message "Not your turn.") (cg-bid--redisplay)) + ((null card) (cg-bid--redisplay)) + (t (cg-net-send-move (list 'play card)) + (cg-put game :message "Card sent — waiting…") + (cg-bid--redisplay)))) + (_ (cg-bid--redisplay)))) + (funcall orig))) +(advice-add 'cg-bid-select :around #'cg-bid--net-client-select-advice) + +(defun cg-bid--net-client-discard-advice (orig) + "Around advice on `cg-bid-discard-marked' (ORIG): send the discard intent." + (if (eq cg-bid--net-role 'client) + (let* ((game cg-bid--game) (marks (cg-get game :marks))) + (cond + ((not (eq (cg-get game :phase) 'kitty)) + (cg-put game :message "Nothing to discard now.") (cg-bid--redisplay)) + ((/= (length marks) 5) + (cg-put game :message + (format "Mark exactly 5 (have %d)." (length marks))) + (cg-bid--redisplay)) + (t (cg-net-send-move (cons 'discard marks)) + (cg-put game :marks nil) + (cg-put game :message "Discard sent — waiting…") + (cg-bid--redisplay)))) + (funcall orig))) +(advice-add 'cg-bid-discard-marked :around #'cg-bid--net-client-discard-advice) + +;;;; Commands + +(defun cg-bid-start-now () + "Start a hosted game immediately, AI filling any empty seats." + (interactive) + (if (and (eq cg-bid--net-role 'host) + (eq (cg-get cg-bid--game :phase) 'lobby)) + (cg-bid--net-start) + (message "Not hosting a lobby."))) +(define-key cg-bid-mode-map "s" #'cg-bid-start-now) + +;;;###autoload +(defun cg-bid-host (port) + "Host a live game of 500 on PORT. Others join with `cg-bid-join'." + (interactive (list (read-number "Host on port: " cg-net-port))) + (let ((buf (get-buffer-create "*500 Bid*"))) + (with-current-buffer buf + (cg-bid-mode) + (setq cg-bid--game (make-instance 'cg-bid-game) + cg-bid--net-role 'host + cg-bid--net-seat 0 + cg-bid--human-seats '(0)) + (cg-put cg-bid--game :phase 'lobby) + (cg-put cg-bid--game :message "Lobby") + (cg-net-host-start cg-bid--game port) + (setf (cg-net-host-next-seat cg-net--host) 1) + (add-hook 'cg-net-connect-functions #'cg-bid--net-on-connect) + (cg-bid--net-lobby-display)) + (switch-to-buffer buf) + (message "Hosting 500 on port %d — waiting for players (press s to start)." + port))) + +;;;###autoload +(defun cg-bid-join (host port name) + "Join a hosted game of 500 at HOST and PORT as NAME." + (interactive (list (read-string "Host: " "127.0.0.1") + (read-number "Port: " cg-net-port) + (read-string "Your name: " (user-login-name)))) + (let ((buf (get-buffer-create "*500 Bid*"))) + (with-current-buffer buf + (cg-bid-mode) + (setq cg-bid--game (make-instance 'cg-bid-game) + cg-bid--net-role 'client + cg-bid--human-seats '(0)) + (cg-put cg-bid--game :phase 'lobby) + (cg-put cg-bid--game :message "Connecting…") + (add-hook 'cg-net-state-functions #'cg-bid--net-client-update) + (cg-net-connect host port name cg-bid--game) + (cg-bid--net-client-update cg-bid--game)) + (switch-to-buffer buf))) + +(provide 'cg-bid-net) +;;; cg-bid-net.el ends here diff --git a/cg-net.el b/cg-net.el index a79b5b5..5e8f1c1 100644 --- a/cg-net.el +++ b/cg-net.el @@ -64,17 +64,24 @@ "Abnormal hook run on a client after the game state is updated. Each function is called with the client's game object.") +(defvar cg-net-connect-functions nil + "Abnormal hook run on the host when a client connects. +Each function is called with (HOST SEAT): the `cg-net-host' struct and +the seat number just assigned to the new connection.") + ;;;; Game integration points (cl-defgeneric cg-net-apply-move (game seat move) "Apply MOVE made by SEAT to GAME on the host. Return non-nil when the move was accepted (and state should broadcast).") -(cl-defgeneric cg-net-game-state (game) - "Return a `read'able representation of GAME's shared state.") +(cl-defgeneric cg-net-game-state (game &optional seat) + "Return a `read'able representation of GAME's shared state for SEAT. +SEAT is the recipient's seat number, letting a game hide other players' +private information; nil requests the full host view.") -(cl-defmethod cg-net-game-state ((game cg-game)) - "Default: return GAME's env plist as the shared state." +(cl-defmethod cg-net-game-state ((game cg-game) &optional _seat) + "Default: return GAME's env plist (no per-seat filtering)." (oref game env)) (cl-defgeneric cg-net-set-game-state (game state) @@ -150,7 +157,8 @@ HANDLER is called with (PROC MSG)." (cg-net--send connection (list :type 'welcome :seat seat)) (cg-net--send connection (list :type 'state - :state (cg-net-game-state (cg-net-host-game cg-net--host)))))) + :state (cg-net-game-state (cg-net-host-game cg-net--host) seat))) + (run-hook-with-args 'cg-net-connect-functions cg-net--host seat))) (defun cg-net--host-handle (proc msg) "Handle one message MSG from a client PROC on the host." @@ -163,11 +171,13 @@ HANDLER is called with (PROC MSG)." (cg-net-host-broadcast)))))) (defun cg-net-host-broadcast () - "Send the current game state to every connected client." + "Send each connected client the game state filtered for its seat." (when cg-net--host - (let ((state (cg-net-game-state (cg-net-host-game cg-net--host)))) + (let ((game (cg-net-host-game cg-net--host))) (dolist (c (cg-net-host-clients cg-net--host)) - (cg-net--send c (list :type 'state :state state)))))) + (cg-net--send c (list :type 'state + :state (cg-net-game-state + game (process-get c 'cg-net-seat)))))))) ;;;; Client diff --git a/test/card-games-tests.el b/test/card-games-tests.el index bc0150a..767d0c8 100644 --- a/test/card-games-tests.el +++ b/test/card-games-tests.el @@ -47,7 +47,6 @@ (ert-deftest cgt-net-loopback () "Host-authoritative move sync over a loopback TCP socket." - ;; Skip gracefully where a TCP server cannot be opened. (condition-case _ (delete-process (make-network-process :name "cgt-probe" :server t :service 0 @@ -268,3 +267,68 @@ (should-not (string-match-p "RET\\] play card" s)) ; key-help removed (should (string-match-p "Help" s)) ; button row present (should-not (string-match-p "New" s))))) ; no mid-hand new deal + +;;;; Live 500 (networking) + +(ert-deftest cgt-bid-net-filter () + "Per-seat 500 state hides others' cards and rotates the viewer South." + (cl-flet ((ck (h) (sort (mapcar #'prin1-to-string h) #'string<))) + (let* ((cg-bid--human-seats '(0 1 2 3)) + (g (cg-bid--deal (make-instance 'cg-bid-game) 3)) + (st (cg-net-game-state g 1))) ; West's viewpoint + ;; West sees its own ten cards rotated to index 0 + (should (equal (ck (aref (plist-get st :hands) 0)) + (ck (cg-bid--hand g 1)))) + ;; opponents are face-down counts only, not the real cards + (should (= 10 (length (aref (plist-get st :hands) 1)))) + (should-not (equal (ck (aref (plist-get st :hands) 1)) + (ck (cg-bid--hand g 2)))) + ;; the kitty stays hidden + (should-not (plist-get st :kitty)) + ;; South (abs 0) bids first; in West's frame that is seat 3 + (should (= 3 (plist-get st :bidder))) + (should (= 1 (plist-get st :you)))))) ; :you is the absolute seat + +(ert-deftest cgt-bid-net-apply () + "The host applies only legal, in-turn moves." + (let* ((cg-bid--human-seats '(0 1 2 3)) + (g (cg-bid--deal (make-instance 'cg-bid-game) 3))) + (should-not (cg-net-apply-move g 1 '(pass))) ; not West's turn yet + (should (cg-net-apply-move g 0 '(pass))) ; South may pass + (should (aref (cg-get g :passed) 0)) + (should-not (cg-net-apply-move g 0 '(play (0 . 0)))) ; wrong phase + )) + +(ert-deftest cgt-bid-net-loopback () + "A 500 move travels client -> host -> filtered broadcast over TCP." + (condition-case _ + (delete-process + (make-network-process :name "cgt-probe2" :server t :service 0 + :host "127.0.0.1" :family 'ipv4)) + (error (ert-skip "TCP not available"))) + (cl-flet ((pump () (dotimes (_ 16) (accept-process-output nil 0.05)))) + (let* ((cg-bid--human-seats '(0 1)) + (hgame (cg-bid--deal (make-instance 'cg-bid-game) 3)) + (srv (cg-net-host-start hgame 0)) + (port (process-contact srv :service)) + (cgame (make-instance 'cg-bid-game))) + (setf (cg-net-host-next-seat cg-net--host) 1) ; reserve South for host + (unwind-protect + (progn + (cg-net-connect "127.0.0.1" port "P1" cgame) + (pump) + ;; the client (seat 1) sees its own ten cards at index 0 + (should (= 10 (length (aref (cg-get cgame :hands) 0)))) + (should (equal + (sort (mapcar #'prin1-to-string (aref (cg-get cgame :hands) 0)) #'string<) + (sort (mapcar #'prin1-to-string (cg-bid--hand hgame 1)) #'string<))) + ;; make it the client's turn, then let it pass + (cg-put hgame :bidder 1) + (cg-net-host-broadcast) + (pump) + (should (= 0 (cg-get cgame :bidder))) ; rotated: West sees itself bidding + (cg-net-send-move '(pass)) + (pump) + (should (aref (cg-get hgame :passed) 1))) + (cg-net-disconnect) + (cg-net-host-stop)))))