DEV Community

Cover image for LGTM Devlog 20: Python Abstract Base Class-based data/quest storage
Yuan Gao
Yuan Gao

Posted on

LGTM Devlog 20: Python Abstract Base Class-based data/quest storage

I changed my mind about quest storage. Previously, I wanted to instantiate each quest as an object of the Quest class, so something like this:

intro_quest = Quest("intro")
intro_quest.add_stage(...)
Enter fullscreen mode Exit fullscreen mode

However, I realized a downside to this, which is that this abstraction is wrong for loading/unloading data. Since the game core loop has to process multiple users' quests in a loop, I would have to somehow copy this instantiated object, or have it cleanly unload user data:

# inside game loop

intro_quest.load(user1_data)
intro_quest.process_whatever()

intro_quest.load(user2_data)
intro_quest.process_whatever()
Enter fullscreen mode Exit fullscreen mode

While this works, I feel this is the wrong abstraction. Instead, I feel it is better if intro_quest was instead an actual Class, which means we would be able to instantiate user data as an object of this quest class, and not have to deal with unloadign data:

For example, if we subclassed Quest as IntroQuest:

IntroQuest(Quest):
  def _init_(self):
    ...
Enter fullscreen mode Exit fullscreen mode
# inside game loop
user1_quest = IntroQuest(user1_data)
user2_quest = IntroQuest(user2_data)
Enter fullscreen mode Exit fullscreen mode

ABCs

To do this, I'm going to turn the parent Quest class into an Abstract Base Classes, which lets me define certain properties and methods which subclasses should have. ABCs were actually in one of my earliest blog post on Dev.to!

The benefit of using an Abstract Base Class (ABC) in Python, is helps ensure the implementation for each Quest is correct - any errors in implementation - if the concrete implementation of a quest is missing needed methods that our game loop will call later, then the code will error out on instantiation, letting us know a function is missing.

So, I can re-define the same Quest from last post as an ABC, declaring some of the metadata I need as abstract class properties. I've also defined a Difficulty enum. semver_safe is the same as last time.

from copy import deepcopy
from typing import Any, Dict, ClassVar
from abc import ABC, abstractmethod
from enum import Enum
from semver import VersionInfo # type:  ignore

from .exceptions import QuestLoadError

class Difficulty(Enum):
    RESERVED = 0
    BEGINNER = 1
    INTERMEDIATE = 2
    ADVANCED = 3
    EXPERT = 4
    HACKER = 5


def semver_safe(start: VersionInfo, dest: VersionInfo) -> bool:
    """ whether semver loading is going to be safe """
    if start.major != dest.major:
        return False

    # check it's not a downgrade of minor version
    if start.minor > dest.minor:
        return False

    return True


class Quest(ABC):
    @property
    @abstractmethod
    def version(cls) -> VersionInfo:
        ...

    @property
    @abstractmethod
    def difficulty(cls) -> Difficulty:
        return NotImplemented

    @property
    @abstractmethod
    def description(cls) -> str:
        return NotImplemented

    default_data: ClassVar[Dict[str, Any]] = {}
    quest_data: Dict[str, Any] = {}
    VERSION_KEY = "_version"

    def __init_subclass__(self):
        self.quest_data = deepcopy(self.default_data)

    def load(self, save_data: Dict[str, Any]) -> None:
        """ Load save data back into structure """

        # check save version is safe before upgrading
        save_semver = VersionInfo.parse(save_data[self.VERSION_KEY])
        if not semver_safe(save_semver, self.version):
            raise QuestLoadError(
                f"Unsafe version mismatch! {save_semver} -> {self.version}"
            )

        self.quest_data.update(save_data)

    def get_save_data(self) -> Dict[str, Any]:
        """ Updates save data with new version and output """

        self.quest_data[self.VERSION_KEY] = str(self.version)
        return self.quest_data

Enter fullscreen mode Exit fullscreen mode

Our concrete implementation now looks like this:

from typing import TYPE_CHECKING
from semver import VersionInfo # type:  ignore
from ..quest_system import Quest, Difficulty


class IntroQuest(Quest):
    version = VersionInfo.parse("0.1.0")
    difficulty = Difficulty.BEGINNER
    description = "The intro quest"

if TYPE_CHECKING:
    IntroQuest()
Enter fullscreen mode Exit fullscreen mode

The if TYPE_CHECKING at the end is there because until you instantiate a class, it won't be checked, so I have to actually instantiate the class in the code, but also I only want to do this at type-checking time. The typing library therefore provides us with a TYPE_CHECKING boolean for this purpose.

An example of an incorrect implementation, where the version is missing

class BrokenQuest(Quest):
    difficulty = Difficulty.BEGINNER
    description = "This quest is broken"

if TYPE_CHECKING:
    BrokenQuest()
Enter fullscreen mode Exit fullscreen mode

Running mypy on this would give us the following error, telling us we're missing version

error: Cannot instantiate abstract class 'BrokenQuest' with abstract attribute 'version'
Enter fullscreen mode Exit fullscreen mode

The load/save tests have been updated accordingly:

def test_quest_load_fail():
    """ Tests a quest load fail due to semver mismatch """

    # generate a bad save data version
    save_data = deepcopy(DebugQuest.default_data)
    save_data[DebugQuest.VERSION_KEY] = str(DebugQuest.version.bump_major())

    # create a new game and try to load with the bad version
    quest = DebugQuest()
    with pytest.raises(QuestLoadError):
        quest.load(save_data)


def test_quest_load_save():
    """ Tests a successful load with matching semvar """

    # generate save data version
    save_data = deepcopy(DebugQuest.default_data)
    save_data[DebugQuest.VERSION_KEY] = str(DebugQuest.version)

    # create a new game and load the good version
    quest = DebugQuest()
    quest.load(save_data)
    assert quest.get_save_data() == save_data
Enter fullscreen mode Exit fullscreen mode

It's a little clunky, as I am copying out the default_data property directly to generate save files.

Auto-loading all the quests

The way I would like the quests to work is I add each quest as a Class (whose base class is Quest), and then the module automatically loads this, so that I don't have to manually maintain a list of quests somewhere.

The code I use for that is the following in the __init__.py file in the quests folder:

from typing import Type
import os
import pkgutil
import importlib
import inspect

from ..system import Quest
from ..exceptions import QuestError

all_quests = {}
for _importer, _name, _ in pkgutil.iter_modules(path=[os.path.dirname(__file__)]):
    _module = importlib.import_module("." + _name, __package__)
    _classes = inspect.getmembers(_module, inspect.isclass)

    for _parent, _class in _classes:
        if Quest in _class.__bases__:
            if _class.__name__ in all_quests:
                raise ValueError(f"Duplicate quests found with name {_class.__name__}")
            all_quests[_class.__name__] = _class

def get_quest_by_name(name: str) -> Type[Quest]:
    try:
        return all_quests[name]
    except KeyError as err:
        raise QuestError(f"No quest name {name}") from err
Enter fullscreen mode Exit fullscreen mode

This allows me to simply from quests import all_quests to fetch all the quests, or, use the get_quest_by_name() conevenience function to do the lookup

The tests can then loop through and instantiate all of the quest classes to double-check they're implemented correctly according to the abstract base class:

def test_quest_class_fail():
    """ Try to load a non-existant class """
    with pytest.raises(QuestError):
        get_quest_by_name("_does not exist_")


def test_get_quest():
    """ A successful class fetch """
    assert get_quest_by_name(DebugQuest.__name__) == DebugQuest


def test_all_quest_subclasses():
    """ Instantiate all quests to check abstract base class implementation """

    for quest_class in all_quests.values():
        quest_class()  # should succeed if correctly implemented
Enter fullscreen mode Exit fullscreen mode

This update to the quest system (still missing actual implementation of quest stages) should now be in a better position to have new quests defined

Discussion (0)