Open Source Adventures: Episode 15: Magic: the Gathering Banlist DSL
Tags: ruby
Published: 2022-03-11 17:06:16

Another tiny thing to showcase. I created [a search engine for Magic: the Gathering cards mtg.wtf](https://mtg.wtf/).

That's a fairly big project, but I want to talk about just a tiny part of it.

### MTG Formats

Magic has many formats. Each format specifies which cards are allowed - usually all cards from specific sets. Some formats are "rotating", so as new sets go in, old sets go out. Most formats only accumulate new cards.

If a card is so powerful that it messes up tournaments, it can get banned in a specific format where it causes issues. Sometimes cards also get unbanned, if environment changed and the ban really doesn't make sense anymore.

Occasionally card can be "restricted", which has different meaning in different formats. For example it can be "banned as Commander" but allowed in regular decks, or only limited to 1-per-deck instead of the usual 4-per-deck. Most formats don't have any restrictions, cards are either banned or not.

### Time Travel Search

The most unusual feature of mtg.wtf is that you can search as if you were at any specific point in the past. Either by date, or by a set name (which means set's release date).

So for example if you want to search for all the creatures which were in Standard back when ISD was released, you can search [`time:isd f:standard t:creature`](https://mtg.wtf/card?q=time%3Aisd+f%3Astandard+t%3Acreature). It's occasionally useful. It would be far more useful if Google News did it.

These two features interact together. To enable time travel, I need to know:

* which cards were already printed at any given time, so we don't return cards that weren't released yet (we know that already)
* which sets are rotating into and out of each format, so we don't return cards that were not in given format at that time (rotation is a once-a-year event nowadays, so no big deal)
* which cards were banned or restricted at any given time (that's what this post is about)

And since we already have this information, mtg.wtf can also display full banlist history for each format, with links to each announcements. As far as I know, nobody else does that.

### BanList DSL

As mtg.wtf is implemented in Ruby, I did the standard Ruby thing for it, and created a small DSL for banlists. So many problems in Ruby end up with "I know, I'll create a DSL". And usually that solves your problem.

Here's a very simple example:

```ruby
BanList.for_format("innistrad block") do
  change(
    "2012-04-01",
    "https://magic.wizards.com/en/articles/archive/feature/march-20-2012-dci-banned-restricted-list-announcement-2012-03-20",
    "Intangible Virtue" => "banned",
    "Lingering Souls" => "banned",
  )
end
```

And [here's what it looks like](https://mtg.wtf/format/innistrad-block).

Here's a more complex one for Pioneer:

```ruby
BanList.for_format("pioneer") do
  format_start(
    "https://magic.wizards.com/en/articles/archive/news/announcing-pioneer-format-2019-10-21",
    "Bloodstained Mire" => "banned",
    "Flooded Strand" => "banned",
    "Polluted Delta" => "banned",
    "Windswept Heath" => "banned",
    "Wooded Foothills" => "banned",
  )

  change(
    "2019-11-08",
    "https://magic.wizards.com/en/articles/archive/news/november-4-2019-pioneer-banned-announcement",
    "Felidar Guardian" => "banned",
    "Leyline of Abundance" => "banned",
    "Oath of Nissa" => "banned",
  )

  change(
    "2019-11-12",
    "https://magic.wizards.com/en/articles/archive/news/november-11-2019-pioneer-banned-announcement",
    "Veil of Summer" => "banned",
  )

  change(
    "2019-12-03",
    "https://magic.wizards.com/en/articles/archive/news/december-2-2019-pioneer-banned-announcement",
    "Once Upon a Time" => "banned",
    "Field of the Dead" => "banned",
    "Smuggler's Copter" => "banned",
  )

  change(
    "2019-12-17",
    "https://magic.wizards.com/en/articles/archive/news/december-16-2019-pioneer-banned-announcement",
    "Oko, Thief of Crowns" => "banned",
    "Nexus of Fate" => "banned",
  )

  change(
    "2020-07-13",
    "https://magic.wizards.com/en/articles/archive/news/july-13-2020-banned-and-restricted-announcement-2020-07-13",
    "Oath of Nissa" => "legal",
  )

  change(
    "2020-08-03",
    "https://magic.wizards.com/en/articles/archive/news/august-8-2020-banned-and-restricted-announcement",
    "Inverter of Truth" => "banned",
    "Kethis, the Hidden Hand" => "banned",
    "Walking Ballista" => "banned",
    "Underworld Breach" => "banned",
  )

  change(
    "2021-02-15",
    "https://magic.wizards.com/en/articles/archive/news/february-15-2021-banned-and-restricted-announcement",
    "Balustrade Spy" => "banned",
    "Teferi, Time Raveler" => "banned",
    "Undercity Informer" => "banned",
    "Uro, Titan of Nature's Wrath" => "banned",
    "Wilderness Reclamation" => "banned",
  )

  change(
    "2022-03-07",
    "https://magic.wizards.com/en/articles/archive/news/march-7-2022-banned-and-restricted-announcement",
    "Lurrus of the Dream-Den" => "banned",
  )
end
```

And [here's what it looks like](https://mtg.wtf/format/pioneer).

### Implementation

Here's just the DSL-specific parts of the code:

```ruby
class BanList
  START = Date.parse("1900-01-01")

  def initialize(format)
    @format = format
    @events = []
    @cards = {}
  end

  def format_start(url, legalities)
    change(START, url, legalities)
  end

  def change(date, url, legalities)
    date = Date.parse(date) unless date.is_a?(Date)
    @events << [date, url, legalities]
    legalities.each do |card, legality|
      @cards[card] ||= []
      @cards[card] << [date, legality]
    end
  end

  class << self
    # BanList for each format is singleton
    def [](format)
      @ban_lists ||= {}
      @ban_lists[format] ||= BanList.new(format)
    end

    def for_format(format, &block)
      ban_list = self[format]
      ban_list.instance_eval(&block)
      ban_list.instance_eval{ validate }
    end

    def all_change_dates
      @ban_lists.values.flat_map(&:change_dates).uniq.sort
    end
  end
end
```

The DSL is just three commands. `BanList.for_format` creates empty object, sets it in a global hash, and `instance_eval`s a block passed.

Inside there's two commands, `change` and `format_start` (which is just `change` with a dummy date in distant past). I sort of wish Ruby had some kind of `Date::MIN` and `Date::MAX`, equivalent to `-infinity` and `+infinity` floats have, as I use dummy dates and times a lot.

Each entry has an URL which can be used both for verification, and to read explanations why a card was banned. These URLs sometimes expire, and I don't have an automated way to check for that. There's usually Internet Archive available for them.

The `BanList` object also has some methods for getting card legality at given time, list of ban events, and so on. For more complex objects, it makes sense to separate the DSL object (some kind of "builder" or such, which receives `instance_eval`) from the result, but I didn't bother with it in this case as it's very simple.

### Was it successful?

The DSL works perfectly.

The only thing I needed to do since I created it was adding some specs to verify that all card names in the banlists are spelled correctly, as sometimes there are typos in the announcements, or silly issues like "Ae" vs "Æ" (old Magic cards used ligature, then they reverted it and retroactively made them separate letters). So now any such cards are flagged instead of just being ignored.

The message here is that Ruby makes it so damn simple to create DSLs, it's something you should always consider doing.

### Coming next

In the next few episodes I'll continue showcasing various tiny projects I did.