324 lines
13 KiB
Python
324 lines
13 KiB
Python
import json
|
|
|
|
from ttfrog.db import schema
|
|
|
|
|
|
def test_manage_character(db, bootstrap):
|
|
with db.transaction():
|
|
darkvision = db.AncestryTrait.filter_by(name="Darkvision").one()
|
|
human = db.Ancestry.filter_by(name="human").one()
|
|
|
|
# create a human character (the default)
|
|
char = schema.Character(name="Test Character", ancestry=human)
|
|
db.add_or_update(char)
|
|
assert char.id == 3
|
|
assert char.name == "Test Character"
|
|
assert char.ancestry.name == "human"
|
|
assert char.armor_class == 10
|
|
assert char.hit_points == 10
|
|
assert char.strength == 10
|
|
assert char.dexterity == 10
|
|
assert char.constitution == 10
|
|
assert char.intelligence == 10
|
|
assert char.wisdom == 10
|
|
assert char.charisma == 10
|
|
assert darkvision not in char.traits
|
|
|
|
# verify basic skills were added at creation time
|
|
for skill in db.Skill.filter(
|
|
schema.Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
|
|
):
|
|
assert char.check_modifier(skill) == 0
|
|
|
|
# switch ancestry to tiefling
|
|
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
|
char.ancestry = tiefling
|
|
db.add_or_update(char)
|
|
char = db.session.get(schema.Character, char.id)
|
|
assert char.ancestry_id == tiefling.id
|
|
assert char.ancestry.name == "tiefling"
|
|
assert darkvision in char.traits
|
|
|
|
# tiefling ancestry adds INT and CHA modifiers
|
|
assert char.intelligence == 11
|
|
assert char.intelligence.base == 10
|
|
assert char.charisma == 12
|
|
assert char.charisma.base == 10
|
|
|
|
# switch ancestry to dragonborn and assert darkvision persists
|
|
char.ancestry = db.Ancestry.filter_by(name="dragonborn").one()
|
|
db.add_or_update(char)
|
|
assert darkvision in char.traits
|
|
|
|
# verify tiefling modifiers were removed
|
|
assert char.intelligence == 10
|
|
assert char.charisma == 10
|
|
|
|
# switch ancestry to human and assert darkvision is removed
|
|
char.ancestry = human
|
|
db.add_or_update(char)
|
|
assert darkvision not in char.traits
|
|
|
|
fighter = db.CharacterClass.filter_by(name="fighter").one()
|
|
rogue = db.CharacterClass.filter_by(name="rogue").one()
|
|
|
|
# assign a class and level
|
|
char.add_class(fighter, level=1)
|
|
db.add_or_update(char)
|
|
assert char.levels == {"fighter": 1}
|
|
assert char.level == 1
|
|
assert char.class_attributes == {}
|
|
|
|
# 'fighting style' is available, but not at this level
|
|
fighting_style = fighter.attribute("Fighting Style")
|
|
assert char.has_class_attribute(fighting_style) is False
|
|
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
|
|
db.add_or_update(char)
|
|
assert char.class_attributes == {}
|
|
|
|
# level up
|
|
char.add_class(fighter, level=7)
|
|
db.add_or_update(char)
|
|
assert char.levels == {"fighter": 7}
|
|
assert char.level == 7
|
|
|
|
# Assert the fighting style is added automatically and idempotent...ly?
|
|
assert char.has_class_attribute(fighting_style)
|
|
assert char.class_attributes[fighting_style.name] == fighting_style.options[0]
|
|
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
|
|
assert char.has_class_attribute(fighting_style)
|
|
db.add_or_update(char)
|
|
|
|
athletics = db.Skill.filter_by(name="athletics").one()
|
|
acrobatics = db.Skill.filter_by(name="acrobatics").one()
|
|
assert athletics in char.skills
|
|
assert acrobatics in char.skills
|
|
assert char.check_modifier(athletics) == char.proficiency_bonus + char.strength.bonus == 3
|
|
assert char.check_modifier(acrobatics) == char.proficiency_bonus + char.dexterity.bonus == 3
|
|
|
|
# multiclass
|
|
char.add_class(rogue, level=1)
|
|
db.add_or_update(char)
|
|
assert char.level == 8
|
|
assert char.levels == {"fighter": 7, "rogue": 1}
|
|
assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 8
|
|
|
|
# remove a class
|
|
char.remove_class(rogue)
|
|
db.add_or_update(char)
|
|
assert char.levels == {"fighter": 7}
|
|
assert char.level == 7
|
|
assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 7
|
|
|
|
# verify hit dice are added and removed correctly
|
|
assert len(char.hit_dice["fighter"]) == char.level_in_class(fighter).level == 7
|
|
assert char.hit_dice["fighter"][0].name == fighter.hit_die_name == "1d10"
|
|
assert char.hit_dice["fighter"][0].stat == fighter.hit_die_stat_name == "_constitution"
|
|
|
|
# remove remaining class by setting level to zero
|
|
char.add_class(fighter, level=0)
|
|
db.add_or_update(char)
|
|
assert char.levels == {}
|
|
assert char.class_attributes == {}
|
|
|
|
# verify the proficiencies added by the classes have been removed
|
|
assert athletics not in char.skills
|
|
assert acrobatics not in char.skills
|
|
assert char.check_modifier(athletics) == 0
|
|
assert char.check_modifier(acrobatics) == 0
|
|
|
|
# ensure we're not persisting any orphan records in the map tables
|
|
dump = json.loads(db.dump())
|
|
assert not [m for m in dump["character_class_attribute_map"] if m["character_id"] == char.id]
|
|
assert not [m for m in dump["class_map"] if m["character_id"] == char.id]
|
|
|
|
|
|
def test_ancestries(db, bootstrap):
|
|
with db.transaction():
|
|
# create the Pygmy Orc ancestry
|
|
porc = schema.Ancestry(
|
|
name="Pygmy Orc",
|
|
size="Small",
|
|
speed=25,
|
|
)
|
|
assert porc.name == "Pygmy Orc"
|
|
assert porc.creature_type == "humanoid"
|
|
assert porc.size == "Small"
|
|
assert porc.speed == 25
|
|
|
|
# create the Relentless Endurance trait and add it to the Orc
|
|
endurance = schema.AncestryTrait(name="Relentless Endurance")
|
|
db.add_or_update(endurance)
|
|
porc.add_trait(endurance, level=1)
|
|
db.add_or_update(porc)
|
|
assert endurance in porc.traits
|
|
|
|
# add a +3 STR modifier
|
|
str_bonus = schema.Modifier(
|
|
name="STR+3 (Pygmy Orc)",
|
|
target="strength",
|
|
stacks=True,
|
|
relative_value=3,
|
|
description="Your Strength score is increased by 3.",
|
|
)
|
|
assert porc.add_modifier(str_bonus) is True
|
|
assert porc.add_modifier(str_bonus) is False # test idempotency
|
|
assert str_bonus in porc.modifiers["strength"]
|
|
|
|
# now create an orc character and assert it gets traits and modifiers
|
|
grognak = schema.Character(name="Grognak the Mighty", ancestry=porc)
|
|
db.add_or_update(grognak)
|
|
|
|
assert endurance in grognak.traits
|
|
|
|
# verify the strength bonus is applied
|
|
assert grognak.strength.base == 10
|
|
assert grognak.strength == 13
|
|
assert grognak.strength.bonus == 1
|
|
assert str_bonus in grognak.modifiers["strength"]
|
|
|
|
# make sure bonuses are applied to checks and saves
|
|
strength = db.Skill.filter_by(name="strength").one()
|
|
assert grognak.check_modifier(strength) == 1
|
|
assert grognak.check_modifier(strength, save=True) == 1
|
|
|
|
|
|
def test_modifiers(db, bootstrap):
|
|
with db.transaction():
|
|
human = db.Ancestry.filter_by(name="human").one()
|
|
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
|
|
|
# no modifiers; speed is ancestry speed
|
|
carl = schema.Character(name="Carl", ancestry=tiefling)
|
|
marx = schema.Character(name="Marx", ancestry=human)
|
|
db.add_or_update([carl, marx])
|
|
assert carl.speed == carl.ancestry.speed == 30
|
|
|
|
cold = schema.Modifier(target="speed", stacks=True, relative_value=-10, name="Cold")
|
|
hasted = schema.Modifier(target="speed", multiply_value=2.0, name="Hasted")
|
|
slowed = schema.Modifier(target="speed", multiply_value=0.5, name="Slowed")
|
|
restrained = schema.Modifier(target="speed", absolute_value=0, name="Restrained")
|
|
reduced = schema.Modifier(target="size", new_value="Tiny", name="Reduced")
|
|
|
|
# reduce speed by 10
|
|
assert carl.add_modifier(cold)
|
|
assert carl.speed == 20
|
|
|
|
# make sure modifiers only apply to carl. Carl is having a bad day.
|
|
assert marx.speed == 30
|
|
|
|
# speed is doubled
|
|
assert carl.remove_modifier(cold)
|
|
assert carl.speed == 30
|
|
assert carl.add_modifier(hasted)
|
|
assert carl.speed == 60
|
|
|
|
# speed is halved, overriding hasted because it was applied after
|
|
assert carl.add_modifier(slowed)
|
|
assert carl.speed == 15
|
|
|
|
# speed is 0
|
|
assert carl.add_modifier(restrained)
|
|
assert carl.speed == 0
|
|
|
|
# no longer restrained, but still slowed
|
|
assert carl.remove_modifier(restrained)
|
|
assert carl.speed == 15
|
|
|
|
# back to normal
|
|
assert carl.remove_modifier(slowed)
|
|
assert carl.remove_modifier(hasted)
|
|
assert carl.speed == carl.ancestry.speed
|
|
|
|
# modifiers can modify string values too
|
|
assert carl.add_modifier(reduced)
|
|
assert carl.size == "Tiny"
|
|
|
|
# modifiers can be applied to skills, even if the character doesn't have a skill associated.
|
|
athletics = db.Skill.filter_by(name="athletics").one()
|
|
assert athletics not in carl.skills
|
|
assert carl.check_modifier(athletics) == 0
|
|
temp_proficiency = schema.Modifier(
|
|
"Expertise in Athletics",
|
|
target="athletics_check",
|
|
stacks=True,
|
|
relative_attribute="expertise_bonus",
|
|
)
|
|
assert carl.add_modifier(temp_proficiency)
|
|
assert carl.check_modifier(athletics) == carl.expertise_bonus + carl.strength.bonus == 2
|
|
assert carl.remove_modifier(temp_proficiency)
|
|
|
|
# fighters get proficiency in athletics by default
|
|
fighter = db.CharacterClass.filter_by(name="fighter").one()
|
|
carl.add_class(fighter)
|
|
db.add_or_update(carl)
|
|
assert carl.check_modifier(athletics) == 1
|
|
|
|
# add the skill directly, which will grant proficiency but will not stack with proficiency from the class
|
|
carl.add_skill(athletics, proficient=True)
|
|
db.add_or_update(carl)
|
|
assert len([s for s in carl.skills if s == athletics]) == 2
|
|
assert carl.check_modifier(athletics) == 1
|
|
|
|
# manually override proficiency with expertise
|
|
carl.add_skill(athletics, expert=True)
|
|
assert carl.check_modifier(athletics) == 2
|
|
assert len([s for s in carl.skills if s == athletics]) == 2
|
|
|
|
# remove expertise
|
|
carl.add_skill(athletics, proficient=True, expert=False)
|
|
assert carl.check_modifier(athletics) == 1
|
|
|
|
# remove the extra skill entirely, but the fighter proficiency remains
|
|
carl.remove_skill(athletics, proficient=True, expert=False, character_class=None)
|
|
assert len([s for s in carl.skills if s == athletics]) == 1
|
|
assert carl.check_modifier(athletics) == 1
|
|
|
|
|
|
def test_defenses(db, bootstrap):
|
|
with db.transaction():
|
|
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
|
carl = schema.Character(name="Carl", ancestry=tiefling)
|
|
assert carl.resistant("fire", magical=False)
|
|
assert carl.resistant("fire", magical=True)
|
|
carl.apply_damage(5, "fire", magical=True)
|
|
assert carl.hit_points == 8 # half damage
|
|
|
|
immunity = [
|
|
schema.Modifier("Fire Immunity", target="defenses.fire", new_value="immune"),
|
|
schema.Modifier("Fire Immunity", target="defenses.magical_fire", new_value="immune")
|
|
]
|
|
for i in immunity:
|
|
carl.add_modifier(i)
|
|
assert carl.immune("fire")
|
|
carl.apply_damage(5, "fire", magical=True)
|
|
carl.apply_damage(5, "fire", magical=False)
|
|
assert carl.hit_points == 8 # no damage
|
|
|
|
vulnerability = [
|
|
schema.Modifier("Fire Vulnerability", target="defenses.fire", new_value="vulnerable"),
|
|
schema.Modifier("Fire Vulnerability", target="defenses.magical_fire", new_value="vulnerable")
|
|
]
|
|
for i in vulnerability:
|
|
carl.add_modifier(i)
|
|
assert carl.vulnerable("fire")
|
|
assert not carl.immune("fire")
|
|
carl.apply_damage(2, "fire", magical=True)
|
|
assert carl.hit_points == 4 # double damage
|
|
|
|
absorbs = [
|
|
schema.Modifier("Absorbs Non-Magical Fire", target="defenses.fire", new_value="absorbs"),
|
|
]
|
|
carl.add_modifier(absorbs[0])
|
|
carl.apply_damage(20, "fire", magical=False)
|
|
assert carl.hit_points == carl._max_hit_points == 10
|
|
|
|
for i in immunity + vulnerability + absorbs:
|
|
carl.remove_modifier(i)
|
|
carl.apply_damage(5, "fire", magical=True)
|
|
assert carl.resistant("fire")
|
|
assert not carl.immune("fire")
|
|
assert not carl.vulnerable("fire")
|
|
assert not carl.absorbs("fire")
|
|
assert carl.hit_points == 8 # half damage
|