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.
This commit is contained in:
parent
6593b49b74
commit
2345f7e1a6
5 changed files with 502 additions and 10 deletions
2
Makefile
2
Makefile
|
|
@ -3,7 +3,7 @@ EMACS ?= emacs
|
||||||
PKG = card-games
|
PKG = card-games
|
||||||
VERSION = 1.0.50
|
VERSION = 1.0.50
|
||||||
# Source files in dependency order (cg-core first).
|
# 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)
|
ELC = $(EL:.el=.elc)
|
||||||
PKGDESC = $(PKG)-pkg.el
|
PKGDESC = $(PKG)-pkg.el
|
||||||
TARDIR = $(PKG)-$(VERSION)
|
TARDIR = $(PKG)-$(VERSION)
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
(require 'cg-net)
|
(require 'cg-net)
|
||||||
(require 'cg-gaps)
|
(require 'cg-gaps)
|
||||||
(require 'cg-bid-ui)
|
(require 'cg-bid-ui)
|
||||||
|
(require 'cg-bid-net)
|
||||||
|
|
||||||
(defvar card-games-list
|
(defvar card-games-list
|
||||||
'(("500 (Bid)" cg-bid
|
'(("500 (Bid)" cg-bid
|
||||||
|
|
|
||||||
417
cg-bid-net.el
Normal file
417
cg-bid-net.el
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
;;; cg-bid-net.el --- Networked live 500 (Bid) -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;; Copyright (C) 2026 Corwin Brust
|
||||||
|
|
||||||
|
;; Author: Corwin Brust <corwin@bru.st>
|
||||||
|
;; Maintainer: Corwin Brust <corwin@bru.st>
|
||||||
|
;; 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
;;; 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
|
||||||
26
cg-net.el
26
cg-net.el
|
|
@ -64,17 +64,24 @@
|
||||||
"Abnormal hook run on a client after the game state is updated.
|
"Abnormal hook run on a client after the game state is updated.
|
||||||
Each function is called with the client's game object.")
|
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
|
;;;; Game integration points
|
||||||
|
|
||||||
(cl-defgeneric cg-net-apply-move (game seat move)
|
(cl-defgeneric cg-net-apply-move (game seat move)
|
||||||
"Apply MOVE made by SEAT to GAME on the host.
|
"Apply MOVE made by SEAT to GAME on the host.
|
||||||
Return non-nil when the move was accepted (and state should broadcast).")
|
Return non-nil when the move was accepted (and state should broadcast).")
|
||||||
|
|
||||||
(cl-defgeneric cg-net-game-state (game)
|
(cl-defgeneric cg-net-game-state (game &optional seat)
|
||||||
"Return a `read'able representation of GAME's shared state.")
|
"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))
|
(cl-defmethod cg-net-game-state ((game cg-game) &optional _seat)
|
||||||
"Default: return GAME's env plist as the shared state."
|
"Default: return GAME's env plist (no per-seat filtering)."
|
||||||
(oref game env))
|
(oref game env))
|
||||||
|
|
||||||
(cl-defgeneric cg-net-set-game-state (game state)
|
(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 'welcome :seat seat))
|
||||||
(cg-net--send connection
|
(cg-net--send connection
|
||||||
(list :type 'state
|
(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)
|
(defun cg-net--host-handle (proc msg)
|
||||||
"Handle one message MSG from a client PROC on the host."
|
"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))))))
|
(cg-net-host-broadcast))))))
|
||||||
|
|
||||||
(defun 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
|
(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))
|
(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
|
;;;; Client
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@
|
||||||
|
|
||||||
(ert-deftest cgt-net-loopback ()
|
(ert-deftest cgt-net-loopback ()
|
||||||
"Host-authoritative move sync over a loopback TCP socket."
|
"Host-authoritative move sync over a loopback TCP socket."
|
||||||
;; Skip gracefully where a TCP server cannot be opened.
|
|
||||||
(condition-case _
|
(condition-case _
|
||||||
(delete-process
|
(delete-process
|
||||||
(make-network-process :name "cgt-probe" :server t :service 0
|
(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-not (string-match-p "RET\\] play card" s)) ; key-help removed
|
||||||
(should (string-match-p "Help" s)) ; button row present
|
(should (string-match-p "Help" s)) ; button row present
|
||||||
(should-not (string-match-p "New" s))))) ; no mid-hand new deal
|
(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)))))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue