Made unit tests for evadventure rules
This commit is contained in:
parent
d19eac8ac9
commit
9361dff184
5 changed files with 418 additions and 87 deletions
|
|
@ -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.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue