diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index 8c1972df80..ed81eb6ab2 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -10,8 +10,11 @@ The combat is handled with a `Script` shared between all combatants; this tracks of combat and handles all timing elements. Unlike in base _Knave_, the MUD version's combat is simultaneous; everyone plans and executes -their turns simultaneously with minimum downtime. This version also includes a stricter -handling of optimal distances than base _Knave_ (this would be handled by the GM normally). +their turns simultaneously with minimum downtime. + +This version is simplified to not worry about things like optimal range etc. So a bow can be used +the same as a sword in battle. One could add a 1D range mechanism to add more strategy by requiring +optimizal positioning. """ @@ -21,21 +24,11 @@ from evennia.scripts.scripts import DefaultScript from evennia.typeclasses.attributes import AttributeProperty from evennia.utils.utils import make_iter from evennia.utils import evmenu, evtable +from .enums import Ability from . import rules -MIN_RANGE = 0 -MAX_RANGE = 4 -MAX_MOVE_RATE = 2 STUNT_DURATION = 2 -RANGE_NAMES = { - 0: "close", # melee, short weapons, fists. long weapons with disadvantage - 1: "near", # melee, long weapons, short weapons with disadvantage - 2: "medium", # thrown, ranged with disadvantage - 3: "far", # ranged, thrown with disadvantage - 4: "disengaging" # no weapons -} - class CombatFailure(RuntimeError): """ @@ -44,19 +37,17 @@ class CombatFailure(RuntimeError): class CombatAction: """ - This describes a combat-action, like 'attack'. + This is the base of a combat-action, like 'attack' or defend. + Inherit from this to make new actions. """ key = 'action' help_text = "Combat action to perform." # action to echo to everyone. post_action_text = "{combatant} performed an action." - optimal_range = 0 - # None for unlimited - max_uses = None - suboptimal_range = 1 - # move actions can be combined with other actions - is_move_action = False + max_uses = None # None for unlimited + # in which order (highest first) to perform the action. If identical, use random order + priority = 0 def __init__(self, combathandler, combatant): self.combathandler = combathandler @@ -71,33 +62,13 @@ class CombatAction: # send only to the combatant. self.combatant.msg(message) - def get_help(self): - return "" - - def check_distance(self, distance, optimal_range=None, suboptimal_range=None): - """Call to easily check and warn for out-of-bound distance""" - - if optimal_range is None: - optimal_range = self.optimal_range - if suboptimal_range is None: - suboptimal_range = self.suboptimal_range - - if distance not in (self.suboptimal_distance, self.optimal_distance): - # if we are neither at optimal nor suboptimal distance, we can't do the stunt - # from here. - self.msg(f"|rYou can't perform {self.key} from {range_names[distance]} distance " - "(must be {range_names[suboptimal_distance]} or, even better, " - "{range_names[optimal_distance]}).|n") - return False - elif self.distance == self.suboptimal_distance: - self.msg(f"|yNote: Performing {self.key} from {range_names[distance]} works, but " - f"the optimal range is {range_names[optimal_range]} (you'll " - "act with disadvantage).") - return True + def get_help(self, *args, **kwargs): + return self.help_text def can_use(self, combatant, *args, **kwargs): """ - Determine if combatant can use this action. + Determine if combatant can use this action. In this implementation, + it fails if already use all of a usage-limited action. Args: combatant (Object): The one performing the action. @@ -111,13 +82,13 @@ class CombatAction: """ return True if self.uses is None else self.uses < self.max_uses - def pre_perform(self, *args, **kwargs): + def pre_use(self, *args, **kwargs): pass - def perform(self, *args, **kwargs): + def use(self, *args, **kwargs): pass - def post_perform(self, *args, **kwargs): + def post_use(self, *args, **kwargs): self.uses += 1 self.combathandler.msg(self.post_action_text.format(combatant=combatant)) @@ -134,21 +105,31 @@ class CombatActionDoNothing(CombatAction): class CombatActionStunt(CombatAction): """ - Perform a stunt. + Perform a stunt. A stunt grants an advantage to yours or another player for their next + action, or a disadvantage to yours or an enemy's next action. + + Note that while the check happens between the user and a target, another (the 'beneficiary' + could still gain the effect. This allows for boosting allies or making them better + defend against an enemy. + + Note: We only count a use if the stunt is successful; they will still spend their turn, but won't + spend a use unless they succeed. """ - optimal_distance = 0 - suboptimal_distance = 1 give_advantage = True give_disadvantage = False - uses = 1 - attack_type = "dexterity" - defense_type = "dexterity" + max_uses = 1 + priority = -1 + # how many turns the stunt's effect apply (that is, how quickly it must be used before the + # advantage/disadvantage is lost). + duration = 5 + attack_type = Ability.DEX + defense_type = Ability.DEX help_text = ("Perform a stunt against a target. This will give you or an ally advantage " "on your next action against the same target [range 0-1, one use per combat. " "Bonus lasts for two turns].") - def perform(self, attacker, defender, *args, beneficiary=None, **kwargs): + def use(self, attacker, defender, *args, beneficiary=None, **kwargs): # quality doesn't matter for stunts, they are either successful or not is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw( @@ -160,11 +141,125 @@ class CombatActionStunt(CombatAction): if is_success: beneficiary = beneficiary if beneficiary else attacker if advantage: - self.gain_advantage(beneficiary, defender) + self.combathandler.gain_advantage(beneficiary, defender) else: - self.gain_disadvantage(defender, beneficiary) + self.combathandler.gain_disadvantage(defender, beneficiary) self.msg + # only spend a use after being successful + uses += 1 + + +class CombatActionAttack(CombatAction): + """ + A regular attack, using a wielded melee weapon. + + """ + key = "attack" + priority = 1 + + def use(self, attacker, defender, *args, **kwargs): + """ + Make an attack against a defender. + + """ + # figure out advantage (gained by previous stunts) + advantage = bool(self.combathandler.advantage_matrix[attacker].pop(defender, False)) + + # figure out disadvantage (by distance or by previous action) + disadvantage = bool(self.combathandler.disadvantage_matrix[attacker].pop(defender, False)) + + is_hit, quality = rules.EvAdventureRollEngine.opposed_saving_throw( + attacker, defender, + attack_type=attacker.weapon.attack_type, + defense_type=attacker.weapon.defense_type, + advantage=advantage, disadvantage=disadvantage + ) + if is_hit: + self.combathandler.resolve_damage(attacker, defender, + critical=quality == "critical success") + + # TODO messaging here + + +class CombatActionUseItem(CombatAction): + """ + Use an item in combat. This is meant for one-off or limited-use items, like potions, scrolls or + wands. We offload the usage checks and usability to the item's own hooks. It's generated dynamically + from the items in the character's inventory (you could also consider using items in the room this way). + + Each usable item results in one possible action. + + It relies on the combat_* hooks on the item: + combat_get_help + combat_can_use + combat_pre_use + combat_pre + combat_post_use + + """ + def get_help(self, item, *args): + return item.combat_get_help(*args) + + def can_use(self, item, combatant, *args, **kwargs): + return item.combat_can_use(combatant, self.combathandler, *args, **kwargs) + + def pre_use(self, item, *args, **kwargs): + item.combat_pre_use(*args, **kwargs) + + def use(self, item, combatant, target, *args, **kwargs): + item.combat_use(combatant, target, *args, **kwargs) + + def post_use(self, item, *args, **kwargs): + item.combat_post_use(*args, **kwargs) + + +class CombatActionFlee(CombatAction): + """ + Fleeing/disengaging from combat means doing nothing but 'running away' for two turn. Unless + someone attempts and succeeds in their 'chase' action, you will leave combat by fleeing at the + end of the second turn. + + """ + key = "flee" + priority = -1 + + def use(self, combatant, target, *args, **kwargs): + # it's safe to do this twice + self.combathandler.flee(combatant) + +class CombatActionChase(CombatAction): + + """ + Chasing is a way to counter a 'flee' action. It is a maximum movement towards the target + and will mean a DEX contest, if the fleeing target loses, they are moved back from + 'disengaging' range and remain in combat at the new distance (likely 2 if max movement + is 2). Advantage/disadvantage are considered. + + """ + key = "chase" + priority = -5 # checked last + + attack_type = Ability.DEX # or is it CON? + defense_type = Ability.DEX + + def use(self, combatant, fleeing_target, *args, **kwargs): + + advantage = bool(self.advantage_matrix[attacker].pop(fleeing_target, False)) + disadvantage = bool(self.disadvantage_matrix[attacker].pop(fleeing_target, False)) + + is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw( + combatant, fleeing_target, + attack_type=self.attack_type, defense_type=self.defense_type, + advantage=advantage, disadvantage=disadvantage + ) + + if is_success: + # managed to stop the target from fleeing/disengaging + self.combatant.unflee(fleeing_target) + else: + pass # they are getting away! + class EvAdventureCombatHandler(DefaultScript): @@ -180,13 +275,11 @@ class EvAdventureCombatHandler(DefaultScript): # turn counter - abstract time turn = AttributeProperty(default=0) - # symmetric distance matrix (handled dynamically). Mapping {combatant1: {combatant2: dist}, ...} - distance_matrix = defaultdict(dict) # advantages or disadvantages gained against different targets advantage_matrix = AttributeProperty(defaultdict(dict)) disadvantage_matrix = AttributeProperty(defaultdict(dict)) - disengaging_combatants = AttributeProperty(default=list()) + fleeing_combatants = AttributeProperty(default=list()) # actions that will be performed before a normal action move_actions = ("approach", "withdraw") @@ -197,50 +290,6 @@ class EvAdventureCombatHandler(DefaultScript): } - def _refresh_distance_matrix(self): - """ - Refresh the distance matrix, either after movement or when a - new combatant enters combat - everyone must have a symmetric - distance to every other combatant (that is, if you are 'near' an opponent, - they are also 'near' to you). - - Distances are abstract and divided into four steps: - - 0. Close (melee, short weapons, fists, long weapons with disadvantage) - 1. Near (melee, long weapons, short weapons with disadvantage) - 2. Medium (thrown, ranged with disadvantage) - 3. Far (ranged, thrown with disadvantage) - 4. Disengaging/fleeing (no weapons can be used) - - Distance is tracked to each opponent individually. One can move 1 step and attack - or up to 2 steps (closer or further away) without attacking. - - New combatants will start at a distance averaged between the optimal ranges - of them and their opponents. - - """ - combatants = self.combatants - distance_matrix = self.distance_matrix - - for combatant1 in combatants: - for combatant2 in combatants: - - if combatant1 == combatant2: - continue - - combatant1_distances = distance_matrix[combatant1] - combatant2_distances = distance_matrix[combatant2] - - if combatant2 not in combatant1_distances or combatant1 not in combatant2_distances: - # this happens on initialization or when a new combatant is added. - # we make sure to update both sides to the distance of the longest - # optimal weapon range. So ranged weapons have advantage going in. - start_optimal = max(combatant1.weapon.distance_optimal, - combatant2.weapon.distance_optimal) - - combatant1_distances[combatant2] = start_optimal - combatant2_distances[combatant1] = start_optimal - def _update_turn_stats(self, combatant, message): """ Store combat messages to display at the end of turn. @@ -261,22 +310,15 @@ class EvAdventureCombatHandler(DefaultScript): """ End of turn operations. - 1. Do all moves - 2. Do all regular actions - 3. Remove combatants that disengaged successfully - 4. Timeout advantages/disadvantages set for longer than STUNT_DURATION + 1. Do all regular actions + 2. Remove combatants that disengaged successfully + 3. Timeout advantages/disadvantages set for longer than STUNT_DURATION """ - # first do all moves + # do all actions for combatant in self.combatants: - action, args, kwargs = self.action_queue[combatant].get( - "move", ("do_nothing", (), {})) - getattr(self, f"action_{action}")(combatant, *args, **kwargs) - # next do all regular actions - for combatant in self.combatants: - action, args, kwargs = self.action_qeueue[combatant].get( - "action", ("do_nothing", (), {})) - getattr(self, f"action_{action}")(combatant, *args, **kwargs) + action, args, kwargs = self.action_queue[combatant] + action.use(combatant, *args, **kwargs) # handle disengaging combatants @@ -285,13 +327,8 @@ class EvAdventureCombatHandler(DefaultScript): for combatant in self.combatants: # check disengaging combatants (these are combatants that managed # to stay at disengaging distance for a turn) - if combatant in self.disengaging_combatants: + if combatant in self.fleeing_combatants: self.disengaging_combatants.remove(combatant) - to_remove.append(combatant) - elif all(1 for distance in self.distance_matrix[combatant].values() - if distance == MAX_RANGE): - # if at max distance (disengaging) from everyone, they are disengaging - self.disengaging_combatants.append(combatant) for combatant in to_remove: # for clarity, we remove here rather than modifying the combatant list @@ -327,27 +364,29 @@ class EvAdventureCombatHandler(DefaultScript): def add_combatant(self, combatant): if combatant not in self.combatants: self.combatants.append(combatant) - self._refresh_distance_matrix() def remove_combatant(self, combatant): if combatant in self.combatants: self.combatants.remove(combatant) - self._refresh_distance_matrix() def get_combat_summary(self, combatant): """ - Get a summary of the current combat state. + Get a summary of the current combat state from the perspective of a + given combatant. You (5/10 health) - Foo (Hurt) distance: You__0__1___X____3_____4 (medium) - Bar (Perfect health): You__X__1___2____3_____4 (close) + Foo (Hurt) [Running away - use 'chase' to stop them!] + Bar (Perfect health) """ table = evtable.EvTable(border_width=0) - table.add_row(f"You ({combatant.hp} / {combatant.hp_max} health)") + # 'You' display + fleeing = "" + if combatant in self.fleeing_combatants: + fleeing = " You are running away! Use 'flee' again next turn." - dist_template = "|x(You)__{0}|x__{1}|x___{2}|x____{3}|x_____|R{4} |x({distname})" + table.add_row(f"You ({combatant.hp} / {combatant.hp_max} health){fleeing}") for comb in self.combatants: @@ -355,20 +394,19 @@ class EvAdventureCombatHandler(DefaultScript): continue name = combatant.key - distance = self.distance_matrix[combatant][comb] - dist_map = {i: '|wX' if i == distance else i for i in range(MAX_RANGE)} - dist_map["distname"] = RANGE_NAMES[distance] health = f"{comb.hurt_level}" - distance_string = dist_template.format(**dist_map) + fleeing = "" + if comb in self.fleeing_combatants: + fleeing = " [Running away! Use 'chase' to stop them!" - table.add_row(f"{name} ({health})", distance_string) + table.add_row(f"{name} ({health}){fleeing}") return str(table) def msg(self, message, targets=None): """ Central place for sending messages to combatants. This allows - for decorating the output in one place if needed. + for adding any combat-specific text-decoration in one place. Args: message (str): The message to send. @@ -384,26 +422,6 @@ class EvAdventureCombatHandler(DefaultScript): for target in self.combatants: target.msg(message) - def move_relative_to(self, combatant, target_combatant, change, - min_dist=MIN_RANGE, max_dist=MAX_RANGE): - """ - Change the distance to a target. - - Args: - combatant (Character): The one doing the change. - target_combatant (Character): The one distance is changed to. - change (int): A +/- change value. Result is always in range 0..4. - - """ - current_dist = self.distance_matrix[combatant][target_combatant] - - change = max(0, min(MAX_MOVE_RATE, change)) - - new_dist = max(min_dist, min(max_dist, current_dist + change)) - - self.distance_matrix[combatant][target_combatant] = new_dist - self.distance_matrix[target_combatant][combatant] = new_dist - def gain_advantage(self, combatant, target): """ Gain advantage against target. Spent by actions. @@ -418,6 +436,14 @@ class EvAdventureCombatHandler(DefaultScript): """ self.disadvantage_matrix[combatant][target] = self.turn + def flee(self, combatant): + if combatant not in self.fleeing_combatants: + self.fleeing_combatants.append(combatant) + + def unflee(self, combatant): + if combatant in self.fleeing_combatants: + self.fleeing_combatants.remove(combatant) + def resolve_damage(self, attacker, defender, critical=False): """ Apply damage to defender. On a critical hit, the damage die @@ -457,7 +483,7 @@ class EvAdventureCombatHandler(DefaultScript): # defender still alive self.msg(defender) - def register_action(self, combatant, action="do_nothing", *args, **kwargs): + def register_action(self, combatant, action=None, *args, **kwargs): """ Register an action by-name. @@ -465,186 +491,24 @@ class EvAdventureCombatHandler(DefaultScript): combatant (Object): The one performing the action. action (str): An available action, will be prepended with `action_` and used to call the relevant handler on this script. - *args: Will be passed to the action method `action_`. - **kwargs: Will be passed into the action method `action_`. """ - if action in self.move_actions: - self.action_queue[combatant]["move"] = (action, args, kwargs) - else: - self.action_queue[combatant]["action"] = (action, args, kwargs) - - # action verbs. All of these start with action_* and should also accept - # *args, **kwargs so that we can make the call-mechanism generic. - - def action_do_nothing(self, combatant, *args, **kwargs): - """Do nothing for a turn.""" - - def action_stunt(self, attacker, defender, attack_type="agility", - defense_type="agility", optimal_distance=0, suboptimal_distance=1, - advantage=True, beneficiary=None, *args, **kwargs): - """ - Stunts does not cause damage but are used to give advantage/disadvantage to combatants - for later turns. The 'attacker' here is the one attemting the stunt against the 'defender'. - If successful, advantage is given to attacker against defender and disadvantage to - defender againt attacker. It's also possible to replace the attacker with another combatant - against the defender - allowing to aid/hinder others on the battlefield. - - Stunt-modifers last a maximum of two turns and are not additive. Advantages and - disadvantages relative to the same target cancel each other out. - - Args: - attacker (Object): The one attempting the stunt. - defender (Object): The one affected by the stunt. - attack_type (str): The ability tested to do the stunt. - defense_type (str): The ability used to defend against the stunt. - optimal_distance (int): At which distance the stunt works normally. - suboptimal_distance (int): At this distance, the stunt is performed at disadvantage. - advantage (bool): If False, try to apply disadvantage to defender - rather than advantage to attacker. - beneficiary (bool): If stunt succeeds, it may benefit another - combatant than the `attacker` doing the stunt. This allows for helping - allies. - - """ - # check if stunt-attacker is at optimal distance - distance = self.distance_matrix[attacker][defender] - disadvantage = False - if suboptimal_distance == distance: - # stunts need to be within range - disadvantage = True - elif self._get_optimal_distance(attacker) != distance: - # if we are neither at optimal nor suboptimal distance, we can't do the stunt - # from here. - raise combatfailure(f"you can't perform this stunt " - f"from {range_names[distance]} distance (must be " - f"{range_names[suboptimal_distance]} or, even better, " - f"{range_names[optimal_distance]}).") - # quality doesn't matter for stunts, they are either successful or not - is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw( - attacker, defender, - attack_type=attack_type, - defense_type=defense_type, - advantage=False, disadvantage=disadvantage, - ) - if is_success: - beneficiary = beneficiary if beneficiary else attacker - if advantage: - self.gain_advantage(beneficiary, defender) - else: - self.gain_disadvantage(defender, beneficiary) - - return is_success - - def action_attack(self, attacker, defender, *args, **kwargs): - """ - Make an attack against a defender. This takes into account distance. The - attack type/defense depends on the weapon/spell/whatever used. - - """ - # check if attacker is at optimal distance - distance = self.distance_matrix[attacker][defender] - - # figure out advantage (gained by previous stunts) - advantage = bool(self.advantage_matrix[attacker].pop(defender, False)) - - # figure out disadvantage (by distance or by previous action) - disadvantage = bool(self.disadvantage_matrix[attacker].pop(defender, False)) - if self._get_suboptimal_distance(attacker) == distance: - # fighting at the wrong range is not good - disadvantage = True - elif self._get_optimal_distance(attacker) != distance: - # if we are neither at optimal nor suboptimal distance, we can't - # attack from this range - raise CombatFailure(f"You can't attack with {attacker.weapon.key} " - f"from {RANGE_NAMES[distance]} distance.") - - is_hit, quality = rules.EvAdventureRollEngine.opposed_saving_throw( - attacker, defender, - attack_type=attacker.weapon.attack_type, - defense_type=attacker.weapon.defense_type, - advantage=advantage, disadvantage=disadvantage - ) - if is_hit: - self.resolve_damage(attacker, defender, critical=quality == "critical success") - - return is_hit - - def action_heal(self, combatant, target, max_distance=1, healing_roll="1d6", *args, **kwargs): - """ - Heal a target. Target can be the combatant itself. - - Args: - combatant (Object): The one performing the heal. - target (Object): The one to be healed (can be the same as combatant). - max_distance (int): Distances *up to* this range allow for healing. - healing_roll (str): The die roll for how many HP to heal. - - Raises: - CombatFailure: If too far away to heal target. - - """ - if target is not combatant: - distance = self.distance_matrix[attacker][defender] - if distance > max_distance: - raise CombatFailure(f"Too far away to heal {target.key}.") - - target.heal(rules.EvAdventureRollEngine.roll(healing_roll), healer=combatant) - - def action_approach(self, combatant, other_combatant, change, *args, **kwargs): - """ - Approach target. Closest is 0. This can be combined with another action. - - """ - self.move_relative_to(combatant, other_combatant, -abs(change), min_dist=MIN_RANGE) - - def action_withdraw(self, combatant, other_combatant, change): - """ - Withdraw from target. Most distant is range 3 - further and you'll be disengaging. - This can be combined with another action. - - """ - self.move_relative_to(combatant, other_combatant, abs(change), max_dist=3) - - def action_flee(self, combatant, *args, **kwargs): - """ - Fleeing/disengaging from combat means moving towards 'disengaging' range from - everyone else and staying there for one turn. - - """ - for other_combatant in self.combatants: - self.move_relative_to(combatant, other_combatant, MAX_MOVE_RATE, max_dist=MAX_RANGE) - - def action_chase(self, combatant, fleeing_target, *args, **kwargs): - """ - Chasing is a way to counter a 'flee' action. It is a maximum movement towards the target - and will mean a DEX contest, if the fleeing target loses, they are moved back from - 'disengaging' range and remain in combat at the new distance (likely 2 if max movement - is 2). Advantage/disadvantage are considered. - - """ - ability = "dexterity" - - advantage = bool(self.advantage_matrix[attacker].pop(fleeing_target, False)) - disadvantage = bool(self.disadvantage_matrix[attacker].pop(fleeing_target, False)) - - is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw( - combatant, fleeing_target, - attack_type=ability, defense_type=ability, - advantage=advantage, disadvantage=disadvantage - ) - - if is_success: - # managed to stop the target from fleeing/disengaging - move closer - if fleeing_target in self.disengaging_combatants: - self.disengaging_combatants.remove(fleeing_target) - self.approach(combatant, fleeing_target, change=MAX_MOVE_RATE) - - return is_success + if not action: + action = CombatActionDoNothing + self.action_queue[combatant] = (action, args, kwargs) # combat menu +combat_script = """ + + + + +""" + + + def _register_action(caller, raw_string, **kwargs): """ Register action with handler. diff --git a/evennia/contrib/tutorials/evadventure/quests.py b/evennia/contrib/tutorials/evadventure/quests.py new file mode 100644 index 0000000000..0eefba39d1 --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/quests.py @@ -0,0 +1,121 @@ +""" +A simple quest system for EvAdventure. + +A quest is represented by a quest-handler sitting as +.quest on a Character. Individual Quests are objects +that track the state and can have multiple steps, each +of which are checked off during the quest's progress. + +The player can use the quest handler to track the +progress of their quests. + +A quest ending can mean a reward or the start of +another quest. + +""" + + +class EvAdventureQuest: + """ + This represents a single questing unit of quest. + + 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 two methods on this object: + check_ and complete_ + + """ + # name + category must be globally unique. They are + # queried as name:category or just name, if category is empty. + name = "" + category = "" + # example: steps = ["start", "step1", "step2", "end"] + steps = [] + + def __init__(self): + step = 0 + + def check(): + pass + + + def progress(self, quester, *args, **kwargs): + """ + + """ + +class EvAdventureQuestHandler: + """ + This sits on the Character, as `.quests`. + + It's initiated using a lazy property on the Character: + + ``` + @lazy_property + def quests(self): + return EvAdventureQuestHandler(self) + ``` + + """ + quest_storage_attribute = "_quests" + quest_storage_attribute_category = "evadventure" + + def __init__(self, obj): + self.obj = obj + self.storage = obj.attributes.get( + self.quest_storage_attribute, + category=self.quest_storage_attribute_category, + default={} + ) + + def quest_storage_key(self, name, category): + return f"{name}:{category}" + + def has(self, quest_name, quest_category=""): + """ + Check if a given quest is registered with the Character. + + Args: + quest_name (str): The name of the quest to check for. + quest_category (str, optional): Quest category, if any. + + Returns: + bool: If the character is following this quest or not. + + """ + return bool(self.get(quest_name, quest_category)) + + def get(self, quest_name, quest_category=""): + """ + Get the quest stored on character, if any. + + Args: + quest_name (str): The name of the quest to check for. + quest_category (str, optional): Quest category, if any. + + Returns: + EvAdventureQuest or None: The quest stored, or None if + Character is not on this quest. + + """ + return self.storage.get(self.quest_key(quest_storage_name, quest_category)) + + def add(self, quest, autostart=True): + """ + Add a new quest + + Args: + quest (EvAdventureQuest): The quest to start. + autostart (bool, optional): If set, the quest will + start immediately. + + """ + + +