emacs-rpgdm/docs/rpgdm-tables-dice.org
Jeremy Friesen 25c282c094 ♻️ Favor cl- functions
When requiring `rpgdm.el` and it's "child" packages, I encounter the
following warnings:

> Warning: ‘destructuring-bind’ is an obsolete alias (as of 27.1); use
> ‘cl-destructuring-bind’ instead.
>
> Warning: ‘incf’ is an obsolete alias (as of 27.1); use ‘cl-incf’
> instead.
>
> Warning: ‘decf’ is an obsolete alias (as of 27.1); use ‘cl-decf’
> instead.
>
> Warning: ‘defstruct’ is an obsolete alias (as of 27.1); use
> ‘cl-defstruct’ instead.

Since we've already required the `cl` package, this should be a noop change.
2023-12-10 09:56:04 -05:00

8.2 KiB
Raw Permalink Blame History

Dice Tables for Games

;;; rpgdm-tables-dice.el --- Rolling dice for choosing items from Tables -*- lexical-binding: t; -*-
;;
;; Copyright (C) 2021 Howard X. Abrams
;;
;; Author: Howard X. Abrams <http://gitlab.com/howardabrams>
;; Maintainer: Howard X. Abrams <howard.abrams@gmail.com>
;; Created: February  5, 2021
;;
;; This file is not part of GNU Emacs.
;;
;;
;;; Commentary:
;;
;;     This file contains the source code, but the concept behind what I'm
;;     calling random dice tables is a bit complex, so I recommend looking
;;     at the original file in `docs/rpgdm-tables-dice.org'.

A "dice table" is a table that is easy to manipulate with dice in a game, and is pretty typical. The general idea is to roll one or more specific dice, and compare the number in the first column to see what the choice.

For instance, Xanathar's Guide to Everything, a Dungeons and Dragons supplement from Wizards of the Coast, allows you to choose a random alignment with the following table:

3d6 Alignment
3 Chaotic evil (50%) or chaotic neutral (50%)
45 Lawful evil
68 Neutral evil
912 Neutral
1315 Neutral good
1617 Lawful good (50%) or lawful neutral (50%)
18 Chaotic good (50%) or chaotic neutral (50%)
;;; Code:

To represent these types of tables, we create a special type, called a dice-table. Where the first "slot" is the dice expression (or the number of sides of a dice to roll), and an associative list of result values and the choice.

(cl-defstruct dice-table dice rows)

How is this used to render the example table above?

(make-dice-table :dice "3d6"
                 :rows '((3  . ("Chaotic evil" "Chaotic neutral"))
                         (5  . "Lawful evil")
                         (8  . "Neutral evil")
                         (12 . "Neutral")
                         (15 . "Neutral good")
                         (17 . ("Lawful good" "Lawful neutral"))
                         (18 . ("Chaotic good" "chaotic neutral"))))

Couple things to notice about this rendering of the table. First, we don't need a range, just the upper bound (for if we roll a 4, we skip over the 3, we are below the next number, so we bugger off with the answer).

Second, a table row could have multiple choices. For instance, if we were to roll a 3, we should flip a coin to choose between chaotic evil and chaotic neutral. In other words, if the value of the row is a list, then we could just select from one of those options.

Let's do the fun part, and select an item from one of these dice-tables. First, we grab the dice expression and the rows of the table and put them into a couple of variables. We use a helper function, rpgdm-tables-dice--choose to get the results of rolling the dice expression

(defun rpgdm-tables--choose-dice-table (table)
  "Choose a string from a random dice table."
  (let* ((roll (rpgdm-roll-sum (dice-table-dice table)))
         (rows (dice-table-rows table))
         (results (rpgdm-tables-dice--choose roll rows)))
    (if (stringp results)
        results
        (seq-random-elt results))))

If the results are not a single string item, we assume we have a list sequence, and return one at random using seq-random-elt.

The helper function is recursive, as we can evaluate each row to see if it matches the dice roll:

(defun rpgdm-tables-dice--choose (roll rows)
  "Given a numeric ROLL, return row that matches.
This assumes ROWS is a sorted list where the first element (the
`car') is a numeric level that if ROLL is less than or equal, we
return the `rest' of the row. Otherwise, we recursively call this
function with the `rest' of the rows."
  (let* ((row (first rows))
         (level (car row))
         (answer (rest row)))

    (if (<= roll level)
        answer
      (rpgdm-tables-dice--choose roll (rest rows)))))

So, let's see it in action, by first assigning the dice-table above, to a variable: alignment-table:

(rpgdm-tables--choose-dice-table alignment-table)
Neutral good

Nice. Now we just have to read and parse the table from an org-mode file.

Since I format my tables in different styles, I need to be able to identify a dice table, I figured I would have a key word, Roll on table with a dice expression from rpgdm-dice.el:

(setq rpgdm-tables-dice-table-regexp (rx "Roll"
                                           (one-or-more space)
                                           (optional (or "on" "for"))
                                           (zero-or-more space)
                                           "Table:"
                                           (zero-or-more space)
                                           (group
                                            (regexp rpgdm-roll-regexp))))

A predicate could return true when this regular expression returns a valid response:

(defun rpgdm-tables-dice-table? ()
  "Return non-nil if current buffer contains a dice-table"
  (goto-char (point-min))
  (re-search-forward rpgdm-tables-dice-table-regexp nil t))

Assuming we just called that function, we can call match-string to pick up that group and then parse the rest of the buffer as a table:

(defun rpgdm-tables--parse-as-dice-table ()
  "Return `dice-table' of lines matching `rpgdm-tables-dice-table-rows'."
  (let ((dice (match-string-no-properties 1))         ; Grab expression before moving on
        (rows ())                                     ; Modify this with add-to-list
        (row-splitter (rx (* space) "|" (* space))))  ; Split rest of table row

    (while (re-search-forward rgpdm-tables-dice-table-rows nil t)
      (let* ((levelstr (match-string-no-properties 1))
             (level    (string-to-number levelstr))
             (row      (match-string-no-properties 2))
             (choices  (split-string row row-splitter t)))
        (add-to-list 'rows (cons level choices))))
    (make-dice-table :dice dice
                     :rows (sort rows (lambda (a b) (< (first a) (first b)))))))

This function relies on a regular expression for parsing the tables:

(setq rgpdm-tables-dice-table-rows (rx bol
                                         (zero-or-more space) "|" (zero-or-more space)
                                         (optional (one-or-more digit)
                                                   (one-or-more "-"))
                                         (group
                                          (one-or-more digit))
                                         (zero-or-more space) "|" (zero-or-more space)
                                         (group (+? any))
                                         (zero-or-more space) "|" (zero-or-more space)
                                         eol))

Let's read the following table into a buffer:

Roll on Table: 3d6

|      3 | Chaotic evil | chaotic neutral |
|   4--5 | Lawful evil  |                 |
|   6--8 | Neutral evil |                 |
|  9--12 | Neutral      |                 |
| 13--15 | Neutral good |                 |
| 16--17 | Lawful good  | lawful neutral  |
|     18 | Chaotic good | chaotic neutral |
(provide 'rpgdm-tables-dice)
;;; rpgdm-tables-dice.el ends here