Biblyon the Great

This zine is dedicated to articles about the fantasy role-playing game Gods & Monsters, and other random musings.

Gods & Monsters Fantasy Role-Playing

Beyond here lie dragons

Automatically roll subtables

Jerry Stratton, April 5, 2014

Now that the “random” script handles percentage tables for wandering encounters, it’s very close to being able to handle the hierarchical encounter charts I use in Gods & Monsters. All that remains is for it to detect that an entry on the table is itself another table.

In order to do this, we need to be able to detect whether an entry matches a table file. We already have a function, tableFactory, that returns a table based on the filename; currently, however, it fails if the filename does not exist as a file. What we can do is make it return “no file” if the file doesn’t exist. In Python terms, we want it to return None instead of returning a Table object.

Before the “open” line in “def tableFactory(name):”, check to see that the filepath exists:

[toggle code]

  • #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
    • if not os.path.exists(filepath):
      • return None
    • items = open(filepath).read()
    • if "\t" in items:
      • table = PercentTable(items)
    • else:
      • table = SimpleTable(items)
    • return table

If the path represented by the filepath variable does not exist, the function returns None. This means we can use it for checking to see if a table file exists. Currently, if there is no “number appearing”, the system assumes “1”. Let’s change it to assume nothing. Change “appearing = '1'” to:

  • #generate the number appearing
  • appearing = ''

Now, we can detect this on making our random choice:

[toggle code]

  • #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 = ''
    • if appearing == '':
      • #is this a reference to another table?
      • subtable = tableFactory(item)
      • if subtable:
        • return subtable.choice()
    • else:
      • numberAppearing = dice.roll(appearing)
      • numberAppearing = ' (' + unicode(numberAppearing) + ')'
    • item = item + numberAppearing
    • return item

Now, when an entry is rolled that does not contain a die roll in parentheses, the script will look to see if that entry is itself a table file. If it is, it returns the choice from that table rather than from the current table. Go back to the Deep Forest table from Percentage-based random tables, and you’ll see it has an item called “Mist encounter”.

Add a “Mist encounter.txt” file to the same folder where “Deep Forest.txt” is stored:

  • 01-15 Fire Spider
  • 16-30 Beaked Sweeper
  • 31-45 Crazy Crabs (d2)
  • 46-60 Pink Horrors (d6)
  • 61-75 Toves (d3)
  • 76-84 Giant Venus Flytraps (d4)
  • 85-90 Mushroom Walker
  • 91-96 Giant Leeches (d8)
  • 97-00 Mist Wraith

Now, you will no longer see “Mist encounter (1)” as an option. Instead, you’ll see a random entry from the Mist Encounter table:

./random 3 Deep\ Forest
1. Petraiad (1)
2. Pink Horrors (3)
3. Pixies (17)

The Pink Horrors come from Mist encounters.txt.

Note that nothing in here tells it to stop if, on rolling on the new table, yet another new table is found. Thus, you might (as in my Gods & Monsters adventures) have a basic table of encounter types, breaking out into more specific tables, which occasionally break out into subtables for dragons or the Chaotic Mist.

There’s another change we need to make: in the past, if we asked, on the command line, for a table that didn’t exist, we received a Python error for that table. Now, our error message is a more cryptic “'NoneType' object has no attribute 'choose'”. That’s because, in our initial use of the table, we are calling the choose method regardless of whether tableFactory gave us a table back. We need to check for None there as well:

[toggle code]

  • #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 re.match(r'[0-9]*d?[1-9][0-9]*$', firstArgument):
    • count = firstArgument
    • tableName = args.pop(0)
  • else:
    • tableName = firstArgument
  • table = tableFactory(tableName)
  • if table:
    • table.choose(count)
  • else:
    • print 'Cannot find table', tableName
  • print

In order to report what the problem is, we need to keep the table’s name; so I renamed the variable for table to tableName so that it doesn’t disappear when we create the table object from tableFactory.

Beyond that, it just does “table.choose(count)” if there is a table, and prints that it cannot find the table if there is no table.

Here is the full script.

[toggle code]

  • #!/usr/bin/python
  • import random
  • import optparse
  • import os
  • import bisect
  • import dice
  • import re
  • 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
    • if not os.path.exists(filepath):
      • return None
    • 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):
      • if type(count) is not int:
        • if count.isdigit():
          • count = int(count)
        • else:
          • count = dice.roll(count + 't')
          • if count == 1:
            • time = 'time:'
          • else:
            • time = 'times:'
          • print 'Rolling', count, time
          • print
      • 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()
  • #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 = ''
        • 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 = ''
      • if appearing == '':
        • #is this a reference to another table?
        • subtable = tableFactory(item)
        • if subtable:
          • return subtable.choice()
      • else:
        • numberAppearing = dice.roll(appearing)
        • numberAppearing = ' (' + unicode(numberAppearing) + ')'
      • item = item + numberAppearing
      • return item
  • 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 re.match(r'[0-9]*d?[1-9][0-9]*$', firstArgument):
      • count = firstArgument
      • tableName = args.pop(0)
    • else:
      • tableName = firstArgument
    • table = tableFactory(tableName)
    • if table:
      • table.choose(count)
    • else:
      • print 'Cannot find table', tableName
    • print

I’ve added the files necessary for the Highland Guidebook encounter charts to the Guidebook’s resources archive. I’ve also made some updates to the script to handle ndx+c and ndx-1, as well as following tables with explanatory text (for example, animals might be “angry or hungry”). It also now successfully handles entries with non-ASCII text, such as diacriticals. I’ll keep the version in the Guidebook archive updated with any future changes as well.

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.

  1. <- Random table rolls