Something new to try: feeding old ascii MMO maps to a gopher.
Inspiration: Optimizing Initial Inputs
Once we start demanding test flexibility and stability via clean starts, we inevitably begin to obsess about startup time. I’ve done a few easy bits in the last few posts, but now it’s time to go in a slightly different direction.
One of the game server’s startup tasks is reading in the world maps. A collection of ascii files (still very similar to the original hand-assembled maps that players compiled in the 1980s) contain the geography of the various towns and dungeons and such. For most of the testing, I replaced these with very simple test maps, but those would need to expand in order to test more of the game logic. A map pre-processing step could be a useful optimization and, perhaps more importantly, fun to do.
But First, a Bit of Cleanup
Let’s make these map files a little more consistent. Might as well start alphabetically, with Annwn
:
|
|
Some comment lines, then a header (XML-style tags, followed by key-value pairs), then the actual map cells (two characters each). Repeat for each level of the map…
/\. pb/\. . . . . | . . . /\/\/\/\
/\/\/\/\/\/\/\/\/\/\/\/\/\/\
<z>-25</z> <x>38</x> <y>18</y> <n>Ruined Tavern Cellar</n>
/\/\/\/\/\/\/\/\/\/\/\/\/\
/\[][][][][][][][][][][]/\
/\[]dn. []. . . []. up[]/\
The comments are optional (makes sense), as seem to be the <f>
tag and the key-value pairs.
The z
, x
, and y
tags make sense as the z-level and offsets. What’re f
and n
?
|
|
|
|
A forestry level (which only makes sense sometimes) and a name for the z-level. The key-value pairs are clearly booleans which apply to that whole z-level, and are false if not present.
To tidy things up, we’ll make all tags except f
mandatory, in the order that’s most common, and order the key-value
pairs alphabetically when present.
Why so strict? Not just for efficiency. If we want to do some simple round-trip testing of map parsing code, having the ability to output a format which exactly matches the input is very helpful.
Why not more strict? Nostalgic attachment to these old ascii files, so we’re trying not to alter them too much.
Ambiguous Whoopsie
Tweaking the files works fine for a while, adding some zero offsets and naming z-levels after their z coordinate where
tags were missing. Then I come across the map file for Torii
:
|
|
Which looks fine until you scroll that top line all the way to the right.

Not sure when that happened; it dates back to the first commit in the repo, but I could easily have messed it up at some point prior to that. But if I fix it, will that y-offset still be correct? Or has it been incorrect all this time? Let’s just shift that line down and leave the offset alone, start up the server, and see what happens.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 321 did not find a suitable spawning point after 40 attempts.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 323 did not find a suitable spawning point after 40 attempts.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 332 did not find a suitable spawning point after 40 attempts.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 333 did not find a suitable spawning point after 40 attempts.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 334 did not find a suitable spawning point after 40 attempts.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 335 did not find a suitable spawning point after 40 attempts.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 336 did not find a suitable spawning point after 40 attempts.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 337 did not find a suitable spawning point after 40 attempts.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 338 did not find a suitable spawning point after 40 attempts.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 339 did not find a suitable spawning point after 40 attempts.
06/28/2023 20:07:59: {SystemWarning} SpawnZone 396 did not find a suitable spawning point after 40 attempts.
And what if I back off the y-offset to 35 to make up for the extra line? No more spawning failures. That settles it.
And now we have a set of map files that a bit more consistent. What should we do with them?
A Bit of Code Trying to Do Nothing
So now let’s have some fun: code up a utility to consume one of these map files, split it into its component parts, and then output the exact same file. I feel like Go will turn out to be a good fit for this project, so we’ll start using it now.
Define Pieces and Consume the Header
First, what are the pieces to split the map into?
|
|
Each map level has the header info that’s global to that level, then a map of cell graphics keyed by their
coordinates. Tagging the fields of the MapHeader
structure with XML element names will make processing the header
almost trivial, as long as it’s wrapped in an outer tag (here we use <mapheader>
):
|
|
Indeed, unmarshalling the XML part is a one-liner. Handling the key-value pairs is a little more awkward; if there were
more than four of them, it’d be worth doing some refactoring (perhaps incorporating them into XML before the
Unmarshal()
call?).
Note that in the case of an error, we return the header data we’ve processed so far. Best practices for error handling usually dictate that the caller cannot make any assumptions about the contents of the other return values in the presence of an error. In a more public API, I wouldn’t tempt the caller with partial info, but in this case it seems like there is troubleshooting benefit to having it.
Always Be Testing
Thank goodness for gotests integration in in your favourite IDE, since even table-driven tests can be fairly verbose:
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func Test_processXMLHeader(t *testing.T) {
type args struct {
mapline string
}
tests := []struct {
name string
args args
want MapHeader
wantErr error
{
name: "minimal",
args: args{
mapline: `<mapheader> <z>15</z> <x>10</x> <y>5</y> <n>zname</n> </mapheader>`,
},
want: MapHeader{XMLName: xml.Name{Space: "", Local: "mapheader"}, ZCoord: 15, XOffset: 10, YOffset: 5,
ZName: "zname"},
},
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
{
name: "bad keyval",
args: args{
mapline: `<mapheader> <z>15</z> <x>10</x> <y>5</y> <n>zname</n> notreal=true townlimits=true </mapheader>`,
},
want: MapHeader{XMLName: xml.Name{Space: "", Local: "mapheader"}, ZCoord: 15, XOffset: 10, YOffset: 5,
ZName: "zname"},
wantErr: errors.New("Unknown key: notreal"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := processXMLHeader(tt.args.mapline, MapHeader{})
if (err != nil && err.Error() != tt.wantErr.Error()) ||
(err == nil && tt.wantErr != nil) {
t.Errorf("processXMLHeader() err = %v, want %v", err, tt.wantErr)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("processXMLHeader() = %v, want %v", got, tt.want)
}
})
}
}
Do we at least cover the code we’ve written so far?
$ go test -cover
PASS
glitch-aura-djinn coverage: 100.0% of statements
ok glitch-aura-djinn 0.002s
So far, so good.
Now Recreate the Header
On the other side, let’s make sure we can take the MapHeader
structure and recreate the text.
|
|
A little more complicated to make sure we recreate the style from the map files. Comments first, then the XML-style part with optional forestry, and each of the key-value pairs that aren’t false. We don’t return any errors, assuming we can create a string from any valid header struct.
The tests are pretty much the same with the input and output swapped, with the addition of comments and newlines.
|
|
$ go test -cover
PASS
glitch-aura-djinn coverage: 100.0% of statements
ok glitch-aura-djinn 0.003s
Round Trip Testing
Now that we have both sides of the coin, we can have some fun with round-trip tests. First we do header->struct->header, and then struct->header->struct.
|
|
|
|
These are a little less verbose, given that the input and desired output are the same. Other than that, though, what am I going to do with this round-trip testing?
Fuzzing. Tomorrow.
One More Thing
Over in the Ruby land of tests, I’ve never had much luck making Rubocop completely happy. On a whim, I decided to install Spotify’s VSCode Ruby extension pack and work on it for a little while.
Though I did opt to override Rubocop with a lot of my own opinions:
|
|
There are certainly engineers who argue that using an opinionated linter with no exceptions is the only way to go, but I struggle to do so with Rubocop. However, it’s a good place to start. Then every time the linter complains, you can consider whether the rule makes sense for your project and team.
Code coverage is similar: Rather than doing too much tweaking to guarantee 100%, exceptions can be considered with appropriate context.