Sunday, May 26, 2019

Building an online randomizer app for Thunderstone Quest, and what was learned along the way

I have enjoyed playing Thunderstone Quest and am glad I painted the minis for it. For some time I have had an itch to make a tool to assist in generating random adventures. There is one out there already, but it has some frustrating defects, such as the selection of heroes not always matching the constraint that there's one per class. I talked to the developer about it and tried sorting through the implementation, but neither of us made any headway.

The other day, I received my To the Barricades expansion, which is the final component to my Kickstarter pledge. We've only had it to the table once, and we enjoyed it despite the inevitable rockiness of first plays. This really got my wheels turning about building a new online randomizer—so I did.

Thunderstone Quest Randomizer:

Source Code:

I worked on this from Thursday night through Sunday morning. It's not going to win any graphic design awards, but it has all the functionality that I wanted from it. The rest of this blog post contains a few things I learned while building it. I will focus on two things: jq and lit-element.

A friend mentioned jq to me several weeks ago as a powerful command-line tool for manipulating JSON. The other randomizer already included a transcription of most of the Thunderstone Quest cards, but it was not in a format that I wanted. This is exactly what jq is built for. However, I don't know exactly what I expected, but it wasn't what I found: jq's system of generators and filters was mind-blowing. It took me a while to wrap my head around it, and even after several hours I feel a bit uncertain of my grasp of it. I ended up crafting a filter like this to do the transformation:

 jq '[to_entries[] | .value | to_entries[] | {"Name": .key} + (.value | del(.Cost,.Types,.Keywords,.Races,.Summary,.Alert,.Light,.Battle,.Spoils,.Special,."After Battle",.Reward,.Entry)) | select(.Category)]' cards.json   

The whole command is wrapped in square brackets because I want the result as an array. The next few commands allow me to wade through the deeply-nested structure of the original format. Then, I push object keys as a new "Name" field into the rest of the object, filtering out all the data from the original source that I did not need. Finally, I pulled out the handful of cards from the original source data that had no Category specifier. Writing this command took several hours of pushing and prodding, but I'm glad I got it figured out.

Although I had pulled out much of the bad structure of the original format, I still had a lot of redundancy. I decided to grin and bear it until I reached a point where I wanted to add metadata about quests. In my first pass, quests were only represented as fields in objects: each card had a "Quest" key with a value listing the name of its quest. I wanted to pull that out so that I could describe a Quest more systematically, as having a code (such as "Q2") and as being part of a set (such as the Champion-level Kickstarter pledge). To do so, I needed to pull out each of the types of cards into their own lists and put these under the corresponding Quest. Unfortunately, I could not figure out how to automate this process for an arbitrary number of categories, and so I did some nasty copy-pasting of commands, manually entering what I would rather have parameterized. Get ready to cringe, because here it is:

 jq '[group_by(.Quest) [] | {"Quest": .[0].Quest, "Heroes": [.[] | select(.Category=="Heroes") | del(.Quest,.Category)], "Items": [.[] | select(.Category=="Items") | del(.Quest,.Category)], "Spells": [.[] | select(.Category=="Spells") | del(.Quest,.Category)], "Weapons": [.[] | select(.Category=="Weapons") | del(.Quest,.Category)], "Guardians": [.[] | select(.Category=="Guardians") | del(.Quest,.Category)], "Dungeon Rooms": [.[] | select(.Category=="Dungeon Rooms") | del(.Quest,.Category)], "Monsters": [.[] | select(.Category=="Monsters") | del(.Quest,.Category)]}]' cards.json  

If any readers know jq better than I do and see how to parameterize the management of each of those card categories, I'd love to know. Fortunately, this is a one-time transformation, since after doing this one, I had to manually enter the data for the new Barricades-level quests. There should be no reason for me to ever have to run this particular transformation again.

I have had an itch to learn Flutter, and I was excited to hear that there will be support for compiling Flutter apps for the Web. However, that's still in a technology preview state, so I decided instead to build my randomizer using Polymer. I've been using Polymer for a few years now, and it's a fascinating system, allowing the development of custom components with two-way data binding. At some point in the last year, I came across LitElement and lit-html, which provide very useful and terse expressions for binding values and also for iterating over arrays—syntactically much nicer than Polymer's dom-repeat. I decided I'd spend some more time with lit-element in my Polymer-based solution.

I ran into a problem in writing an element to filter quests, so that users could choose to get cards only from the sets they own. Making the element show all the quests with checkboxes was fine, and I could track the states of them from within the element, but the values were not reflecting back to the main app that held the filter element. This puzzled me for some time, until I ended up just moving that logic from a nested element into the top-level app: that's not a particularly good design decision, but it is a pragmatic one. At some point later, I was looking for help on the lit-element when I came across this excellent post by James Garbutt on the relationship between Polymer 3 and lit-element. This particular portion blindsided me:
Bindings are one-way in Lit, seeing as they are simply passed down in JavaScript expressions. There is no concept of two-way binding or pushing new values upwards in the tree.
I had been conceiving lit-element as just a way to use some cool lit-html features within a conventional Polymer ecosystem. Garbutt goes on to helpfully add, "Instead of two-way bindings, you can now make use of events or a state store." Events are familiar, of course, but a real part of Polymer's appeal to me was that I could get elegant two-way binding instead of tedious event plumbing.

Now, though, I have to show some of my ignorance, because I had to ask, "What is a state store?" With this term in hand, I discovered the work-in-progress PWA Starter Kit, which combines lit-element with Redux. "Redux" has the shape of a buzzword I would have overheard somewhere on the Internet, but I couldn't have told you what it is. I downloaded the PWA Starter Kit and started fiddling around with it, keeping the docs open beside it. This looked really interesting and exciting as a way to start building off of what I've learned using Polymer... but at this point I was hip deep in a project that I wanted to get done before the weekend was over. I put Redux and the PWA Starter Kit out of my mind, armed myself with the knowledge that lit-element is not what I thought it was, and I went back to the randomizer. I did end up keeping the card filter logic in the top-level application element, where it clutters things up but still works.

I jumped into this project with a relatively unstructured goal. I had an intuition of what I wanted, but I didn't do any sketches or write any specifications. I had a few regression defects along the way that made me wonder if I should have used TDD, but I think the whole project was really in the "experimental programming" mode. Making it free and online means that other people can gain some benefit from my work, but if I were to rebuild it from scratch, I would certainly be more careful about it. Indeed, it's tempting to rebuild it using Redux for state management, but this was already a four-day distraction from my main summer side project.

Incidentally, the randomizer itself is a progressive web app published on GitHub Pages. I was able to repurpose the approach I took for Elixer, my scoring application for Call to Adventure, which I don't think I ever wrote about here. That one is also an open source project hosted on GitHub. It took me a lot more time to make that a full-fledged PWA, but the second time around with the Thunderstone Quest randomizer was much faster.

No comments:

Post a Comment