2025-01-22 17:40:06 +00:00
#+title : Moss and Puddles
#+author : Howard X. Abrams
#+date : 2025-01-18
#+filetags : emacs hamacs
2025-02-15 04:39:13 +00:00
#+lastmod : [2025-02-14 Fri]
2025-01-22 17:40:06 +00:00
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 <http://gitlab.com/howardabrams >
;; 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
2025-02-15 04:39:13 +00:00
(list (vector pud-default-world "howardabrams.com" "4000" "guest" "guest"))
2025-01-22 17:40:06 +00:00
"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
2025-02-15 04:39:13 +00:00
(defun pud-world-name (&optional world)
2025-01-22 17:40:06 +00:00
"Return the name for WORLD as a string."
;; (concat (aref world 3) "@" (aref world 0))
(if (vectorp world)
(aref world 0)
world))
2025-02-15 04:39:13 +00:00
(defun pud-world-network (&optional world)
2025-01-22 17:40:06 +00:00
"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)))
2025-02-15 04:39:13 +00:00
(defun pud-world-character (&optional world)
2025-01-22 17:40:06 +00:00
"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)))
2025-02-15 04:39:13 +00:00
(defun pud-world-password (&optional world)
2025-01-22 17:40:06 +00:00
"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
2025-02-15 04:39:13 +00:00
(defvar pud-cli-file-path "ssh"
"Path to the program used by `run-pud'.")
2025-01-22 17:40:06 +00:00
#+END_SRC
The pud-cli-arguments, holds a list of commandline arguments: the port.
2025-02-15 04:39:13 +00:00
#+BEGIN_SRC emacs-lisp
(defvar pud-cli-arguments '("gremlin.howardabrams.com" "telnet")
"A list of arguments to use before the telnet location.")
#+END_SRC
2025-01-22 17:40:06 +00:00
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)
2025-02-15 04:39:13 +00:00
"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)"
2025-01-22 17:40:06 +00:00
(interactive (list (pud-get-world)))
(let* ((pud-program pud-cli-file-path)
2025-02-15 04:39:13 +00:00
(pud-args (append pud-cli-arguments (pud-world-network world)))
2025-01-22 17:40:06 +00:00
(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)
2025-02-15 04:39:13 +00:00
"Collect and send a `connect' sequence to WORLD.
Where WORLD is a vector of world information."
2025-01-22 17:40:06 +00:00
(interactive (list (pud-get-world)))
(pop-to-buffer (pud-buffer-name world))
(sit-for 1)
2025-02-15 04:39:13 +00:00
;; (setq world (pud-get-world))
2025-01-22 17:40:06 +00:00
(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
2025-02-15 04:39:13 +00:00
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.
2025-01-22 17:40:06 +00:00
#+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'.
\\<pud-mode-map >"
;; 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.
2025-02-15 04:39:13 +00:00
`(,(concat (rx bol (optional "@")) (regexp-opt pud-keywords)) . font-lock-keyword-face)
`(,(rx bol "@" (one-or-more)))
)
2025-01-22 17:40:06 +00:00
"Additional expressions to highlight in `pud-mode'.")
#+END_SRC
2025-02-15 04:39:13 +00:00
* 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 "<f6 >") 'pud-send-line)
#+END_SRC
2025-01-22 17:40:06 +00:00
* 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