tabletop-frog/test/test_schema.py

479 lines
19 KiB
Python
Raw Normal View History

import json
2024-04-20 20:35:24 -07:00
2024-03-24 16:56:13 -07:00
from ttfrog.db import schema
2024-07-13 12:30:43 -07:00
from ttfrog.db.schema.constants import Conditions, DamageType, Defenses
2024-03-24 16:56:13 -07:00
2024-05-04 13:15:54 -07:00
def test_manage_character(db, bootstrap):
2024-03-24 16:56:13 -07:00
with db.transaction():
2024-05-04 13:15:54 -07:00
darkvision = db.AncestryTrait.filter_by(name="Darkvision").one()
human = db.Ancestry.filter_by(name="human").one()
2024-03-24 16:56:13 -07:00
# create a human character (the default)
2024-05-04 13:15:54 -07:00
char = schema.Character(name="Test Character", ancestry=human)
db.add_or_update(char)
assert char.id
2024-03-26 00:53:21 -07:00
assert char.name == "Test Character"
assert char.ancestry.name == "human"
2024-05-04 13:15:54 -07:00
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
2024-07-05 14:42:11 -07:00
2024-03-24 16:56:13 -07:00
assert darkvision not in char.traits
2024-07-05 14:42:11 -07:00
assert char.vision_in_darkness == 0
2024-03-24 16:56:13 -07:00
2024-05-06 00:13:52 -07:00
# 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
2024-03-24 16:56:13 -07:00
# switch ancestry to tiefling
2024-05-04 13:15:54 -07:00
tiefling = db.Ancestry.filter_by(name="tiefling").one()
char.ancestry = tiefling
db.add_or_update(char)
2024-05-04 13:15:54 -07:00
char = db.session.get(schema.Character, char.id)
assert char.ancestry_id == tiefling.id
2024-03-26 00:53:21 -07:00
assert char.ancestry.name == "tiefling"
2024-03-24 16:56:13 -07:00
assert darkvision in char.traits
2024-07-05 14:42:11 -07:00
assert char.vision_in_darkness == 120
2024-03-24 16:56:13 -07:00
2024-05-06 00:13:52 -07:00
# 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
2024-04-20 23:27:47 -07:00
# switch ancestry to dragonborn and assert darkvision persists
2024-05-04 13:15:54 -07:00
char.ancestry = db.Ancestry.filter_by(name="dragonborn").one()
2024-04-20 23:27:47 -07:00
db.add_or_update(char)
assert darkvision in char.traits
2024-05-06 00:13:52 -07:00
# verify tiefling modifiers were removed
assert char.intelligence == 10
assert char.charisma == 10
2024-04-20 23:27:47 -07:00
# switch ancestry to human and assert darkvision is removed
2024-05-04 13:15:54 -07:00
char.ancestry = human
2024-04-20 23:27:47 -07:00
db.add_or_update(char)
assert darkvision not in char.traits
2024-05-04 13:15:54 -07:00
fighter = db.CharacterClass.filter_by(name="fighter").one()
rogue = db.CharacterClass.filter_by(name="rogue").one()
2024-03-24 16:56:13 -07:00
# assign a class and level
assert char.add_class(fighter)
db.add_or_update(char)
2024-03-26 00:53:21 -07:00
assert char.levels == {"fighter": 1}
2024-03-24 16:56:13 -07:00
assert char.level == 1
2024-07-13 12:30:43 -07:00
assert char.class_features == {}
2024-03-26 21:58:04 -07:00
# 'fighting style' is available, but not at this level
2024-07-13 12:30:43 -07:00
fighting_style = fighter.feature("Fighting Style")
assert char.has_class_feature(fighting_style) is False
assert char.add_class_feature(fighter, fighting_style, fighting_style.options[0]) is False
db.add_or_update(char)
2024-07-13 12:30:43 -07:00
assert char.class_features == {}
2024-03-24 16:56:13 -07:00
# level up
char.level_up(fighter, num_levels=6)
db.add_or_update(char)
2024-05-06 00:13:52 -07:00
assert char.levels == {"fighter": 7}
assert char.level == 7
2024-03-24 16:56:13 -07:00
# Assert the fighting style is added automatically and idempotent...ly?
2024-07-13 12:30:43 -07:00
assert char.has_class_feature(fighting_style)
assert char.class_features[fighting_style.name] == fighting_style.options[0]
assert char.add_class_feature(fighter, fighting_style, fighting_style.options[0]) is False
assert char.has_class_feature(fighting_style)
db.add_or_update(char)
2024-03-26 21:58:04 -07:00
2024-05-06 00:13:52 -07:00
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
2024-06-30 16:09:20 -07:00
# assert dexterity bonus apply to initiative
char._dexterity = 17
assert char.dexterity.bonus == 3
assert char.initiative == char.dexterity.bonus == 3
char.add_modifier(schema.Modifier("+1 initiative", target="initiative", relative_value=1))
assert char.initiative == 4
char._dexterity = 10
2024-05-06 00:13:52 -07:00
# multiclass
char.add_class(rogue)
db.add_or_update(char)
2024-05-06 00:13:52 -07:00
assert char.level == 8
assert char.levels == {"fighter": 7, "rogue": 1}
2024-07-13 12:30:43 -07:00
assert list(char.classes.keys()) == ["fighter", "rogue"]
2024-05-14 20:15:42 -07:00
assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 8
2024-03-24 16:56:13 -07:00
2024-07-05 14:42:11 -07:00
# use a hit die
char.spend_hit_die(char.hit_dice["rogue"][0])
assert len([die for die in char.hit_dice_available if die.character_class.name == "rogue"]) == 0
2024-03-24 16:56:13 -07:00
# remove a class
2024-05-04 13:15:54 -07:00
char.remove_class(rogue)
db.add_or_update(char)
2024-05-06 00:13:52 -07:00
assert char.levels == {"fighter": 7}
assert char.level == 7
2024-05-14 20:15:42 -07:00
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"
2024-03-24 16:56:13 -07:00
char.remove_class(fighter)
db.add_or_update(char)
assert char.levels == {}
2024-07-13 12:30:43 -07:00
assert char.class_features == {}
2024-03-24 16:56:13 -07:00
2024-07-05 14:42:11 -07:00
# verify the proficiencies etc. added by the classes have been removed
2024-05-06 00:13:52 -07:00
assert athletics not in char.skills
assert acrobatics not in char.skills
assert char.check_modifier(athletics) == 0
assert char.check_modifier(acrobatics) == 0
2024-07-05 14:42:11 -07:00
assert char.hit_dice == {}
2024-05-06 00:13:52 -07:00
2024-03-26 21:58:04 -07:00
# ensure we're not persisting any orphan records in the map tables
dump = json.loads(db.dump())
2024-07-13 12:30:43 -07:00
assert not [m for m in dump["character_class_feature_map"] if m["character_id"] == char.id]
2024-05-04 13:15:54 -07:00
assert not [m for m in dump["class_map"] if m["character_id"] == char.id]
2024-04-20 23:27:47 -07:00
2024-05-06 00:13:52 -07:00
def test_ancestries(db, bootstrap):
2024-04-20 23:27:47 -07:00
with db.transaction():
# create the Pygmy Orc ancestry
porc = schema.Ancestry(
name="Pygmy Orc",
size="Small",
speed=25,
2024-04-20 23:27:47 -07:00
)
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
2024-07-05 14:42:11 -07:00
# add it again and assert nothing changes
porc.add_trait(endurance, level=1)
assert endurance in porc.traits
2024-05-06 00:13:52 -07:00
# add a +3 STR modifier
str_bonus = schema.Modifier(
name="STR+3 (Pygmy Orc)",
2024-04-21 02:17:47 -07:00
target="strength",
stacks=True,
2024-05-06 00:13:52 -07:00
relative_value=3,
description="Your Strength score is increased by 3.",
2024-04-21 02:17:47 -07:00
)
2024-05-06 00:13:52 -07:00
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"]
2024-04-21 02:17:47 -07:00
# now create an orc character and assert it gets traits and modifiers
2024-04-20 23:27:47 -07:00
grognak = schema.Character(name="Grognak the Mighty", ancestry=porc)
2024-04-21 02:17:47 -07:00
db.add_or_update(grognak)
2024-05-06 00:13:52 -07:00
2024-04-20 23:27:47 -07:00
assert endurance in grognak.traits
2024-04-21 02:17:47 -07:00
# verify the strength bonus is applied
2024-05-04 13:15:54 -07:00
assert grognak.strength.base == 10
2024-05-06 00:13:52 -07:00
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
2024-04-21 02:17:47 -07:00
def test_modifiers(db, bootstrap, carl, tiefling, human, wizard):
2024-04-21 02:17:47 -07:00
with db.transaction():
# no modifiers; speed is ancestry speed
2024-05-04 13:15:54 -07:00
marx = schema.Character(name="Marx", ancestry=human)
db.add_or_update([carl, marx])
2024-04-21 02:17:47 -07:00
assert carl.speed == carl.ancestry.speed == 30
cold = schema.Modifier(target="speed", stacks=True, relative_value=-10, name="Cold")
2024-04-21 21:30:24 -07:00
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")
2024-07-05 14:42:11 -07:00
fly = schema.Modifier(target="fly_speed", absolute_value=30, name="Fly Spell")
2024-04-21 21:30:24 -07:00
2024-07-13 12:30:43 -07:00
# test sets of modifiers from conditions are applied when a condition is active
incapacitated = schema.Condition(name="incapacitated")
incapacitated.add_modifier(schema.Modifier(target="actions_per_turn", absolute_value=0, name="Incapacitated"))
2024-07-13 13:08:47 -07:00
incapacitated.add_modifier(
schema.Modifier(target="bonus_actions_per_turn", absolute_value=0, name="Incapacitated")
)
2024-07-13 12:30:43 -07:00
incapacitated.add_modifier(schema.Modifier(target="reactions_per_turn", absolute_value=0, name="Incapacitated"))
db.add_or_update(incapacitated)
assert carl.actions_per_turn == 1
assert carl.bonus_actions_per_turn == 1
assert carl.reactions_per_turn == 1
assert carl.add_condition(incapacitated)
db.add_or_update(carl)
assert carl.has_condition(incapacitated)
assert carl.actions_per_turn == 0
assert carl.bonus_actions_per_turn == 0
assert carl.reactions_per_turn == 0
assert carl.remove_condition(incapacitated)
db.add_or_update(carl)
assert carl.actions_per_turn == 1
assert carl.bonus_actions_per_turn == 1
assert carl.reactions_per_turn == 1
2024-04-21 02:17:47 -07:00
# reduce speed by 10
2024-04-21 21:30:24 -07:00
assert carl.add_modifier(cold)
2024-04-21 02:17:47 -07:00
assert carl.speed == 20
2024-07-05 14:42:11 -07:00
assert carl.climb_speed == 10
assert carl.swim_speed == 10
assert carl.fly_speed is None
# cast fly
carl.add_modifier(fly)
assert carl.fly_speed == 20
2024-04-21 02:17:47 -07:00
# make sure modifiers only apply to carl. Carl is having a bad day.
assert marx.speed == 30
2024-04-21 02:17:47 -07:00
# speed is doubled
2024-04-21 21:30:24 -07:00
assert carl.remove_modifier(cold)
assert carl.speed == 30
2024-07-13 12:30:43 -07:00
assert carl.fly_speed == 30
2024-04-21 21:30:24 -07:00
assert carl.add_modifier(hasted)
2024-04-21 02:17:47 -07:00
assert carl.speed == 60
# speed is halved, overriding hasted because it was applied after
2024-04-21 21:30:24 -07:00
assert carl.add_modifier(slowed)
2024-04-21 02:17:47 -07:00
assert carl.speed == 15
# speed is 0
2024-04-21 21:30:24 -07:00
assert carl.add_modifier(restrained)
2024-04-21 02:17:47 -07:00
assert carl.speed == 0
# no longer restrained, but still slowed
2024-04-21 21:30:24 -07:00
assert carl.remove_modifier(restrained)
2024-04-21 02:17:47 -07:00
assert carl.speed == 15
# back to normal
2024-04-21 21:30:24 -07:00
assert carl.remove_modifier(slowed)
assert carl.remove_modifier(hasted)
2024-04-21 02:17:47 -07:00
assert carl.speed == carl.ancestry.speed
# modifiers can modify string values too
2024-04-21 21:30:24 -07:00
assert carl.add_modifier(reduced)
2024-04-21 02:17:47 -07:00
assert carl.size == "Tiny"
2024-07-05 14:42:11 -07:00
# cannot remove a modifier that isn't applied
assert not carl.remove_modifier(cold)
# 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)
assert carl.check_modifier(athletics) == 0
# 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) == carl.proficiency_bonus
# 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) == 2
# manually override proficiency with expertise
carl.add_skill(athletics, expert=True)
assert carl.check_modifier(athletics) == 2 * carl.proficiency_bonus
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) == carl.proficiency_bonus
# 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) == carl.proficiency_bonus
2024-05-14 20:15:42 -07:00
2024-07-05 14:42:11 -07:00
# ensure you can't remove a skill already removed
assert not carl.remove_skill(athletics, proficient=True, expert=False, character_class=None)
2024-05-14 20:15:42 -07:00
2024-07-13 12:30:43 -07:00
2024-07-28 20:47:42 -07:00
def test_defenses(db, bootstrap, tiefling, carl):
2024-05-14 20:15:42 -07:00
with db.transaction():
2024-07-28 20:47:42 -07:00
db.add_or_update(carl)
2024-06-30 16:09:20 -07:00
assert carl.resistant(DamageType.fire)
carl.apply_damage(5, DamageType.fire)
2024-05-14 20:15:42 -07:00
assert carl.hit_points == 8 # half damage
2024-07-05 14:42:11 -07:00
# add temp HP
carl.temp_hit_points = 3
assert carl.hit_points == 8
carl.apply_damage(1, DamageType.bludgeoning)
assert carl.hit_points == 8
assert carl.temp_hit_points == 2
carl.apply_damage(3, DamageType.bludgeoning)
assert carl.temp_hit_points == 0
assert carl.hit_points == 7
2024-05-14 20:15:42 -07:00
immunity = [
2024-06-30 16:09:20 -07:00
schema.Modifier("Fire Immunity", target=DamageType.fire, new_value=Defenses.immune),
2024-05-14 20:15:42 -07:00
]
for i in immunity:
carl.add_modifier(i)
2024-06-30 16:09:20 -07:00
assert carl.immune(DamageType.fire)
carl.apply_damage(5, DamageType.fire)
2024-07-05 14:42:11 -07:00
assert carl.hit_points == 7 # no damage
2024-05-14 20:15:42 -07:00
vulnerability = [
2024-06-30 16:09:20 -07:00
schema.Modifier("Fire Vulnerability", target=DamageType.fire, new_value=Defenses.vulnerable),
2024-05-14 20:15:42 -07:00
]
for i in vulnerability:
carl.add_modifier(i)
2024-06-30 16:09:20 -07:00
assert carl.vulnerable(DamageType.fire)
assert not carl.immune(DamageType.fire)
carl.apply_damage(2, DamageType.fire)
2024-07-05 14:42:11 -07:00
assert carl.hit_points == 3 # double damage
2024-05-14 20:15:42 -07:00
2024-06-30 16:09:20 -07:00
absorbs = [schema.Modifier("Absorbs Non-Magical Fire", target=DamageType.fire, new_value=Defenses.absorbs)]
2024-05-14 20:15:42 -07:00
carl.add_modifier(absorbs[0])
2024-06-30 16:09:20 -07:00
carl.apply_damage(20, DamageType.fire)
2024-05-14 20:15:42 -07:00
assert carl.hit_points == carl._max_hit_points == 10
for i in immunity + vulnerability + absorbs:
carl.remove_modifier(i)
2024-06-30 16:09:20 -07:00
carl.apply_damage(5, DamageType.fire)
assert carl.resistant(DamageType.fire)
assert not carl.immune(DamageType.fire)
assert not carl.vulnerable(DamageType.fire)
assert not carl.absorbs(DamageType.fire)
2024-05-14 20:15:42 -07:00
assert carl.hit_points == 8 # half damage
2024-07-13 12:30:43 -07:00
2024-07-28 20:47:42 -07:00
def test_condition_immunity(db, bootstrap, carl, tiefling):
2024-07-13 12:30:43 -07:00
"""
Test immunities prevent conditions from being applied
"""
with db.transaction():
2024-07-28 20:47:42 -07:00
db.add_or_update(carl)
2024-07-13 12:30:43 -07:00
poisoned = schema.Condition(name=DamageType.poison)
poison_immunity = schema.Modifier("Poison Immunity", target=DamageType.poison, new_value=Defenses.immune)
db.add_or_update([carl, poisoned, poison_immunity])
# poison carl
assert carl.add_condition(poisoned)
db.add_or_update(carl)
assert carl.has_condition(poisoned)
# grant carl immunity, which must remove the poisoned condition
assert carl.add_modifier(poison_immunity)
db.add_or_update(carl)
assert not carl.has_condition(poisoned)
# ensure that carl cannot be poisoned while immune to poison
assert not carl.add_condition(poisoned)
# remove the immunity and ensure the previous poison doesn't come back
assert carl.remove_modifier(poison_immunity)
db.add_or_update(carl)
assert not carl.immune(str(poisoned))
assert not carl.has_condition(poisoned)
# carl can be poisoned again
assert carl.add_condition(poisoned)
db.add_or_update(carl)
assert carl.has_condition(poisoned)
2024-07-13 13:08:47 -07:00
2024-07-28 20:47:42 -07:00
def test_partial_immunities(db, bootstrap, carl, tiefling):
2024-07-13 12:30:43 -07:00
"""
Test that individual modifiers applied by a condition can be negated even if not immune to the condition.
"""
with db.transaction():
2024-07-28 20:47:42 -07:00
db.add_or_update(carl)
2024-07-13 13:08:47 -07:00
# Create some modifiers and conditions for this test
fly = schema.Modifier(target="fly_speed", absolute_value=30, name="Fly Spell")
cannot_move = schema.Modifier(name="Cannot Move (Petrified", target="speed", absolute_value=0)
2024-07-13 12:30:43 -07:00
poisoned = schema.Condition(name=DamageType.poison)
poison_immunity = schema.Modifier("Poison Immunity", target=DamageType.poison, new_value=Defenses.immune)
2024-07-13 13:08:47 -07:00
# incapacitated applies several modifiers
2024-07-13 12:30:43 -07:00
incapacitated = schema.Condition(name=Conditions.incapacitated)
incapacitated.add_modifier(schema.Modifier(target="actions_per_turn", absolute_value=0, name="Incapacitated"))
2024-07-13 13:08:47 -07:00
incapacitated.add_modifier(
schema.Modifier(target="bonus_actions_per_turn", absolute_value=0, name="Incapacitated")
)
2024-07-13 12:30:43 -07:00
incapacitated.add_modifier(schema.Modifier(target="reactions_per_turn", absolute_value=0, name="Incapacitated"))
2024-07-13 13:08:47 -07:00
# petrified applies several modifiers but also incapacitates you!
2024-07-13 12:30:43 -07:00
petrified = schema.Condition(name=Conditions.petrified)
assert petrified.add_modifier(poison_immunity)
2024-07-13 13:08:47 -07:00
assert petrified.add_modifier(cannot_move)
2024-07-13 12:30:43 -07:00
assert petrified.add_condition(incapacitated)
2024-07-13 13:08:47 -07:00
db.add_or_update([fly, cannot_move, poisoned, poison_immunity, incapacitated, petrified])
2024-07-13 12:30:43 -07:00
2024-07-13 13:08:47 -07:00
# carl casts fly!
assert carl.fly_speed is None
assert carl.add_modifier(fly)
assert carl.fly_speed == carl.speed == carl.ancestry.speed
2024-07-13 12:30:43 -07:00
db.add_or_update(carl)
2024-07-13 13:08:47 -07:00
# poison carl
2024-07-13 12:30:43 -07:00
assert not carl.immune(DamageType.poison)
2024-07-13 13:08:47 -07:00
assert carl.add_condition(poisoned)
db.add_or_update(carl)
2024-07-13 12:30:43 -07:00
# petrify carl
assert not carl.immune(Conditions.petrified)
assert carl.add_condition(petrified)
db.add_or_update(carl)
# while petrified, carl is immune to poison
assert carl.immune(DamageType.poison)
# but carl still can't move
assert carl.actions_per_turn == 0
2024-07-13 13:08:47 -07:00
assert carl.speed == 0
assert carl.fly_speed == 0
2024-07-13 12:30:43 -07:00
# greater restoration ftw!
assert carl.remove_condition(petrified)
db.add_or_update(carl)
# ...but he's still poisoned. Sorry carl :(
assert carl.has_condition(poisoned)
assert not carl.immune(DamageType.poison)