diff --git a/evennia/contrib/tutorials/evadventure/characters.py b/evennia/contrib/tutorials/evadventure/characters.py index bd397db48c..e655f1e1fb 100644 --- a/evennia/contrib/tutorials/evadventure/characters.py +++ b/evennia/contrib/tutorials/evadventure/characters.py @@ -8,6 +8,7 @@ from evennia.objects.objects import DefaultCharacter, DefaultObject from evennia.typeclasses.attributes import AttributeProperty from evennia.utils.utils import lazy_property, int2str from .objects import EvAdventureObject +from . import rules class EquipmentError(TypeError): @@ -284,6 +285,7 @@ class EvAdventureCharacter(DefaultCharacter): """ + # these are the ability bonuses. Defense is always 10 higher strength = AttributeProperty(default=1) dexterity = AttributeProperty(default=1) constitution = AttributeProperty(default=1) @@ -308,6 +310,24 @@ class EvAdventureCharacter(DefaultCharacter): """Allows to access equipment like char.equipment.worn""" return EquipmentHandler(self) + @property + def weapon(self): + """ + Quick access to the character's currently wielded weapon. + Will return the "Unarmed" weapon if none other are found. + + """ + # TODO + + @property + def armor(self): + """ + Quick access to the character's current armor. + Will return the "Unarmored" armor if none other are found. + + """ + # TODO + def at_pre_object_receive(self, moved_object, source_location, **kwargs): """ Hook called by Evennia before moving an object here. Return False to abort move. @@ -358,6 +378,37 @@ class EvAdventureCharacter(DefaultCharacter): self.equipment.remove(moved_object) + def at_pre_damage(self, dmg, attacker=None): + """ + Called when receiving damage for whatever reason. This + is called *before* hp is evaluated for defeat/death. + + """ + def at_post_damage(self, dmg, attacker=None): + """ + Called when receiving damage for whatever reason. This + is called *before* hp is evaluated for defeat/death. + + """ + + def defeat_message(self, attacker, dmg): + return f"After {attacker.key}'s attack, {self.key} collapses in a heap." + + def at_defeat(self, attacker, dmg): + """ + At this point, character has been defeated but is not killed (their + hp >= 0 but they lost ability bonuses). Called after being defeated in combat or + other situation where health is lost below or equal to 0. + + """ + + def handle_death(self): + """ + Called when character dies. + + """ + + class EvAdventureNPC(DefaultCharacter): """ This is the base class for all non-player entities, including monsters. These diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index 55de4316d1..3b081f32f0 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -19,41 +19,25 @@ from dataclasses import dataclass from collections import defaultdict from evennia.scripts.scripts import DefaultScript from evennia.typeclasses.attributes import AttributeProperty +from evennia.utils.utils import make_iter from . import rules MIN_RANGE = 0 MAX_RANGE = 4 -@dataclass -class CombatantStats: +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 AttackFailure(RuntimeError): """ - Represents temporary combat-only data we need to track - during combat for a single Character. - + Cannot attack for some reason. """ - weapon = None - armor = None - # abstract distance relationship to other combatants - distance_matrix = {} - # actions may affect what works better/worse next round - advantage_actions_next_turn = [] - disadvantage_actions_next_turn = [] - - def get_distance(self, target): - return self.distance_matrix.get(target) - - def change_distance(self, target, change): - """ - Change the distance to an opponent. This is symmetric. - - Args: - target (Object): The target to change the distance to. - change (int): How to change the distance. Negative values to - approach, positive to move away from target. - """ - current_dist = self.distance_matrix.get(target) # will raise error if None, as it should - new_dist = max(MIN_RANGE, min(MAX_RANGE, current_dist + change)) - self.distance_matrix[target] = target.distance_matrix[self] = new_dist class EvAdventureCombat(DefaultScript): @@ -62,12 +46,18 @@ class EvAdventureCombat(DefaultScript): of all active participants. It's also possible to join (or leave) the fray later. """ - combatants = AttributeProperty(default=dict()) - queue = AttributeProperty(default=list()) + combatants = AttributeProperty(default=list()) + action_queue = AttributeProperty(default=dict()) + # turn counter - abstract time - turn = AttributeProperty(default=1) - # symmetric distance matrix - distance_matrix = {} + 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)) + + stunt_duration = 2 def _refresh_distance_matrix(self): """ @@ -85,55 +75,255 @@ class EvAdventureCombat(DefaultScript): 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 3 steps without attacking. + 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. """ - handled = [] - for combatant1, combatant_stats1 in self.combatants.items(): - for combatant2, combatant_stats2 in self.combatants.items(): + combatants = self.combatants + distance_matrix = self.distance_matrix + + for combatant1 in combatants: + for combatant2 in combatants: if combatant1 == combatant2: continue - # only update if data was not available already (or drifted - # out of sync, which should not happen) - dist1 = combatant_stats1.get_distance(combatant2) - dist2 = combatant_stats2.get_distance(combatant1) - if None in (dist1, dist2) or dist1 != dist2: - # a new distance-relation - start out at average distance - avg_range = round(0.5 * (combatant1.weapon.range_optimal - + combatant2.weapon.range_optimal)) - combatant_stats1.distance_matrix[combatant2] = avg_range - combatant_stats2.distance_matrix[combatant1] = avg_range + combatant1_distances = distance_matrix[combatant1] + combatant2_distances = distance_matrix[combatant2] - handled.append(combatant1) - handled.append(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) - self.combatants = handled + combatant1_distances[combatant2] = start_optimal + combatant2_distances[combatant1] = start_optimal - def _move_relative_to(self, combatant, target_combatant, change): + def _start_turn(self): + """ + New turn events + + """ + self.turn += 1 + self.action_queue = {} + + def _end_turn(self): + """ + End of turn cleanup. + + """ + # refresh stunt timeouts + oldest_stunt_age = self.turn - self.stunt_duration + + advantage_matrix = self.advantage_matrix + disadvantage_matrix = self.disadvantage_matrix + + # to avoid modifying the dict while we iterate over it, we + # put the results in new dicts. This also avoids us having to + # delete from the old dicts. + new_advantage_matrix = {} + new_disadvantage_matrix = {} + + for combatant in self.combatants: + new_advantage_matrix[combatant] = { + target: set_at_turn for target, 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() + if set_at_turn > oldest_stunt_age + } + self.advantage_matrix = new_advantage_matrix + self.disadvantage_matrix = new_disadvantage_matrix + + def msg(self, message, targets=None): + """ + Central place for sending messages to combatants. This allows + for decorating the output in one place if needed. + + Args: + message (str): The message to send. + targets (Object or list, optional): Sends message only to + one or more particular combatants. If unset, send to + everyone in the combat. + + """ + if targets: + for target in make_iter(targets): + target.msg(message) + else: + for target in self.combatants: + target.msg(message) + + 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 move_relative_to(self, combatant, target_combatant, change): """ Change the distance to a target. Args: combatant (Character): The one doing the change. - target_combatant (Character): The one changing towards. + target_combatant (Character): The one distance is changed to. change (int): A +/- change value. Result is always in range 0..4. """ - self.combatants[combatant].change_distance(target_combatant, change) - self.combatants[target_combatant].change_distance(combatant, change) + current_dist = self.distance_matrix[combatant][target_combatant] - def add_combatant(self, combatant): - self.combatants[combatant] = CombatantStats( - weapon=combatant.equipment.get("weapon"), - armor=combatant.equipment.armor, + new_dist = max(MIN_RANGE, min(MAX_RANGE, 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. + + """ + self.advantage_matrix[combatant][target] = self.turn + + def gain_disadvantage(self, combatant, target): + """ + Gain disadvantage against target. Spent by actions. + + """ + self.disadvantage_matrix[combatant][target] = self.turn + + def resolve_damage(self, attacker, defender, critical=False): + """ + Apply damage to defender. On a critical hit, the damage die + is rolled twice. + + """ + weapon_dmg_roll = attacker.weapon.damage_roll + + dmg = rules.EvAdventureRollEngine.roll(weapon_dmg_roll) + if critical: + dmg += rules.EvAdventureRollEngine.roll(weapon_dmg_roll) + + defender.hp -= dmg + + # call hook + defender.at_damage(dmg, attacker=attacker) + + if defender.hp <= 0: + # roll on death table. This may or may not kill you + rules.EvAdventureRollEngine.roll_death(self) + + # tell everyone + self.msg(defender.defeat_message(attacker, dmg)) + + if defender.hp > 0: + # they are weakened, but with hp + self.msg("You are alive, but out of the fight. If you want to press your luck, " + "you need to rejoin the combat.", targets=defender) + defender.at_defeat() # note - NPC monsters may still 'die' here + else: + # outright killed + defender.at_death() + + # no matter the result, the combatant is out + self.remove_combatant(defender) + else: + # defender still alive + self.msg(defender) + + def stunt(self, attacker, defender, attack_type="agility", + defense_type="agility", optimal_distance=0, suboptimal_distance=1, + advantage=True, beneficiary=None): + """ + Stunts does not hurt anyone, 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 against 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: + # 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 do the stunt + # from here. + raise AttackFailure(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, ) - self._refresh_distance_matrix() + if is_success: + beneficiary = beneficiary if beneficiary else attacker + if advantage: + self.gain_advantage(beneficiary, defender) + else: + self.gain_disadvantage(defender, beneficiary) - def remove_combatant(self, combatant): - self.combatants.pop(combatant, None) - self._refresh_distance_matrix() + return is_success + + def attack(self, attacker, defender): + """ + Make an attack against a defender. This takes into account distance. + + """ + # 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 AttackFailure(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 diff --git a/evennia/contrib/tutorials/evadventure/objects.py b/evennia/contrib/tutorials/evadventure/objects.py index 2c47ef5e70..fd9e8abcc0 100644 --- a/evennia/contrib/tutorials/evadventure/objects.py +++ b/evennia/contrib/tutorials/evadventure/objects.py @@ -44,10 +44,14 @@ class EvAdventureWeapon(EvAdventureObject): """ wield_slot = AttributeProperty(default="weapon") + + attack_type = AttributeProperty(default="strength") + defense_type = AttributeProperty(default="armor") damage_roll = AttributeProperty(default="1d6") + # at which ranges this weapon can be used. If not listed, unable to use - range_optimal = AttributeProperty(default=0) # normal usage - range_suboptimal = AttributeProperty(default=1) # usage with disadvantage + distance_optimal = AttributeProperty(default=0) # normal usage (fists) + distance_suboptimal = AttributeProperty(default=None) # disadvantage (fists) class EvAdventureRunestone(EvAdventureWeapon): diff --git a/evennia/contrib/tutorials/evadventure/random_tables.py b/evennia/contrib/tutorials/evadventure/random_tables.py index d1a2cc493f..b942dd9fdc 100644 --- a/evennia/contrib/tutorials/evadventure/random_tables.py +++ b/evennia/contrib/tutorials/evadventure/random_tables.py @@ -366,3 +366,15 @@ initiative = [ ('4-6', "PC acts first"), ] + +death_and_dismemberment = [ + "dead", + "dead", # original says 'dismemberment' here, we don't simulate this + "weakened", # -1d4 STR + "unsteady", # -1d4 DEX + "sickly", # -1d4 CON + "addled", # -1d4 INT + "rattled", # -1d4 WIS + "disfigured", # -1d4 CHA +] + diff --git a/evennia/contrib/tutorials/evadventure/rules.py b/evennia/contrib/tutorials/evadventure/rules.py index b735ea1600..08656de9e4 100644 --- a/evennia/contrib/tutorials/evadventure/rules.py +++ b/evennia/contrib/tutorials/evadventure/rules.py @@ -175,61 +175,6 @@ class EvAdventureRollEngine: quality = None return (dice_roll + attack_bonus + modifier) > defender_defense, quality - # specific rolls / actions - - @staticmethod - def melee_attack(attacker, defender, advantage=False, disadvantage=False): - """Close attack (strength vs armor)""" - return opposed_saving_throw( - attacker, defender, attack_type="strength", defense_type="armor", - advantage=advantage, disadvantage=disadvantage) - - @staticmethod - def ranged_attack(attacker, defender, advantage=False, disadvantage=False): - """Ranged attack (wisdom vs armor)""" - return opposed_saving_throw( - attacker, defender, attack_type="wisdom", defense_type="armor", - advantage=advantage, disadvantage=disadvantage) - - @staticmethod - def magic_attack(attacker, defender, advantage=False, disadvantage=False): - """Magic attack (int vs dexterity)""" - return opposed_saving_throw( - attacker, defender, attack_type="intelligence", defense_type="dexterity", - advantage=advantage, disadvantage=disadvantage) - - @staticmethod - def morale_check(defender): - """ - A morale check is done for NPCs/monsters. It's done with a 2d6 against - their morale. - - Args: - defender (NPC): The entity trying to defend its morale. - - Returns: - bool: False if morale roll failed, True otherwise. - - """ - return roll('2d6') <= defender.morale - - @staticmethod - def healing_from_rest(character): - """ - A meal and a full night's rest allow for regaining 1d8 + Const bonus HP. - - Args: - character (Character): The one resting. - - Returns: - int: How much HP was healed. This is never more than how damaged we are. - - """ - # we can't heal more than our damage - damage = character.hp_max - character.hp - healed = roll('1d8') + character.constitution - return min(damage, healed) - @staticmethod def roll_random_table(dieroll, table, table_choices): """ @@ -283,6 +228,83 @@ class EvAdventureRollEngine: roll_result = max(1, min(len(table_choices), roll_result)) return table_choices[roll_result - 1] + # specific rolls / actions + + @staticmethod + def morale_check(defender): + """ + A morale check is done for NPCs/monsters. It's done with a 2d6 against + their morale. + + Args: + defender (NPC): The entity trying to defend its morale. + + Returns: + bool: False if morale roll failed, True otherwise. + + """ + return roll('2d6') <= defender.morale + + @staticmethod + def healing_from_rest(character): + """ + A meal and a full night's rest allow for regaining 1d8 + Const bonus HP. + + Args: + character (Character): The one resting. + + Returns: + int: How much HP was healed. This is never more than how damaged we are. + + """ + # we can't heal more than our damage + damage = character.hp_max - character.hp + healed = roll('1d8') + character.constitution + return min(damage, healed) + + death_map = { + "weakened": "strength", + "unsteady": "dexterity", + "sickly": "constitution", + "addled": "intelligence", + "rattled": "wisdom", + "disfigured": "charisma", + } + + def roll_death(character): + """ + Happens when hitting <= 0 hp. unless dead, + + """ + + result = self.roll_random_table('1d8', 'death_and_dismemberment') + if result == "dead": + character.handle_death() + else: + # survives with degraded abilities (1d4 roll) + abi = death_map[result] + + current_abi = getattr(character, abi) + loss = self.roll("1d4") + + current_abi =- loss + + if current_abi < -10: + # can't lose more - die + character.handle_death() + else: + new_hp = max(character.hp_max, self.roll("1d4")) + setattr(character, abi, current_abi) + character.hp = new_hp + + character.msg( + "~" * 78 + + "\n|yYou survive your brush with death, " + f"but are |r{result.upper()}|y and permenently |rlose {loss} {abi}|y.|n\n" + f"|GYou recover |g{new_hp}|G health|.\n" + + "~" * 78 + ) + # character generation