Percentage-based random tables
Our current random item generator assumes that each item shows up as often as any other item. That’s very OD&D-ish; as often as not, there will be six items per table. But AD&D game masters often use percentage dice to generate some monsters and items more than others. Their tables look like:
01-15 | Orcs (d6) |
16-25 | Goblins (2d20) |
26-00 | Astertarte the Red Dragon |
In this table, most likely for the red dragon’s lair, the red dragon shows up far more often than orcs and goblins. You can see more about this kind of table using the Wandering monster chart assistant.
We can make our random item generator recognize these tables and choose appropriately from them. The main difference is that, instead of creating a list of items and choosing from the list, we will need to generate a number from 1 to 100, and determine where that number falls in the list.
For test purposes, I’m going to use a modification of the default table from the wandering monster assistant:
- 01-30 Orcs (d12)
- 31-50 Mushroom People (2d20)
- 51-70 Pixies (d100)
- 71-83 Goblins (d20)
- 84-95 Giant cucumbers (d4)
- 96-00 Astertarte the Red Dragon
And I’m going to modify the “random” script to separate the parts where the script checks for the file and the part that loads the items in the file:
[toggle code]
- #!/usr/bin/python
- import random
- import optparse
- import os
- parser = optparse.OptionParser()
- parser.add_option('-l', '--locale')
- (options, args) = parser.parse_args()
- #load the table into the appropriate class
-
def tableFactory(name):
- filename = name + '.txt'
- filepath = filename
-
if options.locale:
- localepath = os.path.join(options.locale, filename)
-
if os.path.exists(localepath):
- filepath = localepath
- items = open(filepath).read()
- table = SimpleTable(items)
- return table
- #a table is a list of items that we choose randomly from
-
class Table(object):
- #print some random items from the table
-
def choose(self, count):
-
for counter in range(count):
- print counter+1, self.choice()
-
for counter in range(count):
- #a simple table is a list of items with equal probability
-
class SimpleTable(Table):
- #load the list of items and save it
-
def __init__(self, items):
- self.items = items.splitlines()
- #choose a random item from the table
-
def choice(self):
- return random.choice(self.items)
- count = 1
-
while args:
- firstArgument = args.pop(0)
- #if the first argument is a number, it's the number of random items we want
- #otherwise, it is the table and we want one item from it
-
if firstArgument.isdigit():
- count = int(firstArgument)
- table = args.pop(0)
-
else:
- table = firstArgument
- table = tableFactory(table)
- table.choose(count)
The part that looks for the appropriate file based on the given file name (“monsters” or “dragons” or “gems”) is now in the function called “tableFactory”. This function generates a table for us depending on the contents of the file—although at this point it only knows about one type of file. At the location where it says “table = SimpleTable(items)” we will eventually have it look in the file and create either a SimpleTable or a PercentTable object, depending on the contents.
The part that shows the random item has been broken from one class into two classes. The basic class is “Table”. All it knows to do is print out however many choices that we ask for. It doesn’t even know how to make the choice: it calls a method called “choice” that needs to be on the subclass.
SimpleTable is the subclass: because it has “Table” in parentheses, it inherits everything the Table class knows and also adds its own. SimpleTable has two methods: one to load the list of items into an array, and one to make a choice from that array.
Make these changes and run the script, and it should work exactly as it did before.
So, now that we can have a class for each type of table, let’s make one for percent tables:
[toggle code]
-
class PercentTable(Table):
- #load the list of items and save it
-
def __init__(self, items):
- self.items = []
-
for line in items.splitlines():
- #generate the range, all we need is the high number
- (dieRange, item) = line.split("\t")
-
if '-' in dieRange:
- (lowDie, highDie) = dieRange.split('-')
-
else:
- highDie = dieRange
- highDie = int(highDie)
- #00 is often used in place of 100 in wandering monster tables
-
if highDie == 0:
- highDie = 100
- self.items.append((highDie, item))
- #choose a random item from the table
-
def choice(self):
- #generate a number from 1 to 100
- d100 = random.randrange(100)+1
- #find the corresponding line
- line = bisect.bisect_right(self.items, (d100,))
- (high, item) = self.items[line]
- return item
Rather than simply using the lines in the file as the list of items, this class will loop through each line and store both the high number for that line and the item that high number corresponds to as a tuple. In Python, a tuple is a special kind of list that can be used with functionality such as that provided by bisect. When __init__ is finished, the items property contains:
- [(30, 'Orcs (d12)'), (50, 'Mushroom People (2d20)'), (70, 'Pixies (d100)'), (83, 'Goblins (d20)'), (95, 'Giant cucumbers (d4)'), (100, 'Astertarte the Red Dragon')]
The “choice” method is now also more complex; instead of using random.choice to return one of the items at random, it first generates a number from 1 to 100. It then uses the bisect module to find which item has that random number but does not exceed it. Then, it returns the second piece of that item, which is the name of the random thing we just generated: an Orc, or a Mushroom People, and so on.
At the top of the script, with all of the imports, import the bisect module:
- import bisect
Then, replace “table = SimpleTable(items)” with:
[toggle code]
-
if "\t" in items:
- table = PercentTable(items)
-
else:
- table = SimpleTable(items)
If there is a tab in the file anywhere, the script assumes that this is a d100 table and uses the PercentTable to generate the list; otherwise, it assumes it’s a simple list.
If you run this script as “./random 10 monsters” you should see that Orcs appear most often, Mushroom People next most often, and on down to Astertarte the Red Dragon least often. Sometimes Astertarte won’t show up in the list at all. He has less than a one in ten chance of appearing.
./random 10 monsters 1 Orcs (d12) 2 Mushroom People (2d20) 3 Mushroom People (2d20) 4 Pixies (d100) 5 Orcs (d12) 6 Orcs (d12) 7 Pixies (d100) 8 Pixies (d100) 9 Mushroom People (2d20) 10 Giant cucumbers (d4)
And if you run it one one of the old-style files, it should still return random lines from the file.
How many Mushroom People?
But the new form of table also includes information about how many of each item show up. For each encounter with Orcs, there will be 1 to 12 of them; for each encounter with Mushroom People, there will be 2 to 40 of them. In encounter charts and treasure charts, these are usually marked as d12 or 2d20. Rather than force a new notation or write something to parse standard dice notation, I did a Google search on “python dice notation” and found the dice library.
Then, on the command line, I used “pip search dice” to see that it was available via the Python installer tool. It was, so “sudo pip install dice” installed it.
Once installed, we can import it just like we imported bisect earlier:
- import dice
Update the class by adding a section for storing the “number appearing” die roll as a third item, and then, when pulling it out, roll that die using the new dice library with “dice.roll”:
[toggle code]
-
class PercentTable(Table):
- #load the list of items and save it
-
def __init__(self, items):
- self.items = []
-
for line in items.splitlines():
- #generate the range, all we need is the high number
- (dieRange, item) = line.split("\t")
-
if '-' in dieRange:
- (lowDie, highDie) = dieRange.split('-')
-
else:
- highDie = dieRange
- highDie = int(highDie)
- #00 is often used in place of 100 in wandering monster tables
-
if highDie == 0:
- highDie = 100
- #generate the number appearing
- appearing = '1'
-
if item.endswith(')'):
- dieStart = item.rindex('(')
- appearing = item[dieStart:]
- appearing = appearing.strip('()')
-
if 'd' in appearing:
- appearing = appearing + 't'
- item = item[:dieStart]
- item = item.strip()
- self.items.append((highDie, item, appearing))
- #choose a random item from the table
-
def choice(self):
- #generate a number from 1 to 100
- d100 = random.randrange(100)+1
- #find the corresponding line
- line = bisect.bisect_right(self.items, (d100,))
- (high, item, appearing) = self.items[line]
- numberAppearing = dice.roll(appearing)
- return item + ' (' + unicode(numberAppearing) + ')'
If an item ends with a close parenthesis, this uses the rindex method to get the location of the open parenthesis. The rindex method starts from the end of the text and finds the first match from the end (thus, “reverse index”). We then copy the text of the item variable starting at where the open parenthesis is and going to the end. That’s what “item[dieStart:]” does: it starts at the number represented by dieStart, and goes to the end of the text (because there is no number after the colon).
It then strips off any parentheses and adds the letter ‘t’ to the text if it’s a die roll; according to the documentation for dice this provides the total instead of a list of each die. That is, 2d20 will produce, say, 15, instead of [7, 8].
Depending on usage, it may be necessary to verify that the text with the parentheses is a die roll, but I’ll wait to see what happens in use before adding that extra code.
The “choice” method gets updated to handle the new third value and generate the number appearing from it.
When it returns the random item, it appends the number appearing in parentheses after it. Because Python can’t add numbers to text, it first converts the number “numberAppearing” to text—unicode text.
./random 10 monsters 1 Orcs (1) 2 Goblins (4) 3 Giant cucumbers (2) 4 Giant cucumbers (1) 5 Pixies (58) 6 Pixies (25) 7 Mushroom People (2) 8 Pixies (44) 9 Mushroom People (16) 10 Mushroom People (28)
Make the list look better
When printing things in Python, multiple items can be printed one after the other using the comma. But this also puts a space between those things. When printing out list, we have the ordinal number of each choice printed first, then a space, and then the random choice itself.
It is more normal to put a period or a close parenthesis after the ordinal number. One way to put a period after a number without an intervening space is to use unicode and the ‘+’ operator as we did above:
- print unicode(counter+1)+'.', self.choice()
However, this produces poorly-aligned numbers. Python has string formatting built-in that can align numbers to a specific number of spaces. It uses special codes to specify where an integer goes and where a string goes, and how long they need to be padded to. For example, to pad an integer to three spaces, use “%3i”; to display a string of text, use “%s”.
To do this, we need to know how long the maximum number will be, but we know that because we know what the maximum number is: it’s the count. Change the “choose” method in the Table class to be:
[toggle code]
-
def choose(self, count):
- maximumLength = len(unicode(count))
- formatString = '%' + unicode(maximumLength) + 'i. %s'
-
for counter in range(count):
-
if count > 1:
- print formatString % (counter+1, self.choice())
-
else:
- print self.choice()
-
if count > 1:
First, this converts the count variable from an integer to a string of text, and gets the length of that text. That’s how far the integer needs to be padded to. Then it constructs the format string, which will be like “%2i. %s” or “%1i. %s”. And then it loops through as it did before, but printing the results using the format string. The format string has two codes in it, so it needs a tuple with two items. In this case, those two items are the counter (with one added to it) and the results of the choice method.
And if we only requested one item, it doesn’t bother to print the ordinal number.
./random 10 monsters 1. Orcs (1) 2. Giant cucumbers (1) 3. Pixies (11) 4. Orcs (11) 5. Pixies (40) 6. Mushroom People (24) 7. Orcs (6) 8. Pixies (22) 9. Orcs (8) 10. Pixies (7) ./random monsters Pixies (81)
Here’s another list pulled from the Deep Forest in Lost Castle of the Astronomers:
- 01-12 Large Spiders (d3)
- 13-24 Pixies (d20)
- 25-36 Treeherders (d4)
- 37-47 Huge Spiders (d2)
- 48-57 Apparitions (d100)
- 58-63 Mist encounter
- 64-68 Pegasi (d2)
- 69-73 Unicorns (d3)
- 74-77 Brownies (d20)
- 78-81 Dryad (1)
- 82-85 Petraiad (1)
- 84-85 Petraiads (d4)
- 86-88 Carrion Worms (d4)
- 89-91 Gryphon (1)
- 92 Naiad (1)
- 93-94 Naiads (d20)
- 95-97 Satyrs (d10)
- 98-99 Poltergeist (1)
- 00 Ghouls (d4)
You might notice that Naiad and Petraiad are in twice; this is because in the original, they’re listed as 50% 1 or d4 and 50% 1 or d20. That’s hard to parse with a computer program, and it’s easier to just give them two lines, one for the single instance and one for the group instance.
./random Deep\ Forest Satyrs (9) ./random 3 Deep\ Forest 1. Apparitions (38) 2. Gryphon (1) 3. Pegasi (2)
And here is the full script for reference:
[toggle code]
- #!/usr/bin/python
- import random
- import optparse
- import os
- import bisect
- import dice
- parser = optparse.OptionParser()
- parser.add_option('-l', '--locale')
- (options, args) = parser.parse_args()
- #load the table into the appropriate class
-
def tableFactory(name):
- filename = name + '.txt'
- filepath = filename
-
if options.locale:
- localepath = os.path.join(options.locale, filename)
-
if os.path.exists(localepath):
- filepath = localepath
- items = open(filepath).read()
-
if "\t" in items:
- table = PercentTable(items)
-
else:
- table = SimpleTable(items)
- return table
- #a table is a list of items that we choose randomly from
-
class Table(object):
- #print some random items from the table
-
def choose(self, count):
- maximumLength = len(unicode(count))
- formatString = '%' + unicode(maximumLength) + 'i. %s'
-
for counter in range(count):
-
if count > 1:
- print formatString % (counter+1, self.choice())
-
else:
- print self.choice()
-
if count > 1:
- #a simple table is a list of items with equal probability
-
class SimpleTable(Table):
- #load the list of items and save it
-
def __init__(self, items):
- self.items = items.splitlines()
- #choose a random item from the table
-
def choice(self):
- return random.choice(self.items)
-
class PercentTable(Table):
- #load the list of items and save it
-
def __init__(self, items):
- self.items = []
-
for line in items.splitlines():
- #generate the range, all we need is the high number
- (dieRange, item) = line.split("\t")
-
if '-' in dieRange:
- (lowDie, highDie) = dieRange.split('-')
-
else:
- highDie = dieRange
- highDie = int(highDie)
- #00 is often used in place of 100 in wandering monster tables
-
if highDie == 0:
- highDie = 100
- #generate the number appearing
- appearing = '1'
-
if item.endswith(')'):
- dieStart = item.rindex('(')
- appearing = item[dieStart:]
- appearing = appearing.strip('()')
-
if 'd' in appearing:
- appearing = appearing + 't'
- item = item[:dieStart]
- item = item.strip()
- self.items.append((highDie, item, appearing))
- #choose a random item from the table
-
def choice(self):
- #generate a number from 1 to 100
- d100 = random.randrange(100)+1
- #find the corresponding line
- line = bisect.bisect_right(self.items, (d100,))
- (high, item, appearing) = self.items[line]
- numberAppearing = dice.roll(appearing)
- return item + ' (' + unicode(numberAppearing) + ')'
- count = 1
-
while args:
- firstArgument = args.pop(0)
- #if the first argument is a number, it's the number of random items we want
- #otherwise, it is the table and we want one item from it
-
if firstArgument.isdigit():
- count = int(firstArgument)
- table = args.pop(0)
-
else:
- table = firstArgument
- table = tableFactory(table)
- table.choose(count)
In response to Programming for Gamers: Choosing a random item: If you can understand a roleplaying game’s rules, you can understand programming. Programming is a lot easier.
- Lost Castle of the Astronomers
- This dungeon crawl is suitable for three to six characters of first to third level. This is the basic adventure that Charlotte, Gralen, Sam, and Will went through in The Order of the Astronomers.
- Multiple tables on the same command
- The way the “random” script currently stands, it does one table at a time. Often, however, you have more than one table you know you’re going to need. Why not use one command to rule them all?
- Python dice library: Sam Clements
- “A library for parsing and evaluating dice notation.”
- String Formatting Operations
- “String and Unicode objects have one unique built-in operation: the % operator (modulo). This is also known as the string formatting or interpolation operator. Given format % values (where format is a string or Unicode object), % conversion specifications in format are replaced with zero or more elements of values.”
- Wandering monster chart assistant
- Use the encounter chart assistant to create wandering monster charts using percentages rather than ranges. Copy your tables into this tool to make sure they add up, adjust them, then copy them back out to your word processor. Never worry about missing or overlapping ranges again!
More Programming for Gamers
- Are my dice random?
- My d20 appears to have been rolling a lot of ones, a disaster if I were playing D&D but a boon for Gods & Monsters. Is my die really random, or is it skewed towards a particular result? Use the ‘R’ open source statistics tool to find out.
- Programming for Gamers: Choosing a random item
- If you can understand a roleplaying game’s rules, you can understand programming. Programming is a lot easier.
- Easier random tables
- Rather than having to type --table and --count, why not just type the table name and an optional count number?
- Programming a Roman thumb
- Before we move on to more complex stuff with the “random” script, how about something even simpler? Choose or die, Bezonian!
- Multiple tables on the same command
- The way the “random” script currently stands, it does one table at a time. Often, however, you have more than one table you know you’re going to need. Why not use one command to rule them all?
- 12 more pages with the topic Programming for Gamers, and other related pages
More Python
- Quick-and-dirty old-school island script
- Here’s a Python-based island generator using the tables from the Judges Guild Island Book 1.
- Astounding Scripts on Monterey
- Monterey removes Python 2, which means that you’ll need to replace it if you’re still using any Python 2 scripts; there’s also a minor change with Layer Windows and GraphicConverter.
- Goodreads: What books did I read last week and last month?
- I occasionally want to look in Goodreads for what I read last month or last week, and that currently means sorting by date read and counting down to the beginning and end of the period in question. This Python script will do that search on an exported Goodreads csv file.
- Test classes and objects in python
- One of the advantages of object-oriented programming is that objects can masquerade as each other.
- Timeout class with retry in Python
- In Paramiko’s ssh client, timeouts don’t seem to work; a signal can handle this—and then can also perform a retry.
- 30 more pages with the topic Python, and other related pages
More random tables
- Island Book 1 and old-school tables
- Judges Guild Island Book 1 is a fascinating playground on which to place a sea-going adventure or campaign. It’s also a great example of the usefulness and wildness of old-school encounter tables.
- Random table rolls
- As often as not, when you roll on a random table you are rolling a random number of times. Now that we have a dice library, we can turn the roll count into a die roll.
- Wandering monster chart assistant
- Use the encounter chart assistant to create wandering monster charts using percentages rather than ranges. Copy your tables into this tool to make sure they add up, adjust them, then copy them back out to your word processor. Never worry about missing or overlapping ranges again!
- Multiple tables on the same command
- The way the “random” script currently stands, it does one table at a time. Often, however, you have more than one table you know you’re going to need. Why not use one command to rule them all?
- Easier random tables
- Rather than having to type --table and --count, why not just type the table name and an optional count number?
- Three more pages with the topic random tables, and other related pages
More wandering monsters
- Wandering monster chart assistant
- Use the encounter chart assistant to create wandering monster charts using percentages rather than ranges. Copy your tables into this tool to make sure they add up, adjust them, then copy them back out to your word processor. Never worry about missing or overlapping ranges again!
- I brake for wandering monsters
- How do you create a random encounter table? With love and baling wire.
- Constructing encounter tables using Nisus
- Here’s a Nisus Writer macro that makes it a little easier to create encounter tables.