hamacs/pud.org
2025-02-14 20:39:13 -08:00

14 KiB
Raw Blame History

Moss and Puddles

A literate programming file for a Comint-based MUD client.

Introduction

This project is a simple MUD client for Emacs, based on COM-INT MUD client based on Mickey Petersens essay on Comint.

The default connects to Moss n Puddles, my own MUD which I invite you to join.

  (defvar pud-default-world "moss-n-puddles"
    "The default world to connect.")

This uses telnet (at the moment) for the connection, so you will need to install that first. On Mac, this would be:

  brew install telent

And use a similar command on Linux.

User Credentials

You may want to customize your connections to more worlds.

  (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)

Next, open 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:

  machine moss-n-puddles login [name] password [pass]

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:

  (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)))

And accessibility functions.

  (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)))

And some basic functions that really need to be expanded.

  (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")))

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.

  (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)))))

And a function for the full credentials, which just happens to be what we need to pass to telnet.

  (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)

Basics

Using Comint, and hoping to have the ANSI colors displayed.

  (require 'comint)
  (load "ansi-color" t)

Im going to use good ol fashion telnet for the connection:

  (defvar pud-cli-file-path "ssh"
    "Path to the program used by `run-pud'.")

The pud-cli-arguments, holds a list of commandline arguments: the port.

  (defvar pud-cli-arguments '("gremlin.howardabrams.com" "telnet")
    "A list of arguments to use before the telnet location.")

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.

  (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'.")

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.

  (defvar pud-prompt-regexp "" ; "^\\(?:\\[[^@]+@[^@]+\\]\\)"
    "Prompt for `run-pud'.")

The name of the buffer:

  (defvar pud-buffer-name "*Moss and Puddles*"
    "Name of the buffer to use for the `run-pud' comint instance.")
  (defun pud-buffer-name (&optional world)
    "Return the buffer name associated with WORLD."
    (format "*%s*" (if world
                       (pud-world-name world)
                     pud-default-world)))

The main entry point to the program is the run-pud function:

  (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))))

Connection and/or re-connection:

  (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))))

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.

  (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'.")

Evennia Mode

Make a simple mode for basic highlighting of ev code.

  (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.")



Org Babel

Wouldnt it be nice to be able to write commands in an Org file, and send the command to the connected Mud?

  (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)