Writing my own MUD Client for my MUD Server

This commit is contained in:
Howard Abrams 2025-01-22 09:40:06 -08:00
parent 2cd9a78e29
commit 14e023c730

347
pud.org Normal file
View file

@ -0,0 +1,347 @@
#+title: Moss and Puddles
#+author: Howard X. Abrams
#+date: 2025-01-18
#+filetags: emacs hamacs
#+lastmod: [2025-01-20 Mon]
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 Petersens [[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 "localhost" "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, lets 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
(defsubst 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))
(defsubst 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)))
(defsubst 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)))
(defsubst 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
Im going to use good ol fashion =telnet= for the connection:
#+BEGIN_SRC emacs-lisp
(defvar pud-cli-file-path "/usr/local/bin/telnet"
"Path to the program used by `run-pud'")
#+END_SRC
The pud-cli-arguments, holds a list of commandline arguments: the port.
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 dont 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."
(interactive (list (pud-get-world)))
(let* ((pud-program pud-cli-file-path)
(pud-args (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)
"docstring"
(interactive (list (pud-get-world)))
(pop-to-buffer (pud-buffer-name world))
(sit-for 1)
(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
The previous snippet of code dealt with creating and maintaining the buffer and process, and this piece of code enriches it with font locking and mandatory setup. Namely comint-process-echoes which, depending on the mode and the circumstances, may result in prompts appearing twice. Setting it to t is usually a requirement, but do experiment.
#+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 "\\_<" (regexp-opt pud-keywords) "\\_>") . font-lock-keyword-face))
"Additional expressions to highlight in `pud-mode'.")
#+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