Automating Treasure Table Rolls


One of the first things I automated via a program was determining results on the treasure table.

There is a fair about of rolling to determine treasure. There is also several sub-tables that you could possibly have to roll on, multiple times.

Here is an excerpt of the treasure table from Hyperborea

screenshot of astonishing swordsmen and sorcerors of hyperborea treasure table

Let's use Treasure Class A as an example. This is probably the most rolling you have to do. Determining coin results are easy. Things get more tedious if there are sub-tables. Gems, Jewelry, and Magic have plenty of sub-tables to handle.

Here is the gems table.

screenshot of  astonishing swordsmen and sorcerors of hyperborea gems table

You can even do more rolling if you get the correct result.

screenshot of  astonishing swordsmen and sorcerors of hyperborea gems table

Jewellery has at least 2 sub-tables to roll on. The kind of jewellery.

screen shot of astonishing swordsmen and sorcerors of hyperborea jewellery table

And what it is made of and it's worth.

screen shot of astonishing swordsmen and sorcerors of hyperborea jewellery table 2

This is just the beginning for magic items. A veritable rabbit hole to go down.

screenshot of of  astonishing swordsmen and sorcerors of hyperborea magic items table

It can end after a couple tables.

screenshot of astonishing swordsmen and sorcerors of hyperborea wands table

Eventually you get to the far end of that tree and have to come back for more rolls as needed.

Thankfully, the DM for a game I was in, and Perl Pumpking for some time, also seemed to have a thing to automate tables.

There are lots of tables that can be referenced by a DM. So Rik Signes created Roland to help with automating the rolling on them

Go ahead, give it a read. Rik does a good job explaining Roland. This post will be here waiting for you when you are done.

screenshot of roland blog post

It's not available on CPAN just yet, but I'm working on him. He's says it's bad software but I think it works just fine.

screenshot of roland github repo

Thankfully Roland can make all that rolling and referencing automated. That is assuming you are comfortable with the level of entropy and randomness a pseudo random number generator can give you

Really, go read Rik's blog post and then come back here when you are done.

Here is an example of the output of the program I created.

$ ./roll_treasure a
3500 cp
8000 gp
50 pp
  10,000 gp piece of jewelry
  5,000 gp piece of jewelry
  100 gp piece of jewelry
  750 gp piece of jewelry
  1,000 gp piece of jewelry
  200 gp piece of jewelry
  100 gp piece of jewelry
  100 gp piece of jewelry
  200 gp piece of jewelry
  100 gp piece of jewelry
  500 gp piece of jewelry
  200 gp piece of jewelry
  200 gp piece of jewelry
  200 gp piece of jewelry
  Staff of Withering
  Potion of Delusion (cursed)
  Quarterstaff +2

The program itself, when stripped down, is really quite simple.

my @rolls = @ARGV;
for my $roll ( @rolls ) {
  $roll =~ s/,| +|[()]//g;
  $roll = lcfirst $roll;
  my $count = 1;
  if ( $roll =~ m/\wx\w/ ) {
    ($roll, $count) = split('x', $roll);
  my $path;
  my $roll_length = length($roll);
  if ( 1 == $roll_length ) {
    $path = qq(treasure/class_$roll);
  else {
    $path = qq(treasure/$roll);
  for (1 .. $count ) {
    my $result = qx(~/bin/roland $path);
    say $result;

There is however data entry that must be done.

Each Treasure Class needs a table.

# treasure/class_a
- { file: treasure/a/cp }
- { file: treasure/a/sp }
- { file: treasure/a/ep }
- { file: treasure/a/gp }
- { file: treasure/a/pp }
- { file: treasure/a/gems }
- { file: treasure/a/jewelry }
- { file: treasure/a/magic }

Then each file

# treasure/a/cp
dice: 1d100
    dice: 2d6
      2: 1000 cp
      3: 1500 cp
      4: 2000 cp
      5: 2500 cp
      6: 3000 cp
      7: 3500 cp
      8: 4000 cp
      9: 4500 cp
      10: 5000 cp
      11: 5500 cp
      12: 6000 cp
      26-100: ~

Snippet of the gems table.

93-94: "Violet garnet, [[1d20x50]]gp"
  dice: 1d100
    1-50: "Emerald, [[1d20x100]]gp"
    51-100: "Fire opal, [[1d20x100]]gp"
  dice: 1d100
    1-50: "Opal, [[1d20x100]]gp"
    51-100: "Oriental amethyst, [[1d20x100]]gp"
  dice: 1d100
    1-50: "Oriental topaz, [[1d20x100]]gp"
    51-100: "Sapphire, [[1d20x100]]gp"
  dice: 1d100
    1-50: "Star ruby, [[1d20x100]]gp"
    51-100: "Star sapphire, [[1d20x100]]gp"

Here is the jewelry table

# treasury/jewelry
- { file: treasure/jewelry_type }
- { file: treasure/jewelry_value }

Jewelry type table snippet

# treasury/jewelry_type
dice: 1d20
  1: anklet, no weight
  2: armband, 1 lb
  3: bracelet, no weight
  4: bracer, 1 lb
  5: broach, no weight
  6: chain, 1 lb
  7: circlet, no weight
  8: comb, no weight
  9: crown, 4 lb

Jewelry value table snippet

# treasury/jewelry
dice: 1d100
    dice: 1d12
        - "  made of pewter, worth 1gp"
        - "  made of bronze, worth 1gp"
        - "  made of copper, worth 1gp"
        - "  made of silver, worth 1gp"

What the application runs like on the command line.

$ ./roll_treasure gems
Carnelian, 90gp

$ ./roll_treasure jewelry
crown, 4 lb
  made of electrum, worth 1,000gp

$ ./roll_treasure jewelry
armband, 1 lb
  made of bronze, worth 3gp

$ ./roll_treasure jewelry
mask, 1 lb
    made of copper set with gems, worth 400gp
  Deep blue spinel, 700gp

And that is basically it.

Data Mining Gaming Conventions for Fun and No Profit


My Assumptions

I am going to assume fluency with Perl in this post, along with and understanding of YAML, JSON, and CSV.


I've been going to Gary Con for many years now. It has had several event submission and registration systems since I started going.

In 2017 the convention started using TableTop Events(TTE) for event management. With that came along an API. With an API came a chance for automation and curiosity.

TTE has a decent event search mechanism along with the ability to download a CSV (comma separated values) file of the events.


It has a well documented and expansive API

The API is one way to grab more data than is listed in the event search and CSV file.

There are some example clients that show how one could use the API to gather various convention information.

I'm a sysadmin steeped in Perl so that is what I wrote my example in, but there are example clients writing in Javascript.

There is as a test api for your client if you write your own.

ID please

Now we need to find the conventions ID in TTE.

At the top of the page is a link to Conventions. That page should upcoming conventions as well as a search for conventions.

At the time of this writing Con of Champions is in the event submission phase of things. This one is rather special since it's geared towards helping raise money to keep TTE going. Covid-19 is causing also sorts of financial problems for businesses. Hopefully it can raise enough money to keep JT and crew operational until the pandemic is over along with the dealing any lingering economic damage.

So do a search for Con of Champions.

Copy the link. Now, I'm using Linux so this is relatively simple. Go to the command line and run:

curl 2>1 | grep "/api/convention"

This should return something like:

fetch_api : '/api/convention/105930EE-713D-11EA-AB57-B86D1B681414',
list_api : '/api/convention/105930EE-713D-11EA-AB57-B86D1B681414/event-days',

Event Data

Now that we have a con id we can use the browser to see what the event data is like.

For Con of Champions using the following URL:

This will return JSON like the following:

   "result" : {
      "items" : [
            "is_cancelled" : 0,
            "more_info_uri" : "http://",
            "host_showed_up" : 0,
            "room_id" : "C3F7257A-735E-11EA-BA85-003D1B681414",
            "special_requests" : null,
            "event_number" : 32,
            "date_created" : "2020-03-30 20:06:58",
            "actual_price" : 0,
            "max_tickets" : 100,
            "price" : 0,
            "convention_id" : "105930EE-713D-11EA-AB57-B86D1B681414",
            "space_id" : "CC9EA888-735E-11EA-BA85-113D1B681414",
            "type_id" : "9E78A594-7147-11EA-AB57-1A891B681414",
            "view_uri" : "/conventions/con-of-champions/schedule/32",
            "trashed" : 0,
            "name" : "Just Three Hexes - Start RPG Campaigns Quickly!",
            "room_name" : "Seminars",
            "long_description_html" : null,
            "wait_count" : 0,
            "allow_schedule_conflicts" : 0,
            "start_date" : "2020-05-23 15:00:00",
            "max_hosts" : 1,
            "attendee_head_count" : 0,
            "sold_count" : 0,
            "description" : "\"It takes just three hexes to start a campaign.\" You don't have to map out an entire world to get an awesome campaign rolling! In this seminar, I will show you my published \"3 Hexes\" technique for creating a fun campaign quickly!",
            "sellable" : 1,
            "startdaypart_id" : "9E84AAEC-7147-11EA-AC21-12DFCDE7307A",
            "host_count" : 1,
            "startdaypart_name" : "Saturday at 10:00 AM",
            "alternatedaypart_id" : "9E9044C4-7147-11EA-AC21-12DFCDE7307A",
            "space_name" : "C001",
            "preferreddaypart_id" : "9E84AAEC-7147-11EA-AC21-12DFCDE7307A",
            "custom_fields" : {
               "Whatvirtualsystemwillbeusedtopresent" : "I will be using Zoom to present this seminar, as well as streaming it to my Twitch channel."
            "age_range" : "teen",
            "duration" : 60,
            "submission_id" : "C5E9E018-72B2-11EA-A2B6-C13F1B681414",
            "long_description" : null,
            "is_scheduled" : 1,
            "object_name" : "Event",
            "object_type" : "event",
            "id" : "02BEB31A-72C2-11EA-A2B6-58581B681414",
            "available_count" : 100,
            "date_updated" : "2020-03-31 14:54:57",
            "claimable" : 0

      "paging" : {
         "total_pages" : 9,
         "total_items" : 213,
         "items_per_page" : 25,
         "next_page_number" : 2,
         "page_number" : 1,
         "previous_page_number" : 1

This is everything for an event. Look to see if there is a custom_fields in there. If there is make a note of them because we will need to deal with them.

Some Organizing

So, we have enough info that we need to do a little data wrangling. This can fit nicely into a config file. My current format of choice is YAML

    2020: B8477188-3D42-11E9-998D-F64533A48A88
  username: USERNAME
  password: PASSWORD
  api_key: GET_YOUR_API_KEY
  base_dir: /path/to/dump/data/YEAR
  - Whatvirtualsystemwillbeusedtoplay

Ok, this should be enough to let us write our client to grab event data.

Heading to The Bit Mines

So, lets code up our client to do some data mining.

As a Linux sysadmin I do a lot of Perl programming so that's what I'm going to do. Other languages are left as an exercise for the reader.

First up, mostly boilerplate.

Have it use whatever perl my shell is using, make sure to check if I'm being dumb so check things, and tell me about it.

Load up the modules I need to do the things I want to do. Thanks to those authors that have generously coded them up and made available on the CPAN.

Also, turn on new Perl things, and use a really new Perl thing and don't complain about it.

#!/usr/bin/env perl

use strict;
use warnings;

use Getopt::Long;
use POSIX qw(strftime);
use YAML qw(LoadFile);
use Wing::Client;
use Text::CSV;

use v5.30;

use experimental qw(signatures);

Next chunk, is setting STDOUT to autoflush output. Then setup some variables for the options I want to use and some error checking of them.

$| = 1;

my $config_file;
my $conid_year;

GetOptions (
             "config|c=s"  => \$config_file,
             "year|y=i"  => \$conid_year,
or die ("Error in command line arguments\n");

if ( $conid_year < 2020 ) {
  say q(Invalid year, must be greater than 2019);

This is the bulk of "what to do".

Create a time stamp to give files we generate a useful name, load our config file, and create our Wing object so we can hit up the TTE API.

We move on to do some prep work to prime the pump before we dive in and page through all of the event data there might be.

Finally, dump that event data into a csv file and exit the program with an appropriate exit code.

say q(Start things up);

our $time_stamp = strftime("%Y-%m-%d_%H-%M-%S", localtime(time) );

my ($config) = startup($config_file, $time_stamp);

say q(Create Wing client object);
my ($wing, $session) = get_wings($config);

my $page_number = 1;
say q(Get Data);
print q(  fetching page : ), $page_number;
my ($data) = get_data($wing, $session, $page_number);

my $events_ref;
map { push(@$events_ref, $_) } $data->{items}->@*;

($events_ref) = page_through_events($wing, $session, $events_ref);

make_csv_file($config, $events_ref);


But wait, there's more, I need to go through all the subroutines called.

Just reading in our YAML file and then replacing the YEAR placeholder with the supplied year

sub startup ($config_file, $time_stamp) {

  say q(Read config_file);
  my ($config) = LoadFile($config_file);

  $config->{base_dir} =~ s/YEAR/$conid_year/;


Create the object to talk with the API. Using our config file to provide all the info it needs.

sub get_wings ($config) {

  my $wing = Wing::Client->new(uri => $config->{url});

  my $session = $wing->post('session', 
                       { username   => $config->{username}, 
                         password   => $config->{password}, 
                         api_key_id => $config->{api_key}
  return($wing, $session);

Here's the heart of things. Calling the API, getting the event data, and then passing that data back.

sub get_data ($wing, $session, $page_number) {

  my $conid  = $config->{conid}{$conid_year};

  my $query_string = qq(/api/convention/$conid/events);

  my $data;
  $data = $wing->get($query_string, { 
    session_id               => $session->{id},
    _page_number             => $page_number,
    _items_per_page          => 100,


Now we gotta go through however many pages of data there are.

sub page_through_events ($wing, $session, $events_ref) {
  my $next_page_number  = $data->{paging}{next_page_number};
  my $total_pages       = $data->{paging}{total_pages};

  for my $page_number ( $next_page_number ... $total_pages ) {
    print qq( $page_number);
    ($data) = get_data($wing, $session , $page_number);
    map { push(@$events_ref, $_) } $data->{items}->@*;
  say q();

Once we have all that event data, dump it into a file format that is a bit more usable.

Create a file name and file handle to write to that file. The $csv object is set to use binary so it handles anything in the parsing that might be binary, otherwise it's expecting roughly the ASCII character set. Unexpected things might happen othwerwise.

Get the headers to use in the first row of the file and print that to the csv file. Check to see if there are any errors printing to the file. I added this because I initially didn't have binary parsing turned on and I spent way too much time figuring out where 15 events disappeared from the gathering of them to the printing of them.

Then, loop through the rest of the data.

sub make_csv_file ($config, $events_ref) {
  say q(make event csv file);
  my $csv_file = $config->{base_dir} . qq(/$time_stamp-events.csv);

  open my $fh, '>', $csv_file;

  my $csv = Text::CSV->new({ binary => 1});
  $csv->eol ("\n");

  my ($header_ref) = get_headers($config, $events_ref);

  my ($status) = $csv->print($fh, $header_ref);
  check_csv_status($csv, $status, $header_ref);

  my $count = 1;
  EVENT:for my $event ( $events_ref->@* ) {
    my ($event_info_ref) = get_event_info($event, $config);
    my ($status) = $csv->print($fh, $event_info_ref);
    check_csv_status($csv, $status, $event_info_ref);
    $count += 1;

Sort the keys from the hash that event data is kept in. If there happens to be a custom_fields deal with it, otherwise add to the headers array.

sub get_headers ($config, $events_ref) {
  my @headers;
  for my $header ( sort keys $events_ref->[0]->%* ) {
    if ( $header eq q(custom_fields) ) {
      for my $custom_field ( $config->{custom_fields}->@* ) {
        push(@headers, $custom_field);
    else {
      push(@headers, $header);


Just some mundane error checking to see why something might not have printed to the csv file. If so, die so you can figure out why.

sub  check_csv_status ($csv, $status, $event_info_ref) {
  unless ( 1 == $status ) {
    my ($cde, $str, $pos, $rec, $fld) = $csv->error_diag();
    say  q( csv print error);
    say qq( cde : $cde);
    say qq( str : $str);
    say qq( pos : $pos);
    say qq( rec : $rec);
    say qq( fld : $fld);

    for my $field ( $event_info_ref->@* ) {
      say $field;

For each event go through the hash. If nothing unexpected happens stuff it into the events array ref. The custom_fields key needs to be expanded with any additional fields that it brings along. Fields matching description might have newlines that will really mess up a csv file.

sub get_event_info ( $event, $config ) {
  my $event_array_ref;
  for my $event_key ( sort keys $event->%* ) {
    if ( $event_key eq q(custom_fields) ) {
      ($event_array_ref) = handle_custom_fields($event, $config, $event_array_ref);
    elsif ( $event_key =~ m/description/ ) {
      ($event_array_ref) = escape_newlines($event_key, $event, $event_array_ref);
    else {
      my $value = $event->{$event_key};
      if ( ! defined $value ) {
        $value = q();
        push(@$event_array_ref, q());
      push(@$event_array_ref, $value);
  return ($event_array_ref);

See what the custom_fields entry in the config file needs to add to the events array.

sub handle_custom_fields ($event, $config, $event_array_ref) {
  for my $custom_field ( $config->{custom_fields}->@* ) {
    my $field = $event->{custom_fields}{$custom_field};
    push(@$event_array_ref, $field);

Get rid of newlines in a field. It messes with the formatting that was submitted by the person running the event, but that's life.

sub escape_newlines ($event_key, $event, $event_array_ref) {
  my $field;
  if ( defined $event->{$event_key} ) {
    $event->{$event_key} =~ s/\R|\n//g;
  push(@$event_array_ref, $event->{$event_key});

And that's it for now.

If you think you need more event data than TTE makes available in their csv file for a convention...well now you can go digging.

Roll20 character sheet import



A long term project of mine was to find an easy way to import character sheets into Roll20.

There are tools for D&D 5e and such but nothing for my game of choice, Astonishing Swordsmen and Sorcerers of Hyperborea. Hyperborea for short.

Recently I did find an API using tool that would work. However, that requires you to have a paid account. Which I do. I still might use it.

However, I did find something that mostly works, Roll20 Character Import/Export.

I do have a program that can take a yaml file and populate a form fillable pdf. Taking that and banging my head against the wall for a few weeks. With some help from a friend to understand some javascript I was able to get it working as much I really need it to.

The Base Character Sheet

So, I'm trying out the idea of keeping character info a yaml or markdown file.

I've settled on something like this:

ST: 17
DX: 10
CN: 14
IN: 17
WS: 12
CH: 12
class: fire lord
level: 5

Race: Kelt

Gender: female

Age: 17

HP: 45

XP: 45,000

  - <starter_pack>
  - Hunting horn
  - leather mask
  - <hm>dagger x3[w]
  - <a>small shield

  - Hand Axe +2

  - Flaming Missile
  - Flame Blade
  - Fireball

This has some syntax in it that I built up as I was making my pc generator for Hyperborea. There are many table lookups and such. I then dump the data into a Form Data Forma, (FDF) that I can use to create a Hyperborea character sheet.

I can take data from one format and munge into another.

Now to think about the next data format.

Roll20 Character Sheet

Roll20 has a lot of character sheets you can use in your game. A lot of them, you can even submit yours to be added to the repo.

To figure out what Roll20 has named each of the various attributes you will need to get a copy of your target character sheet. You can either clone their repo or you can find your character sheet in the repo and get that copy.

For HTML munging and extraction I reach for Mojo::DOM.

It doesn't take much to get what I want:

my ($html) = join('',<>);

my $dom = Mojo::DOM->new($html);

say $dom->find('[name]')->map(attr => 'name')->join("\n");

It's just using CSS selectors so it shouldn't be too hard to translate that to the language of your choice.

I then created a mapping to go from my program (centered around an FDF) to the roll20 names:

"Character Name": character_name
"Class": class
Level: level
Align: align

"ST Attack Mod": meleehitbonus
"ST Damage adj": meleedmgbonus 
"Test of ST": testst 
"Feat of ST": featst

Next is the hard part.

Import Character data into Roll20 Character Sheet

I did find something that was relatively easy...ish to import that data, ChatSetAttr.

However, most folks don't pay for roll20. I use it enough and I like to pay for useful things like it, but I still wanted a way to get character sheet data imported.

To be honest...I was looking at the problem off and on for probably a year before I found this Firefox plugin.

I have manually entered the character data in a test campaign I use for ideas I want to try out. I mention this because my initial attempts to import a character that I hadn't manually imported, nor used the ChatSetAttr API didn't work.

Eventually I hit upon exporting a character sheet that I had filled out manually. This gave me the format to import.

    "schema_version": 1,
    "name": "barb_test",
    "avatar": "",
    "bio": "",
    "attribs": [
            "name": "reactionmod",
            "current": "1",
            "max": "",
            "id": "-M4dusSQdUOIdxhCvhKD"

I was then stuck on that id.

I went to the roll20 forums and was pointed to some code.

This code creates a sort of UUID. The main thrust of it being something like:

return function() {
        var c = (new Date()).getTime() + 0, d = c === a;
        a = c;
        for (var e = new Array(8), f = 7; 0 <= f; f--) {
            e[f] = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(c % 64);
            c = Math.floor(c / 64);

A revelation

So, now I needed to translate those functions into my weapon of choice, Perl.

A friend helped me with the translation so I could generate uuid's in the format that roll20 understands.

That bit of help broke open the damn...or...was just enough help to smash my head through the wall I was hitting against.

The repeating items problem

Being able to import basic player info was great.

Now I had to tackle what are in the html as repeating items. Things like gear, abilities, weapon info, spells, etc.

This was mostly a puzzle for my code. The uuid code does have something explicit for repeating items uuids.

            "name": "repeating_melee_-M4dusTPpXLZyuX3dipz_meleeweaponname",
            "current": "dagger",
            "max": "",
            "id": "-M4dusTP3-Ipr2NBQx-P"
            "name": "repeating_melee_-M4dusTPpXLZyuX3dipz_meleeclass",
            "current": "1",
            "max": "",
            "id": "-M4dusTQdb3JG38lovQM"
            "name": "repeating_melee_-M4dusTPpXLZyuX3dipz_meleeatkrate",
            "current": "1/1",
            "max": "",
            "id": "-M4dusTQEEylJV5QjKro"
            "name": "repeating_melee_-M4dusTPpXLZyuX3dipz_meleeatkmod",
            "current": "0",
            "max": "",
            "id": "-M4dusTQ7yLYX5ddp6oD"
            "name": "repeating_melee_-M4dusTPpXLZyuX3dipz_meleedamage",
            "current": "1d4+1",
            "max": "",
            "id": "-M4dusTRgK3BjoBRFvle"

In the above snippet, this represents the info for a melee weapon. It's name, weapon close, attack rate, attack modifiers, and damage. There is a a field for notes which I didn't enter for this weapon.

Each html attribute name is of the format, "repeating_melee_ROWID_attrname". ROWID is replaced with a row id that is similar to the "normal" uuid. Each item in the same line has the same ROWID but different "id"s.

That is basically it.

I am still going through and adding things but now it's just an iterative process. I update the code, fix the syntax errors in my code because I mostly do this at the end of the day and I have very little brain for such task, and then do and import to see what it looks like.

So far the only thing that isn't really working is the equipment list. I'm ok with that. It can be added manually if needed. These programs take a lot of the pain out of this.


  • start with a beginning data format
  • munge to a target format
  • add another target format
  • extract HTML attribute names from an html file
  • use those names to map from initial format to new target format
  • create properly formatted json file
  • test import
  • check that it looks ok
  • lather
  • rinse
  • repeat as needed


And with that...I need to go to bed, maybe watch some WestWorld. Good night y'all.