#+title: Moss and Puddles #+author: Howard X. Abrams #+date: 2025-01-18 #+filetags: emacs hamacs #+lastmod: [2025-02-14 Fri] A literate programming file for a Comint-based MUD client. #+begin_src emacs-lisp :exports none ;;; pud --- a MUD client -*- lexical-binding: t; -*- ;; ;; © 2025 Howard X. Abrams ;; Licensed under a Creative Commons Attribution 4.0 International License. ;; See http://creativecommons.org/licenses/by/4.0/ ;; ;; Author: Howard X. Abrams ;; Maintainer: Howard X. Abrams ;; Created: January 18, 2025 ;; ;; While obvious, GNU Emacs does not include this file or project. ;; ;; *NB:* Do not edit this file. Instead, edit the original literate file at: ;; /Users/howard/src/hamacs/pud.org ;; And tangle the file to recreate this one. ;; ;;; Code: #+end_src * Introduction This project is a simple MUD client for Emacs, based on COM-INT MUD client based on Mickey Petersen’s [[https://www.masteringemacs.org/article/comint-writing-command-interpreter][essay on Comint]]. The default connects to *Moss ‘n Puddles*, my own MUD which I invite you to join. #+BEGIN_SRC emacs-lisp (defvar pud-default-world "moss-n-puddles" "The default world to connect.") #+END_SRC This uses =telnet= (at the moment) for the connection, so you will need to install that first. On Mac, this would be: #+BEGIN_SRC sh brew install telent #+END_SRC And use a similar command on Linux. * User Credentials You may want to customize your connections to more worlds. #+BEGIN_SRC emacs-lisp (defgroup pud nil "An overly simplistic MUD client that works with Evennia." :group 'processes) (defcustom pud-worlds (list (vector pud-default-world "howardabrams.com" "4000" "guest" "guest")) "List of worlds you play in. You need to define the worlds you play in before you can get started. In most worlds, you can start playing using a guest account. Each element WORLD of the list has the following form: \[NAME HOST PORT CHARACTER PASSWORD] NAME identifies the connection, HOST and PORT specify the network connection, CHARACTER and PASSWORD are used to connect automatically. Note that this will be saved in your `custom-file' -- including your passwords! If you don't want that, specify nil as your password." :type '(repeat (vector :tag "World" (string :tag "Name") (string :tag "Host") (integer :tag "Port") (string :tag "Char" :value "guest") (string :tag "Pwd" :value "guest"))) :group 'pud) #+END_SRC Next, open [[file:~/.authinfo.gpg][your authinfo file]], and insert the following line, substituting =[user]= and =[pass]= with your credentials as well as the first entry to match your world: #+BEGIN_SRC conf :tangle no :eval no machine moss-n-puddles login [name] password [pass] #+END_SRC Now, let’s play! Type =run-pud=, and optionally select a world. If you get disconnected, re-run it, or even =pud-reconnect=. The rest of this file describes the code to implement this project. * Code The following function will return the default world: #+BEGIN_SRC emacs-lisp (defun pud-get-default-world () "Return the connection information for the `pud-default-world'. If only one world listed in `pud-worlds', return that." (if (length= pud-worlds 1) (seq-first pud-worlds) (seq-find (lambda (w) (string-equal (aref w 0) pud-default-world)) pud-worlds))) #+END_SRC And accessibility functions. #+BEGIN_SRC emacs-lisp (defun pud-world-name (&optional world) "Return the name for WORLD as a string." ;; (concat (aref world 3) "@" (aref world 0)) (if (vectorp world) (aref world 0) world)) (defun pud-world-network (&optional world) "Return the network details for WORLD as a cons cell (HOST . PORT)." (unless world (setq world (pud-get-default-world))) (list (aref world 1) (aref world 2))) (defun pud-world-character (&optional world) "Return the character for WORLD as a string. Override the customized setting if the world has an entry in authinfo." (unless world (setq world (pud-get-default-world))) (if-let ((auth-results (auth-source-search :host (aref world 0)))) (thread-first auth-results (first) (plist-get :user)) (aref world 3))) (defun pud-world-password (&optional world) "Return the password for WORLD as a string." (unless world (setq world (pud-get-default-world))) (if-let ((auth-results (auth-source-search :host (aref world 0)))) (thread-first auth-results (first) (plist-get :secret) (funcall)) (aref world 4))) #+END_SRC And some basic functions that really need to be expanded. #+BEGIN_SRC emacs-lisp :tangle no (ert-deftest pud-world-name-test () (should (string-equal (pud-world-name "foobar") "foobar")) (should (string-equal (pud-world-name ["foobar" "localhost" "4000" "guest" "guest"]) "foobar"))) (ert-deftest pud-world-network-test () (should (equal (pud-world-network) '("localhost" "4000"))) (should (equal (pud-world-network ["foobar" "overthere" "4000" "guest" "guest"]) '("overthere" "4000")))) (ert-deftest pud-world-character-test () (should (equal (pud-world-character) "guest"))) #+END_SRC Choosing a world… er, connection using a =completing-read= allowing you to choose a world. If =pud-worlds= contains a single value, might as well just return that. #+BEGIN_SRC emacs-lisp (defvar pud-world-history nil "History for `pud-get-world'.") (defun pud-get-world () "Let the user choose a world from `pud-worlds'. The return value is a cons cell, the car is the name of the connection, the cdr holds the connection defails from `pud-worlds'." (if (length= pud-worlds 1) (seq-first pud-worlds)) (let ((world-completions (mapcar (lambda (w) (cons (pud-world-name w) w)) pud-worlds))) (cond ((and world-completions (length= world-completions 1)) (thread-first world-completions (first) (cdr))) (world-completions (thread-first (completing-read "World: " world-completions nil t nil pud-world-history) (assoc world-completions) (cdr))) (t (customize-option 'pud-worlds))))) #+END_SRC And a function for the full credentials, which just happens to be what we need to pass to =telnet=. #+BEGIN_SRC emacs-lisp (defun pud-credentials (&optional world) "Reset the credentials from WORLD from the authinfo system." (setf (elt 3 world) (pud-world-character world)) (setf (elt 4 world) (pud-world-password world)) world) #+END_SRC * Basics Using Comint, and hoping to have the ANSI colors displayed. #+BEGIN_SRC emacs-lisp (require 'comint) (load "ansi-color" t) #+END_SRC I’m going to use good ‘ol fashion =telnet= for the connection: #+BEGIN_SRC emacs-lisp (defvar pud-cli-file-path "ssh" "Path to the program used by `run-pud'.") #+END_SRC The pud-cli-arguments, holds a list of commandline arguments: the port. #+BEGIN_SRC emacs-lisp (defvar pud-cli-arguments '("gremlin.howardabrams.com" "telnet") "A list of arguments to use before the telnet location.") #+END_SRC The empty and currently disused mode map for storing our custom keybindings inherits from =comint-mode-map=, so we get the same keys exposed in =comint-mode=. #+BEGIN_SRC emacs-lisp (defvar pud-mode-map (let ((map (nconc (make-sparse-keymap) comint-mode-map))) ;; example definition (define-key map "\t" 'completion-at-point) map) "Basic mode map for `run-pud'.") #+END_SRC This holds a regular expression that matches the prompt style for the MUD. Not sure if this is going to work, since MUDs typically don’t have prompts. #+BEGIN_SRC emacs-lisp (defvar pud-prompt-regexp "" ; "^\\(?:\\[[^@]+@[^@]+\\]\\)" "Prompt for `run-pud'.") #+END_SRC The name of the buffer: #+BEGIN_SRC emacs-lisp (defvar pud-buffer-name "*Moss and Puddles*" "Name of the buffer to use for the `run-pud' comint instance.") #+END_SRC #+BEGIN_SRC emacs-lisp (defun pud-buffer-name (&optional world) "Return the buffer name associated with WORLD." (format "*%s*" (if world (pud-world-name world) pud-default-world))) #+END_SRC The main entry point to the program is the =run-pud= function: #+BEGIN_SRC emacs-lisp (defun run-pud (world) "Run an inferior instance of `pud-cli' inside Emacs. The WORLD should be vector containing the following: - label for the world - server hostname - server port - username (can be overridden) - password (should be overridden)" (interactive (list (pud-get-world))) (let* ((pud-program pud-cli-file-path) (pud-args (append pud-cli-arguments (pud-world-network world))) (buffer (get-buffer-create (pud-buffer-name world))) (proc-alive (comint-check-proc buffer)) (process (get-buffer-process buffer))) ;; if the process is dead then re-create the process and reset the ;; mode. (unless proc-alive (with-current-buffer buffer (apply 'make-comint-in-buffer "Pud" buffer pud-program nil pud-args) (pud-mode) (visual-line-mode 1) (pud-reconnect world))) ;; Regardless, provided we have a valid buffer, we pop to it. (when buffer (pop-to-buffer buffer)))) #+END_SRC Connection and/or re-connection: #+BEGIN_SRC emacs-lisp (defun pud-reconnect (world) "Collect and send a `connect' sequence to WORLD. Where WORLD is a vector of world information." (interactive (list (pud-get-world))) (pop-to-buffer (pud-buffer-name world)) (sit-for 1) ;; (setq world (pud-get-world)) (let* ((username (pud-world-character world)) (password (pud-world-password world)) (conn-str (format "connect %s %s\n" username password)) (process (get-buffer-process (current-buffer)))) (if process (comint-send-string process conn-str) (insert conn-str)))) #+END_SRC * Pud Mode Note that =comint-process-echoes=, depending on the mode and the circumstances, may result in prompts appearing twice. Setting =comint-process-echoes= to =t= helps with that. #+BEGIN_SRC emacs-lisp (defun pud--initialize () "Helper function to initialize Pud." (setq comint-process-echoes t) (setq comint-use-prompt-regexp nil)) (define-derived-mode pud-mode comint-mode "Pud" "Major mode for `run-pud'. \\" ;; this sets up the prompt so it matches things like: [foo@bar] ;; (setq comint-prompt-regexp pud-prompt-regexp) ;; this makes it read only; a contentious subject as some prefer the ;; buffer to be overwritable. (setq comint-prompt-read-only t) ;; this makes it so commands like M-{ and M-} work. ;; (set (make-local-variable 'paragraph-separate) "\\'") ;; (set (make-local-variable 'font-lock-defaults) '(pud-font-lock-keywords t)) ;; (set (make-local-variable 'paragraph-start) pud-prompt-regexp) ) (add-hook 'pud-mode-hook 'pud--initialize) (defconst pud-keywords '("connect" "get" "look" "use") "List of keywords to highlight in `pud-font-lock-keywords'.") (defvar pud-font-lock-keywords (list ;; highlight all the reserved commands. `(,(concat (rx bol (optional "@")) (regexp-opt pud-keywords)) . font-lock-keyword-face) `(,(rx bol "@" (one-or-more))) ) "Additional expressions to highlight in `pud-mode'.") #+END_SRC * Evennia Mode Make a simple mode for basic highlighting of =ev= code. #+BEGIN_SRC emacs-lisp (define-derived-mode evennia-mode nil "Evennia" "Major mode for editing evennia batch command files. \\{evennia-mode-map} Turning on Evennia mode runs the normal hook `evennia-mode-hook'." (setq-local comment-start "# ") (setq-local comment-start-skip "#+\\s-*") (setq-local require-final-newline mode-require-final-newline) (add-hook 'conevennia-menu-functions 'evennia-mode-conevennia-menu 10 t)) (defvar evennia-mode-font-lock-keywords `(,(rx line-start "@" (one-or-more alnum)) ) "Additional things to highlight in evennia output.") #+END_SRC  * Org Babel Wouldn’t it be nice to be able to write commands in an Org file, and send the command to the connected Mud? #+BEGIN_SRC emacs-lisp :results silent (defun pud-send-line (world) "Send the current line or region to WORLD." (interactive (list (pud-get-world))) (save-window-excursion (save-excursion (let ((text (buffer-substring-no-properties (if (region-active-p) (region-beginning) (beginning-of-line-text) (point)) (if (region-active-p) (region-end) (end-of-line) (point)))) (process (get-buffer-process (current-buffer)))) (pop-to-buffer (pud-buffer-name world)) (goto-char (point-max)) (comint-send-string process (format "%s\n" text)))))) (global-set-key (kbd "") 'pud-send-line) #+END_SRC * Technical Artifacts :noexport: Let's =provide= a name so we can =require= this file: #+begin_src emacs-lisp :exports none (provide 'pud) ;;; pud.el ends here #+end_src #+DESCRIPTION: a MUD client #+PROPERTY: header-args:sh :tangle no #+PROPERTY: header-args:emacs-lisp :tangle yes #+PROPERTY: header-args :results none :eval no-export :comments no mkdirp yes #+OPTIONS: num:nil toc:nil todo:nil tasks:nil tags:nil date:nil #+OPTIONS: skip:nil author:nil email:nil creator:nil timestamp:nil #+INFOJS_OPT: view:nil toc:nil ltoc:t mouse:underline buttons:0 path:http://orgmode.org/org-info.js