Source code for evennia.contrib.tutorials.evadventure.combat_turnbased

"""
EvAdventure Turn-based combat

This implements a turn-based (Final Fantasy, etc) style of MUD combat.

In this variation, all combatants are sharing the same combat handler, sitting on the current room.
The user will receive a menu of combat options and each combatat has a certain time time (e.g. 30s)
to select their next action or do nothing. To speed up play, as soon as everyone in combat selected
their next action, the next turn runs immediately, regardless of the timeout.

With this example, all chosen combat actions are considered to happen at the same time (so you are
able to kill and be killed in the same turn).

Unlike in twitch-like combat, there is no movement while in turn-based combat. Fleeing is a select
action that takes several vulnerable turns to complete.

----

"""

import random
from collections import defaultdict

from evennia import AttributeProperty, CmdSet, Command, EvMenu
from evennia.utils import inherits_from, list_to_string

from .characters import EvAdventureCharacter
from .combat_base import (
    CombatAction,
    CombatActionAttack,
    CombatActionHold,
    CombatActionStunt,
    CombatActionUseItem,
    CombatActionWield,
    EvAdventureCombatBaseHandler,
)
from .enums import Ability


# turnbased-combat needs the flee action too
[docs]class CombatActionFlee(CombatAction): """ Start (or continue) fleeing/disengaging from combat. action_dict = { "key": "flee", } Note: Refer to as 'flee'. """
[docs] def execute(self): combathandler = self.combathandler if self.combatant not in combathandler.fleeing_combatants: # we record the turn on which we started fleeing combathandler.fleeing_combatants[self.combatant] = self.combathandler.turn # show how many turns until successful flight current_turn = combathandler.turn started_fleeing = combathandler.fleeing_combatants[self.combatant] flee_timeout = combathandler.flee_timeout time_left = flee_timeout - (current_turn - started_fleeing) - 1 if time_left > 0: self.msg( "$You() $conj(retreat), being exposed to attack while doing so (will escape in " f"{time_left} $pluralize(turn, {time_left}))." )
[docs]class EvAdventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler): """ A version of the combathandler, handling turn-based combat. """ # available actions in combat action_classes = { "hold": CombatActionHold, "attack": CombatActionAttack, "stunt": CombatActionStunt, "use": CombatActionUseItem, "wield": CombatActionWield, "flee": CombatActionFlee, } # how many turns you must be fleeing before escaping flee_timeout = AttributeProperty(1, autocreate=False) # fallback action if not selecting anything fallback_action_dict = AttributeProperty({"key": "hold"}, autocreate=False) # persistent storage turn = AttributeProperty(0) # who is involved in combat, and their queued action # as {combatant: actiondict, ...} combatants = AttributeProperty(dict) # who has advantage against whom advantage_matrix = AttributeProperty(defaultdict(dict)) disadvantage_matrix = AttributeProperty(defaultdict(dict)) fleeing_combatants = AttributeProperty(dict) defeated_combatants = AttributeProperty(list) # usable script properties # .is_active - show if timer is running
[docs] def give_advantage(self, combatant, target): """ Let a benefiter gain advantage against the target. Args: combatant (Character or NPC): The one to gain the advantage. This may or may not be the same entity that creates the advantage in the first place. target (Character or NPC): The one against which the target gains advantage. This could (in principle) be the same as the benefiter (e.g. gaining advantage on some future boost) """ self.advantage_matrix[combatant][target] = True
[docs] def give_disadvantage(self, combatant, target, **kwargs): """ Let an affected party gain disadvantage against a target. Args: recipient (Character or NPC): The one to get the disadvantage. target (Character or NPC): The one against which the target gains disadvantage, usually an enemy. """ self.disadvantage_matrix[combatant][target] = True
[docs] def has_advantage(self, combatant, target, **kwargs): """ Check if a given combatant has advantage against a target. Args: combatant (Character or NPC): The one to check if they have advantage target (Character or NPC): The target to check advantage against. """ return target in self.fleeing_combatants or bool( self.advantage_matrix[combatant].pop(target, False) )
[docs] def has_disadvantage(self, combatant, target): """ Check if a given combatant has disadvantage against a target. Args: combatant (Character or NPC): The one to check if they have disadvantage target (Character or NPC): The target to check disadvantage against. """ return bool(self.disadvantage_matrix[combatant].pop(target, False))
[docs] def add_combatant(self, combatant): """ Add a new combatant to the battle. Can be called multiple times safely. Args: combatant (EvAdventureCharacter, EvAdventureNPC): Any number of combatants to add to the combat. Returns: bool: If this combatant was newly added or not (it was already in combat). """ if combatant not in self.combatants: self.combatants[combatant] = self.fallback_action_dict return True return False
[docs] def remove_combatant(self, combatant): """ Remove a combatant from the battle. This removes their queue. Args: combatant (EvAdventureCharacter, EvAdventureNPC): A combatant to add to the combat. """ self.combatants.pop(combatant, None) # clean up menu if it exists if combatant.ndb._evmenu: combatant.ndb._evmenu.close_menu()
[docs] def start_combat(self, **kwargs): """ This actually starts the combat. It's safe to run this multiple times since it will only start combat if it isn't already running. """ if not self.is_active: self.start(**kwargs)
[docs] def stop_combat(self): """ Stop the combat immediately. """ for combatant in self.combatants: self.remove_combatant(combatant) self.stop() self.delete()
[docs] def get_combat_summary(self, combatant): """Add your next queued action to summary""" summary = super().get_combat_summary(combatant) next_action = self.get_next_action_dict(combatant) or {"key": "hold"} next_repeat = self.time_until_next_repeat() summary = ( f"{summary}\n Your queued action: [|b{next_action['key']}|n] (|b{next_repeat}s|n until" " next round,\n or until all combatants have chosen their next action)." ) return summary
[docs] def get_sides(self, combatant): """ Get a listing of the two 'sides' of this combat, from the perspective of the provided combatant. The sides don't need to be balanced. Args: combatant (Character or NPC): The one whose sides are to determined. Returns: tuple: A tuple of lists `(allies, enemies)`, from the perspective of `combatant`. Note: The sides are found by checking PCs vs NPCs. PCs can normally not attack other PCs, so are naturally allies. If the current room has the `allow_pvp` Attribute set, then _all_ other combatants (PCs and NPCs alike) are considered valid enemies (one could expand this with group mechanics). """ if self.obj.allow_pvp: # in pvp, everyone else is an ememy allies = [combatant] enemies = [comb for comb in self.combatants if comb != combatant] else: # otherwise, enemies/allies depend on who combatant is pcs = [comb for comb in self.combatants if inherits_from(comb, EvAdventureCharacter)] npcs = [comb for comb in self.combatants if comb not in pcs] if combatant in pcs: # combatant is a PC, so NPCs are all enemies allies = pcs enemies = npcs else: # combatant is an NPC, so PCs are all enemies allies = npcs enemies = pcs return allies, enemies
[docs] def queue_action(self, combatant, action_dict): """ Queue an action by adding the new actiondict. Args: combatant (EvAdventureCharacter, EvAdventureNPC): A combatant queueing the action. action_dict (dict): A dict describing the action class by name along with properties. """ self.combatants[combatant] = action_dict # track who inserted actions this turn (non-persistent) did_action = set(self.ndb.did_action or set()) did_action.add(combatant) if len(did_action) >= len(self.combatants): # everyone has inserted an action. Start next turn without waiting! self.force_repeat()
[docs] def get_next_action_dict(self, combatant): """ Give the action_dict for the next action that will be executed. Args: combatant (EvAdventureCharacter, EvAdventureNPC): The combatant to get the action for. Returns: dict: The next action-dict in the queue. """ return self.combatants.get(combatant, self.fallback_action_dict)
[docs] def execute_next_action(self, combatant): """ Perform a combatant's next queued action. Note that there is _always_ an action queued, even if this action is 'hold', which means the combatant will do nothing. Args: combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing and action. """ # this gets the next dict and rotates the queue action_dict = self.combatants.get(combatant, self.fallback_action_dict) # use the action-dict to select and create an action from an action class action_class = self.action_classes[action_dict["key"]] action = action_class(self, combatant, action_dict) action.execute() action.post_execute() if action_dict.get("repeat", False): # queue the action again *without updating the *.ndb.did_action list* (otherwise # we'd always auto-end the turn if everyone used repeating actions and there'd be # no time to change it before the next round) self.combatants[combatant] = action_dict else: # if not a repeat, set the fallback action self.combatants[combatant] = self.fallback_action_dict
[docs] def check_stop_combat(self): """Check if it's time to stop combat""" # check if anyone is defeated for combatant in list(self.combatants.keys()): if combatant.hp <= 0: # PCs roll on the death table here, NPCs die. Even if PCs survive, they # are still out of the fight. combatant.at_defeat() self.combatants.pop(combatant) self.defeated_combatants.append(combatant) self.msg("|r$You() $conj(fall) to the ground, defeated.|n", combatant=combatant) # check if anyone managed to flee flee_timeout = self.flee_timeout for combatant, started_fleeing in self.fleeing_combatants.items(): if self.turn - started_fleeing >= flee_timeout - 1: # if they are still alive/fleeing and have been fleeing long enough, escape self.msg("|y$You() successfully $conj(flee) from combat.|n", combatant=combatant) self.remove_combatant(combatant) # check if one side won the battle if not self.combatants: # noone left in combat - maybe they killed each other or all fled surviving_combatant = None allies, enemies = (), () else: # grab a random survivor and check if they have any living enemies. surviving_combatant = random.choice(list(self.combatants.keys())) allies, enemies = self.get_sides(surviving_combatant) if not enemies: # if one way or another, there are no more enemies to fight still_standing = list_to_string(f"$You({comb.key})" for comb in allies) knocked_out = list_to_string(comb for comb in self.defeated_combatants if comb.hp > 0) killed = list_to_string(comb for comb in self.defeated_combatants if comb.hp <= 0) if still_standing: txt = [f"The combat is over. {still_standing} are still standing."] else: txt = ["The combat is over. No-one stands as the victor."] if knocked_out: txt.append(f"{knocked_out} were taken down, but will live.") if killed: txt.append(f"{killed} were killed.") self.msg(txt) self.stop_combat()
[docs] def at_repeat(self): """ This method is called every time Script repeats (every `interval` seconds). Performs a full turn of combat, performing everyone's actions in random order. """ self.turn += 1 # random turn order combatants = list(self.combatants.keys()) random.shuffle(combatants) # shuffles in place # do everyone's next queued combat action for combatant in combatants: self.execute_next_action(combatant) self.ndb.did_action = set() # check if one side won the battle self.check_stop_combat()
# ----------------------------------------------------------------------------------- # # Turn-based combat (Final Fantasy style), using a menu # # Activate by adding the CmdTurnCombat command to Character cmdset, then # use it to attack a target. # # ----------------------------------------------------------------------------------- def _get_combathandler(caller, turn_timeout=30, flee_time=3, combathandler_key="combathandler"): """ Get the combat handler for the caller's location. If it doesn't exist, create it. Args: caller (EvAdventureCharacter or EvAdventureNPC): The character/NPC to get the combat handler for. turn_timeout (int): After this time, the turn will roll around. flee_time (int): How many turns it takes to flee. """ return EvAdventureTurnbasedCombatHandler.get_or_create_combathandler( caller.location, interval=turn_timeout, attributes=[("flee_time", flee_time)], key=combathandler_key, ) def _queue_action(caller, raw_string, **kwargs): """ Goto-function that queue the action with the CombatHandler. This always returns to the top-level combat menu "node_combat" """ action_dict = kwargs["action_dict"] _get_combathandler(caller).queue_action(caller, action_dict) return "node_combat" def _rerun_current_node(caller, raw_string, **kwargs): return None, kwargs def _get_default_wizard_options(caller, **kwargs): """ Get the standard wizard options for moving back/forward/abort. This can be appended to the end of other options. """ return [ {"key": ("back", "b"), "goto": (_step_wizard, {**kwargs, **{"step": "back"}})}, {"key": ("abort", "a"), "goto": "node_combat"}, { "key": "_default", "goto": (_rerun_current_node, kwargs), }, ] def _step_wizard(caller, raw_string, **kwargs): """ Many options requires stepping through several steps, wizard style. This will redirect back/forth in the sequence. E.g. Stunt boost -> Choose ability to boost -> Choose recipient -> Choose target -> queue """ steps = kwargs.get("steps", []) nsteps = len(steps) istep = kwargs.get("istep", -1) # one of abort, back, forward step_direction = kwargs.get("step", "forward") if step_direction == "back": # step back in wizard if istep <= 0: return "node_combat" istep = kwargs["istep"] = istep - 1 return steps[istep], kwargs else: # step to the next step in wizard if istep >= nsteps - 1: # we are already at end of wizard - queue action! return _queue_action(caller, raw_string, **kwargs) else: # step forward istep = kwargs["istep"] = istep + 1 return steps[istep], kwargs
[docs]def node_choose_enemy_target(caller, raw_string, **kwargs): """ Choose an enemy as a target for an action """ text = "Choose an enemy to target." action_dict = kwargs["action_dict"] combathandler = _get_combathandler(caller) _, enemies = combathandler.get_sides(caller) options = [ { "desc": target.get_display_name(caller), "goto": ( _step_wizard, {**kwargs, **{"action_dict": {**action_dict, **{"target": target}}}}, ), } for target in enemies ] options.extend(_get_default_wizard_options(caller, **kwargs)) return text, options
[docs]def node_choose_enemy_recipient(caller, raw_string, **kwargs): """ Choose an enemy as a 'recipient' for an action. """ text = "Choose an enemy as a recipient." action_dict = kwargs["action_dict"] combathandler = _get_combathandler(caller) _, enemies = combathandler.get_sides(caller) options = [ { "desc": target.get_display_name(caller), "goto": ( _step_wizard, {**kwargs, **{"action_dict": {**action_dict, **{"recipient": target}}}}, ), } for target in enemies ] options.extend(_get_default_wizard_options(caller, **kwargs)) return text, options
[docs]def node_choose_allied_target(caller, raw_string, **kwargs): """ Choose an enemy as a target for an action """ text = "Choose an ally to target." action_dict = kwargs["action_dict"] combathandler = _get_combathandler(caller) allies, _ = combathandler.get_sides(caller) options.extend( [ { "desc": target.get_display_name(caller), "goto": ( _step_wizard, { **kwargs, **{"action_dict": {**action_dict, **{"target": target}}}, }, ), } for target in allies ] ) options.extend(_get_default_wizard_options(caller, **kwargs)) return text, options
[docs]def node_choose_allied_recipient(caller, raw_string, **kwargs): """ Choose an allied recipient for an action """ text = "Choose an ally as a recipient." action_dict = kwargs["action_dict"] combathandler = _get_combathandler(caller) allies, _ = combathandler.get_sides(caller) options.extend( [ { "desc": target.get_display_name(caller), "goto": ( _step_wizard, { **kwargs, **{ "action_dict": { **action_dict, **{"recipient": target}, } }, }, ), } for target in allies ] ) options.extend(_get_default_wizard_options(caller, **kwargs)) return text, options
[docs]def node_choose_ability(caller, raw_string, **kwargs): """ Select an ability to use/boost etc. """ text = "Choose the ability to apply" action_dict = kwargs["action_dict"] options = [ { "desc": abi.value, "goto": ( _step_wizard, { **kwargs, **{ "action_dict": {**action_dict, **{"stunt_type": abi, "defense_type": abi}}, }, }, ), } for abi in ( Ability.STR, Ability.DEX, Ability.CON, Ability.INT, Ability.INT, Ability.WIS, Ability.CHA, ) ] options.extend(_get_default_wizard_options(caller, **kwargs)) return text, options
[docs]def node_choose_use_item(caller, raw_string, **kwargs): """ Choose item to use. """ text = "Select the item" action_dict = kwargs["action_dict"] options = [ { "desc": item.get_display_name(caller), "goto": ( _step_wizard, {**kwargs, **{"action_dict": {**action_dict, **{"item": item}}}}, ), } for item in caller.equipment.get_usable_objects_from_backpack() ] if not options: text = "There are no usable items in your inventory!" options.extend(_get_default_wizard_options(caller, **kwargs)) return text, options
[docs]def node_choose_wield_item(caller, raw_string, **kwargs): """ Choose item to use. """ text = "Select the item" action_dict = kwargs["action_dict"] options = [ { "desc": item.get_display_name(caller), "goto": ( _step_wizard, {**kwargs, **{"action_dict": {**action_dict, **{"item": item}}}}, ), } for item in caller.equipment.get_wieldable_objects_from_backpack() ] if not options: text = "There are no items in your inventory that you can wield!" options.extend(_get_default_wizard_options(caller, **kwargs)) return text, options
[docs]def node_combat(caller, raw_string, **kwargs): """Base combat menu""" combathandler = _get_combathandler(caller) text = combathandler.get_combat_summary(caller) options = [ { "desc": "attack an enemy", "goto": ( _step_wizard, { "steps": ["node_choose_enemy_target"], "action_dict": {"key": "attack", "target": None, "repeat": True}, }, ), }, { "desc": "Stunt - gain a later advantage against a target", "goto": ( _step_wizard, { "steps": [ "node_choose_ability", "node_choose_enemy_target", "node_choose_allied_recipient", ], "action_dict": {"key": "stunt", "advantage": True}, }, ), }, { "desc": "Stunt - give an enemy disadvantage against yourself or an ally", "goto": ( _step_wizard, { "steps": [ "node_choose_ability", "node_choose_enemy_recipient", "node_choose_allied_target", ], "action_dict": {"key": "stunt", "advantage": False}, }, ), }, { "desc": "Use an item on yourself or an ally", "goto": ( _step_wizard, { "steps": ["node_choose_use_item", "node_choose_allied_target"], "action_dict": {"key": "use", "item": None, "target": None}, }, ), }, { "desc": "Use an item on an enemy", "goto": ( _step_wizard, { "steps": ["node_choose_use_item", "node_choose_enemy_target"], "action_dict": {"key": "use", "item": None, "target": None}, }, ), }, { "desc": "Wield/swap with an item from inventory", "goto": ( _step_wizard, { "steps": ["node_choose_wield_item"], "action_dict": {"key": "wield", "item": None}, }, ), }, { "desc": "flee!", "goto": (_queue_action, {"action_dict": {"key": "flee", "repeat": True}}), }, { "desc": "hold, doing nothing", "goto": (_queue_action, {"action_dict": {"key": "hold"}}), }, { "key": "_default", "goto": "node_combat", }, ] return text, options
# Add this command to the Character cmdset to make turn-based combat available.
[docs]class CmdTurnAttack(Command): """ Start or join combat. Usage: attack [<target>] """ key = "attack" aliases = ["hit", "turnbased combat"] turn_timeout = 30 # seconds flee_time = 3 # rounds
[docs] def parse(self): super().parse() self.args = self.args.strip()
[docs] def func(self): if not self.args: self.msg("What are you attacking?") return target = self.caller.search(self.args) if not target: return if not hasattr(target, "hp"): self.msg("You can't attack that.") return elif target.hp <= 0: self.msg(f"{target.get_display_name(self.caller)} is already down.") return if target.is_pc and not target.location.allow_pvp: self.msg("PvP combat is not allowed here!") return combathandler = _get_combathandler(self.caller, self.turn_timeout, self.flee_time) # add combatants to combathandler. this can be done safely over and over combathandler.add_combatant(self.caller) combathandler.queue_action(self.caller, {"key": "attack", "target": target}) combathandler.add_combatant(target) target.msg("|rYou are attacked by {self.caller.get_display_name(self.caller)}!|n") combathandler.start_combat() # build and start the menu EvMenu( self.caller, { "node_choose_enemy_target": node_choose_enemy_target, "node_choose_allied_target": node_choose_allied_target, "node_choose_enemy_recipient": node_choose_enemy_recipient, "node_choose_allied_recipient": node_choose_allied_recipient, "node_choose_ability": node_choose_ability, "node_choose_use_item": node_choose_use_item, "node_choose_wield_item": node_choose_wield_item, "node_combat": node_combat, }, startnode="node_combat", combathandler=combathandler, auto_look=False, # cmdset_mergetype="Union", persistent=True, )
[docs]class TurnCombatCmdSet(CmdSet): """ CmdSet for the turn-based combat. """ key = "turncombat_cmdset"
[docs] def at_cmdset_creation(self): self.add(CmdTurnAttack())