DeckLock, a static-website generator to generate overviews of KeyForge decks, will be expanded to support Magic: the Gathering. To find more about DeckLock and how it is designed look at the previous post. Magic: the Gathering is a very different game compared to KeyForge. There is a large pool of cards available and players can select a set they wish to play with. There are different formats and the format determines which cards and how many of them you can play. Most formats require decks to be at least 60 cards with any number of basic lands and a maximum 4 copies of other cards. This means each player can bring a completely custom set of cards to the table and we’ll have to come up with a system to support that. Unlike KeyForge where each deck is unique and the list of cards can be found online, here we’ll include a system to add a deck list, with a short description and turn those into pages containing cards.

You can find my DeckLock instance with an overview of my KeyForge and Magic: the Gathering decks (paper and online) here.

Designing a Reader

Here we actually can use a structure analogous to Pelican’s articles. We’ll add a decklist to a designated folder, Pelican will pick up this file, the Reader class will parse it, fetch the decks cards’ information and image online and inject it into an appropriate template.

A common file format to store decks is the Magic Workstation format, which is also included in MTGTop8 (I’ll admit it, I’m netdecking most of the time). This is both machine- and human-readable, there are a number of comment lines which are used to store the name of the deck, who created it, … other lines contain the number of time a card is included in the deck, the set the card is from and the name of the card. Fields are space delimited. Sideboard cards are included the same way, but the line starts with SB:

// NAME : 9 Land Stompy
// CREATOR : Sebastian Proost
// FORMAT : Casual
9 [USG] Forest
4 [MMQ] Land Grant
4 [ALL] Elvish Spirit Guide
3 [MMQ] Vine Dryad
...
SB:  2 [TMP] Root Maze
SB:  4 [ULG] Hidden Gibbons
SB:  3 [ONS] Naturalize
SB:  2 [MMQ] Rushwood Legate
SB:  3 [UDS] Compost

Based on the name of the card, all other information can be pulled from ScryFall, a website the has images for all cards and also includes an API that can be used to fetch useful details to include. The set, included here between square brackets is optional, if nothing is entered the most recent version is selected.

The Reader class

Below you’ll find the code for the Reader, this needs to be a class that is build upon Pelican’s BaseReader, it needs to have a property enabled which should be set to True and a property file_extensions which is a list of extensions that should be processed by this reader, in this case only .mwDeck files. Note that there are a number of helper functions in full code that are required to make this work. Head over to the DeckLock GitHub repository to check out the full code.

Upon creation of an MTGReader the cached data will be loaded (if it exists) and create a path to store card images (if necessary).

The function read attached to the MTGReader is required, here the metadata is set. A category and data are required by Pelican though we won’t be using them so we just specify any value. Here the required template is also specified which is important to ensure the data will be rendered using the correct template. The next block of code reads the .mwDeck file, fetches card details and the image from Scryfall if it isn’t included in the cached data and creates a dictionary that contains all data required for the deck’s page. The page title, slug (url friendly name), the url and the path to the output file are also constructed here from the deck’s name.

The deck data along with empty content is returned, Pelican will ensure a reader is created for each .mwDeck file in the posts folder, processed and combined with a template.

Finally, you’ll need to create a function that adds the reader (here add_reader but you are free to choose another name) and a register function (name is not optional here). The latter will connect the add_reader function to Pelican’s set of readers.

class MTGReader(BaseReader):
    enabled = True

    file_extensions = ['mwDeck']

    def __init__(self, settings):
        super(MTGReader, self).__init__(settings)

        self.cached_data = {}

        if os.path.exists(self.mtg_data_path):
            with open(self.mtg_data_path, 'r') as fin:
                self.cached_data = json.load(fin)

        Path(self.mtg_assets_cards_path(full=True)).mkdir(parents=True, exist_ok=True)

    @property
    def mtg_data_path(self):
        return os.path.join(
            self.settings.get("PATH"), self.settings.get("MTG_PATH"), "mtg.cached_cards.json"
        )

    def write_cache(self):
        with open(self.mtg_data_path, "w") as fout:
            json.dump(self.cached_data, fout, sort_keys=True, indent=4, separators=(",", ": "))

    def mtg_assets_cards_path(self, full=False):
        if full:
            return os.path.join(
                self.settings.get("PATH"), self.settings.get("MTG_ASSETS_PATH"), 'cards'
            )
        else:
            return os.path.join(
                self.settings.get("MTG_ASSETS_PATH"), 'cards'
            )

    def add_card_data(self, card_set, card_name):
        if card_set not in self.cached_data.keys():
            self.cached_data[card_set] = {}
        if card_name not in self.cached_data[card_set]:
            card_data = get_card_data(card_set, card_name)
            self.cached_data[card_set][card_name] = card_data
        else:
            card_data = self.cached_data[card_set][card_name]
        try:
            if "card_faces" in card_data.keys():
                card_data.update(card_data["card_faces"][0])

            img_url = card_data["image_uris"]["border_crop"]
            local_path = get_local_card_img_path(self.mtg_assets_cards_path(full=False), img_url)
            self.cached_data[card_set][card_name]["image_path"] = local_path

            local_path_full = get_local_card_img_path(self.mtg_assets_cards_path(full=True), img_url)
            fetch_image(img_url, local_path_full)
        except:
            print(f"an error occurred fetching {card_name} from set {card_set}")

    def read(self, filename):
        metadata = {'category': 'MTG_Deck',
                    'date': '2020-04-13',
                    'template': 'mtg_deck'
                    }

        deck_data = {
            'main': [],
            'sideboard': []
        }

        with open(filename, 'r') as fin:
            for line in fin:
                if line.startswith('//'):
                    tag, value = parse_meta(line)
                    metadata[tag.lower()] = value
                elif line.strip() != '':
                    sideboard, card_set, card_count, card_name = parse_card_line(line)
                    self.add_card_data(card_set, card_name)

                    card_data = {
                        'name': card_name,
                        'count': card_count,
                        'data': self.cached_data[card_set][card_name],
                        'card_type': parse_card_type(self.cached_data[card_set][card_name]['type_line'])
                    }

                    if sideboard:
                        deck_data['sideboard'].append(card_data)
                    else:
                        deck_data['main'].append(card_data)

        self.write_cache()

        metadata['title'] = metadata['name']
        metadata['slug'] = slugify(metadata['title'], regex_subs=self.settings.get('SLUG_REGEX_SUBSTITUTIONS', []))

        metadata['url'] = f"mtg/{metadata['format']}/{metadata['slug']}/"
        metadata['save_as'] = f"{metadata['url']}index.html"

        parsed = {}
        for key, value in metadata.items():
            parsed[key] = self.process_metadata(key, value)

        parsed['deck'] = deck_data

        return "", parsed


def add_reader(readers):
    readers.reader_classes['mwDeck'] = MTGReader


def register():
    signals.readers_init.connect(add_reader)

Updating the configuration

Just like for KeyForge a few settings need to be included in the configuration file. Even if there are no decks, this setting is required, setting this to False will remove Magic: the Gathering from the main page overview. The other parts are important to make sure the plugin knows where to find and store data.

Unlike in our previous post, the overview page for Magic makes use of Pelican’s build in functions. However, we do need to make sure this file is known to Pelican and properly handled. By adding it to TEMPLATE_PAGES the template mtg_overview.html is known to Pelican and will be rendered and stored as mtg.html.

MTG_ENABLED = True

MTG_PATH = "data"
MTG_ASSETS_PATH = "assets/mtg"

TEMPLATE_PAGES = {'mtg_overview.html': 'mtg.html'}

A Note on card images

DeckLock will download images from Scryfall when building a local version using make html. For private use this is permitted in most countries. However, when building a version to release on the internet including copyrighted images might no be a smart thing to do. Most likely it even is illegal. To this end, there are a few options included in the file publishconf.py which is used when building the version intended for on-line use. The latter can be build with the command make release.

USE_EXTERNAL_LINKS = True
STATIC_EXCLUDES = ['assets/keyforge', 'assets/mtg']

USE_EXTERNAL_LINKS will use links to external platforms for card images rather than the downloaded images. For M:tG, Scryfall will be used, for KeyForge the official images from the master vault are used. By excluding the assets folders for both games by setting STATIC_EXCLUDES, we prevent Pelican from copying the downloaded image to the output intended for hosting DeckLock online.

These settings can be changed but should obviously be used with careful consideration. Re-hosting copyrighted material might have repercussions.

Other improvements

Along with support for Magic: the Gathering a number of other improvements were made. Most notably is the inclusion of a proper logo for DeckLock. But also under the hood there are a number of improvements. Gwent has been added in [part 3].