Skip to content

jeremyf/random-table.el

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

67 Commits
 
 
 
 

Repository files navigation

Random Table

Welcome to random-table.el, an Emacs package for defining random tables and rolling on them.

What is a random table? Maybe it’s a list of given names. And you pick a random one from that list. Maybe it’s the creatures appearing in an RPG dungeon.

I’ve been blogging about this in my Emacs random-table.el Package series.

You may also find my random-tables-data.el useful for what I’ve been setting up.

1 Introduction

1.1 Features

Composing Tables
The results of one table could be to roll on another table or tables; and so on.
Cached Results
Some dice rolls inform later dice rolls. There’s a mechanism to do that; though it likely needs improvement.
Private Tables
Useful when you don’t want to see the named table as an option in the random-table/roll; but you want to reference this table elsewhere.
Prompt for Dice Rolls
Random tables can encode complex procedures, and sometimes you might want to follow the procedures but provide you’re own dice rolls.
Mad Libs Like Interpolation
Instead of picking a table to roll on for random-table/roll you can type an expression (e.g. “There are {2d6} orcs”) and it will interpret that results (e.g. rolling a 6 we’d have “There are 6 orcs”).
Inner Tables
Within a table’s result you can have an inner table (e.g. “you meet a [brigand/priest/child/zealot/haunting nightmare]”) and the parser will pick a random element.
Custom “Reporter”
By default, the results are written to the *Messages* buffer and added to the kill ring. But you can configure to have a different reporter. (see random-table/reporter)
Registering and Caching Prompt Choices
During the life cycle of the random-table/roll you have access to the hash in the random-table/roll/cache variable. Useful for remembering things you’ve already prompted the user for; such as their Charisma modifier.
Extensible Parsing Logic
The random-table/text-replacer-functions is a custom variable that allows for adding and removing functions to provide more extensible logic.

And more.

1.2 Installation

As of <2023-08-21 Mon> this repository is not yet part of any of the package archive (e.g. https://melpa.org). But you can install it via use-package.

(use-package random-table
  :straight (:host github :repo "jeremyf/random-table.el"))

I have tested this using Emacs v29.1; it might work with earlier versions.

1.3 Dependencies

I have tested this package with Emacs 29.1. It might work with earlier versions. Otherwise, there are no external dependencies.

In prior iterations, I used the s package (See https://melpa.org/#/s) but I have since removed that dependency.

2 Usage

2.1 Defining Tables

I type M-x random-table/roll and am prompted to give an Expression. I can select from a pre-populated list of registered tables (via random-table/register). Or I can enter an expression, such as “2d6+1” and random-table/roll will then evaluate the expression.

Let’s look at registering a table:

(random-table/register :name "Coin Toss"
			 :data '("Heads" "Tails"))

When I invoke random-table/roll, I can select “Coin Toss”, it will add to the kill ring and write a message based on the roll; either “Heads” or “Tails”.

I could also call random-table/roll and provide the following: “You toss a coin and it lands on {Coin Toss}”

And will get back: “You toss a coin and it lands on Heads” (assuming you rolled a Heads).

In fact we could register a new table:

(random-table/register :name "Things We Throw"
			 :data '("Rocks"
				 "Tantrum"
				 "Coin and it comes up {Coin Toss}."))

When we “roll” on that table, when we get the “Coin and…” result, we’ll evaluate rolling on the Coin Toss table. The end result is “Coin and it comes up Heads.”

2.2 Replacement Functions

In the above case the “Coin and it comes up {Coin Toss}.” replacement relies on the random-table/text-replacer-functions; in particular the random-table/text-replacer-function/named-table; which looks at the text between { and } and then uses the value between to lookup a registered table.

Customize the random-table/text-replacer-functions to have different strategies for replacing text. For example, I don’t automatically replace dice expression (e.g. “There are 2d4 ogres” does not roll the 2d4 unless it is between { and }.)

However you could configure the replacer functions to allows expand dice expressions.

2.3 Inner Tables

Instead of relying on a custom table for a coin toss, we could call M-x random-table/roll and provide “[heads/tails]”. This syntax leverages the random-table/text-replacer-function/inner-table logic; that is interpret an inner table. We take the text between [ and ] and pick one of the elements; elements are separated by a slash (e.g. /) character.

2.4 Custom Rollers

We can also create ranges, but will need to consider the roller:

(random-table/register :name "Reaction Roll"
			 :roller "2d6"
			 :data '(((2) . "Hostile")
				 ((3 . 5) . "Unfriendly")
				 ((6 . 8) . "Unsure")
				 ((9 . 11) . "Amicable")
				 ((12) . "Friendly")))

Alternatively we can use a function:

(random-table/register :name "Reaction Roll"
			 :roller (lambda (&rest args) (+ 2 (random 6) (random 6)))
			 :data '(((2) . "Hostile")
				 ((3 . 5) . "Unfriendly")
				 ((6 . 8) . "Unsure")
				 ((9 . 11) . "Amicable")
				 ((12) . "Friendly")))

The given :roller is effectively two six-sided dice. And we use the rolled values to then find the correct entry in :data. For example, when we roll a 4 we’d return “Unfriendly”.

The roller can also be a named function; something you can re-use. This is also the place where you could prompt for a modifier or a choice.

Let’s look at a more complicated example:

(defun jf/2d6-plus-prompt-for-bonus (&rest args)
  (let ((modifier (read-number "Modifier: " 0)))
    (list (+ 2 modifier (random 6) (random 6)))))

(random-table/register :name "Reaction Roll with Prompt"
			 :roller #'jf/2d6-plus-prompt-for-bonus
			 :data '(((-1000 . 2) . "Hostile")
				 ((3 . 5) . "Unfriendly")
				 ((6 . 8) . "Unsure")
				 ((9 . 11) . "Amicable")
				 ((12 . 2000) . "Friendly")))

In the above case, when we roll the “Reaction Roll with Prompt”, Emacs will prompt for a Modifier. We’ll then use the given modifier to adjust the dice roll.

We could also use a registered prompt (see random-table/prompt docstring) and our roller could then be a sequence:

(random-table/prompt "Charisma Bonus" :type #'read-number)

(random-table/register :name "Reaction Roll with Prompt"
			 :roller '(+ "2d6" "Charisma Bonus")
			 :data '(((-1000 . 2) . "Hostile")
				 ((3 . 5) . "Unfriendly")
				 ((6 . 8) . "Unsure")
				 ((9 . 11) . "Amicable")
				 ((12 . 2000) . "Friendly")))

The above will add the results of rolling “2d6” to the prompt for the character’s “Charisma Bonus.”

As of <2023-09-18 Mon> , I am considering how I might represent/parse: =’(+ “2d6” (read-number “Charisma Bonus: “))=; I’m uncertain about that syntax compared to what I see as the more legible =’(+ “2d6” “Charisma Bonus”)=; albeit with the need to create a prompt.

2.5 Private Tables

As you register tables, via random-table/register, you add them to the table registry. The list of tables shown in the M-x random-table/roll can become quite lengthy. To register a table, without adding it to the selection list, add :private t as one of the key word arguments.

Below is the “Name” table. When we roll on the “Name” table we’ll pick a random one. Then roll on a “sub-table”. So as to not clutter the list, we mark those “sub-tables” as :private t.

(random-table/register :name "Name"
			 :data '("{Name > Masculine}" "{Name > Feminine}" "{Name > Non-Binary}"))

(random-table/register :name "Name > Masculine"
			 :private t
			 :data '("George" "Michael"))

(random-table/register :name "Name > Feminine"
			 :private t
			 :data '("Mary" "Margaret"))

(random-table/register :name "Name > Non-Binary"
			 :private t
			 :data '("Quin" "Ash"))

2.6 Storing Results for Later Use

Given the composition of tables, we may also want to store the results of the roll for future reference. Why might we do this? Some tables may say “Roll 3 dice. Then on table one use the highest value. And on table two use the lowest value. And on table three, if there are doubles, use the number that is the “double”.

(random-table/register :name "High Low"
			 :roller (lambda (&rest args) (list (+ 1 (random 6)) (+ 1 (random 6))))
			 ;; We include this so that we only return the first data element.  The
			 ;; dice rolls are for the High Value and Low Value
			 :fetcher (lambda (data roll) (car data))
			 :data '("\n- High :: {High Value}\n- Low :: {Low Value}")
			 :store t)

(random-table/register :name "High Value"
			 :reuse "High Low"
			 :private t
			 :filter #'max
			 :data '("One" "Two" "Three" "Four" "Five" "Six"))

(random-table/register :name "Low Value"
			 :reuse "High Low"
			 :private t
			 :filter #'min
			 :data '("One" "Two" "Three" "Four" "Five" "Six"))

As of 2023-08-16 I store the roll in a somewhat naive manner; for a table with :store t, when we “roll on that table” we add to a hash the table name and the results of the roll (e.g. the specific dice as a list). Then until we’ve fully evaluated the roll for that table, we can reference the dice results for that table.

On 2023-09-20, I added random-table/storage/results/get-data-value; this function can retrieve the resolved value of the stored roll. Where random-table/storage/results/get retrieves the dice results (e.g. 1 from a “1d6” roll), the random-table/storage/results/get-data-value interprets the 1 on from the stored table’s data struct.

One thing I introduced in the above was the :fetcher and :filter elements. The :filter takes the dice pool (as a list) and returns an integer. The :fetcher takes the integer and looks things up in the provided :data.

The general flow is:

  • :roll the dice
  • :filter the roll
  • :fetch the filtered result

That flow is defined in random-table/evaluate/table.

2.7 Encoding a Complex New Table

I set about encoding the Death and Dismemberment rules for my Random Table package.

This required a few changes:

  1. I needed the concept of a current_roll. The Death and Dismemberment table.
  2. I wanted dice to be able to return strings and then use those strings as the lookup on the table’s :data.

I did not, at present, worry about the cumulative effects of data. However, I’m seeing how I might do that.

Let’s dig in.

There are five tables to consider for Death and Dismemberment:

  • Physical
  • Acid/Fire
  • Eldritch
  • Lightning
  • Non-Lethal

Here’s how I set about encoding that was as follows:

(random-table/register :name "Death and Dismemberment"
  :roller #'random-table/roller/prompt-from-table-data
  :data '(("Physical" . "{Death and Dismemberment > Physical}")
	     ("Acid/Fire" . "{Death and Dismemberment > Acid/Fire}")
	     ("Eldritch" . "{Death and Dismemberment > Eldritch}")
	     ("Lightning" . "{Death and Dismemberment > Lightning}")
	     ("Non-Lethal" . "{Death and Dismemberment > Non-Lethal}")))

The :roller is a function as follows:

(defun random-table/roller/prompt-from-table-data (table)
  (completing-read
   (format "%s via:" (random-table-name table))
   (random-table-data table) nil t))

In the case of passing the Death and Dismemberment table, you get the following prompt: “Death and Dismemberment via”. And the list of options are: Physical, Acid/Fire, Eldritch, Lightning, and Non-Lethal.

Once I pick the option, I then evaluate the defined sub-table. Let’s look at Death and Dismemberment > Physical.

(random-table/register :name "Death and Dismemberment > Physical"
  :roller (lambda (table) (+ 1 (random 6)))
  :private t
  :data '(((1) . "Death and Dismemberment > Physical > Arm")
	     ((2) . "Death and Dismemberment > Physical > Leg")
	     ((3 . 4) . "Death and Dismemberment > Physical > Torso")
	     ((5 . 6) . "Death and Dismemberment > Physical > Head")))

This is a rather straight-forward table. Let’s say the :roller returns a 5. We will then evaluate the Death and Dismemberment > Physical > Head table; let’s look at that. The resulting table is rather lengthy.

(random-table/register :name "Death and Dismemberment > Physical > Head"
  :roller #'random-table/roller/death-and-dismemberment/damage
  :private t
  :data '(((1 . 10) . "Head Injury; Rolled {current_roll}\n- +1 Injury\n- Concussed for +{current_roll} day(s).")
	     ((11 . 15) . "Head Injury; Rolled {current_roll}\n- +1 Injury\n- Concussed for +{current_roll} day(s).\n- One Fatal Wound.\n- {Save vs. Skullcracked}")
	     ((16 . 1000) . "Head Injury; Rolled {current_roll}\n- +1 Injury\n- Concussed for +{current_roll} day(s).\n- {current_roll} - 14 Fatal Wounds.\n- {Save vs. Skullcracked}")))

The :roller (e.g. random-table/roller/death-and-dismemberment/damage) is as follows:

(defun random-table/roller/death-and-dismemberment/damage (&rest table)
  (+ 1
     (random 12)
     (read-number "Number of Existing Injuries: " 0)
     (read-number "Lethal Damage: " 0)))

We roll a d12, add the number of existing injuries, and accumulated lethal damage. Then look up the result in the :data of Death and Dismemberment > Physical > Head. Let’s say the result is a 12. We’ll need to roll on the the Save vs. Skullcracked table, which I’ve included below:

(random-table/register :name "Save vs. Skullcracked"
  :roller #'random-table/roller/saving-throw
  :private t
  :data '(("Save" . "Saved against cracked skull…gain a new scar.")
	     ("Fail" . "Failed to save against cracked skull.  {Save vs. Skullcracked > Failure}")))

The :roller (e.g. random-table/roller/saving-throw) will prompt for the saving throw score and any modifier to the roll. Then it will return “Fail” or “Save” depending on the results. See the function.

 (defun random-table/roller/saving-throw (table)
   (let ((score (read-number (format "%s\n> Enter Saving Throw Score: " (random-table-name table)) 15))
	   (modifier (read-number (format "%s\n> Modifier: " (random-table-name table)) 0))
	   (roll (+ 1 (random 20))))
     (cond
	((= roll 1) "Fail")
	((= roll 20) "Save")
	((>= (+ roll modifier) score) "Save")
	(t "Fail"))))

Let’s say that we “Fail” the saving throw. We now lookup on the Save vs. Skullcracked > Failure table:

(random-table/register :name "Save vs. Skullcracked > Failure"
			 :private t
			 :data '("Permanently lose 1 Intelligence."
				 "Permanently lose 1 Wisdom."
				 "Permanently lose 1 Charisma."
				 "Lose your left eye. -1 to Ranged Attack."
				 "Lose your right eye. -1 to Ranged Attack."
				 "Go into a coma. You can recover from a coma by making a Con check after 1d6 days, and again after 1d6 weeks if you fail the first check. If you fail both, it is permanent."))

Let’s say we get “Permanently lose 1 Intelligence” for the failed save. Now, working our way back, let’s see what that all evaluates to:

Head Injury; Rolled 12
- +1 Injury
- Concussed for +12 day(s).
- One Fatal Wound.
- Failed to save against cracked skull.  Permanently lose 1 Intelligence

The modified d12 roll resulted in a 12; hence the +12 day(s).

2.8 Prompting for You to Roll the dice

Let’s create a quick table:

(random-table/register
   :name "Random Attribute"
   :data '("Strength"
	     "Constitution"
	     "Dexterity"
	     "Intelligence"
	     "Wisdom"
	     "Charisma"))

Given that I passed the universal prefix arg (e.g. C-u) when I roll on the Random Attribute table then I will get the prompt “Roll 1d6 for:” and the value I enter will be used for looking up the correct :data element.

In this way, you can roll the dice and use this package to encode the rules lookup.

2.9 Exclude a Table from Prompting for a Roll

Any table that has one element in :data will not prompt for the roll. Also, you can specify :exclude-from-prompt t when registering a table; then any “rolls” on that specific table will not prompt to give the dice value.

Ultimately, the goal is to ask for dice rolls when they might be something the player wants to roll.

2.10 Allow for Rudimentary Math Operands with Table Results

In my quest for more random tables and functionality, I worked through Errant’s Hiring Retainers section. Using the PC’s presence, you look-up the morale base. Then roll 2d6, modified by the offer’s generosity, to then determine the modifier to the morale base.

To perform mathematical operations, I continue to leverage the s-format functionality. That is s-format will evaluate and replace the text of the following format: {text}.

Below is the definition of a random Henchman for Errant.

(random-table/register
 :name "Henchman (Errant)"
 :data '("\n- Archetype :: {Henchman > Archetype}\n- Morale :: {(Henchman > Morale Base) + (Henchman > Morale Variable)}"))

The {Henchman > Archetype (Errant)} will look on the following table:

(random-table/register
 :name "Henchman > Archetype"
 :private t
 :roller #'random-table/roller/1d10
 :data '(((1 . 5) . "Warrior")
	     ((6 . 8) . "Professional")
	     ((9 . 10) . "Magic User")))

The {[Henchman > Morale Base] + [Henchman > Morale Variable]} does the following:

  • Roll on Henchman > Morale Base
  • Roll on Henchman > Morale Variable
  • Add those two results together.
(random-table/register
 :name "Henchman > Morale Base"
 :private t
 :roller (lambda (table) (read-number "Hiring PC's Presence Score: "))
 :data '(((3 . 4) . 5)
	   ((5 . 8) . 6)
	   ((9 . 13) . 7)
	   ((14 . 16) . 8)
	   ((17 . 18) . 9)
	   ((19 . 20) . 10)))

(random-table/register
 :name "Henchman > Morale Variable"
 :private t
 :roller (lambda (table)
	     (let* ((options '(("Nothing" . 0) ("+25%" . 1) ("+50%" . 2) ("+75% or more" . 3)))
		    (key (completing-read "Additional Generosity of Offer: " options))
		    (modifier (alist-get key options nil nil #'string=)))
	       (+ modifier (random-table/roller/2d6 table))))
 :data '(((2) . -2)
	   ((3 . 5) . -1)
	   ((6 . 8) . 0)
	   ((9 . 11) . 1)
	   ((12 . 15) . 2)))

2.11 Registering Prompts

Similar to random-table/roller, you can register a prompt via random-table/prompt. There are common prompts (e.g. “Charisma Modifier”). In registering a prompt, during an invocation of random-table/roll each prompt will only be requested once. That is to say, the package will cache the prompt’s response and re-use that through out the roll.

This functionality leverages the per random-table/roll cache (as stored in the random-table/roll/cache variable).

(random-table/prompt "Charisma Modifier"
		       :type #'read-number
		       :default 0)

(random-table/register :name "Reaction Roll"
			 :roller (lambda (table)
				   (+ (random-table/prompt "Charisma Modifier")
				      (random-table/roller/2d6))))

Why include the caching? In reviewing Kevin Crawford’s Scarlet Heroes there’s a table for reaction rolls that asks for a few modifiers, the rolls on one table, and one result is to roll on another table using those same modifiers.

2.11.1 Expressing the :roller as a list for computation

Instead of expressing the :roller as a lambda, you can express it as a list. The following is equivalent to the prior example:

(random-table/prompt "Charisma Modifier"
  		       :type #'read-number
  		       :default 0)

(random-table/register :name "Reaction Roll"
  			 :roller '(+ "2d6" "Charisma Modifier"))

As of v0.7.0 you can include a registered table in a roller.

Consider the following table derived from 1520 HRE: 2d6 Adventure in the Holy Roman Empire.

(random-table/register :name "SOC"
  :roller "2d6"
  :reuse "SOC"
  :store t
  :data '(((2 . 7) . "{CURRENT_ROLL}")
           ((8 . 12) . "{CURRENT_ROLL} (EDU + 1)")))

In the above, a character’s Social (SOC) attribute affects their EDU attribute. Rolling an 8 or higher on SOC adds one to their Education (EDU).

(random-table/register :name "EDU modifier from SOC"
  :reuse "SOC"
  :private t
  :exclude-from-prompt t
  :filter (lambda (&rest dice) (if (>= (car dice) 8) 0 1))
  :data '(0 1))

(random-table/register :name "EDU"
  :roller '(+ "2d6" "EDU modifier from SOC")
  :store t
  :data '(2 3 4 5 6 7 8 9 10 11 12 13))

The “EDU modifier from SOC” table encodes the +1 EDU from SOC. And the :roller for EDU references the filtered value in the “EDU modifier for SOC”.

Note: As of the v0.7.0 I still need to write the possible range for dice rolls. Not ideal and something I’ll fix for v0.8.0.

2.12 Testing All of This

I have added the non-interactive random-table/roll/test-all function; this will roll once on each of the registered non-private tables and report the results. I’ve found it most useful when testing notable refactoring; namely how I handle the :roller slot for a random-table.

3 Updates

3.1 2023-12-02 Update

During Emacs Conf 2023 I watched Howard Abrams’s presentation How I play TTRPGs in Emacs. And I suspect I’ll be migrating to that. What I have works well, but there’s quite a bit I’m loving about what I saw. In particular, having the tables be their own files creates several affordances. Namely sharing those text-based files and repurposing plain-text.

Also, if I’m going to spend effort on the functionality, I’d love to be collaborating. So we’ll see.

My plan is to start converting my tabular data to the plain text formats of the rpgdm package.

3.2 2023-12-17 Update

I have looked at Howard Abram’s rpgdm package and later rpgtk package for inspiration and adoption consideration. However, I have settled on my current approach. In part due to the feature comparisons:

FeatureRPGDM/RPGTKrandom-tables
Automatic dice evaluation (e.g. “There are 2d6 giants” will always roll the 2d6)-
Caching prompt choices; remembering the Charisma Modifier-
Caching rolls for later reference-
Complex dice rollers (e.g. 2d6 + Charisma Modifier + Situational Modifier)-
Conditional dice evaluation (e.g. “2d6” in a table is not evaluated, but “{2d6}” is)-
Custom reporter; configure how you report the results of a roll-
Evaluate text region and roll-
Evaluating dice within a result
Extensible parser functions-
Inner tables (e.g. “You meet a [dragon/knight/peasant]”)
Lazy load tables-
Load text tables (e.g. org-mode, plain text, markdown)-
Mathematical operations of table results-
Multiline output (e.g. table results can include \n)-
Private tables-
Prompt for table evaluated as a “roll” (e.g. I can input “There are {2d6} [giants/frogmen/hermits]” into the table prompt)-
Prompting to provide own dice roll-
Results of a roll can then roll on more tables
Rolled dice overview; the results of each dice and the sum-

I provide the above feature comparison not to diminish the excellent work of Howard, as both of his above packages provide other dice rolling adjacent functionality as well as allow for loading plain text data as a table; a feature that should not be discredited in it’s friendliness as well as greater shareability.

About

Emacs package to provide means of registering and rolling on random tables.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published