diff --git a/docs/source/Persistent-Handler.md b/docs/source/Persistent-Handler.md new file mode 100644 index 0000000000..d4838b0881 --- /dev/null +++ b/docs/source/Persistent-Handler.md @@ -0,0 +1,196 @@ +A _handler_ is a convenient way to group functionality on an object. This allows you to logically group all actions related to that thing in one place. This tutorial expemplifies how to make your own handlers and make sure data you store in them survives a reload. + +For example, when you do `obj.attributes.get("key")` or `obj.tags.add('tagname')` you are evoking handlers stored as `.attributes` and `tags` on the `obj`. On these handlers are methods (`get()` and `add()` in this example). + +## Base Handler example + + +Here is a base way to set up an on-object handler: + +```python + +from evennia import DefaultObject, create_object +from evennia.utils.utils import lazy_property + +class NameChanger: + def __init__(self, obj): + self.obj = obj + + def add_to_key(self, suffix): + self.obj.key = f"self.obj.key_{suffix}" + +# make a test object +class MyObject(DefaultObject): + @lazy_property: + def namechange(self): + return MyHandler(self) + + +obj = create_object(MyObject, key="test") +print(obj.key) +>>> "test" +obj.namechange.add_to_key("extra") +print(obj.key) +>>> "test_extra" +``` + +What happens here is that we make a new class `MyHandler`. We use the `@lazy_property` decorator to set it up - this means the handler will not be actually created until someone really wants to use it, by accessing `obj.namechange` later. The decorated `namechange` method returns the handler and makes sure to initialize it with `self` - this becomes the `obj` inside the handler! + +We then make a silly method `add_to_key` that uses the handler to manipulate the key of the object. In this example, the handler is pretty pointless, but grouping functionality this way can both make for an easy-to-remember API and can also allow you cache data for easy access - this is how the `AttributeHandler` (`.attributes`) and `TagHandler` (`.tags`) works. + +## Persistent storage of data in handler + +Let's say we want to track 'quests' in our handler. A 'quest' is a regular class that represents the quest. Let's make it simple as an example: + +```python +# for example in mygame/world/quests.py + + +class Quest: + + key = "The quest for the red key" + + def __init__(self): + self.current_step = "start" + + def check_progress(self): + # uses self.current_step to check + # progress of this quest + getattr(self, f"step_{self.current_step}")() + + def step_start(self): + # check here if quest-step is complete + self.current_step = "find_the_red_key" + def step_find_the_red_key(self): + # check if step is complete + self.current_step = "hand_in_quest" + def step_hand_in_quest(self): + # check if handed in quest to quest giver + self.current_step = None # finished + +``` + + +We expect the dev to make subclasses of this to implement different quests. Exactly how this works doesn't matter, the key is that we want to track `self.current_step` - a property that _should survive a server reload_. But so far there is no way for `Quest` to accomplish this, it's just a normal Python class with no connection to the database. + +### Handler with save/load capability + +Let's make a `QuestHandler` that manages a character's quests. + +```python +# for example in the same mygame/world/quests.py + + +class QuestHandler: + def __init__(self, obj): + self.obj = obj + self.do_save = False + self._load() + + def _load(self): + self.storage = self.obj.attributes.get( + "quest_storage", default={}, category="quests") + + def _save(self): + self.obj.attributes.add( + "quest_storage", self.storage, category="quests") + self._load() # important + self.do_save = False + + def add(self, questclass): + self.storage[questclass.key] = questclass(self.obj) + self._save() + + def check_progress(self): + for quest in self.storage.values(): + quest.check_progress() + if self.do_save: + # .do_save is set on handler by Quest if it wants to save progress + self._save() + +``` + +The handler is just a normal Python class and has no database-storage on its own. But it has a link to `.obj`, which is assumed to be a full typeclased entity, on which we can create persistent [Attributes](Attributes) to store things however we like! + +We make two helper methods `_load` and +`_save` that handles local fetches and saves `storage` to an Attribute on the object. To avoid saving more than necessary, we have a property `do_save`. This we will set in `Quest` below. + +> Note that once we `_save` the data, we need to call `_load` again. This is to make sure the version we store on the handler is properly de-serialized. If you get an error about data being `bytes`, you probably missed this step. + + +### Make quests storable + +The handler will save all `Quest` objects as a `dict` in an Attribute on `obj`. We are not done yet though, the `Quest` object needs access to the `obj` too - not only will this is important to figure out if the quest is complete (the `Quest` must be able to check the quester's inventory to see if they have the red key, for example), it also allows the `Quest` to tell the handler when its state changed and it should be saved. + +We change the `Quest` such: + +```python +from evennia.utils import dbserialize + + +class Quest: + + def __init__(self, obj): + self.obj = obj + self._current_step = "start" + + def __serialize_dbobjs__(self): + self.obj = dbserialize.dbserialize(self.obj) + + def __deserialize_dbobjs__(self): + if isinstance(self.obj, bytes): + self.obj = dbserialize.dbunserialize(self.obj) + + @property + def questhandler(self): + return self.obj.quests + + @property + def current_step(self): + return self._current_step + + @current_step.setter + def current_step(self, value): + self._current_step = value + self.questhandler.do_save = True # this triggers save in handler! + + # [same as before] + +``` + +The `Quest.__init__` now takes `obj` as argument, to match what we pass to it in `QuestHandler.add`. We want to monitor the changing of `current_step`, so we make it into a `property`. When we edit that value, we set the `do_save` flag on the handler, which means it will save the status to database once it has checked progress on all its quests. + +The `__serialize__dbobjs__` and `__deserialize_dbobjs__` methods are needed because `Attributes` can't store 'hidden' database objects (the `Quest.obj` property. The methods help Evennia serialize/deserialize `Quest` propertly when the handler saves it. For more information, see [Storing Single objects](Attributes#storing-single-objects) in the Attributes documentation. + +### Tying it all together + +The final thing we need to do is to add the quest-handler to the character: + +```python +# in mygame/typeclasses/characters.py + +from evennia import DefaultCharacter +from evennia.utils.utils import lazy_property +from .world.quests import QuestHandler # as an example + + +class Character(DefaultCharacter): + # ... + @lazy_property + def quests(self): + return QuestHandler(self) + +``` + + +You can now make your Quest classes to describe your quests and add them to characters with + + character.quests.add(FindTheRedKey) + +and can later do + + character.quests.check_progress() + +and be sure that quest data is not lost between reloads. + +You can find a full-fledged quest-handler example as [EvAdventure quests](evennia.contribs.tutorials.evadventure.quests) contrib in the Evennia repository. \ No newline at end of file diff --git a/evennia/contrib/tutorials/evadventure/quests.py b/evennia/contrib/tutorials/evadventure/quests.py index d30ce5f153..be8d0a98cd 100644 --- a/evennia/contrib/tutorials/evadventure/quests.py +++ b/evennia/contrib/tutorials/evadventure/quests.py @@ -14,6 +14,10 @@ another quest. """ +from copy import copy, deepcopy + +from evennia.utils import dbserialize + class EvAdventureQuest: """ @@ -22,59 +26,104 @@ class EvAdventureQuest: Properties: name (str): Main identifier for the quest. category (str, optional): This + name must be globally unique. - steps (list): A list of strings, representing how many steps are - in the quest. The first step is always the beginning, when the quest is presented. - The last step is always the end of the quest. It is possible to abort the quest before it ends - it then pauses after the last completed step. - Each step is represented by three methods on this object: - `check_` and `complete_`. `help_` is used to get - a guide/reminder on what you are supposed to do. + Each step of the quest is represented by a `.step_` method. This should check + the status of the quest-step and update the `.current_step` or call `.complete()`. There + are also `.help_` which is either a class-level help string or a method + returning a help text. All properties should be stored on the quester. + Example: + ```py + class MyQuest(EvAdventureQuest): + '''A quest with two steps that ar''' + + start_step = "A" + + help_A = "You need a '_quest_A_flag' on yourself to finish this step!" + help_B = "Finally, you need more than 4 items in your inventory!" + + def step_A(self, *args, **kwargs): + if self.quester.db._quest_A_flag == True: + self.quester.msg("Completed the first step of the quest.") + self.current_step = "end" + self.progress() + + def step_end(self, *args, **kwargs): + if len(self.quester.contents) > 4: + self.quester.msg("Quest complete!") + self.complete() + ``` """ - # name + category must be globally unique. They are - # queried as name:category or just name, if category is empty. key = "basequest" - desc = "This is the base quest. It will just step through its steps immediately." + desc = "This is the base quest class" start_step = "start" - end_text = "This quest is completed!" - # help entries for quests + completed_text = "This quest is completed!" + abandoned_text = "This quest is abandoned." + + # help entries for quests (could also be methods) help_start = "You need to start first" help_end = "You need to end the quest" - def __init__(self, questhandler, start_step="start"): + def __init__(self, quester, start_step=None): if " " in self.key: raise TypeError("The Quest name must not have spaces in it.") - self.questhandler = questhandler - self.current_step = start_step - self.completed = False + self.quester = quester + self._current_step = start_step or self.start_step + self.is_completed = False + self.is_abandoned = False + + def __serialize_dbobjs__(self): + self.quester = dbserialize.dbserialize(self.quester) + + def __deserialize_dbobjs__(self): + if isinstance(self.quester, bytes): + self.quester = dbserialize.dbunserialize(self.quester) @property - def quester(self): - return self.questhandler.obj + def questhandler(self): + return self.quester.quests - def end_quest(self): + @property + def current_step(self): + return self._current_step + + @current_step.setter + def current_step(self, step_name): + self._current_step = step_name + self.questhandler.do_save = True + + def abandon(self): + """ + Call when quest is abandoned. + + """ + self.is_abandoned = True + self.cleanup() + + def complete(self): """ Call this to end the quest. """ - self.completed = True + self.is_completed = True + self.cleanup() def progress(self, *args, **kwargs): """ - This is called whenever the environment expects a quest may be complete. - This will determine which quest-step we are on, run check_, and if it - succeeds, continue with complete_. + This is called whenever the environment expects a quest may need stepping. This will + determine which quest-step we are on and run `step_`, which in turn will figure + out if the step is complete or not. Args: - *args, **kwargs: Will be passed into the check/complete methods. + *args, **kwargs: Will be passed into the step method. """ - if getattr(self, f"check_{self.current_step}")(*args, **kwargs): - getattr(self, f"complete_{self.current_step}")(*args, **kwargs) + if not (self.is_completed or self.is_abandoned): + getattr(self, f"step_{self.current_step}")(*args, **kwargs) def help(self): """ @@ -85,6 +134,11 @@ class EvAdventureQuest: str: The help text for the current step. """ + if self.is_completed: + return self.completed_text + if self.is_abandoned: + return self.abandoned_text + help_resource = ( getattr(self, f"help_{self.current_step}", None) or "You need to {self.current_step} ..." @@ -96,35 +150,22 @@ class EvAdventureQuest: # normally it's just a string return str(help_resource) - # step methods + # step methods and hooks - def check_start(self, *args, **kwargs): + def step_start(self, *args, **kwargs): """ - Check if the starting conditions are met. - - Returns: - bool: If this step is complete or not. If complete, the `complete_start` - method will fire. + Example step that completes immediately. """ - return True + self.complete() - def complete_start(self, *args, **kwargs): + def cleanup(self): """ - Completed start. This should change `.current_step` to the next step to complete - and call `self.progress()` just in case the next step is already completed too. + This is called both when completing the quest, or when it is abandoned prematurely. + Make sure to cleanup any quest-related data stored when following the quest. """ - self.quester.msg("Completed the first step of the quest.") - self.current_step = "end" - self.progress() - - def check_end(self, *args, **kwargs): - return True - - def complete_end(self, *args, **kwargs): - self.quester.msg("Quest complete!") - self.end_quest() + pass class EvAdventureQuestHandler: @@ -146,6 +187,7 @@ class EvAdventureQuestHandler: def __init__(self, obj): self.obj = obj + self.do_save = False self._load() def _load(self): @@ -161,6 +203,8 @@ class EvAdventureQuestHandler: self.storage, category=self.quest_storage_attribute_category, ) + self._load() # important + self.do_save = False def has(self, quest_key): """ @@ -190,52 +234,63 @@ class EvAdventureQuestHandler: """ return self.storage.get(quest_key) - def add(self, quest, autostart=True): + def add(self, quest): """ Add a new quest Args: - quest (EvAdventureQuest): The quest to start. - autostart (bool, optional): If set, the quest will - start immediately. + quest (EvAdventureQuest): The quest class to start. """ - self.storage[quest.key] = quest + self.storage[quest.key] = quest(self.obj) self._save() def remove(self, quest_key): """ - Remove a quest. + Remove a quest. If not complete, it will be abandoned. Args: quest_key (str): The quest to remove. """ - self.storage.pop(quest_key, None) + quest = self.storage.pop(quest_key, None) + if not quest.is_completed: + # make sure to cleanup + quest.abandon() self._save() - def help(self, quest_key=None): + def get_help(self, quest_key=None): """ Get help text for a quest or for all quests. The help text is a combination of the description of the quest and the help-text of the current step. + Args: + quest_key (str, optional): The quest-key. If not given, get help for all + quests in handler. + + Returns: + list: Help texts, one for each quest, or only one if `quest_key` is given. + """ - help_text = [] + help_texts = [] if quest_key in self.storage: quests = [self.storage[quest_key]] + else: + quests = self.storage.values() for quest in quests: - help_text.append(f"|c{quest.key}|n\n {quest.desc}\n\n - {quest.help}") - return "---".join(help_text) + help_texts.append(f"|c{quest.key}|n\n {quest.desc}\n\n - {quest.help()}") + return help_texts - def progress(self, quest_key=None): + def progress(self, quest_key=None, *args, **kwargs): """ Check progress of a given quest or all quests. Args: quest_key (str, optional): If given, check the progress of this quest (if we have it), otherwise check progress on all quests. + *args, **kwargs: Will be passed into each quest's `progress` call. """ if quest_key in self.storage: @@ -244,4 +299,8 @@ class EvAdventureQuestHandler: quests = self.storage.values() for quest in quests: - quest.progress() + quest.progress(*args, **kwargs) + + if self.do_save: + # do_save is set by the quest + self._save() diff --git a/evennia/contrib/tutorials/evadventure/tests/test_combat.py b/evennia/contrib/tutorials/evadventure/tests/test_combat.py index 4dcdd3de6b..108c77b136 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_combat.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_combat.py @@ -5,7 +5,6 @@ Test EvAdventure combat. from unittest.mock import MagicMock, patch -from anything import Something from evennia.utils import create from evennia.utils.test_resources import BaseEvenniaTest @@ -13,13 +12,7 @@ from .. import combat_turnbased from ..characters import EvAdventureCharacter from ..enums import WieldLocation from ..npcs import EvAdventureMob -from ..objects import ( - EvAdventureConsumable, - EvAdventureRunestone, - EvAdventureWeapon, - WeaponEmptyHand, -) -from ..rooms import EvAdventureRoom +from ..objects import EvAdventureConsumable, EvAdventureRunestone, EvAdventureWeapon from .mixins import EvAdventureMixin diff --git a/evennia/contrib/tutorials/evadventure/tests/test_quests.py b/evennia/contrib/tutorials/evadventure/tests/test_quests.py new file mode 100644 index 0000000000..5a65725044 --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/tests/test_quests.py @@ -0,0 +1,150 @@ +""" +Testing Quest functionality. + +""" + +from unittest.mock import MagicMock + +from evennia.utils.test_resources import BaseEvenniaTest + +from .. import quests +from ..objects import EvAdventureObject +from .mixins import EvAdventureMixin + + +class _TestQuest(quests.EvAdventureQuest): + """ + Test quest. + + """ + + key = "testquest" + desc = "A test quest!" + + start_step = "A" + end_text = "This task is completed." + + help_A = "You need to do A first." + help_B = "Next, do B." + + def step_A(self, *args, **kwargs): + """ + Quest-step A is completed when quester carries an item with tag "QuestA" and category + "quests". + """ + # note - this could be done with a direct db query instead to avoid a loop, for a + # unit test it's fine though + if any(obj for obj in self.quester.contents if obj.tags.has("QuestA", category="quests")): + self.quester.msg("Completed step A of quest!") + self.current_step = "B" + self.progress() + + def step_B(self, *args, **kwargs): + """ + Quest-step B is completed when the progress-check is called with a special kwarg + "complete_quest_B" + + """ + if kwargs.get("complete_quest_B", False): + self.quester.msg("Completed step B of quest!") + self.quester.db.test_quest_counter = 0 + self.current_step = "C" + self.progress() + + def help_C(self): + """Testing the method-version of getting a help entry""" + return f"Only C left now, {self.quester.key}!" + + def step_C(self, *args, **kwargs): + """ + Step C (final) step of quest completes when a counter on quester is big enough. + + """ + if self.quester.db.test_quest_counter and self.quester.db.test_quest_counter > 5: + self.quester.msg("Quest complete! Get XP rewards!") + self.quester.db.xp += 10 + self.complete() + + def cleanup(self): + """ + Cleanup data related to quest. + + """ + del self.quester.db.test_quest_counter + + +class EvAdventureQuestTest(EvAdventureMixin, BaseEvenniaTest): + """ + Test questing. + + """ + + def setUp(self): + super().setUp() + self.character.quests.add(_TestQuest) + self.character.msg = MagicMock() + + def _get_quest(self): + return self.character.quests.get(_TestQuest.key) + + def _fulfillA(self): + """Fulfill quest step A""" + EvAdventureObject.create( + key="quest obj", location=self.character, tags=(("QuestA", "quests"),) + ) + + def _fulfillC(self): + """Fullfill quest step C""" + self.character.db.test_quest_counter = 6 + + def test_help(self): + """Get help""" + # get help for all quests + help_txt = self.character.quests.get_help() + self.assertEqual(help_txt, ["|ctestquest|n\n A test quest!\n\n - You need to do A first."]) + + # get help for one specific quest + help_txt = self.character.quests.get_help(_TestQuest.key) + self.assertEqual(help_txt, ["|ctestquest|n\n A test quest!\n\n - You need to do A first."]) + + # help for finished quest + self._get_quest().is_completed = True + help_txt = self.character.quests.get_help() + self.assertEqual(help_txt, ["|ctestquest|n\n A test quest!\n\n - This quest is completed!"]) + + def test_progress__fail(self): + """ + Check progress without having any. + """ + # progress all quests + self.character.quests.progress() + # progress one quest + self.character.quests.progress(_TestQuest.key) + + # still on step A + self.assertEqual(self._get_quest().current_step, "A") + + def test_progress(self): + """ + Fulfill the quest steps in sequess + + """ + # A requires a certain object in inventory + self._fulfillA() + self.character.quests.progress() + self.assertEqual(self._get_quest().current_step, "B") + + # B requires progress be called with specific kwarg + # should not step (no kwarg) + self.character.quests.progress() + self.assertEqual(self._get_quest().current_step, "B") + + # should step (kwarg sent) + self.character.quests.progress(complete_quest_B=True) + self.assertEqual(self._get_quest().current_step, "C") + + # C requires a counter Attribute on char be high enough + self._fulfillC() + self.character.quests.progress() + self.assertEqual(self._get_quest().current_step, "C") # still on last step + self.assertEqual(self._get_quest().is_completed, True)