import json from ttfrog.db import schema from ttfrog.db.schema.conditions import conditions from ttfrog.db.schema.constants import Conditions, DamageType, Defenses 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 assert char.vision_in_darkness == 0 # 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 assert char.vision_in_darkness == 120 # 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_features == {} # 'fighting style' is available, but not at this level 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) assert char.class_features == {} # 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_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) 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 # 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 # multiclass char.add_class(rogue, level=1) db.add_or_update(char) assert char.level == 8 assert char.levels == {"fighter": 7, "rogue": 1} assert list(char.classes.keys()) == ["fighter", "rogue"] assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 8 # 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 # 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_features == {} # verify the proficiencies etc. 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 assert char.hit_dice == {} # 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_feature_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 it again and assert nothing changes porc.add_trait(endurance, level=1) 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") fly = schema.Modifier(target="fly_speed", absolute_value=30, name="Fly Spell") # 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")) incapacitated.add_modifier(schema.Modifier(target="bonus_actions_per_turn", absolute_value=0, name="Incapacitated")) 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 # reduce speed by 10 assert carl.add_modifier(cold) assert carl.speed == 20 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 # 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.fly_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" # 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) # 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 # ensure you can't remove a skill already removed assert not carl.remove_skill(athletics, proficient=True, expert=False, character_class=None) 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(DamageType.fire) carl.apply_damage(5, DamageType.fire) assert carl.hit_points == 8 # half damage # 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 immunity = [ schema.Modifier("Fire Immunity", target=DamageType.fire, new_value=Defenses.immune), ] for i in immunity: carl.add_modifier(i) assert carl.immune(DamageType.fire) carl.apply_damage(5, DamageType.fire) assert carl.hit_points == 7 # no damage vulnerability = [ schema.Modifier("Fire Vulnerability", target=DamageType.fire, new_value=Defenses.vulnerable), ] for i in vulnerability: carl.add_modifier(i) assert carl.vulnerable(DamageType.fire) assert not carl.immune(DamageType.fire) carl.apply_damage(2, DamageType.fire) assert carl.hit_points == 3 # double damage absorbs = [schema.Modifier("Absorbs Non-Magical Fire", target=DamageType.fire, new_value=Defenses.absorbs)] carl.add_modifier(absorbs[0]) carl.apply_damage(20, DamageType.fire) assert carl.hit_points == carl._max_hit_points == 10 for i in immunity + vulnerability + absorbs: carl.remove_modifier(i) 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) assert carl.hit_points == 8 # half damage def test_condition_immunity(db, bootstrap): """ Test immunities prevent conditions from being applied """ with db.transaction(): tiefling = db.Ancestry.filter_by(name="tiefling").one() carl = schema.Character(name="Carl", ancestry=tiefling) 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) def test_partial_immunities(db, bootstrap): """ Test that individual modifiers applied by a condition can be negated even if not immune to the condition. """ with db.transaction(): tiefling = db.Ancestry.filter_by(name="tiefling").one() carl = schema.Character(name="Carl", ancestry=tiefling) poisoned = schema.Condition(name=DamageType.poison) poison_immunity = schema.Modifier("Poison Immunity", target=DamageType.poison, new_value=Defenses.immune) incapacitated = schema.Condition(name=Conditions.incapacitated) incapacitated.add_modifier(schema.Modifier(target="actions_per_turn", absolute_value=0, name="Incapacitated")) incapacitated.add_modifier(schema.Modifier(target="bonus_actions_per_turn", absolute_value=0, name="Incapacitated")) incapacitated.add_modifier(schema.Modifier(target="reactions_per_turn", absolute_value=0, name="Incapacitated")) # petrified incapacitates you, and makes you immune to poison petrified = schema.Condition(name=Conditions.petrified) assert petrified.add_modifier(poison_immunity) assert petrified.add_condition(incapacitated) db.add_or_update([poisoned, poison_immunity, incapacitated, petrified]) # poison carl carl.add_condition(poisoned) db.add_or_update(carl) assert not carl.immune(DamageType.poison) # 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 # 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)