406 lines
14 KiB
Org Mode
406 lines
14 KiB
Org Mode
#+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 <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
|
||
(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'.
|
||
|
||
\\<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.
|
||
`(,(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 "<f6>") '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
|