Implement conditions

This commit is contained in:
evilchili 2024-07-13 12:30:43 -07:00
parent da1b4223ea
commit e2ff1eb027
6 changed files with 295 additions and 88 deletions

View File

@ -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": [
{

View File

@ -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

View File

@ -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]))

View File

@ -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}"

View File

@ -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

View File

@ -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)