diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md index e74f1578ee..2398ff20fe 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md @@ -1,5 +1,9 @@ # Game Quests -```{warning} -This part of the Beginner tutorial is still being developed. -``` \ No newline at end of file +A _quest_ is a common feature of games. From classic fetch-quests like retrieving 10 flowers to complex quest chains involving drama and intrigue, quests need to be properly tracked in our game. + +A quest follows a specific development: + +1. The quest is _started_. This normally involves the player accepting the quest, from a quest-giver, job board or other source. But the quest could also be thrust on the player ("save the family from the burning house before it collapses!") +2. A quest may consist of one or more 'steps'. Each step has its own set of finish conditions. +3. At suitable times the quest is _checked_. This could happen on a timer or when trying to 'hand in' the quest. When checking, the current 'step' is checked against its finish conditions. If ok, that step is closed and the next step is checked until it either hits a step that is not yet complete, or there are no more steps, in which case the entire quest is complete. \ No newline at end of file diff --git a/evennia/contrib/tutorials/evadventure/enums.py b/evennia/contrib/tutorials/evadventure/enums.py index 6ba77d82f9..4ec4405e9f 100644 --- a/evennia/contrib/tutorials/evadventure/enums.py +++ b/evennia/contrib/tutorials/evadventure/enums.py @@ -84,3 +84,15 @@ class ObjType(Enum): MAGIC = "magic" QUEST = "quest" TREASURE = "treasure" + + +class QuestStatus(Enum): + """ + Quest status + + """ + + STARTED = "started" + COMPLETED = "completed" + ABANDONED = "abandoned" + FAILED = "failed" diff --git a/evennia/contrib/tutorials/evadventure/quests.py b/evennia/contrib/tutorials/evadventure/quests.py index be8d0a98cd..1e1f2780da 100644 --- a/evennia/contrib/tutorials/evadventure/quests.py +++ b/evennia/contrib/tutorials/evadventure/quests.py @@ -14,9 +14,7 @@ another quest. """ -from copy import copy, deepcopy - -from evennia.utils import dbserialize +from .enums import QuestStatus class EvAdventureQuest: @@ -40,11 +38,11 @@ class EvAdventureQuest: start_step = "A" - help_A = "You need a '_quest_A_flag' on yourself to finish this step!" + help_A = "You need a 'A_flag' attribute 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: + if self.get_data("A_flag") == True: self.quester.msg("Completed the first step of the quest.") self.current_step = "end" self.progress() @@ -67,21 +65,16 @@ class EvAdventureQuest: help_start = "You need to start first" help_end = "You need to end the quest" - def __init__(self, quester, start_step=None): + def __init__(self, quester, data=None): if " " in self.key: raise TypeError("The Quest name must not have spaces in it.") self.quester = quester - self._current_step = start_step or self.start_step - self.is_completed = False - self.is_abandoned = False + self.data = data or dict() + self._current_step = self.get_data("current_step") - 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) + if not self.current_step: + self.current_step = self.start_step @property def questhandler(self): @@ -94,14 +87,73 @@ class EvAdventureQuest: @current_step.setter def current_step(self, step_name): self._current_step = step_name + self.add_data("current_step", step_name) self.questhandler.do_save = True + @property + def status(self): + return self.get_data("status", QuestStatus.STARTED) + + @status.setter + def status(self, value): + self.add_data("status", value) + + @property + def is_completed(self): + return self.status == QuestStatus.COMPLETED + + @property + def is_abandoned(self): + return self.status == QuestStatus.ABANDONED + + @property + def is_failed(self): + return self.status == QuestStatus.FAILED + + def add_data(self, key, value): + """ + Add data to the quest. This saves it permanently. + + Args: + key (str): The key to store the data under. + value (any): The data to store. + + """ + self.data[key] = value + self.questhandler.save_quest_data(self.key, self.data) + + def remove_data(self, key): + """ + Remove data from the quest permanently. + + Args: + key (str): The key to remove. + + """ + self.data.pop(key, None) + self.questhandler.save_quest_data(self.key, self.data) + + def get_data(self, key, default=None): + """ + Get data from the quest. + + Args: + key (str): The key to get data for. + default (any, optional): The default value to return if key is not found. + + Returns: + any: The data stored under the key. + + """ + return self.data.get(key, default) + def abandon(self): """ Call when quest is abandoned. """ - self.is_abandoned = True + self.add_data("status", QuestStatus.ABANDONED) + self.questhandler.clean_quest_data(self.key) self.cleanup() def complete(self): @@ -109,7 +161,8 @@ class EvAdventureQuest: Call this to end the quest. """ - self.is_completed = True + self.add_data("status", QuestStatus.COMPLETED) + self.questhandler.clean_quest_data(self.key) self.cleanup() def progress(self, *args, **kwargs): @@ -122,8 +175,7 @@ class EvAdventureQuest: *args, **kwargs: Will be passed into the step method. """ - if not (self.is_completed or self.is_abandoned): - getattr(self, f"step_{self.current_step}")(*args, **kwargs) + return getattr(self, f"step_{self.current_step}")(*args, **kwargs) def help(self): """ @@ -162,8 +214,9 @@ class EvAdventureQuest: def cleanup(self): """ 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. + This is for cleaning up any extra state that were set during the quest (stuff in self.data + is automatically cleaned up) """ pass @@ -185,27 +238,84 @@ class EvAdventureQuestHandler: quest_storage_attribute_key = "_quests" quest_storage_attribute_category = "evadventure" + quest_data_attribute_template = "_quest_data_{quest_key}" + quest_data_attribute_category = "evadventure" + def __init__(self, obj): self.obj = obj self.do_save = False + self.quests = {} + self.quest_classes = {} self._load() def _load(self): - self.storage = self.obj.attributes.get( + self.quest_classes = self.obj.attributes.get( self.quest_storage_attribute_key, category=self.quest_storage_attribute_category, default={}, ) + # instantiate all quests + for quest_key, quest_class in self.quest_classes.items(): + self.quests[quest_key] = quest_class(self.obj, self.load_quest_data(quest_key)) def _save(self): self.obj.attributes.add( self.quest_storage_attribute_key, - self.storage, + self.quest_classes, category=self.quest_storage_attribute_category, ) self._load() # important self.do_save = False + def save_quest_data(self, quest_key, data): + """ + Save data for a quest. We store this on the quester as well as updating the quest itself. + + Args: + data (dict): The data to store. This is commonly flags or other data needed to track the + quest. + + """ + quest = self.get(quest_key) + if quest: + quest.data = data + self.obj.attributes.add( + self.quest_data_attribute_template.format(quest_key=quest_key), + data, + category=self.quest_data_attribute_category, + ) + + def load_quest_data(self, quest_key): + """ + Load data for a quest. + + Args: + quest_key (str): The quest to load data for. + + Returns: + dict: The data stored for the quest. + + """ + return self.obj.attributes.get( + self.quest_data_attribute_template.format(quest_key=quest_key), + category=self.quest_data_attribute_category, + default={}, + ) + + def clean_quest_data(self, quest_key): + """ + Remove data for a quest. + + Args: + quest_key (str): The quest to remove data for. + + """ + self.obj.attributes.remove( + self.quest_data_attribute_template.format(quest_key=quest_key), + category=self.quest_data_attribute_category, + ) + + def has(self, quest_key): """ Check if a given quest is registered with the Character. @@ -218,7 +328,7 @@ class EvAdventureQuestHandler: bool: If the character is following this quest or not. """ - return bool(self.storage.get(quest_key)) + return bool(self.quests.get(quest_key)) def get(self, quest_key): """ @@ -232,17 +342,17 @@ class EvAdventureQuestHandler: Character is not on this quest. """ - return self.storage.get(quest_key) + return self.quests.get(quest_key) - def add(self, quest): + def add(self, quest_class): """ Add a new quest Args: - quest (EvAdventureQuest): The quest class to start. + quest_class (EvAdventureQuest): The quest class to start. """ - self.storage[quest.key] = quest(self.obj) + self.quest_classes[quest_class.key] = quest_class self._save() def remove(self, quest_key): @@ -253,10 +363,11 @@ class EvAdventureQuestHandler: quest_key (str): The quest to remove. """ - quest = self.storage.pop(quest_key, None) + quest = self.quests.pop(quest_key, None) if not quest.is_completed: # make sure to cleanup quest.abandon() + self.quest_classes.pop(quest_key, None) self._save() def get_help(self, quest_key=None): @@ -274,10 +385,10 @@ class EvAdventureQuestHandler: """ help_texts = [] - if quest_key in self.storage: - quests = [self.storage[quest_key]] + if quest_key in self.quests: + quests = [self.quests[quest_key]] else: - quests = self.storage.values() + quests = self.quests.values() for quest in quests: help_texts.append(f"|c{quest.key}|n\n {quest.desc}\n\n - {quest.help()}") @@ -293,10 +404,10 @@ class EvAdventureQuestHandler: *args, **kwargs: Will be passed into each quest's `progress` call. """ - if quest_key in self.storage: - quests = [self.storage[quest_key]] + if quest_key in self.quests: + quests = [self.quests[quest_key]] else: - quests = self.storage.values() + quests = self.quests.values() for quest in quests: quest.progress(*args, **kwargs) diff --git a/evennia/contrib/tutorials/evadventure/tests/test_quests.py b/evennia/contrib/tutorials/evadventure/tests/test_quests.py index 5a65725044..2a6fe61021 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_quests.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_quests.py @@ -108,7 +108,7 @@ class EvAdventureQuestTest(EvAdventureMixin, BaseEvenniaTest): 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 + self._get_quest().complete() help_txt = self.character.quests.get_help() self.assertEqual(help_txt, ["|ctestquest|n\n A test quest!\n\n - This quest is completed!"])