diff --git a/src/ttfrog/db/bootstrap/bootstrap.json b/src/ttfrog/db/bootstrap/bootstrap.json index c3ab621..cf8a9e4 100644 --- a/src/ttfrog/db/bootstrap/bootstrap.json +++ b/src/ttfrog/db/bootstrap/bootstrap.json @@ -44,7 +44,7 @@ "starting_skills": 0 } ], - "class_attribute": [], + "class_feature": [], "modifier": [ { "id": 1, @@ -126,8 +126,8 @@ "slug": "PjPdM" } ], - "class_attribute_map": [], - "class_attribute_option": [], + "class_feature_map": [], + "class_feature_option": [], "class_skill_map": [], "modifier_map": [ { @@ -157,7 +157,7 @@ "level": 1 } ], - "character_class_attribute_map": [], + "character_class_feature_map": [], "character_skill_map": [], "class_map": [ { diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index 257933f..aedc554 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -6,8 +6,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from ttfrog.db.base import BaseObject, SlugMixin from ttfrog.db.schema.classes import CharacterClass, ClassFeature -from ttfrog.db.schema.constants import Conditions, DamageType, Defenses -from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat +from ttfrog.db.schema.constants import DamageType, Defenses +from ttfrog.db.schema.modifiers import Condition, Modifier, ModifierMixin, Stat from ttfrog.db.schema.skill import Skill __all__ = [ @@ -32,6 +32,11 @@ def skill_creator(fields): return fields return CharacterSkillMap(**fields) +def condition_creator(fields): + if isinstance(fields, CharacterConditionMap): + return fields + return CharacterConditionMap(**fields) + def attr_map_creator(fields): if isinstance(fields, CharacterClassFeatureMap): @@ -107,6 +112,7 @@ class AncestryTrait(BaseObject, ModifierMixin): """ A trait granted to a character via its Ancestry. """ + __tablename__ = "ancestry_trait" id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True) @@ -140,14 +146,14 @@ class CharacterClassMap(BaseObject): class CharacterClassFeatureMap(BaseObject): - __tablename__ = "character_class_attribute_map" - __table_args__ = (UniqueConstraint("character_id", "class_attribute_id"),) + __tablename__ = "character_class_feature_map" + __table_args__ = (UniqueConstraint("character_id", "class_feature_id"),) id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=False) - class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=False) - option_id: Mapped[int] = mapped_column(ForeignKey("class_attribute_option.id"), nullable=False) + class_feature_id: Mapped[int] = mapped_column(ForeignKey("class_feature.id"), nullable=False) + option_id: Mapped[int] = mapped_column(ForeignKey("class_feature_option.id"), nullable=False) - class_attribute: Mapped["ClassFeature"] = relationship(lazy="immediate") + class_feature: Mapped["ClassFeature"] = relationship(lazy="immediate") option = relationship("ClassFeatureOption", lazy="immediate") character_class = relationship( @@ -159,6 +165,13 @@ class CharacterClassFeatureMap(BaseObject): uselist=False, ) +class CharacterConditionMap(BaseObject): + __tablename__ = "character_condition_map" + __table_args__ = (UniqueConstraint("condition_id", "character_id"), ) + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id")) + character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=True, default=None) + condition = relationship("Condition", lazy="immediate") class Character(BaseObject, SlugMixin, ModifierMixin): __tablename__ = "character" @@ -194,6 +207,10 @@ class Character(BaseObject, SlugMixin, ModifierMixin): nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat} ) + _actions_per_turn: Mapped[int] = mapped_column(nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}) + _bonus_actions_per_turn: Mapped[int] = mapped_column(nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}) + _reactions_per_turn: Mapped[int] = mapped_column(nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}) + vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True}) exhaustion: Mapped[int] = mapped_column(nullable=False, default=0, info={"min": 0, "max": 5}) @@ -203,8 +220,11 @@ class Character(BaseObject, SlugMixin, ModifierMixin): _skills = relationship("CharacterSkillMap", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate") skills = association_proxy("_skills", "skill", creator=skill_creator) - character_class_attribute_map = relationship("CharacterClassFeatureMap", cascade="all,delete,delete-orphan") - attribute_list = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator) + _conditions = relationship("CharacterConditionMap", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate") + conditions = association_proxy("_conditions", "condition", creator=condition_creator) + + character_class_feature_map = relationship("CharacterClassFeatureMap", cascade="all,delete,delete-orphan") + feature_list = association_proxy("character_class_feature_map", "id", creator=attr_map_creator) ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1") ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None) @@ -236,6 +256,8 @@ class Character(BaseObject, SlugMixin, ModifierMixin): unified.update(**self.ancestry.modifiers) for trait in self.traits: unified.update(**trait.modifiers) + for condition in self.conditions: + unified.update(**condition.modifiers) unified.update(**super().modifiers) return unified @@ -253,7 +275,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin): @property def speed(self): - return self._apply_modifiers('speed', self._apply_modifiers("walking_speed", self.ancestry.speed)) + return self._apply_modifiers("speed", self._apply_modifiers("walking_speed", self.ancestry.speed)) @property def climb_speed(self): @@ -287,8 +309,8 @@ class Character(BaseObject, SlugMixin, ModifierMixin): return dict([(mapping.character_class.name, mapping.level) for mapping in self.class_map]) @property - def class_attributes(self): - return dict([(mapping.class_attribute.name, mapping.option) for mapping in self.character_class_attribute_map]) + def class_features(self): + return dict([(mapping.class_feature.name, mapping.option) for mapping in self.character_class_feature_map]) def level_in_class(self, charclass): mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id] @@ -308,14 +330,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin): def absorbs(self, damage_type: DamageType): return self.defense(damage_type) == Defenses.absorbs - def condition(self, condition): - if not self.immune(condition): - return self._apply_modifiers(condition, False) - return False - - def add_condition(self, condition): - self.add_modifier(Modifier(condition, target=condition, new_value=True)) - def defense(self, damage_type: DamageType): return self._apply_modifiers(damage_type, None) @@ -359,10 +373,10 @@ class Character(BaseObject, SlugMixin, ModifierMixin): else: mapping.level = level - # add class attributes with default values + # add class features with default values for lvl in range(1, level + 1): - for attr in newclass.attributes_at_level(lvl): - self.add_class_attribute(newclass, attr, attr.options[0]) + for attr in newclass.features_at_level(lvl): + self.add_class_feature(newclass, attr, attr.options[0]) # add default class skills for skill in newclass.skills[: newclass.starting_skills]: @@ -375,39 +389,74 @@ class Character(BaseObject, SlugMixin, ModifierMixin): def remove_class(self, target): self.class_map = [m for m in self.class_map if m.character_class != target] - for mapping in self.character_class_attribute_map: + for mapping in self.character_class_feature_map: if mapping.character_class == target: - self.remove_class_attribute(mapping.class_attribute) + self.remove_class_feature(mapping.class_feature) for skill in target.skills: self.remove_skill(skill, proficient=True, expert=False, character_class=target) self._hit_dice = [die for die in self._hit_dice if die.character_class != target] - def remove_class_attribute(self, attribute): - self.character_class_attribute_map = [ - m for m in self.character_class_attribute_map if m.class_attribute.id != attribute.id + def remove_class_feature(self, feature): + self.character_class_feature_map = [ + m for m in self.character_class_feature_map if m.class_feature.id != feature.id ] - def has_class_attribute(self, attribute): - return attribute in [m.class_attribute for m in self.character_class_attribute_map] + def has_class_feature(self, feature): + return feature in [m.class_feature for m in self.character_class_feature_map] - def add_class_attribute(self, character_class, attribute, option): - if self.has_class_attribute(attribute): + def add_class_feature(self, character_class, feature, option): + if self.has_class_feature(feature): return False mapping = self.level_in_class(character_class) if not mapping: return False - if attribute not in mapping.character_class.attributes_at_level(mapping.level): + if feature not in mapping.character_class.features_at_level(mapping.level): return False - self.attribute_list.append( + self.feature_list.append( CharacterClassFeatureMap( character_id=self.id, - class_attribute_id=attribute.id, + class_feature_id=feature.id, option_id=option.id, - class_attribute=attribute, + class_feature=feature, ) ) return True + def add_modifier(self, modifier): + if not super().add_modifier(modifier): + return False + + if modifier.new_value != Defenses.immune: + return True + + modified_condition = None + for cond in self.conditions: + if modifier.target == cond.name: + modified_condition = cond + break + if not modified_condition: + return True + + return self.remove_condition(modified_condition) + + def has_condition(self, condition): + return condition in self.conditions + + def add_condition(self, condition): + if self.immune(condition.name): + return False + if self.has_condition(condition): + return False + self._conditions.append(CharacterConditionMap(condition_id=condition.id, character_id=self.id)) + return True + + def remove_condition(self, condition): + if not self.has_condition(condition): + return False + mappings = [mapping for mapping in self._conditions if mapping.condition_id != condition.id] + self._conditions = mappings + return True + def add_skill(self, skill, proficient=False, expert=False, character_class=None): skillmap = None exists = False diff --git a/src/ttfrog/db/schema/classes.py b/src/ttfrog/db/schema/classes.py index 0ec9a6f..659cca7 100644 --- a/src/ttfrog/db/schema/classes.py +++ b/src/ttfrog/db/schema/classes.py @@ -37,23 +37,23 @@ class ClassSkillMap(BaseObject): class ClassFeatureMap(BaseObject): - __tablename__ = "class_attribute_map" - class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), primary_key=True) + __tablename__ = "class_feature_map" + class_feature_id: Mapped[int] = mapped_column(ForeignKey("class_feature.id"), primary_key=True) character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), primary_key=True) level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1) - attribute = relationship("ClassFeature", uselist=False, viewonly=True, lazy="immediate") + feature = relationship("ClassFeature", uselist=False, viewonly=True, lazy="immediate") class ClassFeature(BaseObject): - __tablename__ = "class_attribute" + __tablename__ = "class_feature" id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(nullable=False) options = relationship("ClassFeatureOption", cascade="all,delete,delete-orphan", lazy="immediate") def add_option(self, **kwargs): - option = ClassFeatureOption(attribute_id=self.id, **kwargs) + option = ClassFeatureOption(feature_id=self.id, **kwargs) if not self.options or option not in self.options: - option.attribute_id = self.id + option.feature_id = self.id if not self.options: self.options = [option] else: @@ -66,10 +66,10 @@ class ClassFeature(BaseObject): class ClassFeatureOption(BaseObject): - __tablename__ = "class_attribute_option" + __tablename__ = "class_feature_option" id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(nullable=False) - attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=True) + feature_id: Mapped[int] = mapped_column(ForeignKey("class_feature.id"), nullable=True) class CharacterClass(BaseObject): @@ -80,7 +80,7 @@ class CharacterClass(BaseObject): hit_die_stat_name: Mapped[str] = mapped_column(default="") starting_skills: int = mapped_column(nullable=False, default=0) - attributes = relationship("ClassFeatureMap", cascade="all,delete,delete-orphan", lazy="immediate") + features = relationship("ClassFeatureMap", cascade="all,delete,delete-orphan", lazy="immediate") _skills = relationship("ClassSkillMap", cascade="all,delete,delete-orphan", lazy="immediate") skills = association_proxy("_skills", "skill", creator=skill_creator) @@ -94,28 +94,28 @@ class CharacterClass(BaseObject): return True return False - def add_attribute(self, attribute, level=1): - if not self.attributes or attribute not in self.attributes: - mapping = ClassFeatureMap(character_class_id=self.id, class_attribute_id=attribute.id, level=level) - if not self.attributes: - self.attributes = [mapping] + def add_feature(self, feature, level=1): + if not self.features or feature not in self.features: + mapping = ClassFeatureMap(character_class_id=self.id, class_feature_id=feature.id, level=level) + if not self.features: + self.features = [mapping] else: - self.attributes.append(mapping) + self.features.append(mapping) return True return False @property - def attributes_by_level(self): + def features_by_level(self): by_level = defaultdict(list) - for mapping in self.attributes: - by_level[mapping.level].append(mapping.attribute) + for mapping in self.features: + by_level[mapping.level].append(mapping.feature) return by_level - def attribute(self, name: str): - for mapping in self.attributes: - if mapping.attribute.name.lower() == name.lower(): - return mapping.attribute + def feature(self, name: str): + for mapping in self.features: + if mapping.feature.name.lower() == name.lower(): + return mapping.feature return None - def attributes_at_level(self, level: int): - return list(itertools.chain(*[attrs for lvl, attrs in self.attributes_by_level.items() if lvl <= level])) + def features_at_level(self, level: int): + return list(itertools.chain(*[attrs for lvl, attrs in self.features_by_level.items() if lvl <= level])) diff --git a/src/ttfrog/db/schema/modifiers.py b/src/ttfrog/db/schema/modifiers.py index 76c71b1..20cba7c 100644 --- a/src/ttfrog/db/schema/modifiers.py +++ b/src/ttfrog/db/schema/modifiers.py @@ -1,9 +1,10 @@ from collections import defaultdict from typing import Any, Union -from sqlalchemy import ForeignKey, UniqueConstraint +from sqlalchemy import ForeignKey, UniqueConstraint, String from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import Mapped, mapped_column, relationship +# from sqlalchemy.ext.associationproxy import association_proxy from ttfrog.db.base import BaseObject @@ -71,6 +72,7 @@ class Modifier(BaseObject): relative_attribute: Mapped[str] = mapped_column(nullable=True, default=None) new_value: Mapped[str] = mapped_column(nullable=True, default=None) description: Mapped[str] = mapped_column(default="") + condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id"), init=False, nullable=True, default=None) class ModifierMixin: @@ -277,3 +279,62 @@ class ModifierMixin: modifiable_class=col.info.get("modifiable_class", None), ) raise AttributeError(f"No such attribute on {self.__class__.__name__} object: {attr_name}.") + + +class Condition(BaseObject): + __tablename__ = "condition" + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True) + description: Mapped[str] = mapped_column(default="") + + _modifiers = relationship("Modifier", uselist=True, cascade="all,delete,delete-orphan") + _parent_condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id"), nullable=True, default=None) + conditions = relationship("Condition", lazy="immediate", uselist=True) + + @property + def modifiers(self): + """ + Return all modifiers for the current instance as a dict keyed on target attribute name. + """ + all_modifiers = defaultdict(list) + for modifier in self._modifiers: + all_modifiers[modifier.target].append(modifier) + for condition in self.conditions: + print(condition.modifiers) + all_modifiers.update(**condition.modifiers) + return all_modifiers + + def add_modifier(self, modifier): + if modifier in self._modifiers: + return False + self._modifiers.append(modifier) + return True + + def remove_modifier(self, modifier): + if modifier not in self._modifiers: + return False + self._modifiers = [m for m in self._modifiers if m is not modifier] + return True + + def add_condition(self, condition): + if condition in self.conditions: + return False + if self._parent_condition_id and self._parent_condition_id == condition.id: + return False + self.conditions.append(condition) + return True + + def remove_condition(self, condition): + if condition not in self.conditions: + return False + self.conditions = [c for c in self.conditions if c != condition] + return True + + def __str___(self): + return self.name + + def __repr__(self): + mods = '' + if self._modifiers: + mods = "\n" + "\n".join([f" - {mod}" for mod in self._modifiers]) + return f"{self.name}{mods}" diff --git a/test/conftest.py b/test/conftest.py index 2b33b13..d5e041e 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -86,7 +86,7 @@ def bootstrap(db): # add skills fighter.add_skill(acrobatics) fighter.add_skill(athletics) - fighter.add_attribute(fighting_style, level=2) + fighter.add_feature(fighting_style, level=2) db.add_or_update(fighter) assert acrobatics in fighter.skills assert athletics in fighter.skills diff --git a/test/test_schema.py b/test/test_schema.py index 739c87a..70089a9 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -1,7 +1,8 @@ import json from ttfrog.db import schema -from ttfrog.db.schema.constants import DamageType, Defenses, Conditions +from ttfrog.db.schema.conditions import conditions +from ttfrog.db.schema.constants import Conditions, DamageType, Defenses def test_manage_character(db, bootstrap): @@ -71,14 +72,14 @@ def test_manage_character(db, bootstrap): db.add_or_update(char) assert char.levels == {"fighter": 1} assert char.level == 1 - assert char.class_attributes == {} + assert char.class_features == {} # '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 + 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_attributes == {} + assert char.class_features == {} # level up char.add_class(fighter, level=7) @@ -87,10 +88,10 @@ def test_manage_character(db, bootstrap): 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) + 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() @@ -113,18 +114,9 @@ def test_manage_character(db, bootstrap): db.add_or_update(char) assert char.level == 8 assert char.levels == {"fighter": 7, "rogue": 1} - assert list(char.classes.keys()) == ['fighter', 'rogue'] + assert list(char.classes.keys()) == ["fighter", "rogue"] assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 8 - # test conditions - assert not char.condition(Conditions.frightened) - char.add_condition(Conditions.frightened) - assert char.condition(Conditions.frightened) - char.add_modifier( - schema.Modifier("Immunity: Frightened", target=Conditions.frightened, new_value=Defenses.immune) - ) - assert not char.condition(Conditions.frightened) - # 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 @@ -145,7 +137,7 @@ def test_manage_character(db, bootstrap): char.add_class(fighter, level=0) db.add_or_update(char) assert char.levels == {} - assert char.class_attributes == {} + assert char.class_features == {} # verify the proficiencies etc. added by the classes have been removed assert athletics not in char.skills @@ -156,7 +148,7 @@ def test_manage_character(db, bootstrap): # 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["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] @@ -232,6 +224,27 @@ def test_modifiers(db, bootstrap): 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 @@ -249,6 +262,7 @@ def test_modifiers(db, bootstrap): # 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 @@ -319,6 +333,7 @@ def test_modifiers(db, bootstrap): # 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() @@ -369,3 +384,85 @@ def test_defenses(db, bootstrap): 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)