DM Tools with Awk
Posted:
I picked up Awk on a whim and am blown away by how generally useful it is. What I thought was a quick and dirty tool for parsing tabulated files turns out to be a fully-featured scripting language.
Before I started reading the second edition of
The Awk Programming Language, my only exposure to Awk was
from better-minded folk on Stack Overflow. After copy-pasting a short script
here or there, I was befuddled by the need for explicit BEGIN
and END
statements in Awk one-liners. Shouldn't a program know when it begins and ends?
Why the redundancy?
Oh how wrong I was. Once you understand how Awk works, the syntax of BEGIN
and
END
makes a ton of sense; it's actually a consequence of Awk's coolest
feature. BEGIN
and END
are necessary because the default mode of an Awk
script isn't top-to-bottom execution, like other scripting languages. Instead,
Awk programs are executed repeatedly by default, either on the lines of a file
or an input stream.
To demonstrate, say I have a file where each line contains a location:
Forest
Hills
Desert
...
I can use Awk to turn that list of locations into one that is numbered with a single statement, no loops required:
$ awk '{ print NR ". " $0 }' locations.txt
1. Forest
2. Hills
3. Desert
4. ...
Without the BEGIN
or END
markers (which denote "run this before" and "run
this after"), Awk runs statements on every line of its input. In this case, that
means re-printing each location in the file locations.txt
with some minor
modifications.
Awk provides a bunch of built-ins that make it easy to work within this
execution model. NR
refers to "num row", keeping track of the current line of
input that is being processed. This generates our numbered list.
The dollar-sign variables refer to fields on an individual line. $0
is the
entire line, unmodified. $1
, $2
, and so on refer to subsets of the line,
broken up by a delimiter (e.g. space, tab, or comma) and read from left to
right.
And statements are just the tip of the Awk iceberg! You can assign each statement a "matcher" that only runs the expression on lines that are truthy. Here are a few examples:
# Print every row but the first
NR != 1 { print $0 }
# Only print a row if the first field matches "cat"
$1 ~ /cat/ { print "not a dog" }
# Maybe your second field is a number?
$2 >= 12 && $2 < 18 { print "teenager" }
Now the BEGIN
and END
statements are starting to make more sense.
DMing with Awk
Now for something a little more complicated. As I mentioned before, Awk is a
fully-featured scripting language. You can write functions, generate random
numbers, build arrays, and do everything that you'd expect a normal language to
do (mostly, anyway). I ran across an example in the Awk book that demonstrates
the use of rand()
via dice rolling and it sparked an idea: how useful can a
tool like Awk be for a DM running a Dungeons and Dragons game?
Since Awk is great at reading files, I figured it would also be great for dealing with random tables. Given the locations file that appears earlier in this post, here's how you can select a single location at random:
awk '{data[NR] = $0} END {srand(); print data[int(rand()*length(data))]}' locations.txt
It's easier to read with some annotations:
# Add every line in the file to an array, indexed by the line number
{ data[NR] = $0 }
# After reading the file,
END {
# Seed randomness
srand()
# Pick a random index from the data array and print its respective value
print data[int(rand() * length(data))]
}
I really like how { data[NR] = $0 }
is all that Awk needs to build an array
with the contents of a file. It comes in handy in cases like this where we need
the file contents in memory before we can do something useful.
Now, you might be thinking that this isn't that cool because sort
can already
do it better. And you'd be right!
$ cat locations.txt | sort -R | head -1
Plains
So how about moving on to the next step instead: character generation. The next script implements the charater creation rules from Knave, a game based on old-school Dungeons and Dragons.
The first thing we need to do is generate some attribute scores. Each score can be simulated by rolling three 6-sided dice (d6) and taking the lowest result.
BEGIN {
srand()
map[1] = "str"
map[2] = "dex"
map[3] = "con"
map[4] = "int"
map[5] = "wis"
map[6] = "cha"
print "hp " roll(8)
for (i = 1; i <= 6; i++) {
print map[i] " " lowest_3d6()
}
}
function roll(n) {
return int(rand() * n) + 1
}
function lowest_3d6(_i, _tmp) {
min = roll(6)
for (_i = 1; _i <= 2; _i++) {
_tmp = roll(6)
if (_tmp < min) {
min = _tmp
}
}
return min
}
The output looks like:
$ awk -f knave.awk
hp 6
str 1
dex 2
con 2
int 1
wis 1
cha 4
Since this Awk program is not reading from a file (yet), everything is run in a
BEGIN
block. This allows us to execute Awk without passing in a file or input
stream. Within that BEGIN
block we build a map of integers to attribute names,
making it easy to loop over them to roll for scores. Arrays in Awk are
association lists, so they work well for this use-case.
The strange thing about this code is the use of parameters as local variables in
the function lowest_3d6
. The only way in Awk to make a variable local is to
provide it to the parameter list when declaring a function, as all other
variables are global. Idiomatic Awk attempts to reveal this strangeness by
adding an underscore to the parameter names, as I have done, or by inserting a
bunch of spaces before their place in the function definition.
Next up is to make these characters more interesting by assigning them careers and starting items. A career describes the character's origin, explaining their initial loot as fitting to their backstory. These careers are taken from Knave second edition.
First, a new data file:
acolyte: candlestick, censer, incense
jailer: padlock, 10’ chain, wine jug
acrobat: flash powder, balls, lamp oil
jester: scepter, donkey head, motley
actor: wig, makeup, costume
jeweler: pliers, loupe, tweezers
...
Now that our Awk program is reading lines from a file, we can add a new block that stores careers into an array so we can make a random selection for the player.
# ...snip
{ careers[NR] = $0 }
END {
print "\nCareer & items:"
print careers[roll(100)];
}
When the program is executed with the list of careers, the output looks like this:
$ awk -f knave.awk careers.txt
hp 3
str 1
dex 3
con 3
int 2
wis 3
cha 4
Career & items:
falconer: bird cage, gloves, whistle
Not bad!
I doubt these tools will come in handy for your next DnD campaign, but I hope that this post has inspired you to pick up Awk and give it a go on some unconventional problems.