diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index 9edb8910b0..62b7ffbc95 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -16,13 +16,95 @@ This version is simplified to not worry about things like optimal range etc. So the same as a sword in battle. One could add a 1D range mechanism to add more strategy by requiring optimizal positioning. +The combat is controlled through a menu: + +------------------- main menu +Combat + +You have 30 seconds to choose your next action. If you don't decide, you will hesitate and do +nothing. Available actions: + +1. [A]ttack/[C]ast spell at using your equipped weapon/spell +3. Make [S]tunt (gain/give advantage/disadvantage for future attacks) +4. S[W]ap weapon / spell rune +5. [U]se +6. [F]lee/disengage (takes two turns) +7. [B]lock from fleeing +8. [H]esitate/Do nothing + +You can also use say/emote between rounds. +As soon as all combatants have made their choice (or time out), the round will be resolved +simultaneusly. + +-------------------- attack/cast spell submenu + +Choose the target of your attack/spell: +0: Yourself 3: (wounded) +1: (hurt) +2: (unharmed) + +------------------- make stunt submenu + +Stunts are special actions that don't cause damage but grant advantage for you or +an ally for future attacks - or grant disadvantage to your enemy's future attacks. +The effects of stunts start to apply *next* round. The effect does not stack, can only +be used once and must be taken advantage of within 5 rounds. + +Choose stunt: +1: Trip (give disadvantage DEX) +2: Feint (get advantage DEX against target) +3: ... + +-------------------- make stunt target submenu + +Choose the target of your stunt: +0: Yourself 3: (wounded) +1: (hurt) +2: (unharmed) + +------------------- swap weapon or spell run + +Choose the item to wield. +1: +2: (two hands) +3: +4: ... + +------------------- use item + +Choose item to use. +1: Healing potion (+1d6 HP) +2: Magic pebble (gain advantage, 1 use) +3: Potion of glue (give disadvantage to target) + +------------------- Hesitate/Do nothing + +You hang back, passively defending. + +------------------- Disengage + +You retreat, getting ready to get out of combat. Use two times in a row to +leave combat. You flee last in a round. If anyone Blocks your retreat, this counter resets. + +------------------- Block Fleeing + +You move to block the escape route of an opponent. If you win a DEX challenge, +you'll negate the target's disengage action(s). + +Choose who to block: +1: +2: +3: ... + + """ +from datetime import datetime from collections import defaultdict 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, dbserialize +from evennia.utils import evtable, dbserialize, delay, evmenu from .enums import Ability from . import rules @@ -32,18 +114,29 @@ from . import rules class CombatFailure(RuntimeError): """ Some failure during actions. + """ class CombatAction: """ - This is the base of a combat-action, like 'attack' or defend. - Inherit from this to make new actions. + This is the base of a combat-action, like 'attack' Inherit from this to make new actions. + + Note: + We want to store initialized version of this objects in the CombatHandler (in order to track + usages, time limits etc), so we need to make sure we can serialize it into an Attribute. See + `Attribute` documentation for more about `__serialize_dbobjs__` and `__deserialize_dbobjs__`. """ - key = "action" + key = "Action" + desc = "Option text" + aliases = [] help_text = "Combat action to perform." + + # if no target is needed (always affect oneself) + no_target = False + # action to echo to everyone. post_action_text = "{combatant} performed an action." max_uses = None # None for unlimited @@ -64,23 +157,40 @@ class CombatAction: self.combatant.msg(message) def __serialize_dbobjs__(self): + """ + This is necessary in order to be able to store this entity in an Attribute. + We must make sure to tell Evennia how to serialize internally stored db-objects. + + The `__serialize_dbobjs__` and `__deserialize_dbobjs__` methods form a required pair. + + """ self.combathandler = dbserialize.dbserialize(self.combathandler) self.combatant = dbserialize.dbserialize(self.combatant) def __deserialize_dbobjs__(self): + """ + This is necessary in order to be able to store this entity in an Attribute. + We must make sure to tell Evennia how to deserialize internally stored db-objects. + + The `__serialize_dbobjs__` and `__deserialize_dbobjs__` methods form a required pair. + + """ self.combathandler = dbserialize.dbunserialize(self.combathandler) self.combatant = dbserialize.dbunserialize(self.combatant) def get_help(self, *args, **kwargs): + """ + Allows to customize help message on the fly. By default, just returns `.help_text`. + + """ return self.help_text - def can_use(self, combatant, *args, **kwargs): + def can_use(self, *args, **kwargs): """ Determine if combatant can use this action. In this implementation, - it fails if already use all of a usage-limited action. + it fails if already used up all of a usage-limited action. Args: - combatant (Object): The one performing the action. *args: Any optional arguments. **kwargs: Any optional keyword arguments. @@ -99,66 +209,7 @@ class CombatAction: def post_use(self, *args, **kwargs): self.uses += 1 - self.combathandler.msg(self.post_action_text.format(combatant=combatant)) - - -class CombatActionDoNothing(CombatAction): - """ - Do nothing this turn. - - """ - - help_text = "Hold you position, doing nothing." - post_action_text = "{combatant} does nothing this turn." - - -class CombatActionStunt(CombatAction): - """ - 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. - - """ - - give_advantage = True - give_disadvantage = False - max_uses = 1 - priority = -1 - 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 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( - attacker, - defender, - attack_type=self.attack_type, - defense_type=self.defense_type, - advantage=False, - disadvantage=disadvantage, - ) - if is_success: - beneficiary = beneficiary if beneficiary else attacker - if advantage: - self.combathandler.gain_advantage(beneficiary, defender) - else: - self.combathandler.gain_disadvantage(defender, beneficiary) - - self.msg - # only spend a use after being successful - uses += 1 + self.combathandler.msg(self.post_action_text.format(**kwargs)) class CombatActionAttack(CombatAction): @@ -168,14 +219,20 @@ class CombatActionAttack(CombatAction): """ - key = "attack" + key = "Attack or Cast" + desc = "[A]ttack/[C]ast spell at " + aliases = ("a", "c", "attack", "cast") + help_text = "Make an attack using your currently equipped weapon/spell rune" + priority = 1 - def use(self, attacker, defender, *args, **kwargs): + def use(self, defender, *args, **kwargs): """ Make an attack against a defender. """ + attacker = self.combatant + # figure out advantage (gained by previous stunts) advantage = bool(self.combathandler.advantage_matrix[attacker].pop(defender, False)) @@ -198,11 +255,70 @@ class CombatActionAttack(CombatAction): # TODO messaging here +class CombatActionStunt(CombatAction): + """ + 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. + + """ + + key = "Perform a Stunt" + desc = "Make [S]tunt against " + aliases = ("s", "stunt") + help_text = ( + "A stunt does not cause damage but grants/gives advantage/disadvantage to future " + "actions. The effect needs to be used up within 5 turns." + ) + + give_advantage = True + give_disadvantage = False + max_uses = 1 + priority = -1 + attack_type = Ability.DEX + defense_type = Ability.DEX + help_text = ( + "Perform a stunt against a target. This will give you an advantage or an enemy " + "disadvantage on your next action." + ) + + def use(self, defender, *args, **kwargs): + # quality doesn't matter for stunts, they are either successful or not + + attacker = self.combatant + advantage, disadvantage = False + + is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw( + attacker, + defender, + attack_type=self.attack_type, + defense_type=self.defense_type, + advantage=advantage, + disadvantage=disadvantage, + ) + if is_success: + if advantage: + self.combathandler.gain_advantage(attacker, defender) + else: + self.combathandler.gain_disadvantage(defender, attacker) + + self.msg + # only spend a use after being successful + self.uses += 1 + + 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). + 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. @@ -214,59 +330,78 @@ class CombatActionUseItem(CombatAction): combat_post_use """ + key = "Use Item" + desc = "[U]se item" + aliases = ("u", "item", "use item") + help_text = "Use an item from your inventory." 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 can_use(self, item, *args, **kwargs): + return item.combat_can_use(self.combatant, self.combathandler, *args, **kwargs) def pre_use(self, item, *args, **kwargs): - item.combat_pre_use(*args, **kwargs) + item.combat_pre_use(self.combatant, *args, **kwargs) - def use(self, item, combatant, target, *args, **kwargs): - item.combat_use(combatant, target, *args, **kwargs) + def use(self, item, target, *args, **kwargs): + item.combat_use(self.combatant, target, *args, **kwargs) def post_use(self, item, *args, **kwargs): - item.combat_post_use(*args, **kwargs) + item.combat_post_use(self.combatant, *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 + someone attempts and succeeds in their 'block' action, you will leave combat by fleeing at the end of the second turn. """ - key = "flee" - priority = -1 + key = "Flee/Disengage" + desc = "[F]lee/disengage from combat (takes two turns)" + aliases = ("d", "disengage", "flee") - def use(self, combatant, target, *args, **kwargs): - # it's safe to do this twice - self.combathandler.flee(combatant) + # this only affects us + no_target = True + help_text = ( + "Disengage from combat. Use successfully two times in a row to leave combat at the " + "end of the second round. If someone Blocks you successfully, this counter is reset." + ) -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? + def use(self, *args, **kwargs): + # it's safe to do this twice + self.combathandler.flee(self.combatant) + + +class CombatActionBlock(CombatAction): + + """ + Blocking is, in this context, a way to counter an enemy's 'Flee/Disengage' action. + + """ + + key = "Block" + desc = "[B]lock from fleeing" + aliases = ("b", "block", "chase") + help_text = ( + "Move to block a target from fleeing combat. If you succeed " + "in a DEX vs DEX challenge, they don't get away." + ) + + priority = -1 # must be checked BEFORE the flee action of the target! + + attack_type = Ability.DEX 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)) + advantage = bool(self.advantage_matrix[combatant].pop(fleeing_target, False)) + disadvantage = bool(self.disadvantage_matrix[combatant].pop(fleeing_target, False)) is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw( combatant, @@ -284,6 +419,23 @@ class CombatActionChase(CombatAction): pass # they are getting away! +class CombatActionDoNothing(CombatAction): + """ + Do nothing this turn. + + """ + + key = "Hesitate" + desc = "Do [N]othing/Hesitate" + aliases = ("n", "hesitate", "nothing", "do nothing") + help_text = "Hold you position, doing nothing." + + # affects noone else + no_target = True + + post_action_text = "{combatant} does nothing this turn." + + class EvAdventureCombatHandler(DefaultScript): """ This script is created when combat is initialized and stores a queue @@ -294,22 +446,27 @@ class EvAdventureCombatHandler(DefaultScript): # we use the same duration for all stunts stunt_duration = 3 - # these will all be checked if they are available at a given time. - all_action_classes = [ - CombatActionDoNothing, - CombatActionChase, - CombatActionUseItem, - CombatActionStunt, + # Default actions available to everyone + default_action_classes = [ CombatActionAttack, + CombatActionStunt, + CombatActionUseItem, + CombatActionFlee, + CombatActionBlock, + CombatActionDoNothing, ] # attributes # stores all combatants active in the combat combatants = AttributeProperty(list()) + # each combatant has its own set of actions that may or may not be available + # every round + combatant_actions = AttributeProperty(defaultdict(dict)) + action_queue = AttributeProperty(dict()) - turn_stats = AttributeProperty(defaultdict(list)) + turn_stats = AttributeProperty(dict()) # turn counter - abstract time turn = AttributeProperty(default=0) @@ -317,22 +474,35 @@ class EvAdventureCombatHandler(DefaultScript): advantage_matrix = AttributeProperty(defaultdict(dict)) disadvantage_matrix = AttributeProperty(defaultdict(dict)) - fleeing_combatants = AttributeProperty(default=list()) + fleeing_combatants = AttributeProperty(list()) - # how often this script ticks - the length of each turn (in seconds) - interval = 60 + _warn_time_task = None + + def at_script_creation(self): + + # how often this script ticks - the max length of each turn (in seconds) + self.interval = 60 def at_repeat(self, **kwargs): """ Called every self.interval seconds. The main tick of the script. """ + if self._warn_time_task: + self._warn_time_task.remove() + if self.turn == 0: self._start_turn() else: self._end_turn() self._start_turn() + def _reset_menu(self): + """ + Move menu to the action-selection node. + + """ + def _update_turn_stats(self, combatant, message): """ Store combat messages to display at the end of turn. @@ -340,6 +510,13 @@ class EvAdventureCombatHandler(DefaultScript): """ self.turn_stats[combatant].append(message) + def _warn_time(self, time_remaining): + """ + Send a warning message when time is about to run out. + + """ + self.msg(f"{time_remaining} seconds left in round!") + def _start_turn(self): """ New turn events @@ -349,6 +526,17 @@ class EvAdventureCombatHandler(DefaultScript): self.action_queue = {} self.turn_stats = defaultdict(list) + # start a timer to echo a warning to everyone 15 seconds before end of round + if self.interval >= 0: + # set -1 for unit tests + warning_time = 15 + self._warn_time_task = delay( + self.interval - warning_time, self._warn_time, warning_time) + + for combatant in self.combatants: + # cycle combat menu + combatant.ndb._evmenu.goto("node_select_action", "") + def _end_turn(self): """ End of turn operations. @@ -365,7 +553,7 @@ class EvAdventureCombatHandler(DefaultScript): combatant, (CombatActionDoNothing(self, combatant), (), {}) ) # perform the action on the CombatAction instance - action.use(combatant, *args, **kwargs) + action.use(*args, **kwargs) # handle disengaging combatants @@ -375,7 +563,7 @@ class EvAdventureCombatHandler(DefaultScript): # check disengaging combatants (these are combatants that managed # to stay at disengaging distance for a turn) if combatant in self.fleeing_combatants: - self.disengaging_combatants.remove(combatant) + self.fleeing_combatants.remove(combatant) for combatant in to_remove: # for clarity, we remove here rather than modifying the combatant list @@ -399,34 +587,118 @@ class EvAdventureCombatHandler(DefaultScript): for combatant in self.combatants: new_advantage_matrix[combatant] = { target: set_at_turn - for target, turn in advantage_matrix.items() + for target, set_at_turn in advantage_matrix.items() if set_at_turn > oldest_stunt_age } new_disadvantage_matrix[combatant] = { target: set_at_turn - for target, turn in disadvantage_matrix.items() + for target, set_at_turn in disadvantage_matrix.items() if set_at_turn > oldest_stunt_age } self.advantage_matrix = new_advantage_matrix self.disadvantage_matrix = new_disadvantage_matrix - def add_combatant(self, combatant): + if len(self.combatants) == 1: + # only one combatant left - abort combat + self.stop_combat() + + def add_combatant(self, combatant, session=None): + """ + Add combatant to battle. + + Args: + combatant (Object): The combatant to add. + session (Session, optional): Session to use. + + Notes: + This adds them to the internal list and initiates + all possible actions. If the combatant as an Attribute list + `custom_combat_actions` containing `CombatAction` items, this + will injected and if the `.key` matches, will replace the + default action classes. + + """ if combatant not in self.combatants: self.combatants.append(combatant) + # allow custom character actions (not used by default) + custom_action_classes = combatant.db.custom_combat_actions or [] + + self.combatant_actions[combatant] = { + action_class.key: action_class(self, combatant) + for action_class in self.default_action_classes + custom_action_classes + } + + # start evmenu (menu node definitions at the end of this module) + + evmenu.EvMenu( + combatant, + { + "node_wait_start": node_wait_start, + "node_select_target": node_select_target, + "node_selct_action": node_select_action, + "node_wait_turn": node_wait_turn, + }, + startnode="node_wait_turn", + auto_quit=False, + persistent=True, + session=session, + ) + def remove_combatant(self, combatant): + """ + Remove combatant from battle. + + Args: + combatant (Object): The combatant to remove. + + """ if combatant in self.combatants: self.combatants.remove(combatant) + self.combatant_actions.pop(combatant, None) + combatant.ndb._evmenu.close_menu() + + def start_combat(self): + """ + Start the combat timer and get everyone going. + + """ + for combatant in self.combatants: + combatant.ndb._evmenu.goto("node_select_action", "") + self.start() # starts the script timer + self._start_turn() + + def stop_combat(self): + """ + This is used to stop the combat immediately. + + It can also be called from external systems, for example by + monster AI can do this when only allied players remain. + + """ + for combatant in self.combatants: + self.remove_combatant(combatant) def get_combat_summary(self, combatant): """ Get a summary of the current combat state from the perspective of a given combatant. - You (5/10 health) - Foo (Hurt) [Running away - use 'chase' to stop them!] - Bar (Perfect health) + Args: + combatant (Object): The combatant to get the summary for + + Returns: + str: The summary. + + Example: + + ``` + You (5/10 health) + Foo (Hurt) [Running away - use 'block' to stop them!] + Bar (Perfect health) + + ``` """ table = evtable.EvTable(border_width=0) @@ -447,7 +719,7 @@ class EvAdventureCombatHandler(DefaultScript): health = f"{comb.hurt_level}" fleeing = "" if comb in self.fleeing_combatants: - fleeing = " [Running away! Use 'chase' to stop them!" + fleeing = " [Running away! Use 'block' to stop them!" table.add_row(f"{name} ({health}){fleeing}") @@ -536,28 +808,54 @@ class EvAdventureCombatHandler(DefaultScript): # defender still alive self.msg(defender) - def register_action(self, action, combatant, *args, **kwargs): + def register_action(self, combatant, action_key, *args, **kwargs): """ - Register an action by-name. + Register an action based on its `.key`. Args: combatant (Object): The one performing the action. - action (CombatAction): An available action class to use. + action_key (str): The action to perform, by its `.key`. + *args: Arguments to pass to `action.use`. + **kwargs: Kwargs to pass to `action.use`. """ - if not action: - action = CombatActionDoNothing - self.action_queue[combatant] = (action(self, combatant), args, kwargs) + # get the instantiated action for this combatant + action = self.combatant_actions[combatant].get( + action_key, + CombatActionDoNothing(self, combatant) + ) + + # store the action in the queue + self.action_queue[combatant] = (action, args, kwargs) + + if len(self.action_queue) >= len(self.combatants): + # all combatants registered actions - force the script + # to cycle (will fire at_repeat) + self.force_repeat() + + def get_available_actions(self, combatant, *args, **kwargs): + """ + Get only the actions available to a combatant. + + Args: + combatant (Object): The combatant to get actions for. + *args: Passed to `action.can_use()` + **kwargs: Passed to `action.can_use()` + + Returns: + list: The initiated CombatAction instances available to the + combatant right now. + + Note: + We could filter this by `.can_use` return already here, but then it would just + be removed from the menu. Instead we return all and use `.can_use` in the menu + so we can include the option but gray it out. + + """ + return list(self.combatant_actions[combatant].values()) -# combat menu - -combat_script = """ - - - - -""" +# ------------ start combat menu definitions def _register_action(caller, raw_string, **kwargs): @@ -565,11 +863,16 @@ def _register_action(caller, raw_string, **kwargs): Register action with handler. """ - action = kwargs.get["action"] + action_key = kwargs.get["action_key"] action_args = kwargs["action_args"] action_kwargs = kwargs["action_kwargs"] - combat = caller.scripts.get("combathandler") - combat.register_action(caller, action=action, *action_args, **action_kwargs) + action_target = kwargs["action_target"] + combat_handler = caller._evmenu.combathandler + combat_handler.register_action( + caller, action_key, action_target, *action_args, **action_kwargs) + + # move into waiting + return "node_wait_turn" def node_select_target(caller, raw_string, **kwargs): @@ -578,36 +881,61 @@ def node_select_target(caller, raw_string, **kwargs): with all other actions. """ - action = kwargs.get("action") + action_key = kwargs.get("action_key") action_args = kwargs.get("action_args") action_kwargs = kwargs.get("action_kwargs") combat = caller.scripts.get("combathandler") - text = "Select target for |w{action}|n." + text = "Select target for |w{action_key}|n." - combatants = [combatant for combatant in combat.combatants if combatant is not caller] + # make the apply-self option always the first one, give it key 0 options = [ { - "desc": combatant.key, - "goto": ( - _register_action, - {"action": action, "args": action_args, "kwargs": action_kwargs}, - ), - } - for combatant in combat.combatants - ] - # make the apply-self option always the last one - options.append( - { + "key": "0", "desc": "(yourself)", "goto": ( _register_action, - {"action": action, "args": action_args, "kwargs": action_kwargs}, + { + "action_key": action_key, + "action_args": action_args, + "action_kwargs": action_kwargs, + "action_target": caller, + }, ), } - ) + ] + # filter out ourselves and then make options for everyone else + combatants = [combatant for combatant in combat.combatants if combatant is not caller] + for combatant in combatants: + # automatic menu numbering starts from 1 + options.append( + { + "desc": combatant.key, + "goto": ( + _register_action, + { + "action_key": action_key, + "action_args": action_args, + "action_kwargs": action_kwargs, + "action_target": combatant, + }, + ), + } + ) + return text, options +def _action_unavailable(caller, raw_string, **kwargs): + """ + Selecting an unavailable action. + + """ + action_key = kwargs.get["action_key"] + caller.msg(f"Action '{action_key}' is currently not available.") + # go back to previous node + return + + def node_select_action(caller, raw_string, **kwargs): """ Menu node for selecting a combat action. @@ -615,16 +943,142 @@ def node_select_action(caller, raw_string, **kwargs): """ combat = caller.scripts.get("combathandler") text = combat.get_previous_turn_status(caller) - options = combat.get_available_options(caller) - options = { - "desc": action, - "goto": ( - "node_select_target", - { - "action": action, - }, - ), - } + options = [] + for icount, action in enumerate(combat.get_available_actions(caller)): + # we handle counts manually so we can grey the entire line if action is unavailable. + key = str(icount + 1) + desc = action.desc + + if not action.can_use(): + # action is unavailable. Greyscale the option if not available and route to the + # _action_unavailable helper + key = f"|x{key}|n" + desc = f"|x{desc}|n" + + options.append( + { + "key": key, + "desc": desc, + "goto": ( + _action_unavailable, + { + "action_key": action.key + } + ) + } + ) + elif action.no_target: + # action is available, and requires no target. Redirect to register + # without going via the select-target node. + options.append( + { + "key": key, + "desc": desc, + "goto": ( + _register_action, + { + "action_key": action.key, + "action_args": (), + "action_kwargs": kwargs, + "action_target": None, + }, + ), + } + ) + else: + # action is available and requires a target, so we will select a target next. + options.append( + { + "key": key, + "desc": desc, + "goto": ( + "node_select_target", + { + "action_key": action.key, + "action_args": (), + "action_kwargs": kwargs, + }, + ), + } + ) return text, options + + +def node_wait_turn(caller, raw_string, **kwargs): + """ + Menu node routed to waiting for the round to end (for everyone to choose their actions). + + All menu actions route back to the same node. The CombatHandler will handle moving everyone back + to the `node_select_action` node when the next round starts. + + """ + text = "Waiting for other combatants ..." + + options = { + "key": "_default", + "desc": "(next round will start automatically)", + "goto": "node_wait_turn" + } + return text, options + + +def node_wait_start(caller, raw_string, **kwargs): + """ + Menu node entered when waiting for the combat to start. New players joining an existing + combat will end up here until the previous round is over, at which point the combat handler + will goto everyone to `node_select_action`. + + """ + text = "Waiting for combat round to start ..." + + options = { + "key": "_default", + "desc": "(combat will start automatically)", + "goto": "node_wait_start" + } + return text, options + + +# -------------- end of combat menu definitions + + +def join_combat(caller, *targets, combathandler=None, session=None): + """ + Join or create a new combat involving caller and at least one target, + + Args: + caller (Object): The one starting the combat. + *targets (Objects): Any other targets to pull into combat. At least one target + is required if `combathandler` is not given (a new combat must have at least + one opponent!). + + Keyword Args: + combathandler (EvAdventureCombatHandler): If not given, a new combat will be created and + at least one `*targets` argument must be provided. If given, caller will + join an existing combat. + session (Session, optional): A player session to use. This is useful for multisession modes. + + Returns: + EvAdventureCombatHandler: A created or existing combat handler. + + """ + created = False + if not combathandler: + if not targets: + raise CombatFailure("Must have an opponent to start combat.") + combathandler, _ = EvAdventureCombatHandler.create( + f"Combat_{datetime.utcnow()}", + autostart=False, # means we must use .start() to start the script + ) + created = True + + combathandler.add_combatant(caller, session=session) + for target in targets: + combathandler.add_combatant(target, session=session) + + if created: + combathandler.start_combat() + + return combathandler diff --git a/evennia/contrib/tutorials/evadventure/tests/test_combat.py b/evennia/contrib/tutorials/evadventure/tests/test_combat.py index 9668cefedc..195c6222a5 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_combat.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_combat.py @@ -16,6 +16,7 @@ class EvAdventureTurnbasedCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest): Test the turn-based combat-handler implementation. """ + maxDiff = None @patch( "evennia.contrib.tutorials.evadventure.combat_turnbased" @@ -24,29 +25,40 @@ class EvAdventureTurnbasedCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest): ) def setUp(self): super().setUp() - self.combathandler = combat_turnbased.EvAdventureCombatHandler.objects.create() self.combatant = self.character self.target = create.create_object(EvAdventureCharacter, key="testchar2") - self.combathandler.add_combatant(self.combatant) - self.combathandler.add_combatant(self.target) + + # this already starts turn 1 + self.combathandler = combat_turnbased.join_combat(self.combatant, self.target) + + def tearDown(self): + self.combathandler.delete() def test_remove_combatant(self): self.combathandler.remove_combatant(self.character) def test_start_turn(self): - self.combathandler._start_turn() - self.assertEqual(self.combathandler.turn, 1) self.combathandler._start_turn() self.assertEqual(self.combathandler.turn, 2) + self.combathandler._start_turn() + self.assertEqual(self.combathandler.turn, 3) def test_end_of_turn__empty(self): self.combathandler._end_turn() def test_register_and_run_action(self): - action = combat_turnbased.CombatActionAttack + action_class = combat_turnbased.CombatActionAttack + action = self.combathandler.combatant_actions[self.combatant][action_class.key] + + self.combathandler.register_action(self.combatant, action.key) + + self.assertEqual( + self.combathandler.action_queue[self.combatant], + (action, (), {}) + ) + action.use = MagicMock() - self.combathandler.register_action(action, self.combatant) self.combathandler._end_turn() action.use.assert_called_once() @@ -54,6 +66,6 @@ class EvAdventureTurnbasedCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest): def test_attack(self, mock_randint): mock_randint.return_value = 8 self.combathandler.register_action( - combat_turnbased.CombatActionAttack, self.combatant, self.target + combat_turnbased.CombatActionAttack.key, self.combatant, self.target ) self.combathandler._end_turn() diff --git a/evennia/scripts/taskhandler.py b/evennia/scripts/taskhandler.py index 59cbeb12a5..d0401bedb1 100644 --- a/evennia/scripts/taskhandler.py +++ b/evennia/scripts/taskhandler.py @@ -337,7 +337,7 @@ class TaskHandler(object): Returns: TaskHandlerTask: An object to represent a task. - Reference evennia.scripts.taskhandler.TaskHandlerTask for complete details. + Reference `evennia.scripts.taskhandler.TaskHandlerTask` for complete details. """ # set the completion time