diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index 597a0838e8..fa6e97ed42 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -99,16 +99,19 @@ Choose who to block: """ +import random from collections import defaultdict, deque from evennia.scripts.scripts import DefaultScript from evennia.typeclasses.attributes import AttributeProperty from evennia.utils import dbserialize, delay, evmenu, evtable, logger -from evennia.utils.utils import inherits_from +from evennia.utils.utils import inherits_from, list_to_string from . import rules -from .enums import Ability +from .characters import EvAdventureCharacter +from .enums import Ability, ObjType from .npcs import EvAdventureNPC +from .objects import EvAdventureObject COMBAT_HANDLER_KEY = "evadventure_turnbased_combathandler" COMBAT_HANDLER_INTERVAL = 30 @@ -123,6 +126,346 @@ class CombatFailure(RuntimeError): """ +# Combat action classes + + +class CombatAction: + """ + Parent class for all actions. + + This represents the executable code to run to perform an action. It is initialized from an + 'action-dict', a set of properties stored in the action queue by each combatant. + + """ + + def __init__(self, combathandler, combatant, action_dict): + """ + Each key-value pair in the action-dict is stored as a property on this class + for later access. + + Args: + combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing + the action. + action_dict (dict): A dict containing all properties to initialize on this + class. This should not be any keys with `_` prefix, since these are + used internally by the class. + + """ + self.combathandler = combathandler + self.combatant = combatant + + for key, val in action_dict.items(): + setattr(self, key, val) + + # advantage / disadvantage + # These should be read as 'does have dis/advantaget against '. + def give_advantage(self, recipient, target, **kwargs): + self.combathandler.advantage_matrix[recipient][target] = True + + def give_disadvantage(self, recipient, target, **kwargs): + self.combathandler.disadvantage_matrix[recipient][target] = True + + def has_advantage(self, recipient, target): + return bool(self.combathandler.advantage_matrix[recipient].pop(target, False)) + + def has_disadvantage(self, recipient, target): + return bool(self.combathandler.disadvantage_matrix[recipient].pop(target, False)) + + def lose_advantage(self, recipient, target): + self.combathandler.advantage_matrix[recipient][target] = False + + def lose_disadvantage(self, recipient, target): + self.combathandler.disadvantage_matrix[recipient][target] = False + + def flee(self, fleer): + if fleer not in self.combathandler.fleeing_combatants: + # we record the turn on which we started fleeing + self.combathandler.fleeing_combatants[fleer] = self.combathandler.turn + + def unflee(self, fleer): + self.combathandler.fleeing_combatants.pop(fleer, None) + + def msg(self, message, broadcast=True): + """ + Convenience route to the combathandler msg-sender mechanism. + + Args: + message (str): Message to send; use `$You()` and `$You(other.key)` to refer to + the combatant doing the action and other combatants, respectively. + + """ + self.combathandler.msg(self, message, combatant=self.combatant, broadcast=broadcast) + + def can_use(self): + """ + Called to determine if the action is usable with the current settings. This does not + actually perform the action. + + Returns: + bool: If this action can be used at this time. + + """ + return True + + def execute(self): + """ + Perform the action as the combatant. Should normally make use of the properties + stored on the class during initialization. + + """ + pass + + +class CombatActionDoNothing(CombatAction): + """ + Action that does nothing. + + Note: + Refer to as 'nothing' + """ + + +class CombatActionAttack(CombatAction): + """ + A regular attack, using a wielded weapon. + + action-dict ('attack') + { + "defender": Character/Object + } + + Note: + Refer to as 'attack' + + """ + + def execute(self): + attacker = self.combatant + weapon = attacker.weapon + defender = self.defender + + is_hit, quality, txt = rules.dice.opposed_saving_throw( + attacker, + defender, + attack_type=weapon.attack_type, + defense_type=attacker.weapon.defense_type, + advantage=self.has_advantage(attacker, defender), + disadvantage=self.has_disadvantage(attacker, defender), + ) + self.msg(f"$You() $conj(attack) $You({defender.key}) with {weapon.key}: {txt}") + if is_hit: + # enemy hit, calculate damage + weapon_dmg_roll = attacker.weapon.damage_roll + + dmg = rules.dice.roll(weapon_dmg_roll) + + if quality is Ability.CRITICAL_SUCCESS: + dmg += rules.dice.roll(weapon_dmg_roll) + message = ( + f" $You() |ycritically|n $conj(hit) $You({defender.key}) for |r{dmg}|n damage!" + ) + else: + message = f" $You() $conj(hit) $You({defender.key}) for |r{dmg}|n damage!" + self.msg(message) + + # call hook + defender.at_damage(dmg, attacker=attacker) + + # note that we mustn't remove anyone from combat yet, because this is + # happening simultaneously. So checking of the final hp + # and rolling of death etc happens in the combathandler at the end of the turn. + + else: + # a miss + message = f" $You() $conj(miss) $You({defender.key})." + if quality is Ability.CRITICAL_FAILURE: + attacker.weapon.quality -= 1 + message += ".. it's a |rcritical miss!|n, damaging the weapon." + self.msg(message) + + +class CombatActionStunt(CombatAction): + """ + Perform a stunt the grants a beneficiary (can be self) advantage on their next action against a + target. Whenever performing a stunt that would affect another negatively (giving them disadvantage + against an ally, or granting an advantage against them, we need to make a check first. We don't + do a check if giving an advantage to an ally or ourselves. + + action_dict: + { + "recipient": Character/NPC, + "target": Character/NPC, + "advantage": bool, # if False, it's a disadvantage + "stunt_type": Ability, # what ability (like STR, DEX etc) to use to perform this stunt. + "defense_type": Ability, # what ability to use to defend against (negative) effects of this + stunt. + } + + Note: + refer to as 'stunt'. + + """ + + def execute(self): + attacker = self.combatant + recipient = self.recipient # the one to receive the effect of the stunt + target = self.target # the affected by the stunt (can be the same as recipient/combatant) + is_success = False + + if target == self.combatant: + # can always grant dis/advantage against yourself + defender = attacker + is_success = True + elif recipient == target: + # grant another entity dis/advantage against themselves + defender = recipient + else: + # recipient not same as target; who will defend depends on disadvantage or advantage + # to give. + defender = target if self.advantage else recipient + + if not is_success: + # trying to give advantage to recipient against target. Target defends against caller + is_success, _, txt = rules.dice.opposed_saving_throw( + attacker, + defender, + attack_type=self.stunt_type, + defense_type=self.defense_type, + advantage=self.has_advantage(attacker, defender), + disadvantage=self.has_disadvantage(attacker, defender), + ) + + # deal with results + self.msg(f"$You() $conj(attempt) stunt on $You(defender.key). {txt}") + if is_success: + if self.advantage: + self.give_advantage(recipient, target) + else: + self.give_disadvantage(recipient, target) + self.msg( + f"%You() $conj(cause) $You({recipient.key}) " + f"to gain {'advantage' if self.advantage else 'disadvantage'} " + f"against $You({target.key})!" + ) + else: + self.msg(f"$You({target.key}) resists! $You() $conj(fail) the stunt.") + + +class CombatActionUseItem(CombatAction): + """ + Use an item in combat. This is meant for one-off or limited-use items (so things like + scrolls and potions, not swords and shields). If this is some sort of weapon or spell rune, + we refer to the item to determine what to use for attack/defense rolls. + + action_dict: } + "item": Object + "target": Character/NPC/Object + } + + Note: + Refer to as 'use' + + """ + + def execute(self): + + item = self.item + user = self.combatant + target = self.target + + if user == target: + # always manage to use the item on yourself + is_success = True + else: + if item.has_obj_type(ObjType.WEAPON): + # this is something that harms the target. We need to roll defense + is_success, _, txt = rules.dice.opposed_saving_throw( + user, + target, + attack_type=item.attack_type, + defense_type=item.defense_type, + advantage=self.has_advantage(user, target), + disadvantage=self.has_disadvantage(user, target), + ) + + item.at_use(self.combatant, self.target) + + +class CombatActionWield(CombatAction): + """ + Wield a new weapon (or spell) from your inventory. This will swap out the one you are currently + wielding, if any. + + action_dict = { + "item": Object + } + + Note: + Refer to as 'wield'. + + """ + + def execute(self): + self.combatant.equipment.move(self.item) + + +class CombatActionFlee(CombatAction): + """ + Start (or continue) fleeing/disengaging from combat. + + Note: + Refer to as 'flee'. + + """ + + def execute(self): + self.msg( + "$You() $conj(retreat), and will leave combat next round unless someone successfully " + "blocks the escape." + ) + self.flee(self.combatant) + + +class CombatActionHinder(CombatAction): + """ + Hinder a fleeing opponent from fleeing/disengaging from combat. + + action_dict = { + "target": Character/NPC + } + + Note: + Refer to as 'hinder' + + """ + + def execute(self): + + hinderer = self.combatant + target = self.target + + is_success, _, txt = rules.dice.opposed_saving_throw( + hinderer, + target, + attack_type=Ability.DEX, + defense_type=Ability.DEX, + advantage=self.has_advantage(hinderer, target), + disadvantage=self.has_disadvantage(hinderer, target), + ) + + # handle result + self.msg( + f"$You() $conj(try) to block the retreat of $You({target.key}). {txt}", + ) + if is_success: + # managed to stop the target from fleeing/disengaging + self.unflee(target) + self.msg(f"$You() $conj(block) the retreat of $You({target.key})") + else: + # failed to hinder the target + self.msg(f"$You({target.key}) $conj(dodge) away from you $You()!") + + class EvAdventureCombatHandler(DefaultScript): """ This script is created when a combat starts. It 'ticks' the combat and tracks @@ -134,12 +477,59 @@ class EvAdventureCombatHandler(DefaultScript): max_action_queue_size = 1 # available actions - action_classes = {} + action_classes = { + "nothing": CombatActionDoNothing, + "attack": CombatActionAttack, + } + + # fallback action if not selecting anything + fallback_action = "attack" + + # persistent storage + turn = AttributeProperty(0) # who is involved in combat, and their action queue, # as {combatant: [actiondict, actiondict,...]} combatants = AttributeProperty(defaultdict(list)) + advantage_matrix = AttributeProperty(defaultdict(dict)) + disadvantage_matrix = AttributeProperty(defaultdict(dict)) + + fleeing_combatants = AttributeProperty(dict) + defeated_combatants = AttributeProperty(dict) + + def msg(self, message, combatant=None, broadcast=True): + """ + Central place for sending messages to combatants. This allows + for adding any combat-specific text-decoration in one place. + + Args: + message (str): The message to send. + combatant (Object): The 'You' in the message, if any. + broadcast (bool): If `False`, `combatant` must be included and + will be the only one to see the message. If `True`, send to + everyone in the location. + + Notes: + If `combatant` is given, use `$You/you()` markup to create + a message that looks different depending on who sees it. Use + `$You(combatant_key)` to refer to other combatants. + + """ + location = self.obj + location_objs = location.contents + + exclude = [] + if not broadcast and combatant: + exclude = [obj for obj in location_objs if obj is not combatant] + + location.msg_contents( + message, + exclude=exclude, + from_obj=combatant, + mapping={locobj.key: locobj for locobj in location_objs}, + ) + def add_combatant(self, combatant): """ Add a new combatant to the battle. @@ -163,6 +553,52 @@ class EvAdventureCombatHandler(DefaultScript): """ self.combatants.pop(combatant, None) + def stop_combat(self): + """ + Stop the combat immediately. + + """ + for combatant in self.combatants: + self.remove_combatant(combatant) + self.stop() + self.delete() + + 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 = [comb for comb in pcs if comb != combatant] + enemies = npcs + else: + # combatant is an NPC, so PCs are all enemies + allies = [comb for comb in npcs if comb != combatant] + enemies = pcs + return allies, enemies + def queue_action(self, combatant, action_dict): """ Queue an action by adding the new actiondict to the back of the queue. If the @@ -181,7 +617,7 @@ class EvAdventureCombatHandler(DefaultScript): """ self.combatants[combatant].append(action_dict) - def do_next_action(self, combatant): + 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 'do nothing'. We don't pop anything from the queue, instead we keep @@ -204,9 +640,72 @@ class EvAdventureCombatHandler(DefaultScript): # 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(**action_dict) + action = action_class(combatant, action_dict) - action.execute(combatant) + action.execute() + + def execute_full_turn(self): + """ + Perform a full turn of combat, performing everyone's actions in random order. + + """ + self.turn += 1 + # random turn order + combatants = random.shuffle(list(self.combatants.keys())) + + # do everyone's next queued combat action + for combatant in combatants: + self.execute_next_action(combatant) + + # 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.defeated_combatants.append(self.combatant.pop(combatant)) + self.msg("|r$You() $conj(fall) to the ground, defeated.|n", combatant=combatant) + + # check if anyone managed to flee + for combatant, started_fleeing in dict(self.fleeing_combatants): + if self.turn - started_fleeing > 1: + # if they are still alive/fleeing and started fleeing >1 round ago, they succeed + 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 of they have any living enemies. + surviving_combatant = random.choice(list(self.combatant.keys())) + allies, enemies = self.get_sides(surviving_combatant) + + if not enemies: + # 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( + f"$You({comb.key})" for comb in self.defeated_combatants if comb.hp > 0 + ) + killed = list_to_string( + comb for comb in self.defeated_combatants if comb not in knocked_out + ) + + 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() + + +# Command-based combat commands # ----------------------------------------------------------------------------------- diff --git a/evennia/contrib/tutorials/evadventure/enums.py b/evennia/contrib/tutorials/evadventure/enums.py index a5a7130a5b..26972cfc58 100644 --- a/evennia/contrib/tutorials/evadventure/enums.py +++ b/evennia/contrib/tutorials/evadventure/enums.py @@ -67,6 +67,7 @@ class ObjType(Enum): HELMET = "helmet" CONSUMABLE = "consumable" GEAR = "gear" + THROWABLE = "throwable" MAGIC = "magic" QUEST = "quest" TREASURE = "treasure" diff --git a/evennia/contrib/tutorials/evadventure/objects.py b/evennia/contrib/tutorials/evadventure/objects.py index 6ce919cea6..7d5decd5ea 100644 --- a/evennia/contrib/tutorials/evadventure/objects.py +++ b/evennia/contrib/tutorials/evadventure/objects.py @@ -146,6 +146,21 @@ class EvAdventureConsumable(EvAdventureObject): self.delete() +class EvAdventureThrowable(EvAdventureWeapon, EvAdventureConsumable): + """ + Something you can throw at an enemy to harm them once, like a knife or exploding potion/grenade. + + Note: In Knave, ranged attacks are done with WIS (representing the stillness of your mind?) + + """ + + obj_type = (ObjType.THROWABLE, ObjType.WEAPON, ObjType.CONSUMABLE) + + attack_type = AttributeProperty(Ability.WIS) + defense_type = AttributeProperty(Ability.DEX) + damage_roll = AttributeProperty("1d6") + + class WeaponEmptyHand: """ This is a dummy-class loaded when you wield no weapons. We won't create any db-object for it.