emacs-ironsworn/README.org
2022-02-18 15:28:12 -08:00

64 KiB
Raw Blame History

Ironsworn RPG Help System

While my original goal for creating my rpgdm project for Emacs was to help running a role playing game using a laptop instead of a Dungeon Master Screen. However, once all my games moved online, having my notes in an org file next to all my friend's faces, was doubly helpful. However, what I used the most from this project was the random tables. Need a name? F12 c name … What's the weather like? F12 c weather (and with completing helpers like selectrum and the like, I only needed to type we).

However, the struggles for getting friends to play online proved as challenging as getting them around the table, so I started looking for solo rpg options and discovered Ironsworn RPG. The bulk of the game is its moves and its oracles (random tables for everything). I easily copied sections of the SRD into org files, tables.

After listening to the author, Shawn Tomkin, play the game in his podcast, Ask the Oracle, he used Roll20. The character sheet was brilliant, as each move was described along with being able to roll on them. While I love physically rolling dice, perhaps mimicking the approach in org files in Emacs for solo play may be ideal.

Getting Started

Neither this, nor the rpgdm project are currently in MELPA, so if you wish to follow along at home, you'll need to clone both repos, and add them to your load-path variable with add-to-list:

(add-to-list 'load-path "~/other/emacs-rpgdm")
(add-to-list 'load-path "~/other/emacs-ironsworn")

Or better yet, use something like straight:

  (straight-use-package
   '(el-patch :type git :host gitlab :repo "howardabrams/emacs-rpgdm"))

  (straight-use-package
   '(el-patch :type git :host gitlab :repo "howardabrams/emacs-ironsworn"))

Next, create an org file, and either turn on the rpgdm-mode (minor mode), or simply define a globally accessible shortcut:

  (global-set-key (kbd "<f12>") 'hydra-rpgdm/body)

What I do, is add the following "code" somewhere in my Ironsworn-specific org files:

# Local Variables:
# eval: (progn (require 'rpgdm-ironsworn) (rpgdm-mode))
# End:

Finally, define your character. While I describe the details later, hit M-x and type: rpgdm-ironsworn-new-character and answer the questions. This will create the necessary formatting and you are ready to play with either your key-binding (or F12) to bring up the Hydra of commands:

/git/howard/emacs-ironsworn/media/commit/d04280ba7de68f65a99d6bfe6bab9159cae5fbde/images/ui-screenshot.png

While the interface may change (and I may not update that screenshot too often), let me explain what you're looking at:

  • The 0, 1 and 6 roll dice and display the result. Not very interesting.
  • d rolls the Ironsworn dice, two d10s and a d6 allowing you to add all the modifiers you want. Better.
  • e, h, i, s, and w roll against your character's edge, heart, iron, shadow and wits respectively.
  • p allows you to create a new progress track, or mark progress against it. It has its own submenu:

    /git/howard/emacs-ironsworn/media/commit/d04280ba7de68f65a99d6bfe6bab9159cae5fbde/images/ui-progress.png
  • m lets you choose a move, display the details, and lets you roll against it.

    /git/howard/emacs-ironsworn/media/commit/d04280ba7de68f65a99d6bfe6bab9159cae5fbde/images/list-of-moves.png
  • H, S, G, and M let you change those stats about your character
  • z let's you ask a yes/no question, choosing how likely it is. Good for both solo play as well as turning it back on your players, "How likely do you think moor is haunted?"
  • c displays a list of random tables to roll against:

    /git/howard/emacs-ironsworn/media/commit/d04280ba7de68f65a99d6bfe6bab9159cae5fbde/images/list-of-tables.png
  • Ironsworn expects you to roll many of the tables in tandem, so the O shows a list of these, for instance:

    /git/howard/emacs-ironsworn/media/commit/d04280ba7de68f65a99d6bfe6bab9159cae5fbde/images/ui-oracles.png
  • o calls ace-link to jump around org links
  • J / K are convenient for moving around your org file while you display the UI.
  • y yanks (pastes) the last display table or other message into your document.
  • Y yanks the results of the last move (along with the name of the move).
  • The Super or Hyper key can be use to view a previous message or roll results.
  • And q (or F12) dismisses the UI.

This may be sufficient, but the rest of this document goes into details about how to use this, as well as the code to make it.

Character Sheets

A character sheet, for this project, is just an org mode file where you take notes, and :PROPERTIES: drawers contain the current stats for your character. While most of it is whatever you like it to be … you need to keep a few things in mind.

First, don't narrow the display. Looking up the character's stats, currently doesn't widen the document to find them … at least, not yet.

Character Attributes

Specify unchanging Character Attibutes like iron and shadow in a drawer at Level One Headling, for instance:

* The Adventures of Bred
:PROPERTIES:
:ironsworn-edge: 2
:ironsworn-heart: 1
:ironsworn-iron: 1
:ironsworn-shadow: 2
:ironsworn-wits: 3
:END:

You'll notice that the call to rpgdm-ironsworn-new-character does this.

Character Stats

Set changeable Character Stats like health and supply under any header, keeping in mind that calculations that require a particular value, will start at the point (er, cursor), and works its way up. This means that if you had spirit set to 5 at level 1, set to 3 at a level 2, and is now describing a battle with a Gaunt as a level 3 subheader, where you haven't specified… your spirit at that point is 3.

* The Adventures of Bred
:PROPERTIES:
:ironsworn-spirit: 5
...
:END:
** Journey to Stormlook
:PROPERTIES:
:ironsworn-spirit: 3
:END:
*** Battle with a Gaunt
With the point here, Bred's spirit is 3.
** Sojourn in Stormlook
However, at this level two heading, Bred's spirit is 5 (as it would read the level 1 setting).

You might say, of course, this makes sense, and it should. However, sibling headers as well as lower-level headers are ignored. In other words, we use the tree structure of the org file, not what came earlier in the buffer file.

Progress Tracks

Set Progress Tracks from Iron vows and bonds to tracking monstrous conflicts, once, at a particular headline. That progress is available to that header section and any subsections.

Since bonds and your character's overarching epic vow should always be available, set these at Level 1.

You'll notice that their properties are a bit peculiar, for instance:

,:PROPERTIES:
,:ironsworn-progress-b5f243e0: "Battle with Monstrosity" 8 16
,:ironsworn-health: 3
,:ironsworn-momentum: 3
,:END:

In this example, along with changing both the health and momentum stats, we define a progress. It has a weird ID (that just has to be unique), and properties that describe the progress, the number of ticks to mark per marking, and the number of ticks (4 ticks per box, and 10 boxes per track, means 40 is the max value).

Again, the UI will attempt to update all of these values, so you don't need to concern yourself, but if you are using Emacs, you probably want to know the details.

Details? Did someone say details? Let's talk about the code … all the code that makes this work.

Code

To begin, we'll need the rpgdm project cloned in the load-path variable so that we can load it simply by calling:

(require 'rpgdm)

We also need the name of the directory for this project, so that we can load tables and other documentation.

    (defvar rpgdm-ironsworn-project (file-name-directory load-file-name)
      "The root directory to the emacs-ironsworn project")

Dice Roller

In Ironsworn, all dice rolls follow a pattern where you set the challenge level for a check by rolling challenge dice (two d10s) and compare that against rolling an action die (a single d6 … adding all modifiers to that six-sided die). You always three possible values:

  • A strong hit where your action die + modifiers is larger than both d10 challenge dice.
  • A weak hit is where your action die + modifiers is larger than only one of the d10 challenge dice.
  • A miss happens when your action die + modifiers is equal to or less than both d10 challenge dice.

I describe the rpgdm-ironsworn-roll that implements this below, but I need a helper function.

When we roll, I want one of those three results printed, but in different colors, to easily distinguish. I also want to see the numbers, so we can display those too. Oh, and if the same number shows on both d10s, you should introduce a significant plot twist, so this function, given all the numbers, returns a nicely formatted string:

  (defun rpgdm-ironsworn--results (action modifier one-challenge two-challenge)
    (let* ((action-results (+ action modifier))
           (str-results (cond
                         ((and (> action-results one-challenge) (> action-results two-challenge))
                          (propertize "Strong hit" 'face '(:foreground "green")))
                         ((or  (> action-results one-challenge) (> action-results two-challenge))
                          (propertize "Weak hit" 'face '(:foreground "yellow")))
                         (t (propertize "Miss" 'face '(:foreground "red")))))
           (matched-msg  (if (= one-challenge two-challenge)
                             (propertize " ← Create a Twist" 'face '(:foreground "orange"))
                           "")))
      (format "%s %s %d %s%d %s %d%s %s %d %s %d %s" str-results
              (propertize "::" 'face '(:foreground "#888"))
              (+ action modifier)
              (propertize "(" 'face '(:foreground "#888"))
              action
              (propertize "+" 'face '(:foreground "#888"))
              modifier
              (propertize ")" 'face '(:foreground "#888"))
              (propertize "→" 'face '(:foreground "light blue"))
              one-challenge
              (propertize "/" 'face '(:foreground "#888"))
              two-challenge matched-msg)))

So the following messages, given various rolls should cover those possibilities with text properties:

  (rpgdm-ironsworn--results 3 2 4 1)  ; "Strong hit :: 5 (3 + 2) → 4 / 1"
  (rpgdm-ironsworn--results 3 2 8 1)  ; "Weak hit :: 5 (3 + 2) → 8 / 1"
  (rpgdm-ironsworn--results 3 2 8 6)  ; "Miss :: 5 (3 + 2) → 8 / 6"
  (rpgdm-ironsworn--results 3 2 6 6)  ; "Miss :: 5 (3 + 2) → 6 / 6  ← Create a Twist"

The basic interface will query for a modifer, roll all three dice, and then display the results using the rpgdm-message function:

  (defun rpgdm-ironsworn-roll (modifier)
    "Display a Hit/Miss message based on comparing a d6 action
  roll (added to MODIFIER) vs. two d10 challenge dice."
    (interactive "nModifier: ")
    (let ((one-challenge (rpgdm--roll-die 10))
          (two-challenge (rpgdm--roll-die 10))
          (action-roll   (rpgdm--roll-die 6)))
      (rpgdm-message (rpgdm-ironsworn--results action-roll modifier
                                               one-challenge two-challenge))))

Character Information

But what might be nice is to have a character sheet that has the default modifiers, and then we wouldn't have to give the modifiers, at least, not the basic ones. You will store these stats and other numbers in your org file, and we'll work the details of that later.

Creating a Character

Assuming we have a new character, let's query the user for all of these stats, and call rpgdm-ironsworn-store-character-state for each. This function attempts to only put the bare minimum into the Org File, as I would expect, gentle reader, to have some firm opinions on how this file should be formatted.

Character Template

We assume you have created an org-file, and the template will just append some basic text at the end of the buffer. Note that if you don't give this function a name, it will randomly choose one.

  (defun rpgdm-ironsworn--new-character-template (name)
    "Insert a basic Ironsworn template at the end of the current buffer."
    (when (s-blank? name)
      (setq name (rpgdm-tables-choose "names-ironlander")))

    (let ((frmt (seq-random-elt '("* The Adventures of %s"
                                  "* The Journeys of %s"
                                  "* %s, an Epic Saga"
                                  "* The Epic of %s"
                                  "* Travels of %s"))))
      (goto-char (point-max))
      (insert "# Local Variables:
  # eval: (progn (require 'rpgdm-ironsworn) (rpgdm-mode))
  # End:
  ")
      (insert (format frmt name))))
Character Assets

We need a way to insert three assets, however, I don't want duplicates, or two companions, or … well, I may come up with more rules, but I codify those rules into a function that returns a list, if it is good, or nil otherwise:

  (defun rpgdm-ironsworn--good-character-assets (asset-files)
    "Return ASSET-FILES if all given are _good enough_.
  That is, all are unique, only one companion, etc."
    (when (and
           (equal asset-files (seq-uniq asset-files))
           (<= (seq-length
                (seq-filter (lambda (file) (string-match (rx "companions") file))
                            asset-files))
               1))
      asset-files))

And I can write a little unit test to verify my test cases:

  (ert-deftest rpgdm-ironsworn--good-character-assets-test ()
    (should (rpgdm-ironsworn--good-character-assets     '("foo" "bar" "baz")))
    (should-not (rpgdm-ironsworn--good-character-assets '("foo" "bar" "foo")))
    (should-not (rpgdm-ironsworn--good-character-assets '("assets/companions/dog.org"
                                                          "assets/paths/good-guy.org"
                                                          "assets/companions/monkey.org"))))

The rules say to start off with three assets, so let's have a function that can give us three random assets:

  (defun rpgdm-ironsworn--some-character-assets (asset-filenames &optional number)
    "Return a list of NUMBER elements from ASSET-FILENAMES... randomly.
  If NUMBER is nil, then return 3."
    (unless number
      (setq number 3))
    (loop for x from 1 to number
          collect (seq-random-elt asset-filenames)))

And again, a little unit test would be nice:

  (ert-deftest rpgdm-ironsworn--some-character-assets-test ()
    (should (= 4 (seq-length (rpgdm-ironsworn--some-character-assets '(1 2 3 4 5 6) 4))))
    (should (= 3 (seq-length (rpgdm-ironsworn--some-character-assets '(1 2 3 4 5 6))))))

And a function that will read the assets directory (recursively) and return a list of three filenames randomly chosen. Once we get the list of filenames, we use a inner helper function, good-enough-list, that picks three filenames, and if they are not good enough, will call itself until it picks three good ones. Shouldn't take too many iterations.

  (defun rpgdm-ironsworn--new-character-asset-files ()
    "Return the file names of three assets from the `assets' directory.
  The chosen assets are _good_ in that they won't have duplicates, etc."
    (let ((asset-files (thread-first rpgdm-ironsworn-project
                         (f-join "assets")
                         (directory-files-recursively (rx (one-or-more any) ".org") nil))))

      (defun good-enough-list (assets)
        (let ((answer (thread-first assets
                        rpgdm-ironsworn--some-character-assets
                        rpgdm-ironsworn--good-character-assets)))
          (if answer
              answer
            (good-enough-list assets))))

      (good-enough-list asset-files)))

Finally, we have a function that inserts the contents of three randomly chosen assets:

  (defun rpgdm-ironsworn--new-character-assets ()
    "Insert the contents of three character assets from the assets directory."
    (let ((asset-files (rpgdm-ironsworn--new-character-asset-files)))
      (goto-char (point-max))
      (insert "\n** Assets\n")
      (dolist (file asset-files)
        (insert-file-contents file nil)
        (insert "\n\n"))))
Character Stats

This function will query the user for all of the stats and other properties that we will need to store before we can play the game.

  (defun rpgdm-ironsworn--new-character-stats ()
    "Query the user for a new character's stats, and add them as
  properties using the `rpgdm-ironsworn-store-character-state' and
  the `rpgdm-ironsworn-progress-create' functions."
    (dolist (stat '(edge heart iron shadow wits))
      (rpgdm-ironsworn-store-character-state stat
          (read-string (format "What '%s' stat: " stat))))

    (dolist (stat '(health spirit supply))
      (rpgdm-ironsworn-store-character-state stat 5))
    (rpgdm-ironsworn-store-character-state 'momentum 2)

    (rpgdm-ironsworn-progress-create (read-string "What title should we give this new character's Epic vow? ") 1)
    (rpgdm-ironsworn-progress-create "Bonds" 1)
    (search-forward ":END:")
    (end-of-line)
    (insert "\n** Bonds\n")
    (insert (format "  - Your home settlement of %s\n" (rpgdm-tables-choose "settlement-names"))))
New Character Interface

Do we choose the stats first, or the assets? Do we ask, or have two separate functions? Perhaps the clearest approach is to do both, create two process functions, and then query the user which they would prefer.

  (defun rpgdm-ironsworn--new-character-stats-first (&optional name)
    "Insert a new character template, query for the stats, then insert assets."
    (rpgdm-ironsworn--new-character-template name)
    (rpgdm-ironsworn--new-character-stats)
    (rpgdm-ironsworn--new-character-assets))

  (defun rpgdm-ironsworn--new-character-assets-first (&optional name)
    "Insert a new character template, insert assets then query for the stats."
    (rpgdm-ironsworn--new-character-template name)
    ;; Saving and restoring point, means the properties should be in the
    ;; correct, top-level position.
    (save-excursion
      (rpgdm-ironsworn--new-character-assets))
    (rpgdm-ironsworn--new-character-stats))

  (defun rpgdm-ironsworn-new-character (name order)
    "Interactively query the user for a new character's attribute.
          This function _appends_ this information to the current buffer,
          which should be using the `org-mode' major mode."
    (interactive (list
                  (read-string "What is the new character's name? ")
                  (completing-read "What order should we build this? " '("Statistics first" "Assets first"))))
    (if (equal order "Assets first")
        (rpgdm-ironsworn--new-character-assets-first)
      (rpgdm-ironsworn--new-character-stats-first))
    (message "Alright, the template is complete. Edit away!" name))

Showing a Character Stats

Sure, you could open up the appropriate drawer to see a character's stats, but we could do better? An updated org table? A separate buffer? For the moment, I am just going to display it in the mini-buffer whenever I want to see it:

  (defun rpgdm-ironsworn--display-stat (stat character)
    (let* ((value (gethash stat character))
           (s-val (number-to-string value))
           (color (cond
                   ((< value 1) "red")
                   ((< value 3) "orange")
                   ((< value 4) "yellow")
                   (t "green"))))
      (propertize s-val 'face `(:foreground ,color))))

  (defun rpgdm-ironsworn-character-display ()
    "Easily display the character's stats and other things."
    (interactive)
    (let ((character (rpgdm-ironsworn-current-character-state)))
      (rpgdm-message "Edge: %d  Heart: %d  Iron: %d  Shadow: %d  Wits: %d
  Health: %s  Spirit: %s  Supply: %s  Momentum: %d"
                     (rpgdm-ironsworn-character-stat 'edge character)
                     (rpgdm-ironsworn-character-stat 'heart character)
                     (rpgdm-ironsworn-character-stat 'iron character)
                     (rpgdm-ironsworn-character-stat 'shadow character)
                     (rpgdm-ironsworn-character-stat 'wits character)

                     (rpgdm-ironsworn--display-stat 'health character)
                     (rpgdm-ironsworn--display-stat 'spirit character)
                     (rpgdm-ironsworn--display-stat 'supply character)

                     (gethash 'momentum character 5))))

Retrieving Character Stats

We need an internal representation of a character using a hash table of the attributes and other stats. One key feature is that I want to be able to look up a stat by either symbol or string, e.g. 'edge or "edge" or even :edge. For this, I define a comparator, er, a Lisp test that uses a function to convert to a common format, a string:

  (defun rpgdm-ironsworn-to-string (a)
    "Return a lowercase string from either a string, keyword or symbol."
    (downcase
     (cond
      ((keywordp a) (substring (symbol-name a) 1))
      ((symbolp  a) (symbol-name a))
      (t            a))))

  (define-hash-table-test 'str-or-keys
    (lambda (a b)
      (string-equal (rpgdm-ironsworn-to-string a) (rpgdm-ironsworn-to-string b)))

    (lambda (s) (sxhash-equal (rpgdm-ironsworn-to-string s))))

With this hash-table test in place, we will create And a help function to retrieve the stats of the character is just a wrapper around gethash:

  (defun rpgdm-ironsworn-character-stat (stat &optional character)
    "Return integer value associated with a character's STAT."
    (when (null character)
      (setq character (rpgdm-ironsworn-current-character-state)))
    (gethash stat character 1))

Just to prove it to ourselves, all of the following expressions return the same number (however, only run this in an org file that has a character properly formatted in it):

  (rpgdm-ironsworn-character-stat 'wits)
  (rpgdm-ironsworn-character-stat "wits")
  (rpgdm-ironsworn-character-stat "Wits")
  (rpgdm-ironsworn-character-stat :wits)

Adjusting Character Stats

We need to modify some of the stored values, like health and supply:

  (defun rpgdm-ironsworn-adjust-stat (stat adj &optional default)
    "Increase or decrease the current character's STAT by ADJ."
    (let ((value (+ (gethash stat rpgdm-ironsworn-character default) adj)))
      ;; TODO: Delete this hash bidness
      (puthash stat value rpgdm-ironsworn-character)
      (rpgdm-ironsworn-store-character-state stat value)))

  (defun rpgdm-ironsworn-adjust-health (health-adj)
    "Increase or decrease the current character's health by HEALTH-ADJ."
    (interactive "nHealth Adjustment: ")
    (rpgdm-ironsworn-adjust-stat 'health health-adj 5))

  (defun rpgdm-ironsworn-adjust-spirit (spirit-adj)
    "Increase or decrease the current character's spirit by SPIRIT-ADJ."
    (interactive "nSpirit Adjustment: ")
    (rpgdm-ironsworn-adjust-stat 'spirit spirit-adj 5))

  (defun rpgdm-ironsworn-adjust-supply (supply-adj)
    "Increase or decrease the current character's supply by SUPPLY-ADJ."
    (interactive "nSupply Adjustment: ")
    (rpgdm-ironsworn-adjust-stat 'supply supply-adj 5))

  (defun rpgdm-ironsworn-adjust-momentum (momentum-adj)
    "Increase or decrease the current character's momentum by MOMENTUM-ADJ."
    (interactive "nMomentum Adjustment: ")
    (rpgdm-ironsworn-adjust-stat 'momentum momentum-adj 2))

Roll against Character Stats

Which allows us to create character stat-specific rolling functions:

  (defun rpgdm-ironsworn-roll-stat (stat modifier)
    "Roll an action based on a loaded character's STAT with a MODIFIER."
    (interactive (list (completing-read "Stat Modifier: " '(Edge Heart Iron Shadow Wits))
                       (read-string "Other Modifier: ")))
    (let ((all-mods (+ (rpgdm-ironsworn-character-stat stat)
                       (string-to-number modifier))))
      (rpgdm-ironsworn-roll all-mods)))

And we could have a function for each:

  (defun rpgdm-ironsworn-roll-edge (modifier)
    "Roll an action based on a loaded character's Edge stat with a MODIFIER."
    (interactive (list (read-string "Edge + Modifier: ")))
    (rpgdm-ironsworn-roll-stat :edge modifier))

  (defun rpgdm-ironsworn-roll-heart (modifier)
    "Roll an action based on a loaded character's Heart stat with a MODIFIER."
    (interactive (list (read-string "Heart + Modifier: ")))
    (rpgdm-ironsworn-roll-stat :heart modifier))

  (defun rpgdm-ironsworn-roll-iron (modifier)
    "Roll an action based on a loaded character's Iron stat with a MODIFIER."
    (interactive (list (read-string "Iron + Modifier: ")))
    (rpgdm-ironsworn-roll-stat :iron modifier))

  (defun rpgdm-ironsworn-roll-shadow (modifier)
    "Roll an action based on a loaded character's Shadow stat with a MODIFIER."
    (interactive (list (read-string "Shadow + Modifier: ")))
    (rpgdm-ironsworn-roll-stat :shadow modifier))

  (defun rpgdm-ironsworn-roll-wits (modifier)
    "Roll an action based on a loaded character's Wits stat with a MODIFIER."
    (interactive (list (read-string "Wits + Modifier: ")))
    (rpgdm-ironsworn-roll-stat :wits modifier))

Moves

The moves directory contains one org file for each move. These files contains properties for how a move should behave. To make "a move" involves opening the move description file in a side-buffer, getting details about the move from the file, and then rolling some dice.

  (defun rpgdm-ironsworn--move-tuple (file)
    (let* ((regx (rx "moves/"
                     (group (one-or-more (not "/")))
                     "/"
                     (group (one-or-more (not ".")))
                     ".org" eol))
           (mtch (string-match regx file))
           (type (match-string 1 file))
           (name (thread-last (match-string 2 file)
                   (s-replace-regexp "-" " ")
                   (s-titleize))))
      (list (format "%s:: %s" type name) file)))

And let's verify the format:

  (ert-deftest rpgdm-ironsworn--move-tuple-test ()
    (let ((file "moves/fate/ask-the-oracle.org")
          (full "~/other/over/here/moves/fate/ask-the-oracle.org"))
      (should (equal (list "fate:: Ask The Oracle" file)
                     (rpgdm-ironsworn--move-tuple file)))
      (should (equal (list "fate:: Ask The Oracle" full)
                     (rpgdm-ironsworn--move-tuple full)))))

Once I read the list of moves, I want to cache it, using a poor-person's memoize feature:

  (defvar rpgdm-ironsworn-moves () "A list of tuples of the move and the file containing its goodness.")

Oh, one issue… how do I know where the data files for the moves are?

    (defun rpgdm-ironsworn-moves ()
      "Return a list containing available moves, and the filename containing
      the moves instructions, and other properties. Note that this function is
      memoized, in that re-calling this function will return a cached copy."
      (unless rpgdm-ironsworn-moves
        (setq rpgdm-ironsworn-moves
              (mapcar 'rpgdm-ironsworn--move-tuple
                      (directory-files-recursively
                       (concat rpgdm-ironsworn-project "moves")
                       ".*\.org$"))))
      rpgdm-ironsworn-moves)

Choosing a move comes from using the completing-read along with a list of all moves, like this:

  (completing-read "Move: " (rpgdm-ironsworn-moves))

We'll wrap that in a function to let the user choose a nicely formatted move, but return the file containing the move.

  (defun rpgdm-ironsworn-choose-move ()
    (let* ((move (completing-read "Move: " (rpgdm-ironsworn-moves)))
           (tuple (assoc move (rpgdm-ironsworn-moves))))
      (cadr tuple)))

Another feature I want, is that after completing a move, to put the results in a register, so that I can paste it into my notes file:

  (defun rpgdm-ironsworn--store-move (title results)
    "Store the results in a `m' register. It should also include
  the name of the move, based on the current file."
    (set-register ?m (format "# %s ... %s " title results)))

Now, let's do the Move interface. We need to load the documentation, and retrieve its move-stats property, and then possibly do something, like roll:

  (defun rpgdm-ironsworn-make-move (move-file)
    "Make an Ironsworn move by loading MOVE-FILE, and optionally querying the
  user to make an initial roll based on the properties in the file."
    (interactive (list (rpgdm-ironsworn-choose-move)))

    ;; Normally, we'd call `save-window-excursion', however, that buries the file
    ;; we show, and I think we should leave it up for study.
    (let (props title
          (orig-buf (window-buffer)))
      (find-file-other-window move-file)
      (goto-char (point-min))
      (setq title (cdr (assoc "ITEM" (org-entry-properties))))
      (setq props (first (org-property-values "move-stats")))
      (pop-to-buffer orig-buf)
      (rpgdm-ironsworn--store-move title (rpgdm-ironsworn--make-move props))))

The rpgdm-ironsworn--make-move call does something based on the properties stored in the file:

  • If nil, do nothing
  • If single stat that is progress, call rpgdm-ironsworn-progress-roll
  • If single stat, call rpgdm-ironsworn-roll-stat with that stat as a symbol, e.g. 'wits
  • If first entry is >, call rpgdm-ironsworn-roll-stat with the largest of the listed character's stats
  • If first entry is <, call rpgdm-ironsworn-roll-stat with the lowest of the listed character's stats
  • Otherwise, let the user choose the stat, from the property list, and can call rpgdm-ironsworn-roll-stat

Seems like a job for cond:

  (defun rpgdm-ironsworn--make-move (move-props)
    "Query user for rolls based on the MOVE-PROPS."
   (let* ((props (s-split " " (or move-props "")))
          (count (seq-length props))
          (stats (seq-filter (lambda (s) (string-match (rx (one-or-more alpha)) s)) props))
          (first (first props)))
     (cond
      ((seq-empty-p stats)       nil)
      ((equal first "progress")  (call-interactively 'rpgdm-ironsworn-progress-roll))
      ((= count 1)               (rpgdm-ironsworn-roll-with first))
      ((equal first ">")         (rpgdm-ironsworn-roll-best stats))
      (t                         (rpgdm-ironsworn-roll-stat
                                  (completing-read "Stat Choice: " stats)
                                  (read-string "Modifier: "))))))

  (defun rpgdm-ironsworn-roll-with (stat)
    "Roll against a character STAT, as a string, and prompt for modifier.
  See `rpgdm-ironsworn-roll-stat' for details."
    (rpgdm-ironsworn-roll-stat stat (read-string "Modifier: ")))

  (defun rpgdm-ironsworn-roll-best (options)
    "Call `rpgdm-ironsworn-roll' with a characters largest stat from OPTIONS."
    (let* ((values  (seq-map 'rpgdm-ironsworn-character-stat options))
           (largest (seq-max values))
           (modifier (read-string "Modifier: "))
           (all-mods (+ largest (string-to-number modifier))))
      (rpgdm-ironsworn-roll all-mods)))

Progress

The concept of a progress can be anything from an overland journey to a battle with an Elder Boar. A function to create a new one (asking for a name), another function to mark progress, and a third to remove it.

While the 10 boxes are easy for pen-and-paper games, we really need the number a ticks marking progress amounts:

  (defvar rpgdm-ironsworn-progress-levels '(("Troublesome" . 12)
                                            ("Dangerous" . 8) ("Formidable" . 4)
                                            ("Extreme" . 2) ("Epic" . 1))
    "The five levels of progression for an Ironsworn progress track.")

Given a label, can we get the level as a value:

  (defun rpgdm-ironsworn-progress-level (label)
    "Return the level (number of ticks to mark) of progress LABEL.
  For instance, if LABEL is `Dangerous', this returns `8'."
    (alist-get label rpgdm-ironsworn-progress-levels 8 nil 'equal))

Since we'll store only the numbers, we might want to get back the label:

  (defun rpgdm-ironsworn-progress-level-label (level)
    "Return the label associated with the progress LEVEL, e.g. dangerous."
    (car (rassoc level rpgdm-ironsworn-progress-levels)))

A helper function for allowing the user to choose which track to mark progress against:

  (defun rpgdm-ironsworn-progress-track-choose (&optional allow-other)
    "Query the user for a particular stored track. If ALLOW-OTHER is non-nil,
  we append a choice, <other>, which allows the user to choose the number
  of squares that have been marked against some progress."
    (let* ((other "<other>")
           (tracks  (rpgdm-ironsworn-character-progresses))
           (choices (if allow-other
                        (append tracks (list other))
                      tracks))
           (original (completing-read "Progress Track: " choices)))
      (if (and allow-other (equal original other))
          (read-number "Completed Track Amount [0-10]: ")
        original)))

Adding a progress to a character amounts to an arbitrary name, and the number of ticks, that amount to a level. For instance, we want to mark two boxes against a dangerous track, which is 8 ticks. We store this in the character's hash-table, under the key, progress-tracks:

  (defun rpgdm-ironsworn-progress-create (name level)
    "Add a new progress track, NAME, of a particular LEVEL.
  Stored as a property in the org file. Keep in mind that the
  NAME should be a short title, not a description."
    (interactive (list (read-string "Progress Name: ")
                       (completing-read "Progress Level: "
                                        rpgdm-ironsworn-progress-levels)))

    (let* ((level-value (rpgdm-ironsworn-progress-level level))
           (track-id    (substring (secure-hash 'md5 name) 0 8))
           (track-prop  (format "ironsworn-progress-%s" track-id))
           (track-val   (format "\"%s\" %d %d" name level-value 0)))
      (org-set-property track-prop track-val)))

Interactively, we can call the -mark function multiple times, but we might want to be able to call it multiple times (for instance, in a battle using a sword, we can mark progress twice). Regardless, we call rpgdm-ironsworn--mark-progress-track to update the character sheet.

  (defun rpgdm-ironsworn-progress-mark (name &optional times)
    "Mark progress against a track, NAME. Instead of calling this function multiple
  times, you can specify the number of TIMES to mark progress."
    (interactive (list (rpgdm-ironsworn-progress-track-choose)))
    (unless times (setq times 1))
    (dotimes (idx times)
      (rpgdm-ironsworn-mark-progress-track name)))

If we call the -amount function interactively, we should get the current results displayed, otherwise, we really just want the number of full boxes marked:

  (defun rpgdm-ironsworn-progress-amount (name)
    "Display the progress made against a track, NAME."
    (interactive (list (rpgdm-ironsworn-progress-track-choose)))
    (let* ((tracks  (rpgdm-ironsworn-character-progresses))
           (matched (--filter (equal name (first it)) tracks))
           (track   (first matched))
           (value   (second track))
           (level   (rpgdm-ironsworn-progress-level-label value))
           (ticks   (third track))
           (boxes   (/ ticks 4)))
      (if (called-interactively-p 'any)
          (rpgdm-message "[%d] Progress on %s: %d  (Ticks: %d)" level name boxes ticks)
        boxes)))

Rolling against the progress just means we need to request that value instead of rolling the d6, but in this case, we should either take a currently registered progress track, or simply specify a completed amount:

  (defun rpgdm-ironsworn-progress-roll (progress-value)
    "Display a Hit/Miss message based on comparing the PROGRESS-VALUE
  to rolling two d10 challenge dice."
    (interactive (list (rpgdm-ironsworn-progress-track-choose t)))
    (unless (numberp progress-value)
      (setq progress-value (rpgdm-ironsworn-progress-amount progress-value)))

    (let ((one-challenge (rpgdm--roll-die 10))
          (two-challenge (rpgdm--roll-die 10)))
      (rpgdm-message (rpgdm-ironsworn--results progress-value 0
                                               one-challenge two-challenge))))

When we've finished a track, we can remove it from the hash to not clutter the interace:

  (defun rpgdm-ironsworn-progress-delete (name)
    "Remove the progress track, NAME."
    (interactive (list (rpgdm-ironsworn-progress-track-choose)))
    (let ((tracks      (gethash 'progress-tracks rpgdm-ironsworn-character)))
      (ignore-errors
        (remhash name tracks))))

Let's make sure these function work as we expect:

  (ert-deftest rpgdm-ironsworn-progress-test ()
    (let ((track "Battling a Grue"))
      (rpgdm-ironsworn-progress-delete track)
      (rpgdm-ironsworn-progress-create track "Dangerous")
      (should (= (rpgdm-ironsworn-progress-amount track) 0))
      (rpgdm-ironsworn-progress-mark track)
      (should (= (rpgdm-ironsworn-progress-amount track) 2))
      (rpgdm-ironsworn-progress-mark track 2)
      (should (= (rpgdm-ironsworn-progress-amount track) 6))))

  (ert-deftest rpgdm-ironsworn-progress-test ()
    (let ((track "Battling an Extreme Grue"))
      (rpgdm-ironsworn-progress-delete track)
      (rpgdm-ironsworn-progress-create track "Extreme")
      (should (= (rpgdm-ironsworn-progress-amount track) 0))
      (rpgdm-ironsworn-progress-mark track)
      (should (= (rpgdm-ironsworn-progress-amount track) 0))
      (rpgdm-ironsworn-progress-mark track 2)
      (should (= (rpgdm-ironsworn-progress-amount track) 1))))

Oracles

Shawn Tompkin has created some useful oracles (random tables) to consult. I'm breaking my own rpgdm project convention, and having this code automatically load those tables.

  (rpgdm-tables-load (concat rpgdm-ironsworn-project "tables"))

He designed many of the tables to work together, for instance, you should roll on both the actions and themes and combine the result to kick-start your ideas.

Rolling on one table is simple, but here we have a collection of helper function to roll on multiple tables, and display the result altogether.

Action-Theme

This function displays an entry from both the actions and themes tables.

  (defun rpgdm-ironsworn-oracle-action-theme ()
    "Rolls on two tables at one time."
    (interactive)
    (let ((action (rpgdm-tables-choose "actions"))
          (theme  (rpgdm-tables-choose "themes")))
      (rpgdm-message "%s / %s" action theme)))

  (puthash "action-and-theme :: Roll on both tables"
           'rpgdm-ironsworn-oracle-action-theme
           rpgdm-tables)

Character

This function display a single entry of all the character-specific tables, including, a name, their role and current activity, a one-word description, as well as more hidden aspects, like the character's goal and disposition.

  (defun rpgdm-ironsworn-oracle-npc ()
    "Roll on all the character-related tables and show them together.
  You'll need to pick and choose what works and discard what doesn't."
    (interactive)
    (let ((name        (rpgdm-tables-choose "names-ironlander"))
          (goal        (rpgdm-tables-choose "character-goal"))
          (role        (rpgdm-tables-choose "character-role"))
          (activity    (rpgdm-tables-choose "character-activity"))
          (description (rpgdm-tables-choose "character-descriptor"))
          (disposition (rpgdm-tables-choose "character-disposition")))
      (rpgdm-message "%s, %s %s (Activity: %s  Disposition: %s  Goal: %s)"
                     name description role activity disposition goal)))

  (puthash "npc :: Roll on all character tables"
           'rpgdm-ironsworn-oracle-npc rpgdm-tables)

Combat Action

The combat action table isn't often tactical, and I prefer combining the method and target as an event. For instance, the following could be results from this method:

  • Defy Object or Attack with precision.
  • Overwhelm Opening or Intimidate or frighten.
  • Withdraw Opening or Use the terrain to gain advantage.
  (defun rpgdm-ironsworn-oracle-combat ()
    (interactive)
    (let ((action (rpgdm-tables-choose "combat-action"))
          (method (rpgdm-tables-choose "combat-event-method"))
          (target (rpgdm-tables-choose "combat-event-target")))
      (rpgdm-message "%s %s or %s" method target action)))

  (puthash "combat-action-events :: Roll on all combat tables"
           'rpgdm-ironsworn-oracle-combat rpgdm-tables)

Feature

This function combines the aspect and focus of a feature, for instance:

  • Mystical / Message … could it be an iron pillar with runes?
  • Unusual / Refuge … could it be a friendly barge passing by?
  • Foul / Opening … could it be a crack in the ground spewing sulfur odors?
  (defun rpgdm-ironsworn-oracle-feature ()
    "Rolls on two tables at one time for a Site's feature."
    (interactive)
    (let ((aspect (rpgdm-tables-choose "feature-aspect"))
          (focus  (rpgdm-tables-choose "feature-focus")))
      (rpgdm-message "%s / %s" aspect focus)))

  (puthash "feature-aspect-and-focus :: Roll on both feature tables"
           'rpgdm-ironsworn-oracle-feature rpgdm-tables)

Site Nature

In the Ironsworn Delve expansion, you can randomly choose a dangerous place, for instance:

Ravaged Shadowfen
Mire of Shrouded Silence
Haunted Barrow
Grave of Radeks Shadow
Infested Barrow
Selpulcher of Wasted Bone

Notice we also generate a name for the place.

  (defun rpgdm-ironsworn-oracle-site-nature ()
    "Rolls on two tables at one time for a random Site."
    (interactive)
    (let* ((theme  (rpgdm-tables-choose "site-theme"))
           (domain (rpgdm-tables-choose "site-domain"))
           (place  (downcase domain))
           (name   (rpgdm-ironsworn-oracle-site-name place)))
      (rpgdm-message "%s %s :: %s" theme domain name)))

  (puthash "site-nature :: Roll on both site theme and domain tables"
           'rpgdm-ironsworn-oracle-site-nature rpgdm-tables)

Site Name

Using the interesting random name generator from the Ironsworn: Delve source book. Requires a place-type to help limit the values that can be in place and then looks up the details on the tables in the ironsworn directory.

  (defun rpgdm-ironsworn-oracle-site-name (&optional place-type)
    "Rolling on multiple tables to return a random site name."
    (interactive (list (completing-read "Place type: "
                                        '(barrow cavern icereach mine pass ruin
                                                 sea-cave shadowfen stronghold
                                                 tanglewood underkeep))))
    (unless place-type
      (setq place-type "unknown"))
    (let ((description (rpgdm-tables-choose "site-name-description"))
          (detail (rpgdm-tables-choose "site-name-detail"))
          (namesake (rpgdm-tables-choose "site-name-namesake"))
          (place  (rpgdm-tables-choose (format "site-name-place-%s" place-type)))
          (roll   (rpgdm--roll-die 100)))
      (rpgdm-message
       (cond
        ((<= roll 25) (format "%s %s" description place))
        ((<= roll 50) (format "%s of %s" place detail))
        ((<= roll 70) (format "%s of %s %s" place description detail))
        ((<= roll 80) (format "%s of %s's %s" place namesake detail))
        ((<= roll 85) (format "%s's %s" namesake place))
        ((<= roll 95) (format "%s %s of %s" description place namesake))
        (t            (format "%s of %s" place namesake))))))

  (puthash "site-name :: Generate a name for a dangerous site"
           'rpgdm-ironsworn-oracle-site-name rpgdm-tables)

So, let's generate some random place names:

(rpgdm-ironsworn-oracle-site-name "barrow")     ; "Tomb of Storms"
(rpgdm-ironsworn-oracle-site-name "cavern")     ; "Lair of Khulans Truth"
(rpgdm-ironsworn-oracle-site-name "icereach")   ; "Barrens of Erisias Winter"
(rpgdm-ironsworn-oracle-site-name "mine")       ; "Lode of Ashen Lament"
(rpgdm-ironsworn-oracle-site-name "pass")       ; "Sunken Highlands"
(rpgdm-ironsworn-oracle-site-name "ruin")       ; "Sanctum of Khulans Truth"
(rpgdm-ironsworn-oracle-site-name "sea-cave")   ; "Silent Caves"
(rpgdm-ironsworn-oracle-site-name "shadowfen")  ; "Floodlands of Nightmare Despair"
(rpgdm-ironsworn-oracle-site-name "stronghold") ; "Crumbling Bastion"
(rpgdm-ironsworn-oracle-site-name "tanglewood") ; "Bramble of Endless Strife"
(rpgdm-ironsworn-oracle-site-name "underkeep")  ; "Underkeep of Lament"
(rpgdm-ironsworn-oracle-site-name)              ; "Sundered Mists of Khulan"

Threat

Generate a random threat and its motivations by coding the threat, but using the many threats available:

  (defvar rpgdm-ironsworn-oracle-threats '("Burgeoning Conflict" "Ravaging Horde"
                                           "Cursed Site" "Malignant Plague"
                                           "Scheming Leader" "Zealous Cult"
                                           "Environmental Calamity" "Power-Hungry Mystic"
                                           "Rampaging Creature")
    "A list of threats that correspond to tables")

  (defun rpgdm-ironsworn-oracle-threat-goal (&optional category)
    "Given a CATEGORY, display a threat goal."
    (interactive (list (completing-read "Threat: " rpgdm-ironsworn-oracle-threats)))
    (unless category
      (setq category (seq-random-elt rpgdm-ironsworn-oracle-threats)))
    (let ((table-name (format "threat-%s" (downcase (string-replace " " "-" category)))))
      (rpgdm-message "%s: %s" category (rpgdm-tables-choose table-name))))

  (puthash "threat-goal :: Generate a goal for a particular threat"
           'rpgdm-ironsworn-oracle-threat-goal rpgdm-tables)

And we can have a random threat:

(rpgdm-ironsworn-oracle-threat-goal)

Ironsworn Interface

Ironsworn introduces a simplified flip-a-coin oracle, that might be nice to integrate.

  (defun rpgdm-ironsworn-oracle ()
    "Given a LIKLIHOOD as a single character, return weighted coin flip."
    (interactive)
    (let* ((prompt "What are the odds?
    c) Almost Certain  l) Likely  h) 50/50  u) Unlikely  n) Small Chance ")
           (odds (read-char prompt))
           (roll (rpgdm--roll-die 100))
           (yes! (when (or (and (= roll 11) (eq odds ?c))
                           (and (= roll 26) (eq odds ?l))
                           (and (= roll 51) (eq odds ?h))
                           (and (= roll 76) (eq odds ?u))
                           (and (= roll 91) (eq odds ?n)))
                   t))
           (yes  (when (or (and (> roll 11) (eq odds ?c))
                           (and (> roll 26) (eq odds ?l))
                           (and (> roll 51) (eq odds ?h))
                           (and (> roll 76) (eq odds ?u))
                           (and (> roll 91) (eq odds ?n)))
                   t)))
      (rpgdm-message "%s %s %s"
                     (if yes! "Extreme" "")
                     (if yes "Yes" "No")
                     (if yes! "or a twist." ""))))

Most tables and charts in the books require rolling a percentile die, so lets create helper functions to call the rpgdm-roll function (which takes a dice string expression, and returns a random number that matches it):

  (defun rpgdm-roll-d6 ()
    "Roll and display a six-sided die roll."
    (interactive)
    (rpgdm-roll "d6"))

  (defun rpgdm-roll-d10 ()
    "Roll and display a ten-sided die roll."
    (interactive)
    (rpgdm-roll "d10"))

  (defun rpgdm-roll-d100 ()
    "Roll and display a percentage die roll."
    (interactive)
    (rpgdm-roll "d100"))

While the move interface puts the details of the move in the m register, I figured a function on the Hydra would be easier:

  (defun rpgdm-ironsworn-paste-last-move ()
    "Insert the contents of the `m' register, which should have last move."
    (interactive)
    (insert "\n" (get-register ?m)))

Can a Hydra call a hydra? Let's more all the special oracle and progress functions to sub-hydras:

  (defhydra hydra-rpgdm-oracles (:color blue)
    "Oracles"
    ("a" rpgdm-ironsworn-oracle-action-theme "Action/Theme")
    ("c" rpgdm-ironsworn-oracle-combat       "Combat")
    ("f" rpgdm-ironsworn-oracle-feature      "Feature")
    ("n" rpgdm-ironsworn-oracle-npc          "NPC")
    ("p" rpgdm-ironsworn-oracle-site-name    "Site Name")
    ("s" rpgdm-ironsworn-oracle-site-nature  "Site Nature")
    ("t" rpgdm-ironsworn-oracle-threat-goal  "Threat's Goal"))

  (defhydra hydra-rpgdm-progress (:color blue)
    "Progress Tracks"
    ("n" rpgdm-ironsworn-progress-create "new")
    ("m" rpgdm-ironsworn-progress-mark   "mark")
    ("p" rpgdm-ironsworn-progress-amount "show")
    ("d" rpgdm-ironsworn-progress-delete "delete")
    ("r" rpgdm-ironsworn-progress-roll   "roll"))

I'd like to repurpose the RPGDM Hydra to be more specific to Ironsworn, so this has both the instructions on how to use it and the key-to-function mapping:

  (defhydra hydra-rpgdm (:color blue :hint nil)
    "
      ^Dice^     0=d100 1=d10 6=d6       ^Adjust^      ^Oracles/Tables^     ^Moving/Editing^      ^Messages^
   ----------------------------------------------------------------------------------------------------------------------------------------------------
      _d_: Roll Dice  _p_: Progress       _H_: Health   _z_: Yes/No Oracle   _o_: Links            ⌘-h: Show Stats
      _e_: Roll Edge  _s_: Roll Shadow    _S_: Spirit   _c_: Show Oracle     _J_/_K_: Page up/dn     ⌘-l: Last Results
      _h_: Roll Heart _w_: Roll Wits      _G_: Supply   _O_: Other Oracles   _N_/_W_: Narrow/Widen   ⌘-k: ↑ Previous
      _i_: Roll Iron  _m_: Make Move      _M_: Momentum _T_: Load Oracles    _y_/_Y_: Yank/Move      ⌘-j: ↓ Next   "
    ("d" rpgdm-ironsworn-roll)    ("D" rpgdm-ironsworn-progress-roll)
    ("z" rpgdm-ironsworn-oracle)  ("O" rpgdm-oracle)

    ("e" rpgdm-ironsworn-roll-edge)
    ("h" rpgdm-ironsworn-roll-heart)
    ("i" rpgdm-ironsworn-roll-iron)
    ("s" rpgdm-ironsworn-roll-shadow)
    ("w" rpgdm-ironsworn-roll-wits)
    ("m" rpgdm-ironsworn-make-move :color pink)

    ("H" rpgdm-ironsworn-adjust-health :color pink)
    ("S" rpgdm-ironsworn-adjust-spirit :color pink)
    ("G" rpgdm-ironsworn-adjust-supply :color pink)
    ("M" rpgdm-ironsworn-adjust-momentum :color pink)

    ("T" rpgdm-tables-load)       ("c" rpgdm-tables-choose)     ("C" rpgdm-tables-choose :color pink)
    ("O" hydra-rpgdm-oracles/body)
    ("p" hydra-rpgdm-progress/body)

    ("o" ace-link)                 ("N" org-narrow-to-subtree)   ("W" widen)
    ("K" scroll-down :color pink)  ("J" scroll-up :color pink)
    ("y" rpgdm-paste-last-message) ("Y" rpgdm-ironsworn-paste-last-move)
    ("s-h" rpgdm-ironsworn-character-display)
    ("C-m" rpgdm-last-results :color pink)
    ("C-n" rpgdm-last-results-next :color pink)
    ("C-p" rpgdm-last-results-previous :color pink)
    ("s-l" rpgdm-last-results :color pink)
    ("s-j" rpgdm-last-results-next :color pink)
    ("s-k" rpgdm-last-results-previous :color pink)

    ("0" rpgdm-roll-d100 :color pink)
    ("1" rpgdm-roll-d10 :color pink)
    ("6" rpgdm-roll-d6 :color pink)
    ("q" nil "quit") ("<f6>" nil))

Org Interface

Seems that it would be nice to cache all the player information in an org file. That way, it is editable, but also, we could tuck the data away in property drawers, where a value, like Supply or Health, could change from section to section.

Couple of patterns:

  • We store all data as org properties that begin with :IRONSWORN-xyz
  • A property in a top level will be overwritten by the same property in a lower level header, this allows a default value at the top, and then specifics lower down.
  • We set progress, on the other hand, only once in the file, and update it
  • Choosing progress to mark will only be available by walking "up" the tree, as we view progress in sibling trees as completed.

Store Character State

We set all of these values in the current tree using org-set-property, as rpgdm-ironsworn-store-character-state will function as a wrapper around it:

  (defun rpgdm-ironsworn-store-character-state (stat value)
    "Store the VALUE of a character's STAT in the current org tree property.
  Note that STAT should be a symbol, like `supply' and VALUE should be a
  number, but doesn't have to be."
    (let ((prop (format "ironsworn-%s" (symbol-name stat))))
      (when (numberp value)
        (setq value (number-to-string value)))
      (org-set-property prop value)))

Property Key Conversions and Predicates

Loading state means looking at properties, and we distinguish Ironsworn-specific with a few functions:

  (defun rpgdm-ironsworn--property-p (prop)
    "Given a symbol PROP, return non-nil if it is an ironsworn keyword.
  Specifically, does it begin with `:IRONSWORN-'"
    (let ((p (symbol-name prop)))
      (string-match (rx bos ":IRONSWORN-") p)))

  (defun rpgdm-ironsworn--progress-p (prop)
    "Given a symbol PROP, return non-nil if it is an ironsworn progress keyword.
  Specifically, does it begin with `:IRONSWORN-PROGRESS'"
    (let ((p (symbol-name prop)))
      (string-match (rx bos ":IRONSWORN-PROGRESS-") p)))

Note: We may set the property in lowercase, but the symbol always comes back in uppercase.

Since we store both normal and progress props together, we need to distinguish between the two symbols:

  (defun rpgdm-ironsworn--short-progress-p (prop)
    (let ((p (symbol-name prop)))
      (s-starts-with-p "progress-" p)))

We also need a property-symbol to string conversion. I don't know if the string should be downcase-d or not, but why not?

  (defun rpgdm-ironsworn--progress-to-str (prop)
    "Convert a progress symbol, PROP to a string."
    (downcase (substring (symbol-name prop) 1)))

A test is always explanatory for how this function should behave:

  (ert-deftest rpgdm-ironsworn--progress-to-str-test ()
    (should (equal (rpgdm-ironsworn--progress-to-str :IRONSWORN-PROGRESS-EPIC)
                   "ironsworn-progress-epic")))

Why do we need it? Well, when we will want this when we re-set the property.

Locating the Properties

As I've mentioned before, the code needs to walk "up" an Org Tree looking for properties. The crux is using the internal org-elementget-node-properties function, which returns a property list iff the point is on a header.

So the general idea is:

  • Move to the previous header, e.g. org-up-element
  • Collect the properties
  • Move up to that header's parent
  • Collect its properties, etc.
  • Stop if the point is at the top-level header

Since we need to know if we are at the top-level, we could have a function, org-heading-level that returns 1 if we are at the top-level, and 0 if we aren't at any level:

  (defun org-heading-level ()
    "Return heading level of the element at the point. 0 otherwise."
    (if-let ((level-str (org-element-property :level (org-element-at-point))))
        level-str
      0))

Enough chit-chat, let's write this function. While we are at it, let's convert the property symbols into short symbols, e.g. :IRONSWORN-SHADOW should just be shadow, and number values should be numeric:

  (defun rpgdm-ironsworn--current-character-state (results)
    "Recursive helper to insert current header properties in RESULTS.
  Calls itself if it is not looking at the top-level header in the
  file. If a property is already in the hash table, RESULTS, it is
  not overwritten, thereby having lower-level subtrees take
  precendence over similar settings in higher headers."
    (defun key-convert (ironsworn-prop)
      (make-symbol (downcase (substring (symbol-name ironsworn-prop) 11))))

    (defun value-convert (value)
      (if (string-match (rx bos (one-or-more digit) eos) value)
          (string-to-number value)
        value))

    (let ((props (org-element--get-node-properties)))
      (loop for (k v) on props by (function cddr) do
            ;; If key is ironsworn property, but isn't in the table...
            (when (rpgdm-ironsworn--property-p k)
              (let ((key (key-convert k))
                    (val (value-convert v)))
                (unless (gethash key results)
                  (puthash key val results)))))

      (unless (= (org-heading-level) 1)
        (org-up-element)
        (rpgdm-ironsworn--current-character-state results))))

  (defun rpgdm-ironsworn-current-character-state ()
    "Return all set properties based on cursor position in org doc.
  Note that values in sibling trees are ignored, and settings in
  lower levels of the tree headings take precedence."
    (save-excursion
      (let ((results (make-hash-table :test 'str-or-keys)))
        (unless (eq 'headline (org-element-type (org-element-at-point)))
          (org-up-element))

        (rpgdm-ironsworn--current-character-state results)
        results)))

Reading Progress Tracks from Org Files

A progress track looks like this:

:ironsworn-progress-bonds: "Character Bonds" 1 2

Where the property, ironsworn-progress-ID ends with a unique ID (more on that later). The value is a three-part list of a name, the number of ticks implied by "marking progress", e.g Epic in 1 tick, where bothersome is 12 ticks. The final element is the amount of progress from 0 to 40.

Org can return the value, but breaking it up requires a regular expression:

  (defun rpgdm-ironsworn--progress-values (value)
    "Parse a string VALUE returning a list of parts,
  Including the initial name, the number of ticks to mark,
  and the current progress of the track."
    (let ((regxp (rx "\""
                    (group (one-or-more (not "\"")))
                    "\"" (one-or-more space)
                    (group (one-or-more digit))
                    (one-or-more space)
                    (group (one-or-more digit)))))
      (when (string-match regxp value)
        (list (match-string 1 value)
              (string-to-number (match-string 2 value))
              (string-to-number (match-string 3 value))))))

Using the rpgdm-ironsworn-current-character-state function to return all properties in the org file, we can filter our all the progress tracks, and then return a list of the parsed values:

  (defun rpgdm-ironsworn-character-progresses ()
    "Return a list where each element is progress track list.
  Each sublist has three values, a name, the ticks to mark,
  and the current progress of the track."
    (let ((state (rpgdm-ironsworn-current-character-state)))
      (thread-last state
                   (hash-table-keys)
                   (-filter #'rpgdm-ironsworn--short-progress-p)
                   (--map (gethash it state))
                   (-map #'rpgdm-ironsworn--progress-values))))

The function works fine for completing-read, as it accepts a list of lists:

(completing-read "Progress: " (rpgdm-ironsworn-character-progresses))

Unlike the rpgdm-ironsworn-store-character-state function, we want up update the location in the org file where a progress track is defined and stored.

I like the recursive notion of walking higher in the org file using org-up-element, however, having a function use both Common Lisp's loop as well as the Clojure-inspired -let* really shows the fusion that Emacs has become:

   (defun rpgdm-ironsworn--mark-progress-track (str)
     "Given a progress track's name, STR, update its progress mark."
     (let ((props (org-element--get-node-properties)))
       (loop for (k v) on props by (function cddr) do
             (when (rpgdm-ironsworn--progress-p k)
               (-let* (((label level progress) (rpgdm-ironsworn--progress-values v)))
                 (when (equal str label)
                   (org-set-property (rpgdm-ironsworn--progress-to-str k)
                                     (format "\"%s\" %d %d"
                                             label level (+ level progress)))
                   (cl-return))))))

     ;; Did not find a match at this level, let's pop up one and try again:
     (unless (= (org-heading-level) 1)
       (org-up-element)
       (rpgdm-ironsworn--mark-progress-track str)))

And since we move the cursor, we need to save-excursion, and we'll make this the interactive one:

  (defun rpgdm-ironsworn-mark-progress-track (label)
    "Given a progress track's name, STR, update its progress mark."
    (interactive (list (completing-read "Progress: " (rpgdm-ironsworn--progresses))))
    (save-excursion
      (rpgdm-ironsworn--mark-progress-track label)))

Summary

Funny that I wrote the code here before even playing the game. Hope I like playing it as much as hacking this out.

(provide 'rpgdm-ironsworn)
;;; rpgdm-ironsworn.el ends here