Writing my own MUD Client for my MUD Server
This commit is contained in:
parent
2cd9a78e29
commit
14e023c730
1 changed files with 347 additions and 0 deletions
347
pud.org
Normal file
347
pud.org
Normal 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 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 "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, 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
|
||||
(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
|
||||
|
||||
I’m 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 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."
|
||||
(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
|
Loading…
Reference in a new issue