achievements contrib

This commit is contained in:
Cal 2024-03-20 19:45:53 -06:00
parent a1e5b356a9
commit e1f289b08f
4 changed files with 666 additions and 0 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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",
)