applying changes from feedback

This commit is contained in:
Cal 2024-03-29 16:46:45 -06:00
parent be9ad0278a
commit 30151b7e1f
3 changed files with 191 additions and 112 deletions

View file

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

View file

@ -0,0 +1,8 @@
from .achievements import (
get_achievement,
search_achievement,
all_achievements,
track_achievements,
get_achievement_progress,
CmdAchieve,
)

View file

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