Write your own achievement

Achievement without persistent data

Suppose you want to create an achievement Foo awarded when user successfully run a command on a file foo. Let’s write this achievement.

Meta-information

First, we need to define a class and define meta-information: any achievement is a subclass of Achievement. Two arguments are compulsory:

  • title: if None, your class is an abstract achievement, meant to be subclassed; if a string, your achievement is an actual achievement. See the class documentation for other attributes;

  • description: your achievement must have a description. The first non-empty line of your class docstring is used, unless _description is defined, when it is used instead.

See the class documentation to get more information about other attributes.

from clachievements.achievements import Achievement
from clachievements.testutils import test_lock, test_unlock

class Foo(Achievement):
    """Successfully run a command on file `foo`."""

    title = "Foo"

Unlocking the achievement

Great: you have an achievement. But it is never unlocked: it will be frustrating for the user.

An achievement is a context manager: its __enter__() and __exit__() methods are called before and after the actual system call. They can be used to test the command line, the environment before and after the command, etc.

Here, we test that:

  • foo is a positional argument;

  • the command did not fail.

If so, we call unlock() to unlock the argument. It ensures that the argument is marked as unlocked, and it displays a pop-up to notify the user. No need to make sure that parallel calls to your achievement might unlock it at the same time: it is handled within the unlock() method itself.

from clachievements.achievements import Achievement
from clachievements.testutils import test_lock, test_unlock

class Foo(Achievement):
    """Successfully run a command on file `foo`."""

    title = "Foo"

    def __exit__(self, exc_type, exc_value, traceback):
        if "foo" in self.command.positional:
            if isinstance(exc_value, SystemExit):
                if exc_value.code == 0:
                    self.unlock()

Testing

If we are done, the achievement will work, but the unit tests will fail. An achievement must define a test that unlock the achievement.

Each achievement must define a static or class method, decorated with test_unlock(). This method must iterate strings which are shell commands, unlocking the achievement. To be wrapped by CLAchievements, system calls must use string substitution: "foo bar" will call the foo binary, not wrapped by CLAchievements, where "{bin.foo} bar" will call the foo binary, wrapped by CLAchievements.

You can add as many test methods as you want. You can also define test methods that must not unlock achievements, by decorating them with test_lock().

When performing tests, each test method is run inside an empty temporary directory, which will be deleted afterward.

from clachievements.achievements import Achievement
from clachievements.testutils import test_lock, test_unlock

class Foo(Achievement):
    """Successfully run a command on file `foo`."""

    title = "Foo"

    def __exit__(self, exc_type, exc_value, traceback):
        if "foo" in self.command.positional:
            if isinstance(exc_value, SystemExit):
                if exc_value.code == 0:
                    self.unlock()

    @staticmethod
    @test_unlock
    def test_touch():
        yield "{bin.touch} foo"

    @staticmethod
    @test_lock
    def test_ls():
      yield "{bin.ls} foo"

Achievement with persistent data

Now, we want a new achievement FooBar to be triggered when 50 successful commands have been run on a file foo. Let’s do this.

To do this, we have to store the number of successful commands. A class is defined to ease this process: SimplePersistentDataAchievement. It is wrong (see below), but is works for simple cases.

When using this class, a row is created in the CLAchievements database with this achievement name.

  • The first time this achievement is created, this row is filled with the content of attribute default_data.

  • When accessing to data, data is read from the database.

  • When assigning a value to data, data is written to the database.

Any picklable data can be stored using this method.

This is simple, but this is not robust to concurrent access: if an integrity error occurs when assigning a value to data, it is silently ignored.

With this example achievement, if I run this argument 50 times in parallel, about 30 of the assignments are ignored. If I were to design a life critical application, this would be a big issues. But this is only a game: it does not work perfectly, but it is so much simpler to implement!

from clachievements.achievements import SimplePersistentDataAchievement
from clachievements.testutils import test_lock, test_unlock

class FooBar(SimplePersistentDataAchievement):
    """Successfully run 50 command on file `foo`."""

    title = "FooBar"
    default_data = 0

    def __exit__(self, exc_type, exc_value, traceback):
        if "foo" in self.command.positional:
            if isinstance(exc_value, SystemExit):
                if exc_value.code == 0:
                    self.data += 1
        if self.data >= 50:
            self.unlock()

    @staticmethod
    @test_lock
    def test_touch():
        for _ in range(49):
            yield "{bin.touch} foo"

    @staticmethod
    @test_unlock
    def test_ls_touch():
        for _ in range(25):
            yield "{bin.touch} foo"
            yield "{bin.ls} foo"

More

Suppose this error-prone persistent data management does not suit you. Just write your own: within the achievement, the sqlite3 database connection is available as self.database.conn. Do whatever you want with it (without breaking other plugin databases)!

In this case, to be sure not to mess with tables of CLA core or other plugins, use the tables named (case insensitive) achievement_YourPluginName or achievement_YourPluginName_*.

Methods first() and last() can be used to initialize or clean the achievement: the first one is called the first time the achievement is ever loaded (so it can be used to create some tables into the database), while the last one is called when the achievement has just been unlocked (so it can be used to clean stuff). Both these methods are meant to be subclassed, and are expected to call super().first(...) at the beginning of their code.