From 9361dff1848680107cec3a1f36585cdd61bc4ce4 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 26 May 2022 16:23:11 +0200 Subject: [PATCH] Made unit tests for evadventure rules --- .../tutorials/evadventure/combat_turnbased.py | 39 ++- .../contrib/tutorials/evadventure/enums.py | 3 + .../tutorials/evadventure/random_tables.py | 10 +- .../contrib/tutorials/evadventure/rules.py | 138 ++++---- .../contrib/tutorials/evadventure/tests.py | 315 +++++++++++++++++- 5 files changed, 418 insertions(+), 87 deletions(-) diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index ed81eb6ab2..4e7b890965 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -27,8 +27,6 @@ from evennia.utils import evmenu, evtable from .enums import Ability from . import rules -STUNT_DURATION = 2 - class CombatFailure(RuntimeError): """ @@ -152,7 +150,8 @@ class CombatActionStunt(CombatAction): class CombatActionAttack(CombatAction): """ - A regular attack, using a wielded melee weapon. + A regular attack, using a wielded weapon. Depending on weapon type, this will be a ranged or + melee attack. """ key = "attack" @@ -261,14 +260,26 @@ class CombatActionChase(CombatAction): pass # they are getting away! - class EvAdventureCombatHandler(DefaultScript): """ This script is created when combat is initialized and stores a queue of all active participants. It's also possible to join (or leave) the fray later. """ + # these will all be checked if they are available at a given time. + all_action_classes = [ + CombatActionDoNothing, + CombatActionChase, + CombatActionUseItem, + CombatActionStunt, + CombatActionAttack + ] + + # attributes + + # stores all combatants active in the combat combatants = AttributeProperty(list()) + combatant_actions = AttributeProperty(defaultdict(dict)) action_queue = AttributeProperty(dict()) turn_stats = AttributeProperty(defaultdict(list)) @@ -281,15 +292,10 @@ class EvAdventureCombatHandler(DefaultScript): fleeing_combatants = AttributeProperty(default=list()) + # actions that will be performed before a normal action move_actions = ("approach", "withdraw") - def at_init(self): - self.ndb.actions = { - "do_nothing": CombatActionDoNothing, - } - - def _update_turn_stats(self, combatant, message): """ Store combat messages to display at the end of turn. @@ -312,12 +318,16 @@ class EvAdventureCombatHandler(DefaultScript): 1. Do all regular actions 2. Remove combatants that disengaged successfully - 3. Timeout advantages/disadvantages set for longer than STUNT_DURATION + 3. Timeout advantages/disadvantages """ # do all actions for combatant in self.combatants: - action, args, kwargs = self.action_queue[combatant] + # read the current action type selected by the player + action_class, args, kwargs = self.action_queue[combatant] + # get the already initialized CombatAction instance (where state can be tracked) + action = self.combatant_actions[combatant][action_class] + # perform the action on the CombatAction instance action.use(combatant, *args, **kwargs) # handle disengaging combatants @@ -364,10 +374,13 @@ class EvAdventureCombatHandler(DefaultScript): def add_combatant(self, combatant): if combatant not in self.combatants: self.combatants.append(combatant) + for action_class in self.all_action_classes: + self.combatant_actions[combatant][action_class] = action_class(self, combatant) def remove_combatant(self, combatant): if combatant in self.combatants: self.combatants.remove(combatant) + self.combatant_actions[combatant][action_class].pop(None) def get_combat_summary(self, combatant): """ @@ -489,7 +502,7 @@ class EvAdventureCombatHandler(DefaultScript): Args: combatant (Object): The one performing the action. - action (str): An available action, will be prepended with `action_` and + action (CombatAction): An available action, will be prepended with `action_` and used to call the relevant handler on this script. """ diff --git a/evennia/contrib/tutorials/evadventure/enums.py b/evennia/contrib/tutorials/evadventure/enums.py index 1fbbf353b2..c6c24d7b97 100644 --- a/evennia/contrib/tutorials/evadventure/enums.py +++ b/evennia/contrib/tutorials/evadventure/enums.py @@ -42,6 +42,9 @@ class Ability(Enum): LEVEL = "level" XP = "xp" + CRITICAL_FAILURE = "critical_failure" + CRITICAL_SUCCESS = "critical_success" + class WieldLocation(Enum): """ Wield (or wear) locations. diff --git a/evennia/contrib/tutorials/evadventure/random_tables.py b/evennia/contrib/tutorials/evadventure/random_tables.py index b942dd9fdc..cac6566369 100644 --- a/evennia/contrib/tutorials/evadventure/random_tables.py +++ b/evennia/contrib/tutorials/evadventure/random_tables.py @@ -204,7 +204,7 @@ character_generation = { "student", "tracker", ], - "mifortuntes": [ + "misfortune": [ "abandoned", "addicted", "blackmailed", @@ -238,15 +238,15 @@ character_generation = { ('20', "chain"), ], "helmets and shields": [ - ('1-13', "no helmet"), + ('1-13', "no helmet or shield"), ('14-16', "helmet"), ('17-19', "shield"), ('20', "helmet and shield"), ], "starting weapon": [ # note: these are all d6 dmg weapons - ('1-7', "dagger", - '8-13', "club", - '14-20', "staff"), + ('1-7', "dagger"), + ('8-13', "club"), + ('14-20', "staff"), ], "dungeoning gear": [ "rope, 50ft", diff --git a/evennia/contrib/tutorials/evadventure/rules.py b/evennia/contrib/tutorials/evadventure/rules.py index ffe6c236a3..150458d035 100644 --- a/evennia/contrib/tutorials/evadventure/rules.py +++ b/evennia/contrib/tutorials/evadventure/rules.py @@ -26,8 +26,10 @@ from random import randint from evennia.utils.evform import EvForm from evennia.utils.evtable import EvTable from .enums import Ability -from .utils import roll -from .random_tables import character_generation as chargen_table +from .random_tables import ( + character_generation as chargen_table, + death_and_dismemberment as death_table +) # Basic rolls @@ -96,13 +98,13 @@ class EvAdventureRollEngine: """ if not (advantage or disadvantage) or (advantage and disadvantage): # normal roll - return roll("1d20") + return self.roll("1d20") elif advantage: - return max(roll("1d20"), roll("1d20")) + return max(self.roll("1d20"), self.roll("1d20")) else: - return min(roll("1d20"), roll("1d20")) + return min(self.roll("1d20"), self.roll("1d20")) - def saving_throw(self, character, bonus_type=Ability.STR, + def saving_throw(self, character, bonus_type=Ability.STR, target=15, advantage=False, disadvantage=False, modifier=0): """ A saving throw without a clear enemy to beat. In _Knave_ all unopposed saving @@ -112,9 +114,11 @@ class EvAdventureRollEngine: character (Object): The one attempting to save themselves. bonus_type (enum.Ability): The ability bonus to apply, like strength or charisma. - advantage (bool): Roll 2d20 and use the bigger number. - disadvantage (bool): Roll 2d20 and use the smaller number. - modifier (int): An additional +/- modifier to the roll. + target (int, optional): Used for opposed throws (in Knave any regular + saving through must always beat 15). + advantage (bool, optional): Roll 2d20 and use the bigger number. + disadvantage (bool, optional): Roll 2d20 and use the smaller number. + modifier (int, optional): An additional +/- modifier to the roll. Returns: tuple: (bool, str): If the save was passed or not. The second element is the @@ -127,15 +131,15 @@ class EvAdventureRollEngine: Trying to overcome the effects of poison, roll d20 + Constitution-bonus above 15. """ - bonus = getattr(character, bonus_type, 1) + bonus = getattr(character, bonus_type.value, 1) dice_roll = self.roll_with_advantage_or_disadvantage(advantage, disadvantage) if dice_roll == 1: - quality = "critical failure" + quality = Ability.CRITICAL_FAILURE elif dice_roll == 20: - quality = "critical success" + quality = Ability.CRITICAL_SUCCESS else: quality = None - return (dice_roll + bonus + modifier) > 15, quality + return (dice_roll + bonus + modifier) > target, quality def opposed_saving_throw( self, attacker, defender, @@ -162,19 +166,13 @@ class EvAdventureRollEngine: Advantage and disadvantage cancel each other out. """ - attack_bonus = getattr(attacker, attack_type.value, 1) - # defense is always bonus + 10 in Knave defender_defense = getattr(defender, defense_type.value, 1) + 10 - dice_roll = self.roll_with_advantage_or_disadvantage(advantage, disadvantage) - if dice_roll == 1: - quality = "critical failure" - elif dice_roll == 20: - quality = "critical success" - else: - quality = None - return (dice_roll + attack_bonus + modifier) > defender_defense, quality + return self.saving_throw(attacker, bonus_type=attack_type, + target=defender_defense, + advantage=advantage, disadvantage=disadvantage, + modifier=modifier) - def roll_random_table(self, dieroll, table, table_choices): + def roll_random_table(self, dieroll, table_choices): """ Make a roll on a random table. @@ -196,7 +194,9 @@ class EvAdventureRollEngine: If the roll is outside of the listing, the closest edge value is used. """ - roll_result = roll(dieroll) + roll_result = self.roll(dieroll) + if not table_choices: + return None if isinstance(table_choices[0], (tuple, list)): # tuple with range conditional, like ('1-5', "Blue") or ('10', "Purple") @@ -218,9 +218,9 @@ class EvAdventureRollEngine: # if we have no result, we are outside of the range, we pick the edge values. It is also # possible the range contains 'gaps', but that'd be an error in the random table itself. if roll_result > max_range: - return max_range + return table_choices[-1][1] else: - return min_range + return table_choices[0][1] else: # regular list - one line per value. roll_result = max(1, min(len(table_choices), roll_result)) @@ -240,7 +240,7 @@ class EvAdventureRollEngine: bool: False if morale roll failed, True otherwise. """ - return roll('2d6') <= defender.morale + return self.roll('2d6') <= defender.morale def heal(self, character, amount): """ @@ -254,7 +254,7 @@ class EvAdventureRollEngine: damage = character.hp_max - character.hp character.hp += min(damage, amount) - def healing_from_rest(self, character): + def heal_from_rest(self, character): """ A meal and a full night's rest allow for regaining 1d8 + Const bonus HP. @@ -265,7 +265,7 @@ class EvAdventureRollEngine: int: How much HP was healed. This is never more than how damaged we are. """ - self.heal(character, roll('1d8') + character.constitution) + self.heal(character, self.roll('1d8') + character.constitution) death_map = { "weakened": "strength", @@ -282,7 +282,7 @@ class EvAdventureRollEngine: """ - result = self.roll_random_table('1d8', 'death_and_dismemberment') + result = self.roll_random_table('1d8', death_table) if result == "dead": character.handle_death() else: @@ -298,6 +298,7 @@ class EvAdventureRollEngine: # can't lose more - die character.handle_death() else: + # refresh health, but get permanent ability loss new_hp = max(character.hp_max, self.roll("1d4")) setattr(character, abi, current_abi) character.hp = new_hp @@ -326,13 +327,11 @@ class EvAdventureCharacterGeneration: online players can (and usually will) just disconnect and reroll until they get values they are happy with. - So, in standard Knave, the character's attribute bonus is rolled randomly and will give a + In standard Knave, the character's attribute bonus is rolled randomly and will give a value 1-6; and there is no guarantee for 'equal' starting characters. Instead we homogenize the results to a flat +2 bonus and let people redistribute the points afterwards. This also allows us to show off some more advanced concepts in the - chargen menu, but you can also easily make it random like in base Knave by using the - (currently unused, but included) `roll_attribute_bonus` function above to get the bonus - instead of the flat +2. + chargen menu. In the same way, Knave uses a d8 roll to get the initial hit points. Instead we use a flat max of 8 HP to start, in order to give players a little more survivability. @@ -349,12 +348,12 @@ class EvAdventureCharacterGeneration: """ # for clarity we initialize the engine here rather than use the # global singleton at the end of the module - dice = EvAdventureRollEngine() + roll_engine = EvAdventureRollEngine() # name will likely be modified later - self.name = dice.roll_random_table('1d282', chargen_table['name']) + self.name = roll_engine.roll_random_table('1d282', chargen_table['name']) - # base attribute bonuses + # base attribute bonuses (flat +1 bonus) self.strength = 2 self.dexterity = 2 self.constitution = 2 @@ -363,17 +362,17 @@ class EvAdventureCharacterGeneration: self.charisma = 2 # physical attributes (only for rp purposes) - self.physique = dice.roll_random_table('1d20', chargen_table['physique']) - self.face = dice.roll_random_table('1d20', chargen_table['face']) - self.skin = dice.roll_random_table('1d20', chargen_table['skin']) - self.hair = dice.roll_random_table('1d20', chargen_table['hair']) - self.clothing = dice.roll_random_table('1d20', chargen_table['clothing']) - self.speech = dice.roll_random_table('1d20', chargen_table['speech']) - self.virtue = dice.roll_random_table('1d20', chargen_table['virtue']) - self.vice = dice.roll_random_table('1d20', chargen_table['vice']) - self.background = dice.roll_random_table('1d20', chargen_table['background']) - self.misfortune = dice.roll_random_table('1d20', chargen_table['misfortune']) - self.alignment = dice.roll_random_table('1d20', chargen_table['alignment']) + self.physique = roll_engine.roll_random_table('1d20', chargen_table['physique']) + self.face = roll_engine.roll_random_table('1d20', chargen_table['face']) + self.skin = roll_engine.roll_random_table('1d20', chargen_table['skin']) + self.hair = roll_engine.roll_random_table('1d20', chargen_table['hair']) + self.clothing = roll_engine.roll_random_table('1d20', chargen_table['clothing']) + self.speech = roll_engine.roll_random_table('1d20', chargen_table['speech']) + self.virtue = roll_engine.roll_random_table('1d20', chargen_table['virtue']) + self.vice = roll_engine.roll_random_table('1d20', chargen_table['vice']) + self.background = roll_engine.roll_random_table('1d20', chargen_table['background']) + self.misfortune = roll_engine.roll_random_table('1d20', chargen_table['misfortune']) + self.alignment = roll_engine.roll_random_table('1d20', chargen_table['alignment']) # same for all self.exploration_speed = 120 @@ -384,21 +383,22 @@ class EvAdventureCharacterGeneration: self.level = 1 # random equipment - self.armor = dice.roll_random_table('1d20', chargen_table['armor']) + self.armor = roll_engine.roll_random_table('1d20', chargen_table['armor']) - _helmet_and_shield = dice.roll_random_table('1d20', chargen_table["helmets and shields"]) + _helmet_and_shield = roll_engine.roll_random_table( + '1d20', chargen_table["helmets and shields"]) self.helmet = "helmet" if "helmet" in _helmet_and_shield else "none" self.shield = "shield" if "shield" in _helmet_and_shield else "none" - self.weapon = dice.roll_random_table(chargen_table['1d20', "starting_weapon"]) + self.weapon = roll_engine.roll_random_table('1d20', chargen_table["starting weapon"]) self.backpack = [ "ration", "ration", - dice.roll_random_table(chargen_table['1d20', "dungeoning gear"]), - dice.roll_random_table(chargen_table['1d20', "dungeoning gear"]), - dice.roll_random_table(chargen_table['1d20', "general gear 1"]), - dice.roll_random_table(chargen_table['1d20', "general gear 2"]), + roll_engine.roll_random_table('1d20', chargen_table["dungeoning gear"]), + roll_engine.roll_random_table('1d20', chargen_table["dungeoning gear"]), + roll_engine.roll_random_table('1d20', chargen_table["general gear 1"]), + roll_engine.roll_random_table('1d20', chargen_table["general gear 2"]), ] def build_desc(self): @@ -432,18 +432,21 @@ class EvAdventureCharacterGeneration: much input validation here, we do make sure we don't overcharge ourselves though. """ - # we use getattr() to fetch the Ability of e.g. the .strength property etc - source_current_bonus = getattr(self, source_attribute.value, 1) - target_current_bonus = getattr(self, target_attribute.value, 1) + if source_attribute == target_attribute: + return - if source_current_bonus - value < 1: + # we use getattr() to fetch the Ability of e.g. the .strength property etc + source_current = getattr(self, source_attribute.value, 1) + target_current = getattr(self, target_attribute.value, 1) + + if source_current - value < 1: raise ValueError(f"You can't reduce the {source_attribute} bonus below +1.") - if target_current_bonus + value > 6: + if target_current + value > 6: raise ValueError(f"You can't increase the {target_attribute} bonus above +6.") # all is good, apply the change. - setattr(self, source_attribute, source_current_bonus - value) - setattr(self, target_attribute, source_current_bonus + value) + setattr(self, source_attribute.value, source_current - value) + setattr(self, target_attribute.value, target_current + value) def apply(self, character): """ @@ -459,9 +462,8 @@ class EvAdventureCharacterGeneration: character.wisdom = self.wisdom character.charisma = self.charisma - character.armor = self.armor_bonus - # character.exploration_speed = self.exploration_speed - # character.combat_speed = self.combat_speed + character.weapon = self.weapon + character.armor = self.armor character.hp = self.hp character.level = self.level @@ -532,7 +534,7 @@ class EvAdventureImprovement: will need to be done earlier, when the user selects the ability to increase. """ - dice = EvAdventureRollEngine() + roll_engine = EvAdventureRollEngine() character.level += 1 for ability in set(abilities[:amount_of_abilities_to_upgrades]): @@ -621,7 +623,7 @@ class EvAdventureCharacterSheet: # singletons # access sheet as rules.character_sheet.get(character) -character_sheet = CharacterSheet() +character_sheet = EvAdventureCharacterSheet() # access rolls e.g. with rules.dice.opposed_saving_throw(...) dice = EvAdventureRollEngine() # access improvement e.g. with rules.improvement.add_xp(character, xp) diff --git a/evennia/contrib/tutorials/evadventure/tests.py b/evennia/contrib/tutorials/evadventure/tests.py index 52b0b1e279..33aed12289 100644 --- a/evennia/contrib/tutorials/evadventure/tests.py +++ b/evennia/contrib/tutorials/evadventure/tests.py @@ -3,10 +3,17 @@ Tests for EvAdventure. """ +from parameterized import parameterized +from unittest.mock import patch, MagicMock, call from evennia.utils import create from evennia.utils.test_resources import BaseEvenniaTest -from .character import EvAdventureCharacter +from .characters import EvAdventureCharacter from .objects import EvAdventureObject +from . import enums +from . import combat_turnbased +from . import rules +from . import random_tables + class EvAdventureMixin: def setUp(self): @@ -24,3 +31,309 @@ class EvAdventureMixin: class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest): pass + +class EvAdventureTurnbasedCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest): + """ + Test the turn-based combat-handler implementation. + + """ + def setUp(self): + super().setUp() + self.combathandler = combat_turnbased.EvAdventureCombatHandler() + self.combathandler.add_combatant(self.character) + + def test_remove_combatant(self): + self.combathandler.remove_combatant(self.character) + + +class EvAdventureRollEngineTest(BaseEvenniaTest): + """ + Test the roll engine in the rules module. This is the core of any RPG. + + """ + def setUp(self): + super().setUp() + self.roll_engine = rules.EvAdventureRollEngine() + + @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_roll(self, mock_randint): + mock_randint.return_value = 8 + self.assertEqual(self.roll_engine.roll("1d6"), 8) + mock_randint.assert_called_with(1, 6) + + self.assertEqual(self.roll_engine.roll("2d8"), 2 * 8) + mock_randint.assert_called_with(1, 8) + + self.assertEqual(self.roll_engine.roll("4d12"), 4 * 8) + mock_randint.assert_called_with(1, 12) + + self.assertEqual(self.roll_engine.roll("8d100"), 8 * 8) + mock_randint.assert_called_with(1, 100) + + def test_roll_limits(self): + with self.assertRaises(TypeError): + self.roll_engine.roll('100d6', max_number=10) # too many die + with self.assertRaises(TypeError): + self.roll_engine.roll('100') # no d + with self.assertRaises(TypeError): + self.roll_engine.roll('dummy') # non-numerical + with self.assertRaises(TypeError): + self.roll_engine.roll('Ad4') # non-numerical + with self.assertRaises(TypeError): + self.roll_engine.roll('1d10000') # limit is d1000 + + @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_roll_with_advantage_disadvantage(self, mock_randint): + mock_randint.return_value = 9 + + # no advantage/disadvantage + self.assertEqual(self.roll_engine.roll_with_advantage_or_disadvantage(), 9) + mock_randint.assert_called_once() + mock_randint.reset_mock() + + # cancel each other out + self.assertEqual( + self.roll_engine.roll_with_advantage_or_disadvantage( + disadvantage=True, advantage=True), 9) + mock_randint.assert_called_once() + mock_randint.reset_mock() + + # run with advantage/disadvantage + self.assertEqual( + self.roll_engine.roll_with_advantage_or_disadvantage(advantage=True), 9) + mock_randint.assert_has_calls([call(1, 20), call(1, 20)]) + mock_randint.reset_mock() + + self.assertEqual( + self.roll_engine.roll_with_advantage_or_disadvantage(disadvantage=True), 9) + mock_randint.assert_has_calls([call(1, 20), call(1, 20)]) + mock_randint.reset_mock() + + @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_saving_throw(self, mock_randint): + mock_randint.return_value = 8 + + character = MagicMock() + character.strength = 2 + character.dexterity = 1 + + self.assertEqual( + self.roll_engine.saving_throw(character, bonus_type=enums.Ability.STR), + (False, None)) + self.assertEqual( + self.roll_engine.saving_throw(character, bonus_type=enums.Ability.DEX, modifier=1), + (False, None)) + self.assertEqual( + self.roll_engine.saving_throw( + character, + advantage=True, + bonus_type=enums.Ability.DEX, modifier=6), + (False, None)) + self.assertEqual( + self.roll_engine.saving_throw( + character, + disadvantage=True, + bonus_type=enums.Ability.DEX, modifier=7), + (True, None)) + + mock_randint.return_value = 1 + self.assertEqual( + self.roll_engine.saving_throw( + character, + disadvantage=True, + bonus_type=enums.Ability.STR, modifier=2), + (False, enums.Ability.CRITICAL_FAILURE)) + + mock_randint.return_value = 20 + self.assertEqual( + self.roll_engine.saving_throw( + character, + disadvantage=True, + bonus_type=enums.Ability.STR, modifier=2), + (True, enums.Ability.CRITICAL_SUCCESS)) + + @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_opposed_saving_throw(self, mock_randint): + mock_randint.return_value = 10 + + attacker, defender = MagicMock(), MagicMock() + attacker.strength = 1 + defender.armor = 2 + + self.assertEqual( + self.roll_engine.opposed_saving_throw( + attacker, defender, + attack_type=enums.Ability.STR, defense_type=enums.Ability.ARMOR + ), + (False, None) + ) + self.assertEqual( + self.roll_engine.opposed_saving_throw( + attacker, defender, + attack_type=enums.Ability.STR, defense_type=enums.Ability.ARMOR, + modifier=2 + ), + (True, None) + ) + + @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_roll_random_table(self, mock_randint): + mock_randint.return_value = 10 + + self.assertEqual( + self.roll_engine.roll_random_table( + "1d20", random_tables.character_generation['physique']), + "scrawny" + ) + self.assertEqual( + self.roll_engine.roll_random_table( + "1d20", random_tables.character_generation['vice']), + "irascible" + ) + self.assertEqual( + self.roll_engine.roll_random_table( + "1d20", random_tables.character_generation['alignment']), + "neutrality" + ) + self.assertEqual( + self.roll_engine.roll_random_table( + "1d20", random_tables.character_generation['helmets and shields']), + "no helmet or shield" + ) + # testing faulty rolls outside of the table ranges + mock_randint.return_value = 25 + self.assertEqual( + self.roll_engine.roll_random_table( + "1d20", random_tables.character_generation['helmets and shields']), + "helmet and shield" + ) + mock_randint.return_value = -10 + self.assertEqual( + self.roll_engine.roll_random_table( + "1d20", random_tables.character_generation['helmets and shields']), + "no helmet or shield" + ) + + @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_morale_check(self, mock_randint): + defender = MagicMock() + defender.morale = 12 + + mock_randint.return_value = 7 # 2d6 is rolled, so this will become 14 + self.assertEqual(self.roll_engine.morale_check(defender), False) + + mock_randint.return_value = 3 # 2d6 is rolled, so this will become 6 + self.assertEqual(self.roll_engine.morale_check(defender), True) + + @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_heal_from_rest(self, mock_randint): + character = MagicMock() + character.hp_max = 8 + character.hp = 1 + character.constitution = 1 + + mock_randint.return_value = 5 + self.roll_engine.heal_from_rest(character) + self.assertEqual(character.hp, 7) # hp + 1d8 + consititution bonus + mock_randint.assert_called_with(1, 8) # 1d8 + + self.roll_engine.heal_from_rest(character) + self.assertEqual(character.hp, 8) # can't have more than max hp + + @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_roll_death(self, mock_randint): + character = MagicMock() + character.strength = 13 + character.hp = 0 + character.hp_max = 8 + + # death + mock_randint.return_value = 1 + self.roll_engine.roll_death(character) + character.handle_death.assert_called() + # strength loss + mock_randint.return_value = 3 + self.roll_engine.roll_death(character) + self.assertEqual(character.strength, 10) + + +class EvAdventureCharacterGenerationTest(BaseEvenniaTest): + """ + Test the Character generator tracing object in the rule engine. + + """ + + @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def setUp(self, mock_randint): + super().setUp() + mock_randint.return_value = 10 + self.chargen = rules.EvAdventureCharacterGeneration() + + def test_base_chargen(self): + self.assertEqual(self.chargen.strength, 2) + self.assertEqual(self.chargen.physique, "scrawny") + self.assertEqual(self.chargen.skin, "pockmarked") + self.assertEqual(self.chargen.hair, "greased") + self.assertEqual(self.chargen.clothing, "stained") + self.assertEqual(self.chargen.misfortune, "exiled") + self.assertEqual(self.chargen.armor, "gambeson") + self.assertEqual(self.chargen.shield, "shield") + self.assertEqual(self.chargen.backpack, ['ration', 'ration', 'waterskin', + 'waterskin', 'drill', 'twine']) + + def test_build_desc(self): + self.assertEqual( + self.chargen.build_desc(), + "Herbalist. Wears stained clothes, and has hoarse speech. Has a scrawny physique, " + "a broken face, pockmarked skin and greased hair. Is honest, but irascible. " + "Has been exiled in the past. Favors neutrality." + ) + + + @parameterized.expand([ + # source, target, value, new_source_val, new_target_val + (enums.Ability.CON, enums.Ability.STR, 1, 1, 3), + (enums.Ability.INT, enums.Ability.DEX, 1, 1, 3), + (enums.Ability.CHA, enums.Ability.CON, 1, 1, 3), + (enums.Ability.STR, enums.Ability.WIS, 1, 1, 3), + (enums.Ability.WIS, enums.Ability.CHA, 1, 1, 3), + (enums.Ability.DEX, enums.Ability.DEX, 1, 2, 2), + ]) + def test_adjust_attribute(self, source, target, value, new_source_val, new_target_val): + self.chargen.adjust_attribute(source, target, value) + self.assertEqual( + getattr(self.chargen, source.value), new_source_val, f"{source}->{target}") + self.assertEqual( + getattr(self.chargen, target.value), new_target_val, f"{source}->{target}") + + def test_adjust_consecutive(self): + # gradually shift all to STR (starts at 2) + self.chargen.adjust_attribute(enums.Ability.CON, enums.Ability.STR, 1) + self.chargen.adjust_attribute(enums.Ability.CHA, enums.Ability.STR, 1) + self.chargen.adjust_attribute(enums.Ability.DEX, enums.Ability.STR, 1) + self.chargen.adjust_attribute(enums.Ability.WIS, enums.Ability.STR, 1) + self.assertEqual(self.chargen.constitution, 1) + self.assertEqual(self.chargen.strength, 6) + + # max is 6 + with self.assertRaises(ValueError): + self.chargen.adjust_attribute(enums.Ability.INT, enums.Ability.STR, 1) + # minimum is 1 + with self.assertRaises(ValueError): + self.chargen.adjust_attribute(enums.Ability.DEX, enums.Ability.WIS, 1) + + # move all from str to wis + self.chargen.adjust_attribute(enums.Ability.STR, enums.Ability.WIS, 5) + + self.assertEqual(self.chargen.strength, 1) + self.assertEqual(self.chargen.wisdom, 6) + + def test_apply(self): + character = MagicMock() + + self.chargen.apply(character) + + self.assertTrue(character.db.desc.startswith("Herbalist")) + self.assertEqual(character.armor, "gambeson") + + character.equipment.store.assert_called()