Made unit tests for evadventure rules

This commit is contained in:
Griatch 2022-05-26 16:23:11 +02:00
parent d19eac8ac9
commit 9361dff184
5 changed files with 418 additions and 87 deletions

View file

@ -27,8 +27,6 @@ from evennia.utils import evmenu, evtable
from .enums import Ability from .enums import Ability
from . import rules from . import rules
STUNT_DURATION = 2
class CombatFailure(RuntimeError): class CombatFailure(RuntimeError):
""" """
@ -152,7 +150,8 @@ class CombatActionStunt(CombatAction):
class CombatActionAttack(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" key = "attack"
@ -261,14 +260,26 @@ class CombatActionChase(CombatAction):
pass # they are getting away! pass # they are getting away!
class EvAdventureCombatHandler(DefaultScript): class EvAdventureCombatHandler(DefaultScript):
""" """
This script is created when combat is initialized and stores a queue 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. 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()) combatants = AttributeProperty(list())
combatant_actions = AttributeProperty(defaultdict(dict))
action_queue = AttributeProperty(dict()) action_queue = AttributeProperty(dict())
turn_stats = AttributeProperty(defaultdict(list)) turn_stats = AttributeProperty(defaultdict(list))
@ -281,15 +292,10 @@ class EvAdventureCombatHandler(DefaultScript):
fleeing_combatants = AttributeProperty(default=list()) fleeing_combatants = AttributeProperty(default=list())
# actions that will be performed before a normal action # actions that will be performed before a normal action
move_actions = ("approach", "withdraw") move_actions = ("approach", "withdraw")
def at_init(self):
self.ndb.actions = {
"do_nothing": CombatActionDoNothing,
}
def _update_turn_stats(self, combatant, message): def _update_turn_stats(self, combatant, message):
""" """
Store combat messages to display at the end of turn. Store combat messages to display at the end of turn.
@ -312,12 +318,16 @@ class EvAdventureCombatHandler(DefaultScript):
1. Do all regular actions 1. Do all regular actions
2. Remove combatants that disengaged successfully 2. Remove combatants that disengaged successfully
3. Timeout advantages/disadvantages set for longer than STUNT_DURATION 3. Timeout advantages/disadvantages
""" """
# do all actions # do all actions
for combatant in self.combatants: 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) action.use(combatant, *args, **kwargs)
# handle disengaging combatants # handle disengaging combatants
@ -364,10 +374,13 @@ class EvAdventureCombatHandler(DefaultScript):
def add_combatant(self, combatant): def add_combatant(self, combatant):
if combatant not in self.combatants: if combatant not in self.combatants:
self.combatants.append(combatant) 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): def remove_combatant(self, combatant):
if combatant in self.combatants: if combatant in self.combatants:
self.combatants.remove(combatant) self.combatants.remove(combatant)
self.combatant_actions[combatant][action_class].pop(None)
def get_combat_summary(self, combatant): def get_combat_summary(self, combatant):
""" """
@ -489,7 +502,7 @@ class EvAdventureCombatHandler(DefaultScript):
Args: Args:
combatant (Object): The one performing the action. 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. used to call the relevant handler on this script.
""" """

View file

@ -42,6 +42,9 @@ class Ability(Enum):
LEVEL = "level" LEVEL = "level"
XP = "xp" XP = "xp"
CRITICAL_FAILURE = "critical_failure"
CRITICAL_SUCCESS = "critical_success"
class WieldLocation(Enum): class WieldLocation(Enum):
""" """
Wield (or wear) locations. Wield (or wear) locations.

View file

@ -204,7 +204,7 @@ character_generation = {
"student", "student",
"tracker", "tracker",
], ],
"mifortuntes": [ "misfortune": [
"abandoned", "abandoned",
"addicted", "addicted",
"blackmailed", "blackmailed",
@ -238,15 +238,15 @@ character_generation = {
('20', "chain"), ('20', "chain"),
], ],
"helmets and shields": [ "helmets and shields": [
('1-13', "no helmet"), ('1-13', "no helmet or shield"),
('14-16', "helmet"), ('14-16', "helmet"),
('17-19', "shield"), ('17-19', "shield"),
('20', "helmet and shield"), ('20', "helmet and shield"),
], ],
"starting weapon": [ # note: these are all d6 dmg weapons "starting weapon": [ # note: these are all d6 dmg weapons
('1-7', "dagger", ('1-7', "dagger"),
'8-13', "club", ('8-13', "club"),
'14-20', "staff"), ('14-20', "staff"),
], ],
"dungeoning gear": [ "dungeoning gear": [
"rope, 50ft", "rope, 50ft",

View file

@ -26,8 +26,10 @@ from random import randint
from evennia.utils.evform import EvForm from evennia.utils.evform import EvForm
from evennia.utils.evtable import EvTable from evennia.utils.evtable import EvTable
from .enums import Ability from .enums import Ability
from .utils import roll from .random_tables import (
from .random_tables import character_generation as chargen_table character_generation as chargen_table,
death_and_dismemberment as death_table
)
# Basic rolls # Basic rolls
@ -96,13 +98,13 @@ class EvAdventureRollEngine:
""" """
if not (advantage or disadvantage) or (advantage and disadvantage): if not (advantage or disadvantage) or (advantage and disadvantage):
# normal roll # normal roll
return roll("1d20") return self.roll("1d20")
elif advantage: elif advantage:
return max(roll("1d20"), roll("1d20")) return max(self.roll("1d20"), self.roll("1d20"))
else: 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): advantage=False, disadvantage=False, modifier=0):
""" """
A saving throw without a clear enemy to beat. In _Knave_ all unopposed saving 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. character (Object): The one attempting to save themselves.
bonus_type (enum.Ability): The ability bonus to apply, like strength or bonus_type (enum.Ability): The ability bonus to apply, like strength or
charisma. charisma.
advantage (bool): Roll 2d20 and use the bigger number. target (int, optional): Used for opposed throws (in Knave any regular
disadvantage (bool): Roll 2d20 and use the smaller number. saving through must always beat 15).
modifier (int): An additional +/- modifier to the roll. 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: Returns:
tuple: (bool, str): If the save was passed or not. The second element is the 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. 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) dice_roll = self.roll_with_advantage_or_disadvantage(advantage, disadvantage)
if dice_roll == 1: if dice_roll == 1:
quality = "critical failure" quality = Ability.CRITICAL_FAILURE
elif dice_roll == 20: elif dice_roll == 20:
quality = "critical success" quality = Ability.CRITICAL_SUCCESS
else: else:
quality = None quality = None
return (dice_roll + bonus + modifier) > 15, quality return (dice_roll + bonus + modifier) > target, quality
def opposed_saving_throw( def opposed_saving_throw(
self, attacker, defender, self, attacker, defender,
@ -162,19 +166,13 @@ class EvAdventureRollEngine:
Advantage and disadvantage cancel each other out. 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 defender_defense = getattr(defender, defense_type.value, 1) + 10
dice_roll = self.roll_with_advantage_or_disadvantage(advantage, disadvantage) return self.saving_throw(attacker, bonus_type=attack_type,
if dice_roll == 1: target=defender_defense,
quality = "critical failure" advantage=advantage, disadvantage=disadvantage,
elif dice_roll == 20: modifier=modifier)
quality = "critical success"
else:
quality = None
return (dice_roll + attack_bonus + modifier) > defender_defense, quality
def roll_random_table(self, dieroll, table, table_choices): def roll_random_table(self, dieroll, table_choices):
""" """
Make a roll on a random table. 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. 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)): if isinstance(table_choices[0], (tuple, list)):
# tuple with range conditional, like ('1-5', "Blue") or ('10', "Purple") # 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 # 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. # possible the range contains 'gaps', but that'd be an error in the random table itself.
if roll_result > max_range: if roll_result > max_range:
return max_range return table_choices[-1][1]
else: else:
return min_range return table_choices[0][1]
else: else:
# regular list - one line per value. # regular list - one line per value.
roll_result = max(1, min(len(table_choices), roll_result)) roll_result = max(1, min(len(table_choices), roll_result))
@ -240,7 +240,7 @@ class EvAdventureRollEngine:
bool: False if morale roll failed, True otherwise. bool: False if morale roll failed, True otherwise.
""" """
return roll('2d6') <= defender.morale return self.roll('2d6') <= defender.morale
def heal(self, character, amount): def heal(self, character, amount):
""" """
@ -254,7 +254,7 @@ class EvAdventureRollEngine:
damage = character.hp_max - character.hp damage = character.hp_max - character.hp
character.hp += min(damage, amount) 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. 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. 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 = { death_map = {
"weakened": "strength", "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": if result == "dead":
character.handle_death() character.handle_death()
else: else:
@ -298,6 +298,7 @@ class EvAdventureRollEngine:
# can't lose more - die # can't lose more - die
character.handle_death() character.handle_death()
else: else:
# refresh health, but get permanent ability loss
new_hp = max(character.hp_max, self.roll("1d4")) new_hp = max(character.hp_max, self.roll("1d4"))
setattr(character, abi, current_abi) setattr(character, abi, current_abi)
character.hp = new_hp character.hp = new_hp
@ -326,13 +327,11 @@ class EvAdventureCharacterGeneration:
online players can (and usually will) just disconnect and reroll until they get values online players can (and usually will) just disconnect and reroll until they get values
they are happy with. 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 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 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 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 chargen menu.
(currently unused, but included) `roll_attribute_bonus` function above to get the bonus
instead of the flat +2.
In the same way, Knave uses a d8 roll to get the initial hit points. Instead we use a 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. 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 # for clarity we initialize the engine here rather than use the
# global singleton at the end of the module # global singleton at the end of the module
dice = EvAdventureRollEngine() roll_engine = EvAdventureRollEngine()
# name will likely be modified later # 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.strength = 2
self.dexterity = 2 self.dexterity = 2
self.constitution = 2 self.constitution = 2
@ -363,17 +362,17 @@ class EvAdventureCharacterGeneration:
self.charisma = 2 self.charisma = 2
# physical attributes (only for rp purposes) # physical attributes (only for rp purposes)
self.physique = dice.roll_random_table('1d20', chargen_table['physique']) self.physique = roll_engine.roll_random_table('1d20', chargen_table['physique'])
self.face = dice.roll_random_table('1d20', chargen_table['face']) self.face = roll_engine.roll_random_table('1d20', chargen_table['face'])
self.skin = dice.roll_random_table('1d20', chargen_table['skin']) self.skin = roll_engine.roll_random_table('1d20', chargen_table['skin'])
self.hair = dice.roll_random_table('1d20', chargen_table['hair']) self.hair = roll_engine.roll_random_table('1d20', chargen_table['hair'])
self.clothing = dice.roll_random_table('1d20', chargen_table['clothing']) self.clothing = roll_engine.roll_random_table('1d20', chargen_table['clothing'])
self.speech = dice.roll_random_table('1d20', chargen_table['speech']) self.speech = roll_engine.roll_random_table('1d20', chargen_table['speech'])
self.virtue = dice.roll_random_table('1d20', chargen_table['virtue']) self.virtue = roll_engine.roll_random_table('1d20', chargen_table['virtue'])
self.vice = dice.roll_random_table('1d20', chargen_table['vice']) self.vice = roll_engine.roll_random_table('1d20', chargen_table['vice'])
self.background = dice.roll_random_table('1d20', chargen_table['background']) self.background = roll_engine.roll_random_table('1d20', chargen_table['background'])
self.misfortune = dice.roll_random_table('1d20', chargen_table['misfortune']) self.misfortune = roll_engine.roll_random_table('1d20', chargen_table['misfortune'])
self.alignment = dice.roll_random_table('1d20', chargen_table['alignment']) self.alignment = roll_engine.roll_random_table('1d20', chargen_table['alignment'])
# same for all # same for all
self.exploration_speed = 120 self.exploration_speed = 120
@ -384,21 +383,22 @@ class EvAdventureCharacterGeneration:
self.level = 1 self.level = 1
# random equipment # 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.helmet = "helmet" if "helmet" in _helmet_and_shield else "none"
self.shield = "shield" if "shield" 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 = [ self.backpack = [
"ration", "ration",
"ration", "ration",
dice.roll_random_table(chargen_table['1d20', "dungeoning gear"]), roll_engine.roll_random_table('1d20', chargen_table["dungeoning gear"]),
dice.roll_random_table(chargen_table['1d20', "dungeoning gear"]), roll_engine.roll_random_table('1d20', chargen_table["dungeoning gear"]),
dice.roll_random_table(chargen_table['1d20', "general gear 1"]), roll_engine.roll_random_table('1d20', chargen_table["general gear 1"]),
dice.roll_random_table(chargen_table['1d20', "general gear 2"]), roll_engine.roll_random_table('1d20', chargen_table["general gear 2"]),
] ]
def build_desc(self): def build_desc(self):
@ -432,18 +432,21 @@ class EvAdventureCharacterGeneration:
much input validation here, we do make sure we don't overcharge ourselves though. 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 if source_attribute == target_attribute:
source_current_bonus = getattr(self, source_attribute.value, 1) return
target_current_bonus = getattr(self, target_attribute.value, 1)
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.") 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.") raise ValueError(f"You can't increase the {target_attribute} bonus above +6.")
# all is good, apply the change. # all is good, apply the change.
setattr(self, source_attribute, source_current_bonus - value) setattr(self, source_attribute.value, source_current - value)
setattr(self, target_attribute, source_current_bonus + value) setattr(self, target_attribute.value, target_current + value)
def apply(self, character): def apply(self, character):
""" """
@ -459,9 +462,8 @@ class EvAdventureCharacterGeneration:
character.wisdom = self.wisdom character.wisdom = self.wisdom
character.charisma = self.charisma character.charisma = self.charisma
character.armor = self.armor_bonus character.weapon = self.weapon
# character.exploration_speed = self.exploration_speed character.armor = self.armor
# character.combat_speed = self.combat_speed
character.hp = self.hp character.hp = self.hp
character.level = self.level character.level = self.level
@ -532,7 +534,7 @@ class EvAdventureImprovement:
will need to be done earlier, when the user selects the ability to increase. will need to be done earlier, when the user selects the ability to increase.
""" """
dice = EvAdventureRollEngine() roll_engine = EvAdventureRollEngine()
character.level += 1 character.level += 1
for ability in set(abilities[:amount_of_abilities_to_upgrades]): for ability in set(abilities[:amount_of_abilities_to_upgrades]):
@ -621,7 +623,7 @@ class EvAdventureCharacterSheet:
# singletons # singletons
# access sheet as rules.character_sheet.get(character) # access sheet as rules.character_sheet.get(character)
character_sheet = CharacterSheet() character_sheet = EvAdventureCharacterSheet()
# access rolls e.g. with rules.dice.opposed_saving_throw(...) # access rolls e.g. with rules.dice.opposed_saving_throw(...)
dice = EvAdventureRollEngine() dice = EvAdventureRollEngine()
# access improvement e.g. with rules.improvement.add_xp(character, xp) # access improvement e.g. with rules.improvement.add_xp(character, xp)

View file

@ -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 import create
from evennia.utils.test_resources import BaseEvenniaTest from evennia.utils.test_resources import BaseEvenniaTest
from .character import EvAdventureCharacter from .characters import EvAdventureCharacter
from .objects import EvAdventureObject from .objects import EvAdventureObject
from . import enums
from . import combat_turnbased
from . import rules
from . import random_tables
class EvAdventureMixin: class EvAdventureMixin:
def setUp(self): def setUp(self):
@ -24,3 +31,309 @@ class EvAdventureMixin:
class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest): class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest):
pass 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()