diff --git a/pud.org b/pud.org new file mode 100644 index 0000000..145b81b --- /dev/null +++ b/pud.org @@ -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 + ;; 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'. + + \\" + ;; 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