From e1f289b08f0bcd754951c11a1c72ff5078f2b011 Mon Sep 17 00:00:00 2001 From: Cal Date: Wed, 20 Mar 2024 19:45:53 -0600 Subject: [PATCH 1/8] achievements contrib --- .../game_systems/achievements/README.md | 119 ++++++ .../game_systems/achievements/__init__.py | 0 .../game_systems/achievements/achievements.py | 370 ++++++++++++++++++ .../game_systems/achievements/tests.py | 177 +++++++++ 4 files changed, 666 insertions(+) create mode 100644 evennia/contrib/game_systems/achievements/README.md create mode 100644 evennia/contrib/game_systems/achievements/__init__.py create mode 100644 evennia/contrib/game_systems/achievements/achievements.py create mode 100644 evennia/contrib/game_systems/achievements/tests.py diff --git a/evennia/contrib/game_systems/achievements/README.md b/evennia/contrib/game_systems/achievements/README.md new file mode 100644 index 0000000000..d60d8061ce --- /dev/null +++ b/evennia/contrib/game_systems/achievements/README.md @@ -0,0 +1,119 @@ +# Achievements + +A simple, but reasonably comprehensive, system for tracking achievements. Achievements are defined using ordinary Python dicts, reminiscent of the core prototypes system, and while it's expected you'll use it only on Characters or Accounts, they can be tracked for any typeclassed object. + +## Installation + +Once you've defined your achievement dicts in a module, assign that module to the `ACHIEVEMENT_CONTRIB_MODULES` setting in your settings.py + +Optionally, you can specify what attribute achievement progress is stored in, with the setting `ACHIEVEMENT_CONTRIB_ATTRIBUTE`. By default it's just "achievements". + +There is also a command available to let players check their achievements - `from evennia.contrib.game_systems.achievements.achievements import CmdAchieve` and then add `CmdAchieve` to your default Character and/or Account cmdsets. + +#### Settings examples + +One module: + +```python +# in server/conf/settings.py + +ACHIEVEMENT_CONTRIB_MODULES = "world.achievements" +``` + +Multiple modules, with a custom-defined attribute: + +```python +# in server/conf/settings.py + +ACHIEVEMENT_CONTRIB_MODULES = ["world.crafting.achievements", "world.mobs.achievements"] +ACHIEVEMENT_CONTRIB_ATTRIBUTE = "achieve_progress" +``` + +A custom-defined attribute including category: + +```python +# in server/conf/settings.py + +ACHIEVEMENT_CONTRIB_MODULES = "world.achievements" +ACHIEVEMENT_CONTRIB_ATTRIBUTE = ("achievements", "systems") +``` + +## Usage + +### Defining your achievements + +This achievement system is designed to use ordinary dicts for the achievement data - however, there are certain keys which, if present in the dict, define how the achievement is progressed or completed. + +- **name** (str): The searchable name for the achievement. Doesn't need to be unique. +- **desc** (str): A longer description of the achievement. Common uses for this would be flavor text or hints on how to complete it. +- **category** (str): The type of actions this achievement tracks. e.g. purchasing 10 apples might have a category of "buy", or killing 10 rats might have a category of "defeat". +- **tracking** (str or list): The *specific* things this achievement tracks. e.g. the previous example of killing rats might have a `"tracking"` value of `"rat"`. An achievement can also track multiple things: instead of only tracking buying apples, you might want to track apples and pears. For that situation, you can assign it to a list of values to check against: e.g. `["apple", "pear"]` +- **tracking_type** (str): *Default value if unset: `"sum"`* There are two valid tracking types: "sum" (which is the default) and "separate". `"sum"` will increment a single counter every time any of the tracked items match. `"separate"` will have a counter for each individual item in the tracked items. (This is really only useful when `"tracking"` is a list.) +- **count** (int): *Default value if unset: 1* The number of tallies this achievement's requirements need to build up in order to complete the achievement. e.g. the previous example of killing rats would have a `"count"` value of `10`. For achievements using the "separate" tracking type, *each* item being tracked must tally up to this number to be completed +- **prereqs** (str or list): The *keys* of any achievements which must be completed before this achievement can start tracking progress. An achievement's key is the variable name it's assigned to in your achievement module. + +You can add any additional keys to your achievement dicts that you want, and they'll be included with all the rest of the achievement data when using the contrib's functions. This could be useful if you want to add extra metadata to your achievements for your own features. + +#### Examples + +A simple achievement which you can get just for logging in the first time. This achievement has no prerequisites and it only needs to be fulfilled once to complete, so it doesn't need to define most of the keys. +```python +# This achievement has the unique key of "FIRST_LOGIN_ACHIEVE" +FIRST_LOGIN_ACHIEVE = { + "name": "Welcome!", # the searchable, player-friendly display name + "desc": "We're glad to have you here.", # the longer description + "category": "login", # the type of action this tracks + "tracking": "first", # the specific login action +} +``` + +An achievement for killing 10 rats, and another for killing 10 *dire* rats which requires the "kill 10 rats" achievement to be completed first. +```python +ACHIEVE_TEN_RATS = { + "name": "The Usual", + "desc": "Why do all these inns have rat problems?", + "category": "defeat", + "tracking": "rat", + "count": 10, +} + +ACHIEVE_DIRE_RATS = { + "name": "Once More, But Bigger", + "desc": "Somehow, normal rats just aren't enough any more.", + "category": "defeat", + "tracking": "dire rat", + "count": 10, + "prereqs": "ACHIEVE_TEN_RATS", +} +``` + +An achievement for buying 5 each of apples, oranges, and pears. +```python +FRUIT_BASKET_ACHIEVEMENT = { + "name": "A Fan of Fruit", # note, there is no desc here - that's allowed! + "category": "buy", + "tracking": ("apple", "orange", "pear"), + "count": 5, + "tracking_type": "separate", +} +``` + +### `track_achievements` + +The primary mechanism for using the achievements system is the `track_achievements` function. In any actions or functions in your game's mechanics which you might want to track in an achievement, add a call to `track_achievements` to update the achievement progress for that individual. + +For example, you might have a collection achievement for buying 10 apples, and a general `buy` command players could use. In your `buy` command, after the purchase is completed, you could add the following line: + +```python + track_achievements(self.caller, "buy", obj.name, count=quantity) +``` + +In this case, `obj` is the fruit that was just purchased, and `quantity` is the amount they bought. + +The `track_achievements` function does also return a value: an iterable of keys for any achievements which were newly completed by that update. You can ignore this value, or you can use it to e.g. send a message to the player with their latest achievements. + +### The `achievements` command + +The contrib's provided command, `CmdAchieve`, aims to be usable as-is, with multiple switches to filter achievements by various progress statuses and the ability to search by achievement names. + +To make it easier to integrate into your own particular game (e.g. accommodating some of that extra achievement data you might have added), the code for formatting a particular achievement's data for display is in `CmdAchieve.format_achievement`, making it easy to overload for your custom display styling without reimplementing the whole command. diff --git a/evennia/contrib/game_systems/achievements/__init__.py b/evennia/contrib/game_systems/achievements/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/game_systems/achievements/achievements.py b/evennia/contrib/game_systems/achievements/achievements.py new file mode 100644 index 0000000000..7f0319d0fb --- /dev/null +++ b/evennia/contrib/game_systems/achievements/achievements.py @@ -0,0 +1,370 @@ +""" +Achievements + +This provides a system for adding and tracking player achievements in your game. + +Achievements are defined as dicts, loosely similar to the prototypes system. + +An example of an achievement dict: + + EXAMPLE_ACHIEVEMENT = { + "name": "Some Achievement", + "desc": "This is not a real achievement.", + "category": "crafting", + "tracking": "box", + "count": 5, + "prereqs": "ANOTHER_ACHIEVEMENT", + } + +The recognized fields for an achievement are: + +- name (str): The name of the achievement. This is not the key and does not need to be unique. +- desc (str): The longer description of the achievement. Common uses for this would be flavor text + or hints on how to complete it. +- category (str): The type of things this achievement tracks. e.g. visiting 10 locations might have + a category of "post move", or killing 10 rats might have a category of "defeat". +- tracking (str or list): The *specific* thing this achievement tracks. e.g. the above example of + 10 rats, the tracking field would be "rat". +- tracking_type: The options here are "sum" and "separate". "sum" means that matching any tracked + item will increase the total. "separate" means all tracked items are counted individually. + This is only useful when tracking is a list. The default is "sum". +- count (int): The total tallies the tracked item needs for this to be completed. e.g. for the rats + example, it would be 10. The default is 1 +- prereqs (str or list): An optional achievement key or list of keys that must be completed before + this achievement is available. + +To add achievement tracking, put `track_achievements` in your relevant hooks. + +Example: + + def at_use(self, user, **kwargs): + # track this use for any achievements about using an object named our name + finished_achievements = track_achievements(user, category="use", tracking=self.key) + +Despite the example, it's likely to be more useful to reference a tag than the object's key. +""" + +from collections import Counter +from django.conf import settings +from evennia.utils import logger +from evennia.utils.utils import all_from_module, is_iter, make_iter, string_partial_matching +from evennia.utils.evmore import EvMore +from evennia.commands.default.muxcommand import MuxCommand + +# this is either a string of the attribute name, or a tuple of strings of the attribute name and category +_ACHIEVEMENT_ATTR = make_iter(getattr(settings, "ACHIEVEMENT_ATTRIBUTE", "achievements")) + +_ACHIEVEMENT_INFO = None + + +def _load_achievements(): + """ + Loads the achievement data from settings, if it hasn't already been loaded. + + Returns: + achievements (dict) - the loaded achievement info + """ + global _ACHIEVEMENT_INFO + if _ACHIEVEMENT_INFO is None: + _ACHIEVEMENT_INFO = {} + if modules := getattr(settings, "ACHIEVEMENT_MODULES", None): + for module_path in make_iter(modules): + _ACHIEVEMENT_INFO |= { + key.lower(): val + for key, val in all_from_module(module_path).items() + if isinstance(val, dict) + } + else: + logger.log_warn("No achievement modules have been added to settings.") + return _ACHIEVEMENT_INFO + + +def track_achievements(achiever, category=None, tracking=None, count=1, **kwargs): + """ + Update and check achievement progress. + + Args: + achiever (Account or Character): The entity that's collecting achievement progress. + + Keyword args: + category (str or None): The category of an achievement. + tracking (str or None): The specific item being tracked in the achievement. + + Returns: + completed (tuple): The keys of any achievements that were completed by this update. + """ + if not (all_achievements := _load_achievements()): + # there are no achievements available, there's nothing to do + return tuple() + + # split out the achievement attribute info + attr_key = _ACHIEVEMENT_ATTR[0] + attr_cat = _ACHIEVEMENT_ATTR[1] if len(_ACHIEVEMENT_ATTR) > 1 else None + + # get the achiever's progress data, and detach from the db so we only read/write once + if progress_data := achiever.attributes.get(attr_key, default={}, category=attr_cat): + progress_data = progress_data.deserialize() + + # filter all of the achievements down to the relevant ones + relevant_achievements = ( + (key, val) + for key, val in all_achievements.items() + if (not category or category in make_iter(val["category"])) # filter by category + and (not tracking or tracking in make_iter(val["tracking"])) # filter by tracked item + and not progress_data.get(key, {}).get("completed") # filter by completion status + and all( + progress_data.get(prereq, {}).get("completed") + for prereq in make_iter(val.get("prereqs", [])) + ) # filter by prereqs + ) + + completed = [] + # loop through all the relevant achievements and update the progress data + for achieve_key, achieve_data in relevant_achievements: + if target_count := achieve_data.get("count"): + # check if we need to track things individually or not + separate_totals = achieve_data.get("tracking_type", "sum") == "separate" + if achieve_key not in progress_data: + progress_data[achieve_key] = {} + if separate_totals and is_iter(achieve_data["tracking"]): + # do the special handling for tallying totals separately + i = achieve_data["tracking"].index(tracking) + if "progress" not in progress_data[achieve_key]: + # initialize the item counts + progress_data[achieve_key]["progress"] = [ + 0 for _ in range(len(achieve_data["tracking"])) + ] + # increment the matching index count + progress_data[achieve_key]["progress"][i] += count + # have we reached the target on all items? if so, we've completed it + if min(progress_data[achieve_key]["progress"]) >= target_count: + completed.append(achieve_key) + else: + progress_count = progress_data[achieve_key].get("progress", 0) + # update the achievement data + progress_data[achieve_key]["progress"] = progress_count + count + # have we reached the target? if so, we've completed it + if progress_data[achieve_key]["progress"] >= target_count: + completed.append(achieve_key) + else: + # no count means you just need to do the thing to complete it + completed.append(achieve_key) + + for key in completed: + if key not in progress_data: + progress_data[key] = {} + progress_data[key]["completed"] = True + + # write the updated progress back to the achievement attribute + achiever.attributes.add(attr_key, progress_data, category=attr_cat) + + # return all the achievements we just completed + return tuple(completed) + + +def get_achievement(key): + """ + Get an achievement by its key. + + Args: + key (str): The achievement key. This is the variable name the achievement dict is assigned to. + + Returns: + dict or None: The achievement data, or None if it doesn't exist + """ + if not (all_achievements := _load_achievements()): + # there are no achievements available, there's nothing to do + return None + if data := all_achievements.get(key): + return dict(data) + return None + + +def all_achievements(): + """ + Returns a dict of all achievements in the game. + """ + # we do this to prevent accidental in-memory modification of reference data + return dict((key, dict(val)) for key, val in _load_achievements().items()) + + +def get_progress(achiever, key): + """ + Retrieve the progress data on a particular achievement for a particular achiever. + + Args: + achiever (Account or Character): The entity tracking achievement progress. + key (str): The achievement key + + Returns: + data (dict): The progress data + """ + # split out the achievement attribute info + attr_key = _ACHIEVEMENT_ATTR[0] + attr_cat = _ACHIEVEMENT_ATTR[1] if len(_ACHIEVEMENT_ATTR) > 1 else None + if progress_data := achiever.attributes.get(attr_key, default={}, category=attr_cat): + # detach the data from the db to avoid data corruption and return the data + return progress_data.deserialize().get(key, {}) + else: + # just return an empty dict + return {} + + +def search_achievement(search_term): + """ + Search for an achievement by name. + + Args: + search_term (str): The string to search for. + + Returns: + results (dict): A dict of key:data pairs of matching achievements. + """ + if not (all_achievements := _load_achievements()): + # there are no achievements available, there's nothing to do + return {} + keys, names = zip(*((key, val["name"]) for key, val in all_achievements.items())) + indices = string_partial_matching(names, search_term) + + return dict((keys[i], dict(all_achievements[keys[i]])) for i in indices) + + +class CmdAchieve(MuxCommand): + """ + view achievements + + Usage: + achievements[/switches] [args] + + Switches: + all View all achievements, including locked ones. + completed View achievements you've completed. + progress View achievements you have partially completed + + Check your achievement statuses or browse the list. Providing a command argument + will search all your currently unlocked achievements for matches, and the switches + will filter the list to something other than "all unlocked". Combining a command + argument with a switch will search only in that list. + + Examples: + achievements apples + achievements/all + achievements/progress rats + """ + + key = "achievements" + aliases = ( + "achievement", + "achieve", + ) + switch_options = ("progress", "completed", "done", "all") + + def format_achievement(self, achievement_data): + """ + Formats the raw achievement data for display. + + Args: + achievement_data (dict): The data to format. + + Returns + str: The display string to be sent to the caller. + + """ + template = """\ +|w{name}|n +{desc} +{status} +""".rstrip() + + if achievement_data.get("completed"): + # it's done! + status = "|gCompleted!|n" + elif not achievement_data.get("progress"): + status = "|yNot Started|n" + else: + count = achievement_data.get("count") + # is this achievement tracking items separately? + if is_iter(achievement_data["progress"]): + # we'll display progress as how many items have been completed + completed = Counter(val >= count for val in achievement_data["progress"])[True] + pct = (completed * 100) // count + else: + # we display progress as the percent of the total count + pct = (achievement_data["progress"] * 100) // count + status = f"{pct}% complete" + + return template.format( + name=achievement_data.get("name", ""), + desc=achievement_data.get("desc", ""), + status=status, + ) + + def func(self): + if self.args: + # we're doing a name lookup + if not (achievements := search_achievement(self.args.strip())): + self.msg(f"Could not find any achievements matching '{self.args.strip()}'.") + return + else: + # we're checking against all achievements + if not (achievements := all_achievements()): + self.msg("There are no achievements in this game.") + return + + # split out the achievement attribute info + attr_key = _ACHIEVEMENT_ATTR[0] + attr_cat = _ACHIEVEMENT_ATTR[1] if len(_ACHIEVEMENT_ATTR) > 1 else None + + # get the achiever's progress data, and detach from the db so we only read once + if progress_data := self.caller.attributes.get(attr_key, default={}, category=attr_cat): + progress_data = progress_data.deserialize() + # if the caller is not an account, we get their account progress too + if self.caller != self.account: + if account_progress := self.account.attributes.get( + attr_key, default={}, category=attr_cat + ): + progress_data |= account_progress.deserialize() + + # go through switch options + # we only show achievements that are in progress + if "progress" in self.switches: + # we filter our data to incomplete achievements, and combine the base achievement data into it + achievement_data = { + key: achievements[key] | data + for key, data in progress_data.items() + if not data.get("completed") + } + + # we only show achievements that are completed + elif "completed" in self.switches or "done" in self.switches: + # we filter our data to finished achievements, and combine the base achievement data into it + achievement_data = { + key: achievements[key] | data + for key, data in progress_data.items() + if data.get("completed") + } + + # we show ALL achievements + elif "all" in self.switches: + # we merge our progress data into the full dict of achievements + achievement_data = achievements | progress_data + + # we show all of the currently available achievements regardless of progress status + else: + achievement_data = { + key: data + for key, data in achievements.items() + if all( + progress_data.get(prereq, {}).get("completed") + for prereq in make_iter(data.get("prereqs", [])) + ) + } | progress_data + + if not achievement_data: + self.msg("There are no matching achievements.") + return + + achievement_str = "\n".join( + self.format_achievement(data) for _, data in achievement_data.items() + ) + EvMore(self.caller, achievement_str) diff --git a/evennia/contrib/game_systems/achievements/tests.py b/evennia/contrib/game_systems/achievements/tests.py new file mode 100644 index 0000000000..61d7cc6d78 --- /dev/null +++ b/evennia/contrib/game_systems/achievements/tests.py @@ -0,0 +1,177 @@ +from evennia.utils.test_resources import BaseEvenniaTest, BaseEvenniaCommandTest +from mock import patch +from . import achievements + +_dummy_achievements = { + "ACHIEVE_ONE": { + "name": "First Achievement", + "desc": "A first achievement for first achievers.", + "category": "login", + }, + "COUNTING_ACHIEVE": { + "name": "The Count", + "desc": "One, two, three! Three counters! Ah ah ah!", + "category": "get", + "tracking": "thing", + "count": 3, + }, + "COUNTING_TWO": { + "name": "Son of the Count", + "desc": "Four, five, six! Six counters!", + "category": "get", + "tracking": "thing", + "count": 3, + "prereqs": "COUNTING_ACHIEVE", + }, + "SEPARATE_ITEMS": { + "name": "Apples and Pears", + "desc": "Get some apples and some pears.", + "category": "get", + "tracking": ("apple", "pear"), + "tracking_type": "separate", + "count": 3, + }, +} + + +def _dummy_achieve_loader(): + """returns predefined achievement data for testing instead of loading""" + return _dummy_achievements + + +class TestAchievements(BaseEvenniaTest): + @patch( + "evennia.contrib.game_systems.achievements.achievements._load_achievements", + _dummy_achieve_loader, + ) + def test_completion(self): + """no defined count means a single match completes it""" + self.assertIn( + "ACHIEVE_ONE", + achievements.track_achievements(self.char1, category="login", track="first"), + ) + + @patch( + "evennia.contrib.game_systems.achievements.achievements._load_achievements", + _dummy_achieve_loader, + ) + def test_counter_progress(self): + """progressing a counter should update the achiever""" + # this should not complete any achievements; verify it returns the right empty result + self.assertEqual(achievements.track_achievements(self.char1, "get", "thing"), tuple()) + # first, verify that the data is created + self.assertTrue(self.char1.attributes.has("achievements")) + self.assertEqual(self.char1.db.achievements["COUNTING_ACHIEVE"]["progress"], 1) + # verify that it gets updated + achievements.track_achievements(self.char1, "get", "thing") + self.assertEqual(self.char1.db.achievements["COUNTING_ACHIEVE"]["progress"], 2) + + # also verify that `get_progress` returns the correct data + self.assertEqual(achievements.get_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 2}) + + @patch( + "evennia.contrib.game_systems.achievements.achievements._load_achievements", + _dummy_achieve_loader, + ) + def test_prereqs(self): + """verify progress is not counted on achievements with unmet prerequisites""" + achievements.track_achievements(self.char1, "get", "thing") + # this should mark progress on COUNTING_ACHIEVE, but NOT on COUNTING_TWO + self.assertEqual(achievements.get_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 1}) + self.assertEqual(achievements.get_progress(self.char1, "COUNTING_TWO"), {}) + + # now we complete COUNTING_ACHIEVE... + self.assertIn( + "COUNTING_ACHIEVE", achievements.track_achievements(self.char1, "get", "thing", count=2) + ) + # and track again to progress COUNTING_TWO + achievements.track_achievements(self.char1, "get", "thing") + self.assertEqual(achievements.get_progress(self.char1, "COUNTING_TWO"), {"progress": 1}) + + @patch( + "evennia.contrib.game_systems.achievements.achievements._load_achievements", + _dummy_achieve_loader, + ) + def test_separate_tracking(self): + """achievements with 'tracking_type': 'separate' should count progress for each item""" + # getting one item only increments that one item + achievements.track_achievements(self.char1, "get", "apple") + progress = achievements.get_progress(self.char1, "SEPARATE_ITEMS") + self.assertEqual(progress["progress"], [1, 0]) + # the other item then increments that item + achievements.track_achievements(self.char1, "get", "pear") + progress = achievements.get_progress(self.char1, "SEPARATE_ITEMS") + self.assertEqual(progress["progress"], [1, 1]) + # completing one does not complete the achievement + self.assertEqual( + achievements.track_achievements(self.char1, "get", "apple", count=2), tuple() + ) + # completing the second as well DOES complete the achievement + self.assertIn( + "SEPARATE_ITEMS", achievements.track_achievements(self.char1, "get", "pear", count=2) + ) + + @patch( + "evennia.contrib.game_systems.achievements.achievements._load_achievements", + _dummy_achieve_loader, + ) + def test_search_achievement(self): + """searching for achievements by name""" + results = achievements.search_achievement("count") + self.assertEqual(["COUNTING_ACHIEVE", "COUNTING_TWO"], list(results.keys())) + + +class TestAchieveCommand(BaseEvenniaCommandTest): + @patch( + "evennia.contrib.game_systems.achievements.achievements._load_achievements", + _dummy_achieve_loader, + ) + def test_switches(self): + # print only achievements that have no prereqs + expected_output = "\n".join( + f"{data['name']}\n{data['desc']}\nNot Started" + for key, data in _dummy_achievements.items() + if not data.get("prereqs") + ) + self.call(achievements.CmdAchieve(), "", expected_output) + # print all achievements + expected_output = "\n".join( + f"{data['name']}\n{data['desc']}\nNot Started" + for key, data in _dummy_achievements.items() + ) + self.call(achievements.CmdAchieve(), "/all", expected_output) + # these should both be empty + self.call(achievements.CmdAchieve(), "/progress", "There are no matching achievements.") + self.call(achievements.CmdAchieve(), "/done", "There are no matching achievements.") + # update one and complete one, then verify they show up correctly + achievements.track_achievements(self.char1, "login") + achievements.track_achievements(self.char1, "get", "thing") + self.call( + achievements.CmdAchieve(), + "/progress", + "The Count\nOne, two, three! Three counters! Ah ah ah!\n33% complete", + ) + self.call( + achievements.CmdAchieve(), + "/done", + "First Achievement\nA first achievement for first achievers.\nCompleted!", + ) + + @patch( + "evennia.contrib.game_systems.achievements.achievements._load_achievements", + _dummy_achieve_loader, + ) + def test_search(self): + # by default, only returns matching items that are trackable + self.call( + achievements.CmdAchieve(), + " count", + "The Count\nOne, two, three! Three counters! Ah ah ah!\nNot Started", + ) + # with switches, returns matching items from the switch set + self.call( + achievements.CmdAchieve(), + "/all count", + "The Count\nOne, two, three! Three counters! Ah ah ah!\nNot Started\n" + + "Son of the Count\nFour, five, six! Six counters!\nNot Started", + ) From be9ad0278aab8c33e2f0d90427e0647f854c7a07 Mon Sep 17 00:00:00 2001 From: Cal Date: Thu, 21 Mar 2024 00:13:47 -0600 Subject: [PATCH 2/8] minor fixes --- .../game_systems/achievements/achievements.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/evennia/contrib/game_systems/achievements/achievements.py b/evennia/contrib/game_systems/achievements/achievements.py index 7f0319d0fb..6383be62a4 100644 --- a/evennia/contrib/game_systems/achievements/achievements.py +++ b/evennia/contrib/game_systems/achievements/achievements.py @@ -52,7 +52,7 @@ from evennia.utils.evmore import EvMore from evennia.commands.default.muxcommand import MuxCommand # this is either a string of the attribute name, or a tuple of strings of the attribute name and category -_ACHIEVEMENT_ATTR = make_iter(getattr(settings, "ACHIEVEMENT_ATTRIBUTE", "achievements")) +_ACHIEVEMENT_ATTR = make_iter(getattr(settings, "ACHIEVEMENT_CONTRIB_ATTRIBUTE", "achievements")) _ACHIEVEMENT_INFO = None @@ -67,12 +67,12 @@ def _load_achievements(): global _ACHIEVEMENT_INFO if _ACHIEVEMENT_INFO is None: _ACHIEVEMENT_INFO = {} - if modules := getattr(settings, "ACHIEVEMENT_MODULES", None): + if modules := getattr(settings, "ACHIEVEMENT_CONTRIB_MODULES", None): for module_path in make_iter(modules): _ACHIEVEMENT_INFO |= { - key.lower(): val + key: val for key, val in all_from_module(module_path).items() - if isinstance(val, dict) + if isinstance(val, dict) and not key.startswith('_') } else: logger.log_warn("No achievement modules have been added to settings.") @@ -109,8 +109,8 @@ def track_achievements(achiever, category=None, tracking=None, count=1, **kwargs relevant_achievements = ( (key, val) for key, val in all_achievements.items() - if (not category or category in make_iter(val["category"])) # filter by category - and (not tracking or tracking in make_iter(val["tracking"])) # filter by tracked item + if (not category or category in make_iter(val.get("category",[]))) # filter by category + and (not tracking or not val.get("tracking") or tracking in make_iter(val.get("tracking",[]))) # filter by tracked item and not progress_data.get(key, {}).get("completed") # filter by completion status and all( progress_data.get(prereq, {}).get("completed") @@ -126,7 +126,7 @@ def track_achievements(achiever, category=None, tracking=None, count=1, **kwargs separate_totals = achieve_data.get("tracking_type", "sum") == "separate" if achieve_key not in progress_data: progress_data[achieve_key] = {} - if separate_totals and is_iter(achieve_data["tracking"]): + if separate_totals and is_iter(achieve_data.get("tracking")): # do the special handling for tallying totals separately i = achieve_data["tracking"].index(tracking) if "progress" not in progress_data[achieve_key]: @@ -352,13 +352,13 @@ class CmdAchieve(MuxCommand): # we show all of the currently available achievements regardless of progress status else: achievement_data = { - key: data + key: data | progress_data.get(key, {}) for key, data in achievements.items() if all( progress_data.get(prereq, {}).get("completed") for prereq in make_iter(data.get("prereqs", [])) ) - } | progress_data + } if not achievement_data: self.msg("There are no matching achievements.") From 30151b7e1f0a7d4fc652eb24a6fccfa88ad2a9be Mon Sep 17 00:00:00 2001 From: Cal Date: Fri, 29 Mar 2024 16:46:45 -0600 Subject: [PATCH 3/8] applying changes from feedback --- .../game_systems/achievements/README.md | 133 +++++++++----- .../game_systems/achievements/__init__.py | 8 + .../game_systems/achievements/achievements.py | 162 ++++++++++-------- 3 files changed, 191 insertions(+), 112 deletions(-) diff --git a/evennia/contrib/game_systems/achievements/README.md b/evennia/contrib/game_systems/achievements/README.md index d60d8061ce..06233c207e 100644 --- a/evennia/contrib/game_systems/achievements/README.md +++ b/evennia/contrib/game_systems/achievements/README.md @@ -2,63 +2,26 @@ A simple, but reasonably comprehensive, system for tracking achievements. Achievements are defined using ordinary Python dicts, reminiscent of the core prototypes system, and while it's expected you'll use it only on Characters or Accounts, they can be tracked for any typeclassed object. -## Installation - -Once you've defined your achievement dicts in a module, assign that module to the `ACHIEVEMENT_CONTRIB_MODULES` setting in your settings.py - -Optionally, you can specify what attribute achievement progress is stored in, with the setting `ACHIEVEMENT_CONTRIB_ATTRIBUTE`. By default it's just "achievements". - -There is also a command available to let players check their achievements - `from evennia.contrib.game_systems.achievements.achievements import CmdAchieve` and then add `CmdAchieve` to your default Character and/or Account cmdsets. - -#### Settings examples - -One module: - -```python -# in server/conf/settings.py - -ACHIEVEMENT_CONTRIB_MODULES = "world.achievements" -``` - -Multiple modules, with a custom-defined attribute: - -```python -# in server/conf/settings.py - -ACHIEVEMENT_CONTRIB_MODULES = ["world.crafting.achievements", "world.mobs.achievements"] -ACHIEVEMENT_CONTRIB_ATTRIBUTE = "achieve_progress" -``` - -A custom-defined attribute including category: - -```python -# in server/conf/settings.py - -ACHIEVEMENT_CONTRIB_MODULES = "world.achievements" -ACHIEVEMENT_CONTRIB_ATTRIBUTE = ("achievements", "systems") -``` - -## Usage - -### Defining your achievements +## Creating achievements This achievement system is designed to use ordinary dicts for the achievement data - however, there are certain keys which, if present in the dict, define how the achievement is progressed or completed. +- **key** (str): *Default value if unset: the variable name.* The unique, case-insensitive key identifying this achievement. - **name** (str): The searchable name for the achievement. Doesn't need to be unique. - **desc** (str): A longer description of the achievement. Common uses for this would be flavor text or hints on how to complete it. -- **category** (str): The type of actions this achievement tracks. e.g. purchasing 10 apples might have a category of "buy", or killing 10 rats might have a category of "defeat". -- **tracking** (str or list): The *specific* things this achievement tracks. e.g. the previous example of killing rats might have a `"tracking"` value of `"rat"`. An achievement can also track multiple things: instead of only tracking buying apples, you might want to track apples and pears. For that situation, you can assign it to a list of values to check against: e.g. `["apple", "pear"]` +- **category** (str): The category of conditions which this achievement tracks. It will most likely be an action and you will most likely specify it based on where you're checking from. e.g. killing 10 rats might have a category of "defeat", which you'd then check from your code that runs when a player defeats something. +- **tracking** (str or list): The *specific* condition this achievement tracks. e.g. the previous example of killing rats might have a `"tracking"` value of `"rat"`. This value will most likely be taken from a specific object in your code, like a tag on the defeated object, or the ID of a visited location. An achievement can also track multiple things: instead of only tracking buying apples, you might want to track apples and pears. For that situation, you can assign it to a list of values to check against: e.g. `["apple", "pear"]` - **tracking_type** (str): *Default value if unset: `"sum"`* There are two valid tracking types: "sum" (which is the default) and "separate". `"sum"` will increment a single counter every time any of the tracked items match. `"separate"` will have a counter for each individual item in the tracked items. (This is really only useful when `"tracking"` is a list.) - **count** (int): *Default value if unset: 1* The number of tallies this achievement's requirements need to build up in order to complete the achievement. e.g. the previous example of killing rats would have a `"count"` value of `10`. For achievements using the "separate" tracking type, *each* item being tracked must tally up to this number to be completed - **prereqs** (str or list): The *keys* of any achievements which must be completed before this achievement can start tracking progress. An achievement's key is the variable name it's assigned to in your achievement module. You can add any additional keys to your achievement dicts that you want, and they'll be included with all the rest of the achievement data when using the contrib's functions. This could be useful if you want to add extra metadata to your achievements for your own features. -#### Examples +### Example achievements -A simple achievement which you can get just for logging in the first time. This achievement has no prerequisites and it only needs to be fulfilled once to complete, so it doesn't need to define most of the keys. +A simple achievement which you can get just for logging in the first time. This achievement has no prerequisites and it only needs to be fulfilled once to complete, so it doesn't need to define most of the fields. ```python -# This achievement has the unique key of "FIRST_LOGIN_ACHIEVE" +# This achievement has the unique key of "first_login_achieve" FIRST_LOGIN_ACHIEVE = { "name": "Welcome!", # the searchable, player-friendly display name "desc": "We're glad to have you here.", # the longer description @@ -69,7 +32,9 @@ FIRST_LOGIN_ACHIEVE = { An achievement for killing 10 rats, and another for killing 10 *dire* rats which requires the "kill 10 rats" achievement to be completed first. ```python +# This achievement has the unique key of "ten_rats" instead of "achieve_ten_rats" ACHIEVE_TEN_RATS = { + "key": "ten_rats", "name": "The Usual", "desc": "Why do all these inns have rat problems?", "category": "defeat", @@ -98,6 +63,31 @@ FRUIT_BASKET_ACHIEVEMENT = { } ``` + +## Installation + +Once you've defined your achievement dicts in one or more module, assign that module to the `ACHIEVEMENT_CONTRIB_MODULES` setting in your settings.py + +> Note: If any achievements have the same unique key, whichever conflicting achievement is processed *last* will be the only one loaded into the game. Case is ignored, so "ten_rats", "Ten_Rats" and "TEN_RATS" will conflict. "ten_rats" and "ten rats" will not. + +```python +# in server/conf/settings.py + +ACHIEVEMENT_CONTRIB_MODULES = ["world.achievements"] +``` + +There is also a command available to let players check their achievements - `from evennia.contrib.game_systems.achievements.achievements import CmdAchieve` and then add `CmdAchieve` to your default Character and/or Account cmdsets. + +**Optional** - The achievements contrib stores individual progress data on the `achievements` attribute by default, visible via `obj.db.attributes`. You can change this in your settings if necessary, e.g.: + +```py +# in settings.py + +ACHIEVEMENT_CONTRIB_ATTRIBUTE = ("achievement_data", "systems") +``` + +## Usage + ### `track_achievements` The primary mechanism for using the achievements system is the `track_achievements` function. In any actions or functions in your game's mechanics which you might want to track in an achievement, add a call to `track_achievements` to update the achievement progress for that individual. @@ -105,15 +95,68 @@ The primary mechanism for using the achievements system is the `track_achievemen For example, you might have a collection achievement for buying 10 apples, and a general `buy` command players could use. In your `buy` command, after the purchase is completed, you could add the following line: ```python - track_achievements(self.caller, "buy", obj.name, count=quantity) + from contrib.game_systems.achievements import track_achievements + track_achievements(self.caller, category="buy", tracking=obj.name, count=quantity) ``` In this case, `obj` is the fruit that was just purchased, and `quantity` is the amount they bought. The `track_achievements` function does also return a value: an iterable of keys for any achievements which were newly completed by that update. You can ignore this value, or you can use it to e.g. send a message to the player with their latest achievements. +### `search_achievement` + +A utility function for searching achievements by name or description. It handles partial matching and returns a dictionary of matching achievements. The provided `achievement` command for in-game uses this function to find matching achievements from user inputs. + +#### Example: +```py +>>> from evennia.contrib.game_systems.achievements import search_achievement +>>> search_achievement("fruit") +{'fruit_basket_achievement': {'name': 'A Fan of Fruit', 'category': 'buy', 'tracking': ('apple', 'orange', 'pear'), 'count': 5, 'tracking_type': 'separate'}} +>>> search_achievement("rat") +{'ten_rats': {'key': 'ten_rats', 'name': 'The Usual', 'desc': 'Why do all these inns have rat problems?', 'category': 'defeat', 'tracking': 'rat', 'count': 10}, {'achieve_dire_rats': {'name': 'Once More, But Bigger', 'desc': 'Somehow, normal rats just aren't enough any more.', 'category': 'defeat', 'tracking': 'dire rat', 'count': 10, 'prereqs': "ACHIEVE_TEN_RATS"}} +``` + +### `get_achievement` + +A utility function for retrieving a specific achievement's data from the achievement's unique key. It cannot be used for searching, but if you already have an achievement's key - for example, from the results of `track_achievements` - you can retrieve the rest of its data this way. + +#### Example: + +```py +from evennia.contrib.game_systems.achievements import get_achievement + +def toast(achiever, completed_list): + if completed_list: + # we've completed some achievements! + completed_data = [get_achievement(key) for key in args] + names = [data.get('name') for data in completed] + achiever.msg(f"|wAchievement Get!|n {iter_to_str(name for name in names if name)}")) +``` + ### The `achievements` command The contrib's provided command, `CmdAchieve`, aims to be usable as-is, with multiple switches to filter achievements by various progress statuses and the ability to search by achievement names. To make it easier to integrate into your own particular game (e.g. accommodating some of that extra achievement data you might have added), the code for formatting a particular achievement's data for display is in `CmdAchieve.format_achievement`, making it easy to overload for your custom display styling without reimplementing the whole command. + +#### Example output + +``` +> achievements +The Usual +Why do all these inns have rat problems? +70% complete +A Fan of Fruit + +Not Started +``` +``` +> achievements/progress +The Usual +Why do all these inns have rat problems? +70% complete +``` +``` +> achievements/done +There are no matching achievements. +``` diff --git a/evennia/contrib/game_systems/achievements/__init__.py b/evennia/contrib/game_systems/achievements/__init__.py index e69de29bb2..15d57e7025 100644 --- a/evennia/contrib/game_systems/achievements/__init__.py +++ b/evennia/contrib/game_systems/achievements/__init__.py @@ -0,0 +1,8 @@ +from .achievements import ( + get_achievement, + search_achievement, + all_achievements, + track_achievements, + get_achievement_progress, + CmdAchieve, +) diff --git a/evennia/contrib/game_systems/achievements/achievements.py b/evennia/contrib/game_systems/achievements/achievements.py index 6383be62a4..f6ec9b0a8e 100644 --- a/evennia/contrib/game_systems/achievements/achievements.py +++ b/evennia/contrib/game_systems/achievements/achievements.py @@ -38,7 +38,7 @@ To add achievement tracking, put `track_achievements` in your relevant hooks. Example: def at_use(self, user, **kwargs): - # track this use for any achievements about using an object named our name + # track this use for any achievements that are categorized as "use" and are tracking something that matches our key finished_achievements = track_achievements(user, category="use", tracking=self.key) Despite the example, it's likely to be more useful to reference a tag than the object's key. @@ -53,30 +53,61 @@ from evennia.commands.default.muxcommand import MuxCommand # this is either a string of the attribute name, or a tuple of strings of the attribute name and category _ACHIEVEMENT_ATTR = make_iter(getattr(settings, "ACHIEVEMENT_CONTRIB_ATTRIBUTE", "achievements")) +_ATTR_KEY = _ACHIEVEMENT_ATTR[0] +_ATTR_CAT = _ACHIEVEMENT_ATTR[1] if len(_ACHIEVEMENT_ATTR) > 1 else None -_ACHIEVEMENT_INFO = None +# load the achievements data +_ACHIEVEMENT_DATA = {} +if modules := getattr(settings, "ACHIEVEMENT_CONTRIB_MODULES", None): + for module_path in make_iter(modules): + module_achieves = { + val.key("key", key).lower(): val + for key, val in all_from_module(module_path).items() + if isinstance(val, dict) and not key.startswith("_") + } + if any(key in _ACHIEVEMENT_DATA for key in module_achieves.keys()): + logger.log_warn( + "There are conflicting achievement keys! Only the last achievement registered to the key will be recognized." + ) + _ACHIEVEMENT_DATA |= module_achieves +else: + logger.log_warn("No achievement modules have been added to settings.") -def _load_achievements(): +def _read_player_data(achiever): """ - Loads the achievement data from settings, if it hasn't already been loaded. + helper function to get a player's achievement data from the database. + + Args: + achiever (Object or Account): The achieving entity Returns: - achievements (dict) - the loaded achievement info + dict: The deserialized achievement data. """ - global _ACHIEVEMENT_INFO - if _ACHIEVEMENT_INFO is None: - _ACHIEVEMENT_INFO = {} - if modules := getattr(settings, "ACHIEVEMENT_CONTRIB_MODULES", None): - for module_path in make_iter(modules): - _ACHIEVEMENT_INFO |= { - key: val - for key, val in all_from_module(module_path).items() - if isinstance(val, dict) and not key.startswith('_') - } - else: - logger.log_warn("No achievement modules have been added to settings.") - return _ACHIEVEMENT_INFO + if data := achiever.attributes.get(_ATTR_KEY, default={}, category=_ATTR_CAT): + # detach the data from the db + data.deserialize() + # return the data + return data + + +def _write_player_data(achiever, data): + """ + helper function to write a player's achievement data to the database. + + Args: + achiever (Object or Account): The achieving entity + data (dict): The full achievement data for this entity. + + Returns: + None + + Notes: + This function will overwrite any existing achievement data for the entity. + """ + if not isinstance(data, dict): + raise ValueError("Achievement data must be a dict.") + achiever.attributes.add(_ATTR_KEY, data, category=_ATTR_CAT) def track_achievements(achiever, category=None, tracking=None, count=1, **kwargs): @@ -91,26 +122,25 @@ def track_achievements(achiever, category=None, tracking=None, count=1, **kwargs tracking (str or None): The specific item being tracked in the achievement. Returns: - completed (tuple): The keys of any achievements that were completed by this update. + tuple: The keys of any achievements that were completed by this update. """ - if not (all_achievements := _load_achievements()): + if not _ACHIEVEMENT_DATA: # there are no achievements available, there's nothing to do return tuple() - # split out the achievement attribute info - attr_key = _ACHIEVEMENT_ATTR[0] - attr_cat = _ACHIEVEMENT_ATTR[1] if len(_ACHIEVEMENT_ATTR) > 1 else None - - # get the achiever's progress data, and detach from the db so we only read/write once - if progress_data := achiever.attributes.get(attr_key, default={}, category=attr_cat): - progress_data = progress_data.deserialize() + # get the achiever's progress data + progress_data = _read_player_data(achiever) # filter all of the achievements down to the relevant ones relevant_achievements = ( (key, val) - for key, val in all_achievements.items() - if (not category or category in make_iter(val.get("category",[]))) # filter by category - and (not tracking or not val.get("tracking") or tracking in make_iter(val.get("tracking",[]))) # filter by tracked item + for key, val in _ACHIEVEMENT_DATA.items() + if (not category or category in make_iter(val.get("category", []))) # filter by category + and ( + not tracking + or not val.get("tracking") + or tracking in make_iter(val.get("tracking", [])) + ) # filter by tracked item and not progress_data.get(key, {}).get("completed") # filter by completion status and all( progress_data.get(prereq, {}).get("completed") @@ -121,7 +151,7 @@ def track_achievements(achiever, category=None, tracking=None, count=1, **kwargs completed = [] # loop through all the relevant achievements and update the progress data for achieve_key, achieve_data in relevant_achievements: - if target_count := achieve_data.get("count"): + if target_count := achieve_data.get("count", 1): # check if we need to track things individually or not separate_totals = achieve_data.get("tracking_type", "sum") == "separate" if achieve_key not in progress_data: @@ -156,7 +186,7 @@ def track_achievements(achiever, category=None, tracking=None, count=1, **kwargs progress_data[key]["completed"] = True # write the updated progress back to the achievement attribute - achiever.attributes.add(attr_key, progress_data, category=attr_cat) + _write_player_data(achiever, progress_data) # return all the achievements we just completed return tuple(completed) @@ -172,10 +202,10 @@ def get_achievement(key): Returns: dict or None: The achievement data, or None if it doesn't exist """ - if not (all_achievements := _load_achievements()): + if not _ACHIEVEMENT_DATA: # there are no achievements available, there's nothing to do return None - if data := all_achievements.get(key): + if data := _ACHIEVEMENT_DATA.get(key.lower()): return dict(data) return None @@ -183,12 +213,15 @@ def get_achievement(key): def all_achievements(): """ Returns a dict of all achievements in the game. + + Returns: + dict """ - # we do this to prevent accidental in-memory modification of reference data - return dict((key, dict(val)) for key, val in _load_achievements().items()) + # we do this to mitigate accidental in-memory modification of reference data + return dict((key, dict(val)) for key, val in _ACHIEVEMENT_DATA.items()) -def get_progress(achiever, key): +def get_achievement_progress(achiever, key): """ Retrieve the progress data on a particular achievement for a particular achiever. @@ -197,14 +230,11 @@ def get_progress(achiever, key): key (str): The achievement key Returns: - data (dict): The progress data + dict: The progress data """ - # split out the achievement attribute info - attr_key = _ACHIEVEMENT_ATTR[0] - attr_cat = _ACHIEVEMENT_ATTR[1] if len(_ACHIEVEMENT_ATTR) > 1 else None - if progress_data := achiever.attributes.get(attr_key, default={}, category=attr_cat): - # detach the data from the db to avoid data corruption and return the data - return progress_data.deserialize().get(key, {}) + if progress_data := _read_player_data(achiever): + # get the specific key's data + return progress_data.get(key, {}) else: # just return an empty dict return {} @@ -212,21 +242,26 @@ def get_progress(achiever, key): def search_achievement(search_term): """ - Search for an achievement by name. + Search for an achievement containing the search term. If no matches are found in the achievement names, it searches + in the achievement descriptions. Args: search_term (str): The string to search for. Returns: - results (dict): A dict of key:data pairs of matching achievements. + dict: A dict of key:data pairs of matching achievements. """ - if not (all_achievements := _load_achievements()): + if not _ACHIEVEMENT_DATA: # there are no achievements available, there's nothing to do return {} - keys, names = zip(*((key, val["name"]) for key, val in all_achievements.items())) + keys, names, descs = zip( + *((key, val["name"], val["desc"]) for key, val in _ACHIEVEMENT_DATA.items()) + ) indices = string_partial_matching(names, search_term) + if not indices: + indices = string_partial_matching(descs, search_term) - return dict((keys[i], dict(all_achievements[keys[i]])) for i in indices) + return dict((keys[i], dict(_ACHIEVEMENT_DATA[keys[i]])) for i in indices) class CmdAchieve(MuxCommand): @@ -256,9 +291,16 @@ class CmdAchieve(MuxCommand): aliases = ( "achievement", "achieve", + "achieves", ) switch_options = ("progress", "completed", "done", "all") + template = """\ +|w{name}|n +{desc} +{status} +""".rstrip() + def format_achievement(self, achievement_data): """ Formats the raw achievement data for display. @@ -270,11 +312,6 @@ class CmdAchieve(MuxCommand): str: The display string to be sent to the caller. """ - template = """\ -|w{name}|n -{desc} -{status} -""".rstrip() if achievement_data.get("completed"): # it's done! @@ -293,7 +330,7 @@ class CmdAchieve(MuxCommand): pct = (achievement_data["progress"] * 100) // count status = f"{pct}% complete" - return template.format( + return self.template.format( name=achievement_data.get("name", ""), desc=achievement_data.get("desc", ""), status=status, @@ -311,19 +348,10 @@ class CmdAchieve(MuxCommand): self.msg("There are no achievements in this game.") return - # split out the achievement attribute info - attr_key = _ACHIEVEMENT_ATTR[0] - attr_cat = _ACHIEVEMENT_ATTR[1] if len(_ACHIEVEMENT_ATTR) > 1 else None - - # get the achiever's progress data, and detach from the db so we only read once - if progress_data := self.caller.attributes.get(attr_key, default={}, category=attr_cat): - progress_data = progress_data.deserialize() - # if the caller is not an account, we get their account progress too + # get the achiever's progress data + progress_data = _read_player_data(self.caller) if self.caller != self.account: - if account_progress := self.account.attributes.get( - attr_key, default={}, category=attr_cat - ): - progress_data |= account_progress.deserialize() + progress_data |= _read_player_data(self.account) # go through switch options # we only show achievements that are in progress From 758ffb80c8e29f89f375fd48484eb53b788c238a Mon Sep 17 00:00:00 2001 From: Cal Date: Fri, 29 Mar 2024 17:02:54 -0600 Subject: [PATCH 4/8] update achieve tests --- .../game_systems/achievements/tests.py | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/evennia/contrib/game_systems/achievements/tests.py b/evennia/contrib/game_systems/achievements/tests.py index 61d7cc6d78..bcff5c3464 100644 --- a/evennia/contrib/game_systems/achievements/tests.py +++ b/evennia/contrib/game_systems/achievements/tests.py @@ -34,15 +34,10 @@ _dummy_achievements = { } -def _dummy_achieve_loader(): - """returns predefined achievement data for testing instead of loading""" - return _dummy_achievements - - class TestAchievements(BaseEvenniaTest): @patch( - "evennia.contrib.game_systems.achievements.achievements._load_achievements", - _dummy_achieve_loader, + "evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA", + _dummy_achievements, ) def test_completion(self): """no defined count means a single match completes it""" @@ -52,8 +47,8 @@ class TestAchievements(BaseEvenniaTest): ) @patch( - "evennia.contrib.game_systems.achievements.achievements._load_achievements", - _dummy_achieve_loader, + "evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA", + _dummy_achievements, ) def test_counter_progress(self): """progressing a counter should update the achiever""" @@ -66,19 +61,19 @@ class TestAchievements(BaseEvenniaTest): achievements.track_achievements(self.char1, "get", "thing") self.assertEqual(self.char1.db.achievements["COUNTING_ACHIEVE"]["progress"], 2) - # also verify that `get_progress` returns the correct data - self.assertEqual(achievements.get_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 2}) + # also verify that `get_achievement_progress` returns the correct data + self.assertEqual(achievements.get_achievement_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 2}) @patch( - "evennia.contrib.game_systems.achievements.achievements._load_achievements", - _dummy_achieve_loader, + "evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA", + _dummy_achievements, ) def test_prereqs(self): """verify progress is not counted on achievements with unmet prerequisites""" achievements.track_achievements(self.char1, "get", "thing") # this should mark progress on COUNTING_ACHIEVE, but NOT on COUNTING_TWO - self.assertEqual(achievements.get_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 1}) - self.assertEqual(achievements.get_progress(self.char1, "COUNTING_TWO"), {}) + self.assertEqual(achievements.get_achievement_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 1}) + self.assertEqual(achievements.get_achievement_progress(self.char1, "COUNTING_TWO"), {}) # now we complete COUNTING_ACHIEVE... self.assertIn( @@ -86,21 +81,21 @@ class TestAchievements(BaseEvenniaTest): ) # and track again to progress COUNTING_TWO achievements.track_achievements(self.char1, "get", "thing") - self.assertEqual(achievements.get_progress(self.char1, "COUNTING_TWO"), {"progress": 1}) + self.assertEqual(achievements.get_achievement_progress(self.char1, "COUNTING_TWO"), {"progress": 1}) @patch( - "evennia.contrib.game_systems.achievements.achievements._load_achievements", - _dummy_achieve_loader, + "evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA", + _dummy_achievements, ) def test_separate_tracking(self): """achievements with 'tracking_type': 'separate' should count progress for each item""" # getting one item only increments that one item achievements.track_achievements(self.char1, "get", "apple") - progress = achievements.get_progress(self.char1, "SEPARATE_ITEMS") + progress = achievements.get_achievement_progress(self.char1, "SEPARATE_ITEMS") self.assertEqual(progress["progress"], [1, 0]) # the other item then increments that item achievements.track_achievements(self.char1, "get", "pear") - progress = achievements.get_progress(self.char1, "SEPARATE_ITEMS") + progress = achievements.get_achievement_progress(self.char1, "SEPARATE_ITEMS") self.assertEqual(progress["progress"], [1, 1]) # completing one does not complete the achievement self.assertEqual( @@ -112,8 +107,8 @@ class TestAchievements(BaseEvenniaTest): ) @patch( - "evennia.contrib.game_systems.achievements.achievements._load_achievements", - _dummy_achieve_loader, + "evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA", + _dummy_achievements, ) def test_search_achievement(self): """searching for achievements by name""" @@ -123,8 +118,8 @@ class TestAchievements(BaseEvenniaTest): class TestAchieveCommand(BaseEvenniaCommandTest): @patch( - "evennia.contrib.game_systems.achievements.achievements._load_achievements", - _dummy_achieve_loader, + "evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA", + _dummy_achievements, ) def test_switches(self): # print only achievements that have no prereqs @@ -158,8 +153,8 @@ class TestAchieveCommand(BaseEvenniaCommandTest): ) @patch( - "evennia.contrib.game_systems.achievements.achievements._load_achievements", - _dummy_achieve_loader, + "evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA", + _dummy_achievements, ) def test_search(self): # by default, only returns matching items that are trackable From b480847582301ba66f355a98ef14786031549cde Mon Sep 17 00:00:00 2001 From: Cal Date: Fri, 29 Mar 2024 17:53:27 -0600 Subject: [PATCH 5/8] fix achievement docs and code --- .../game_systems/achievements/README.md | 20 ++++++++--- .../game_systems/achievements/achievements.py | 35 +++++++++++-------- .../game_systems/achievements/tests.py | 12 +++++-- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/evennia/contrib/game_systems/achievements/README.md b/evennia/contrib/game_systems/achievements/README.md index 06233c207e..a406dcd6f0 100644 --- a/evennia/contrib/game_systems/achievements/README.md +++ b/evennia/contrib/game_systems/achievements/README.md @@ -2,6 +2,8 @@ A simple, but reasonably comprehensive, system for tracking achievements. Achievements are defined using ordinary Python dicts, reminiscent of the core prototypes system, and while it's expected you'll use it only on Characters or Accounts, they can be tracked for any typeclassed object. +The contrib provides several functions for tracking and accessing achievements, as well as a basic in-game command for viewing achievement status. + ## Creating achievements This achievement system is designed to use ordinary dicts for the achievement data - however, there are certain keys which, if present in the dict, define how the achievement is progressed or completed. @@ -92,14 +94,24 @@ ACHIEVEMENT_CONTRIB_ATTRIBUTE = ("achievement_data", "systems") The primary mechanism for using the achievements system is the `track_achievements` function. In any actions or functions in your game's mechanics which you might want to track in an achievement, add a call to `track_achievements` to update the achievement progress for that individual. -For example, you might have a collection achievement for buying 10 apples, and a general `buy` command players could use. In your `buy` command, after the purchase is completed, you could add the following line: +Using the "kill 10 rats" example achievement from earlier, you might have some code that triggers when a character is defeated: for the sake of example, we'll pretend we have an `at_defeated` method on the base Object class that gets called when the Object is defeated. + +Adding achievement tracking to it could then look something like this: ```python - from contrib.game_systems.achievements import track_achievements - track_achievements(self.caller, category="buy", tracking=obj.name, count=quantity) +from contrib.game_systems.achievements import track_achievements + +class Object(ObjectParent, DefaultObject): + # .... + + def at_defeated(self, victor): + """called when this object is defeated in combat""" + # we'll use the "mob_type" tag category as the tracked information for achievements + mob_type = self.tags.get(category="mob_type") + track_achievements(victor, category="defeated", tracking=mob_type, count=1) ``` -In this case, `obj` is the fruit that was just purchased, and `quantity` is the amount they bought. +If a player defeats something tagged `rat` with a tag category of `mob_type`, it'd now count towards the rat-killing achievement. The `track_achievements` function does also return a value: an iterable of keys for any achievements which were newly completed by that update. You can ignore this value, or you can use it to e.g. send a message to the player with their latest achievements. diff --git a/evennia/contrib/game_systems/achievements/achievements.py b/evennia/contrib/game_systems/achievements/achievements.py index f6ec9b0a8e..8de7868589 100644 --- a/evennia/contrib/game_systems/achievements/achievements.py +++ b/evennia/contrib/game_systems/achievements/achievements.py @@ -7,23 +7,27 @@ Achievements are defined as dicts, loosely similar to the prototypes system. An example of an achievement dict: - EXAMPLE_ACHIEVEMENT = { - "name": "Some Achievement", - "desc": "This is not a real achievement.", - "category": "crafting", - "tracking": "box", - "count": 5, - "prereqs": "ANOTHER_ACHIEVEMENT", + ACHIEVE_DIRE_RATS = { + "name": "Once More, But Bigger", + "desc": "Somehow, normal rats just aren't enough any more.", + "category": "defeat", + "tracking": "dire rat", + "count": 10, + "prereqs": "ACHIEVE_TEN_RATS", } The recognized fields for an achievement are: +- key (str): The unique, case-insensitive key identifying this achievement. The variable name is + used by default. - name (str): The name of the achievement. This is not the key and does not need to be unique. - desc (str): The longer description of the achievement. Common uses for this would be flavor text or hints on how to complete it. -- category (str): The type of things this achievement tracks. e.g. visiting 10 locations might have - a category of "post move", or killing 10 rats might have a category of "defeat". -- tracking (str or list): The *specific* thing this achievement tracks. e.g. the above example of +- category (str): The category of conditions which this achievement tracks. It will most likely be + an action and you will most likely specify it based on where you're checking from. + e.g. killing 10 rats might have a category of "defeat", which you'd then check from your code + that runs when a player defeats something. +- tracking (str or list): The specific condition this achievement tracks. e.g. for the above example of 10 rats, the tracking field would be "rat". - tracking_type: The options here are "sum" and "separate". "sum" means that matching any tracked item will increase the total. "separate" means all tracked items are counted individually. @@ -37,11 +41,12 @@ To add achievement tracking, put `track_achievements` in your relevant hooks. Example: - def at_use(self, user, **kwargs): - # track this use for any achievements that are categorized as "use" and are tracking something that matches our key - finished_achievements = track_achievements(user, category="use", tracking=self.key) + def at_defeated(self, victor): + # called when this object is defeated in combat + # we'll use the "mob_type" tag category as the tracked information for achievements + mob_type = self.tags.get(category="mob_type") + track_achievements(victor, category="defeated", tracking=mob_type, count=1) -Despite the example, it's likely to be more useful to reference a tag than the object's key. """ from collections import Counter @@ -86,7 +91,7 @@ def _read_player_data(achiever): """ if data := achiever.attributes.get(_ATTR_KEY, default={}, category=_ATTR_CAT): # detach the data from the db - data.deserialize() + data = data.deserialize() # return the data return data diff --git a/evennia/contrib/game_systems/achievements/tests.py b/evennia/contrib/game_systems/achievements/tests.py index bcff5c3464..b552972af3 100644 --- a/evennia/contrib/game_systems/achievements/tests.py +++ b/evennia/contrib/game_systems/achievements/tests.py @@ -62,7 +62,9 @@ class TestAchievements(BaseEvenniaTest): self.assertEqual(self.char1.db.achievements["COUNTING_ACHIEVE"]["progress"], 2) # also verify that `get_achievement_progress` returns the correct data - self.assertEqual(achievements.get_achievement_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 2}) + self.assertEqual( + achievements.get_achievement_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 2} + ) @patch( "evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA", @@ -72,7 +74,9 @@ class TestAchievements(BaseEvenniaTest): """verify progress is not counted on achievements with unmet prerequisites""" achievements.track_achievements(self.char1, "get", "thing") # this should mark progress on COUNTING_ACHIEVE, but NOT on COUNTING_TWO - self.assertEqual(achievements.get_achievement_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 1}) + self.assertEqual( + achievements.get_achievement_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 1} + ) self.assertEqual(achievements.get_achievement_progress(self.char1, "COUNTING_TWO"), {}) # now we complete COUNTING_ACHIEVE... @@ -81,7 +85,9 @@ class TestAchievements(BaseEvenniaTest): ) # and track again to progress COUNTING_TWO achievements.track_achievements(self.char1, "get", "thing") - self.assertEqual(achievements.get_achievement_progress(self.char1, "COUNTING_TWO"), {"progress": 1}) + self.assertEqual( + achievements.get_achievement_progress(self.char1, "COUNTING_TWO"), {"progress": 1} + ) @patch( "evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA", From d8a0ebc226b3fe3c035fdb37bbe48443d3019386 Mon Sep 17 00:00:00 2001 From: Cal Date: Fri, 29 Mar 2024 23:04:15 -0600 Subject: [PATCH 6/8] typo fixes --- .../game_systems/achievements/achievements.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/evennia/contrib/game_systems/achievements/achievements.py b/evennia/contrib/game_systems/achievements/achievements.py index 8de7868589..f0cd189e12 100644 --- a/evennia/contrib/game_systems/achievements/achievements.py +++ b/evennia/contrib/game_systems/achievements/achievements.py @@ -66,7 +66,7 @@ _ACHIEVEMENT_DATA = {} if modules := getattr(settings, "ACHIEVEMENT_CONTRIB_MODULES", None): for module_path in make_iter(modules): module_achieves = { - val.key("key", key).lower(): val + val.get("key", key).lower(): val for key, val in all_from_module(module_path).items() if isinstance(val, dict) and not key.startswith("_") } @@ -93,7 +93,7 @@ def _read_player_data(achiever): # detach the data from the db data = data.deserialize() # return the data - return data + return data or {} def _write_player_data(achiever, data): @@ -110,8 +110,6 @@ def _write_player_data(achiever, data): Notes: This function will overwrite any existing achievement data for the entity. """ - if not isinstance(data, dict): - raise ValueError("Achievement data must be a dict.") achiever.attributes.add(_ATTR_KEY, data, category=_ATTR_CAT) @@ -324,12 +322,12 @@ class CmdAchieve(MuxCommand): elif not achievement_data.get("progress"): status = "|yNot Started|n" else: - count = achievement_data.get("count") + count = achievement_data.get("count",1) # is this achievement tracking items separately? if is_iter(achievement_data["progress"]): # we'll display progress as how many items have been completed completed = Counter(val >= count for val in achievement_data["progress"])[True] - pct = (completed * 100) // count + pct = (completed * 100) // len(achievement_data['progress']) else: # we display progress as the percent of the total count pct = (achievement_data["progress"] * 100) // count @@ -380,7 +378,10 @@ class CmdAchieve(MuxCommand): # we show ALL achievements elif "all" in self.switches: # we merge our progress data into the full dict of achievements - achievement_data = achievements | progress_data + achievement_data = { + key: data | progress_data.get(key, {}) + for key, data in achievements.items() + } # we show all of the currently available achievements regardless of progress status else: From fa17412687cef71e5b37bff8ef2700bb296dd97d Mon Sep 17 00:00:00 2001 From: Cal Date: Sat, 27 Apr 2024 13:46:10 -0600 Subject: [PATCH 7/8] revise achievements docs --- .../game_systems/achievements/README.md | 175 ++++++++++++------ 1 file changed, 123 insertions(+), 52 deletions(-) diff --git a/evennia/contrib/game_systems/achievements/README.md b/evennia/contrib/game_systems/achievements/README.md index a406dcd6f0..55bae77aef 100644 --- a/evennia/contrib/game_systems/achievements/README.md +++ b/evennia/contrib/game_systems/achievements/README.md @@ -4,24 +4,70 @@ A simple, but reasonably comprehensive, system for tracking achievements. Achiev The contrib provides several functions for tracking and accessing achievements, as well as a basic in-game command for viewing achievement status. +## Installation + +This contrib requires creation one or more module files containing your achievement data, which you then add to your settings file to make them available. + +> See the section below on "Creating Achievements" for what to put in this module. + +```python +# in server/conf/settings.py + +ACHIEVEMENT_CONTRIB_MODULES = ["world.achievements"] +``` + +To allow players to check their achievements, you'll also want to add the `achievements` command to your default Character and/or Account command sets. + +```python +# in commands/default_cmdsets.py + +from evennia.contrib.game_systems.achievements.achievements import CmdAchieve + +class CharacterCmdSet(default_cmds.CharacterCmdSet): + key = "DefaultCharacter" + + def at_cmdset_creation(self): + # ... + self.add(CmdAchieve) +``` + +**Optional** - The achievements contrib stores individual progress data on the `achievements` attribute by default, visible via `obj.db.attributes`. You can change this by assigning an attribute (key, category) tuple to the setting `ACHIEVEMENT_CONTRIB_ATTRIBUTE` + +Example: +```py +# in settings.py + +ACHIEVEMENT_CONTRIB_ATTRIBUTE = ("progress_data", "achievements") +``` + + ## Creating achievements -This achievement system is designed to use ordinary dicts for the achievement data - however, there are certain keys which, if present in the dict, define how the achievement is progressed or completed. +An achievement is represented by a simple python dictionary defined at the module level in your achievements module(s). + +Each achievement requires certain specific keys to be defined to work properly, along with several optional keys that you can use to override defaults. + +> Note: Any additional keys not described here are included in the achievement data when you access those acheivements through the contrib, so you can easily add your own extended features. + +#### Required keys + +- **name** (str): The searchable name for the achievement. Doesn't need to be unique. +- **category** (str): The category, or general type, of condition which can progress this achievement. Usually this will be a player action or result. e.g. you would use a category of "defeat" on an achievement for killing 10 rats. +- **tracking** (str or list): The specific subset of condition which can progress this achievement. e.g. you would use a tracking value of "rat" on an achievement for killing 10 rats. An achievement can also track multiple things, for example killing 10 rats or snakes. For that situation, assign a list of all the values to check against, e.g. `["rat", "snake"]` + +#### Optional keys - **key** (str): *Default value if unset: the variable name.* The unique, case-insensitive key identifying this achievement. -- **name** (str): The searchable name for the achievement. Doesn't need to be unique. +> Note: If any achievements have the same unique key, only *one* will be loaded. It is case-insensitive, but punctuation is respected - "ten_rats", "Ten_Rats" and "TEN_RATS" will conflict, but "ten_rats" and "ten rats" will not. - **desc** (str): A longer description of the achievement. Common uses for this would be flavor text or hints on how to complete it. -- **category** (str): The category of conditions which this achievement tracks. It will most likely be an action and you will most likely specify it based on where you're checking from. e.g. killing 10 rats might have a category of "defeat", which you'd then check from your code that runs when a player defeats something. -- **tracking** (str or list): The *specific* condition this achievement tracks. e.g. the previous example of killing rats might have a `"tracking"` value of `"rat"`. This value will most likely be taken from a specific object in your code, like a tag on the defeated object, or the ID of a visited location. An achievement can also track multiple things: instead of only tracking buying apples, you might want to track apples and pears. For that situation, you can assign it to a list of values to check against: e.g. `["apple", "pear"]` -- **tracking_type** (str): *Default value if unset: `"sum"`* There are two valid tracking types: "sum" (which is the default) and "separate". `"sum"` will increment a single counter every time any of the tracked items match. `"separate"` will have a counter for each individual item in the tracked items. (This is really only useful when `"tracking"` is a list.) -- **count** (int): *Default value if unset: 1* The number of tallies this achievement's requirements need to build up in order to complete the achievement. e.g. the previous example of killing rats would have a `"count"` value of `10`. For achievements using the "separate" tracking type, *each* item being tracked must tally up to this number to be completed -- **prereqs** (str or list): The *keys* of any achievements which must be completed before this achievement can start tracking progress. An achievement's key is the variable name it's assigned to in your achievement module. +- **count** (int): *Default value if unset: 1* The number of tallies this achievement's requirements need to build up in order to complete the achievement. e.g. killing 10 rats would have a `"count"` value of `10`. For achievements using the "separate" tracking type, *each* item being tracked must tally up to this number to be completed. +- **tracking_type** (str): *Default value if unset: `"sum"`* There are two valid tracking types: "sum" (which is the default) and "separate". `"sum"` will increment a single counter every time any of the tracked items match. `"separate"` will have a counter for each individual item in the tracked items. ("See the Example Achievements" section for a demonstration of the difference.) +- **prereqs** (str or list): The *keys* of any achievements which must be completed before this achievement can start tracking progress. -You can add any additional keys to your achievement dicts that you want, and they'll be included with all the rest of the achievement data when using the contrib's functions. This could be useful if you want to add extra metadata to your achievements for your own features. ### Example achievements -A simple achievement which you can get just for logging in the first time. This achievement has no prerequisites and it only needs to be fulfilled once to complete, so it doesn't need to define most of the fields. +A simple achievement which you can get just for logging in the first time. This achievement has no prerequisites and it only needs to be fulfilled once to complete. ```python # This achievement has the unique key of "first_login_achieve" FIRST_LOGIN_ACHIEVE = { @@ -32,7 +78,7 @@ FIRST_LOGIN_ACHIEVE = { } ``` -An achievement for killing 10 rats, and another for killing 10 *dire* rats which requires the "kill 10 rats" achievement to be completed first. +An achievement for killing a total of 10 rats, and another for killing 10 *dire* rats which requires the "kill 10 rats" achievement to be completed first. The dire rats achievement won't begin tracking *any* progress until the first achievement is completed. ```python # This achievement has the unique key of "ten_rats" instead of "achieve_ten_rats" ACHIEVE_TEN_RATS = { @@ -54,10 +100,23 @@ ACHIEVE_DIRE_RATS = { } ``` -An achievement for buying 5 each of apples, oranges, and pears. +An achievement for buying a total of 5 of apples, oranges, *or* pears. The "sum" tracking types means that all items are tallied together - so it can be completed by buying 5 apples, or 5 pears, or 3 apples, 1 orange and 1 pear, or any other combination of those three fruits that totals to 5. + +```python +FRUIT_FAN_ACHIEVEMENT = { + "name": "A Fan of Fruit", # note, there is no desc here - that's allowed! + "category": "buy", + "tracking": ("apple", "orange", "pear"), + "count": 5, + "tracking_type": "sum", # this is the default, but it's included here for clarity +} +``` + +An achievement for buying 5 *each* of apples, oranges, and pears. The "separate" tracking type means that each of the tracked items is tallied independently of the other items - so you will need 5 apples, 5 oranges, and 5 pears. ```python FRUIT_BASKET_ACHIEVEMENT = { - "name": "A Fan of Fruit", # note, there is no desc here - that's allowed! + "name": "Fruit Basket", + "desc": "One kind of fruit just isn't enough.", "category": "buy", "tracking": ("apple", "orange", "pear"), "count": 5, @@ -66,39 +125,23 @@ FRUIT_BASKET_ACHIEVEMENT = { ``` -## Installation - -Once you've defined your achievement dicts in one or more module, assign that module to the `ACHIEVEMENT_CONTRIB_MODULES` setting in your settings.py - -> Note: If any achievements have the same unique key, whichever conflicting achievement is processed *last* will be the only one loaded into the game. Case is ignored, so "ten_rats", "Ten_Rats" and "TEN_RATS" will conflict. "ten_rats" and "ten rats" will not. - -```python -# in server/conf/settings.py - -ACHIEVEMENT_CONTRIB_MODULES = ["world.achievements"] -``` - -There is also a command available to let players check their achievements - `from evennia.contrib.game_systems.achievements.achievements import CmdAchieve` and then add `CmdAchieve` to your default Character and/or Account cmdsets. - -**Optional** - The achievements contrib stores individual progress data on the `achievements` attribute by default, visible via `obj.db.attributes`. You can change this in your settings if necessary, e.g.: - -```py -# in settings.py - -ACHIEVEMENT_CONTRIB_ATTRIBUTE = ("achievement_data", "systems") -``` - ## Usage -### `track_achievements` +The two main things you'll need to do in order to use the achievements contrib in your game are **tracking achievements** and **getting achievement information**. The first is done with the function `track_achievements`; the second can be done with `search_achievement` or `get_achievement`. -The primary mechanism for using the achievements system is the `track_achievements` function. In any actions or functions in your game's mechanics which you might want to track in an achievement, add a call to `track_achievements` to update the achievement progress for that individual. +### Tracking achievements + +#### `track_achievements` + +In any actions or functions in your game's mechanics which you might want to track in an achievement, add a call to `track_achievements` to update that player's achievement progress. Using the "kill 10 rats" example achievement from earlier, you might have some code that triggers when a character is defeated: for the sake of example, we'll pretend we have an `at_defeated` method on the base Object class that gets called when the Object is defeated. Adding achievement tracking to it could then look something like this: ```python +# in typeclasses/objects.py + from contrib.game_systems.achievements import track_achievements class Object(ObjectParent, DefaultObject): @@ -106,31 +149,41 @@ class Object(ObjectParent, DefaultObject): def at_defeated(self, victor): """called when this object is defeated in combat""" - # we'll use the "mob_type" tag category as the tracked information for achievements + # we'll use the "mob_type" tag-category as the tracked info + # this way we can have rats named "black rat" and "brown rat" that are both rats mob_type = self.tags.get(category="mob_type") + # only one mob was defeated, so we include a count of 1 track_achievements(victor, category="defeated", tracking=mob_type, count=1) ``` If a player defeats something tagged `rat` with a tag category of `mob_type`, it'd now count towards the rat-killing achievement. -The `track_achievements` function does also return a value: an iterable of keys for any achievements which were newly completed by that update. You can ignore this value, or you can use it to e.g. send a message to the player with their latest achievements. +You can also have the tracking information hard-coded into your game, for special or unique situations. The achievement described earlier, `FIRST_LOGIN_ACHIEVE`, for example, would be tracked like this: -### `search_achievement` - -A utility function for searching achievements by name or description. It handles partial matching and returns a dictionary of matching achievements. The provided `achievement` command for in-game uses this function to find matching achievements from user inputs. - -#### Example: ```py ->>> from evennia.contrib.game_systems.achievements import search_achievement ->>> search_achievement("fruit") -{'fruit_basket_achievement': {'name': 'A Fan of Fruit', 'category': 'buy', 'tracking': ('apple', 'orange', 'pear'), 'count': 5, 'tracking_type': 'separate'}} ->>> search_achievement("rat") -{'ten_rats': {'key': 'ten_rats', 'name': 'The Usual', 'desc': 'Why do all these inns have rat problems?', 'category': 'defeat', 'tracking': 'rat', 'count': 10}, {'achieve_dire_rats': {'name': 'Once More, But Bigger', 'desc': 'Somehow, normal rats just aren't enough any more.', 'category': 'defeat', 'tracking': 'dire rat', 'count': 10, 'prereqs': "ACHIEVE_TEN_RATS"}} +# in typeclasses/accounts.py +from contrib.game_systems.achievements import track_achievements + +class Account(DefaultAccount): + # ... + + def at_first_login(self, **kwargs): + # this function is only called on the first time the account logs in + # so we already know and can just tell the tracker that this is the first + track_achievements(self, category="login", tracking="first") ``` -### `get_achievement` +The `track_achievements` function does also return a value: an iterable of keys for any achievements which were newly completed by that update. You can ignore this value, or you can use it to e.g. send a message to the player with their latest achievements. -A utility function for retrieving a specific achievement's data from the achievement's unique key. It cannot be used for searching, but if you already have an achievement's key - for example, from the results of `track_achievements` - you can retrieve the rest of its data this way. +### Getting achievements + +The main method for getting a specific achievement's information is `get_achievement`, which takes an already-known achievement key and returns the data for that one achievement. + +For handling more variable and player-friendly input, however, there is also `search_achievement`, which does partial matching on not just the keys, but also the display names and descriptions for the achievements. + +#### `get_achievement` + +A utility function for retrieving a specific achievement's data from the achievement's unique key. It cannot be used for searching, but if you already have an achievement's key - for example, from the results of `track_achievements` - you can retrieve its data this way. #### Example: @@ -139,17 +192,35 @@ from evennia.contrib.game_systems.achievements import get_achievement def toast(achiever, completed_list): if completed_list: - # we've completed some achievements! + # `completed_data` will be a list of dictionaries - unrecognized keys return empty dictionaries completed_data = [get_achievement(key) for key in args] names = [data.get('name') for data in completed] achiever.msg(f"|wAchievement Get!|n {iter_to_str(name for name in names if name)}")) ``` +#### `search_achievement` + +A utility function for searching achievements by name or description. It handles partial matching and returns a dictionary of matching achievements. The provided `achievement` command for in-game uses this function to find matching achievements from user inputs. + +#### Example: + +The first example does a search for "fruit", which returns the fruit medley achievement as it contains "fruit" in the key and name. + +The second example searches for "usual", which returns the ten rats achievement due to its display name. + +```py +>>> from evennia.contrib.game_systems.achievements import search_achievement +>>> search_achievement("fruit") +{'fruit_basket_achievement': {'name': 'Fruit Basket', 'desc': "One kind of fruit just isn't enough.", 'category': 'buy', 'tracking': ('apple', 'orange', 'pear'), 'count': 5, 'tracking_type': 'separate'}} +>>> search_achievement("usual") +{'ten_rats': {'key': 'ten_rats', 'name': 'The Usual', 'desc': 'Why do all these inns have rat problems?', 'category': 'defeat', 'tracking': 'rat', 'count': 10}} +``` + ### The `achievements` command The contrib's provided command, `CmdAchieve`, aims to be usable as-is, with multiple switches to filter achievements by various progress statuses and the ability to search by achievement names. -To make it easier to integrate into your own particular game (e.g. accommodating some of that extra achievement data you might have added), the code for formatting a particular achievement's data for display is in `CmdAchieve.format_achievement`, making it easy to overload for your custom display styling without reimplementing the whole command. +To make it easier to customize for your own game (e.g. displaying some of that extra achievement data you might have added), the format and style code is split out from the command logic into the `format_achievement` method and the `template` attribute, both on `CmdAchieve` #### Example output From 7a83a2951a40a9491e9445ad81fefec0be600c80 Mon Sep 17 00:00:00 2001 From: Cal Date: Sat, 27 Apr 2024 14:05:09 -0600 Subject: [PATCH 8/8] fix stray tabs --- evennia/contrib/game_systems/achievements/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/evennia/contrib/game_systems/achievements/README.md b/evennia/contrib/game_systems/achievements/README.md index 55bae77aef..217b9e54e0 100644 --- a/evennia/contrib/game_systems/achievements/README.md +++ b/evennia/contrib/game_systems/achievements/README.md @@ -24,9 +24,9 @@ To allow players to check their achievements, you'll also want to add the `achie from evennia.contrib.game_systems.achievements.achievements import CmdAchieve class CharacterCmdSet(default_cmds.CharacterCmdSet): - key = "DefaultCharacter" + key = "DefaultCharacter" - def at_cmdset_creation(self): + def at_cmdset_creation(self): # ... self.add(CmdAchieve) ``` @@ -191,11 +191,11 @@ A utility function for retrieving a specific achievement's data from the achieve from evennia.contrib.game_systems.achievements import get_achievement def toast(achiever, completed_list): - if completed_list: + if completed_list: # `completed_data` will be a list of dictionaries - unrecognized keys return empty dictionaries - completed_data = [get_achievement(key) for key in args] - names = [data.get('name') for data in completed] - achiever.msg(f"|wAchievement Get!|n {iter_to_str(name for name in names if name)}")) + completed_data = [get_achievement(key) for key in args] + names = [data.get('name') for data in completed] + achiever.msg(f"|wAchievement Get!|n {iter_to_str(name for name in names if name)}")) ``` #### `search_achievement`