Compare commits
No commits in common. "e2ff1eb0274dab30c2b517583e41b8389415d5c5" and "551140b5bce7f4f9371fa6eababe53250995c63e" have entirely different histories.
e2ff1eb027
...
551140b5bc
|
@ -44,7 +44,7 @@
|
|||
"starting_skills": 0
|
||||
}
|
||||
],
|
||||
"class_feature": [],
|
||||
"class_attribute": [],
|
||||
"modifier": [
|
||||
{
|
||||
"id": 1,
|
||||
|
@ -126,8 +126,8 @@
|
|||
"slug": "PjPdM"
|
||||
}
|
||||
],
|
||||
"class_feature_map": [],
|
||||
"class_feature_option": [],
|
||||
"class_attribute_map": [],
|
||||
"class_attribute_option": [],
|
||||
"class_skill_map": [],
|
||||
"modifier_map": [
|
||||
{
|
||||
|
@ -157,7 +157,7 @@
|
|||
"level": 1
|
||||
}
|
||||
],
|
||||
"character_class_feature_map": [],
|
||||
"character_class_attribute_map": [],
|
||||
"character_skill_map": [],
|
||||
"class_map": [
|
||||
{
|
||||
|
|
|
@ -5,9 +5,9 @@ from sqlalchemy.ext.associationproxy import association_proxy
|
|||
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 DamageType, Defenses
|
||||
from ttfrog.db.schema.modifiers import Condition, Modifier, ModifierMixin, Stat
|
||||
from ttfrog.db.schema.classes import CharacterClass, ClassAttribute
|
||||
from ttfrog.db.schema.constants import Conditions, DamageType, Defenses
|
||||
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
|
||||
from ttfrog.db.schema.skill import Skill
|
||||
|
||||
__all__ = [
|
||||
|
@ -15,7 +15,7 @@ __all__ = [
|
|||
"AncestryTrait",
|
||||
"AncestryTraitMap",
|
||||
"CharacterClassMap",
|
||||
"CharacterClassFeatureMap",
|
||||
"CharacterClassAttributeMap",
|
||||
"Character",
|
||||
"Modifier",
|
||||
]
|
||||
|
@ -32,16 +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):
|
||||
if isinstance(fields, CharacterClassAttributeMap):
|
||||
return fields
|
||||
return CharacterClassFeatureMap(**fields)
|
||||
return CharacterClassAttributeMap(**fields)
|
||||
|
||||
|
||||
class HitDie(BaseObject):
|
||||
|
@ -85,9 +80,9 @@ class Ancestry(BaseObject, ModifierMixin):
|
|||
size: Mapped[str] = mapped_column(nullable=False, default="medium")
|
||||
speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99})
|
||||
|
||||
fly_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
||||
climb_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
||||
swim_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
||||
_fly_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
||||
_climb_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
||||
_swim_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
||||
|
||||
_traits = relationship(
|
||||
"AncestryTraitMap", init=False, uselist=True, cascade="all,delete,delete-orphan", lazy="immediate"
|
||||
|
@ -97,8 +92,16 @@ class Ancestry(BaseObject, ModifierMixin):
|
|||
def traits(self):
|
||||
return [mapping.trait for mapping in self._traits]
|
||||
|
||||
@property
|
||||
def climb_speed(self):
|
||||
return self._climb_speed or int(self.speed / 2)
|
||||
|
||||
@property
|
||||
def swim_speed(self):
|
||||
return self._swim_speed or int(self.speed / 2)
|
||||
|
||||
def add_trait(self, trait, level=1):
|
||||
if trait not in self.traits:
|
||||
if not self._traits or trait not in self._traits:
|
||||
mapping = AncestryTraitMap(ancestry_id=self.id, trait=trait, level=level)
|
||||
if not self._traits:
|
||||
self._traits = [mapping]
|
||||
|
@ -107,6 +110,9 @@ class Ancestry(BaseObject, ModifierMixin):
|
|||
return True
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class AncestryTrait(BaseObject, ModifierMixin):
|
||||
"""
|
||||
|
@ -118,6 +124,9 @@ class AncestryTrait(BaseObject, ModifierMixin):
|
|||
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
|
||||
description: Mapped[Text] = mapped_column(Text, default="")
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class CharacterSkillMap(BaseObject):
|
||||
__tablename__ = "character_skill_map"
|
||||
|
@ -144,34 +153,30 @@ class CharacterClassMap(BaseObject):
|
|||
|
||||
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.character.name}, {self.character_class.name}, level {self.level}"
|
||||
|
||||
class CharacterClassFeatureMap(BaseObject):
|
||||
__tablename__ = "character_class_feature_map"
|
||||
__table_args__ = (UniqueConstraint("character_id", "class_feature_id"),)
|
||||
|
||||
class CharacterClassAttributeMap(BaseObject):
|
||||
__tablename__ = "character_class_attribute_map"
|
||||
__table_args__ = (UniqueConstraint("character_id", "class_attribute_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_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_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: Mapped["ClassFeature"] = relationship(lazy="immediate")
|
||||
option = relationship("ClassFeatureOption", lazy="immediate")
|
||||
class_attribute: Mapped["ClassAttribute"] = relationship(lazy="immediate")
|
||||
option = relationship("ClassAttributeOption", lazy="immediate")
|
||||
|
||||
character_class = relationship(
|
||||
"CharacterClass",
|
||||
secondary="class_map",
|
||||
primaryjoin="CharacterClassFeatureMap.character_id == CharacterClassMap.character_id",
|
||||
primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id",
|
||||
secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id",
|
||||
viewonly=True,
|
||||
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"
|
||||
|
@ -207,11 +212,7 @@ 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})
|
||||
_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})
|
||||
|
||||
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
|
||||
|
@ -220,11 +221,8 @@ 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)
|
||||
|
||||
_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)
|
||||
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan")
|
||||
attribute_list = association_proxy("character_class_attribute_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)
|
||||
|
@ -250,17 +248,24 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
def expertise_bonus(self):
|
||||
return 2 * self.proficiency_bonus
|
||||
|
||||
@property
|
||||
def proficiencies(self):
|
||||
unified = {}
|
||||
unified.update(**self._proficiencies)
|
||||
|
||||
@property
|
||||
def modifiers(self):
|
||||
unified = {}
|
||||
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
|
||||
|
||||
@property
|
||||
def check_modifiers(self):
|
||||
return [self.check_modifier(skill) for skill in self.skills]
|
||||
|
||||
@property
|
||||
def classes(self):
|
||||
return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map])
|
||||
|
@ -275,22 +280,19 @@ 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.ancestry.speed)
|
||||
|
||||
@property
|
||||
def climb_speed(self):
|
||||
return self._apply_modifiers("climb_speed", self.ancestry.climb_speed or int(self.speed / 2))
|
||||
return self._apply_modifiers("climb_speed", self.ancestry._climb_speed)
|
||||
|
||||
@property
|
||||
def swim_speed(self):
|
||||
return self._apply_modifiers("swim_speed", self.ancestry.swim_speed or int(self.speed / 2))
|
||||
return self._apply_modifiers("swim_speed", self.ancestry._swim_speed)
|
||||
|
||||
@property
|
||||
def fly_speed(self):
|
||||
modified = self._apply_modifiers("fly_speed", self.ancestry.fly_speed or 0)
|
||||
if self.ancestry.fly_speed is None and not modified:
|
||||
return None
|
||||
return self._apply_modifiers("speed", modified)
|
||||
return self._apply_modifiers("fly_speed", self.ancestry._fly_speed)
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
|
@ -309,8 +311,8 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
return dict([(mapping.character_class.name, mapping.level) for mapping in self.class_map])
|
||||
|
||||
@property
|
||||
def class_features(self):
|
||||
return dict([(mapping.class_feature.name, mapping.option) for mapping in self.character_class_feature_map])
|
||||
def class_attributes(self):
|
||||
return dict([(mapping.class_attribute.name, mapping.option) for mapping in self.character_class_attribute_map])
|
||||
|
||||
def level_in_class(self, charclass):
|
||||
mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id]
|
||||
|
@ -330,6 +332,12 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
def absorbs(self, damage_type: DamageType):
|
||||
return self.defense(damage_type) == Defenses.absorbs
|
||||
|
||||
def conditions(self):
|
||||
return [self._apply_modifiers(f"conditions.{name}") for name in Conditions]
|
||||
|
||||
def condition(self, condition_name: str):
|
||||
return self._apply_modifiers(f"conditions.{condition_name}", False)
|
||||
|
||||
def defense(self, damage_type: DamageType):
|
||||
return self._apply_modifiers(damage_type, None)
|
||||
|
||||
|
@ -373,10 +381,10 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
else:
|
||||
mapping.level = level
|
||||
|
||||
# add class features with default values
|
||||
# add class attributes with default values
|
||||
for lvl in range(1, level + 1):
|
||||
for attr in newclass.features_at_level(lvl):
|
||||
self.add_class_feature(newclass, attr, attr.options[0])
|
||||
for attr in newclass.attributes_at_level(lvl):
|
||||
self.add_class_attribute(newclass, attr, attr.options[0])
|
||||
|
||||
# add default class skills
|
||||
for skill in newclass.skills[: newclass.starting_skills]:
|
||||
|
@ -389,75 +397,42 @@ 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_feature_map:
|
||||
for mapping in self.character_class_attribute_map:
|
||||
if mapping.character_class == target:
|
||||
self.remove_class_feature(mapping.class_feature)
|
||||
self.remove_class_attribute(mapping.class_attribute)
|
||||
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_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 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 has_class_feature(self, feature):
|
||||
return feature in [m.class_feature for m in self.character_class_feature_map]
|
||||
def has_class_attribute(self, attribute):
|
||||
return attribute in [m.class_attribute for m in self.character_class_attribute_map]
|
||||
|
||||
def add_class_feature(self, character_class, feature, option):
|
||||
if self.has_class_feature(feature):
|
||||
def add_class_attribute(self, character_class, attribute, option):
|
||||
if self.has_class_attribute(attribute):
|
||||
return False
|
||||
mapping = self.level_in_class(character_class)
|
||||
if not mapping:
|
||||
return False
|
||||
if feature not in mapping.character_class.features_at_level(mapping.level):
|
||||
if attribute not in mapping.character_class.attributes_at_level(mapping.level):
|
||||
return False
|
||||
self.feature_list.append(
|
||||
CharacterClassFeatureMap(
|
||||
self.attribute_list.append(
|
||||
CharacterClassAttributeMap(
|
||||
character_id=self.id,
|
||||
class_feature_id=feature.id,
|
||||
class_attribute_id=attribute.id,
|
||||
option_id=option.id,
|
||||
class_feature=feature,
|
||||
class_attribute=attribute,
|
||||
)
|
||||
)
|
||||
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):
|
||||
# if not self.id:
|
||||
# raise Exception("Cannot add a skill before the character has been persisted.")
|
||||
skillmap = None
|
||||
exists = False
|
||||
if skill in self.skills:
|
||||
|
@ -525,10 +500,14 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
|
||||
self.hit_points = max(0, self.hit_points - (total - self.temp_hit_points))
|
||||
self.temp_hit_points = 0
|
||||
return
|
||||
|
||||
def spend_hit_die(self, die):
|
||||
die.spent = True
|
||||
|
||||
def reset_hit_die(self, die):
|
||||
die.spent = False
|
||||
|
||||
def __after_insert__(self, session):
|
||||
"""
|
||||
Called by the session after_flush event listener to add default joins in other tables.
|
||||
|
|
|
@ -9,9 +9,9 @@ from ttfrog.db.base import BaseObject
|
|||
from ttfrog.db.schema.skill import Skill
|
||||
|
||||
__all__ = [
|
||||
"ClassFeatureMap",
|
||||
"ClassFeature",
|
||||
"ClassFeatureOption",
|
||||
"ClassAttributeMap",
|
||||
"ClassAttribute",
|
||||
"ClassAttributeOption",
|
||||
"CharacterClass",
|
||||
"Skill",
|
||||
"ClassSkillMap",
|
||||
|
@ -36,24 +36,24 @@ class ClassSkillMap(BaseObject):
|
|||
skill = relationship("Skill", lazy="immediate")
|
||||
|
||||
|
||||
class ClassFeatureMap(BaseObject):
|
||||
__tablename__ = "class_feature_map"
|
||||
class_feature_id: Mapped[int] = mapped_column(ForeignKey("class_feature.id"), primary_key=True)
|
||||
class ClassAttributeMap(BaseObject):
|
||||
__tablename__ = "class_attribute_map"
|
||||
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.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)
|
||||
feature = relationship("ClassFeature", uselist=False, viewonly=True, lazy="immediate")
|
||||
attribute = relationship("ClassAttribute", uselist=False, viewonly=True, lazy="immediate")
|
||||
|
||||
|
||||
class ClassFeature(BaseObject):
|
||||
__tablename__ = "class_feature"
|
||||
class ClassAttribute(BaseObject):
|
||||
__tablename__ = "class_attribute"
|
||||
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")
|
||||
options = relationship("ClassAttributeOption", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
|
||||
def add_option(self, **kwargs):
|
||||
option = ClassFeatureOption(feature_id=self.id, **kwargs)
|
||||
option = ClassAttributeOption(attribute_id=self.id, **kwargs)
|
||||
if not self.options or option not in self.options:
|
||||
option.feature_id = self.id
|
||||
option.attribute_id = self.id
|
||||
if not self.options:
|
||||
self.options = [option]
|
||||
else:
|
||||
|
@ -65,11 +65,11 @@ class ClassFeature(BaseObject):
|
|||
return f"{self.id}: {self.name}"
|
||||
|
||||
|
||||
class ClassFeatureOption(BaseObject):
|
||||
__tablename__ = "class_feature_option"
|
||||
class ClassAttributeOption(BaseObject):
|
||||
__tablename__ = "class_attribute_option"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
feature_id: Mapped[int] = mapped_column(ForeignKey("class_feature.id"), nullable=True)
|
||||
attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.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)
|
||||
|
||||
features = relationship("ClassFeatureMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
attributes = relationship("ClassAttributeMap", 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_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]
|
||||
def add_attribute(self, attribute, level=1):
|
||||
if not self.attributes or attribute not in self.attributes:
|
||||
mapping = ClassAttributeMap(character_class_id=self.id, class_attribute_id=attribute.id, level=level)
|
||||
if not self.attributes:
|
||||
self.attributes = [mapping]
|
||||
else:
|
||||
self.features.append(mapping)
|
||||
self.attributes.append(mapping)
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def features_by_level(self):
|
||||
def attributes_by_level(self):
|
||||
by_level = defaultdict(list)
|
||||
for mapping in self.features:
|
||||
by_level[mapping.level].append(mapping.feature)
|
||||
for mapping in self.attributes:
|
||||
by_level[mapping.level].append(mapping.attribute)
|
||||
return by_level
|
||||
|
||||
def feature(self, name: str):
|
||||
for mapping in self.features:
|
||||
if mapping.feature.name.lower() == name.lower():
|
||||
return mapping.feature
|
||||
def attribute(self, name: str):
|
||||
for mapping in self.attributes:
|
||||
if mapping.attribute.name.lower() == name.lower():
|
||||
return mapping.attribute
|
||||
return None
|
||||
|
||||
def features_at_level(self, level: int):
|
||||
return list(itertools.chain(*[attrs for lvl, attrs in self.features_by_level.items() if lvl <= level]))
|
||||
def attributes_at_level(self, level: int):
|
||||
return list(itertools.chain(*[attrs for lvl, attrs in self.attributes_by_level.items() if lvl <= level]))
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
from collections import defaultdict
|
||||
from typing import Any, Union
|
||||
|
||||
from sqlalchemy import ForeignKey, UniqueConstraint, String
|
||||
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||
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
|
||||
|
||||
|
@ -72,7 +71,6 @@ 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:
|
||||
|
@ -138,9 +136,7 @@ class ModifierMixin:
|
|||
Returns True if the modifier was added; False if was already present.
|
||||
"""
|
||||
if modifier.absolute_value is not None and modifier.relative_value is not None and modifier.multiple_value:
|
||||
raise AttributeError(
|
||||
f"You must provide only one of absolute, relative, and multiple values {modifier}."
|
||||
) # pragma: no cover
|
||||
raise AttributeError(f"You must provide only one of absolute, relative, and multiple values {modifier}.")
|
||||
|
||||
if [mod for mod in self.modifier_map if mod.modifier == modifier]:
|
||||
return False
|
||||
|
@ -159,7 +155,7 @@ class ModifierMixin:
|
|||
|
||||
Returns True if it was removed and False if it wasn't present.
|
||||
"""
|
||||
if modifier not in self.modifiers.get(modifier.target, []):
|
||||
if modifier not in self.modifiers[modifier.target]:
|
||||
return False
|
||||
self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier]
|
||||
return True
|
||||
|
@ -179,7 +175,7 @@ class ModifierMixin:
|
|||
for key in col.info.keys():
|
||||
if key.startswith("modifiable"):
|
||||
return col
|
||||
return None # pragma: no cover
|
||||
return None
|
||||
|
||||
def _get_modifiable_base(self, attr_name: str) -> object:
|
||||
"""
|
||||
|
@ -221,7 +217,7 @@ class ModifierMixin:
|
|||
if modifier.relative_value is not None:
|
||||
return base_value + modifier.relative_value
|
||||
|
||||
raise Exception(f"Cannot apply modifier: {modifier = }") # pragma: no cover
|
||||
raise Exception(f"Cannot apply modifier: {modifier = }")
|
||||
|
||||
def _apply_modifiers(self, target: str, initial: Any, modifiable_class: type = None) -> Modifiable:
|
||||
"""
|
||||
|
@ -262,7 +258,7 @@ class ModifierMixin:
|
|||
"""
|
||||
col = self._modifiable_column(attr_name)
|
||||
if col is not None:
|
||||
raise AttributeError(f"You cannot modify .{attr_name}. Did you mean ._{attr_name}?") # pragma: no cover
|
||||
raise AttributeError(f"You cannot modify .{attr_name}. Did you mean ._{attr_name}?")
|
||||
return super().__setattr__(attr_name, value)
|
||||
|
||||
def __getattr__(self, attr_name):
|
||||
|
@ -279,62 +275,3 @@ 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}"
|
||||
|
|
|
@ -73,7 +73,7 @@ def bootstrap(db):
|
|||
db.add_or_update([acrobatics, athletics])
|
||||
|
||||
# classes
|
||||
fighting_style = schema.ClassFeature("Fighting Style")
|
||||
fighting_style = schema.ClassAttribute("Fighting Style")
|
||||
fighting_style.add_option(name="Archery")
|
||||
fighting_style.add_option(name="Defense")
|
||||
db.add_or_update(fighting_style)
|
||||
|
@ -86,7 +86,7 @@ def bootstrap(db):
|
|||
# add skills
|
||||
fighter.add_skill(acrobatics)
|
||||
fighter.add_skill(athletics)
|
||||
fighter.add_feature(fighting_style, level=2)
|
||||
fighter.add_attribute(fighting_style, level=2)
|
||||
db.add_or_update(fighter)
|
||||
assert acrobatics in fighter.skills
|
||||
assert athletics in fighter.skills
|
||||
|
|
|
@ -21,8 +21,3 @@ def test_dump_load(db, bootstrap):
|
|||
def test_loader(db, bootstrap):
|
||||
loader.load(db.dump())
|
||||
assert len(db.Ancestry.all()) > 0
|
||||
|
||||
|
||||
def test_default(db):
|
||||
loader.load()
|
||||
assert len(db.Ancestry.all()) > 0
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import json
|
||||
|
||||
from ttfrog.db import schema
|
||||
from ttfrog.db.schema.conditions import conditions
|
||||
from ttfrog.db.schema.constants import Conditions, DamageType, Defenses
|
||||
from ttfrog.db.schema.constants import DamageType, Defenses
|
||||
|
||||
|
||||
def test_manage_character(db, bootstrap):
|
||||
|
@ -24,9 +23,7 @@ def test_manage_character(db, bootstrap):
|
|||
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(
|
||||
|
@ -42,7 +39,6 @@ def test_manage_character(db, bootstrap):
|
|||
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
|
||||
|
@ -72,14 +68,14 @@ def test_manage_character(db, bootstrap):
|
|||
db.add_or_update(char)
|
||||
assert char.levels == {"fighter": 1}
|
||||
assert char.level == 1
|
||||
assert char.class_features == {}
|
||||
assert char.class_attributes == {}
|
||||
|
||||
# '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
|
||||
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_features == {}
|
||||
assert char.class_attributes == {}
|
||||
|
||||
# level up
|
||||
char.add_class(fighter, level=7)
|
||||
|
@ -88,10 +84,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_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)
|
||||
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()
|
||||
|
@ -114,13 +110,8 @@ 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 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)
|
||||
|
@ -137,18 +128,17 @@ def test_manage_character(db, bootstrap):
|
|||
char.add_class(fighter, level=0)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {}
|
||||
assert char.class_features == {}
|
||||
assert char.class_attributes == {}
|
||||
|
||||
# verify the proficiencies etc. added by the classes have been removed
|
||||
# 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
|
||||
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["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]
|
||||
|
||||
|
||||
|
@ -172,10 +162,6 @@ def test_ancestries(db, bootstrap):
|
|||
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)",
|
||||
|
@ -222,39 +208,10 @@ def test_modifiers(db, bootstrap):
|
|||
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
|
||||
|
@ -262,7 +219,6 @@ 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
|
||||
|
||||
|
@ -287,9 +243,6 @@ def test_modifiers(db, bootstrap):
|
|||
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
|
||||
|
@ -330,9 +283,6 @@ def test_modifiers(db, bootstrap):
|
|||
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():
|
||||
|
@ -342,16 +292,6 @@ def test_defenses(db, bootstrap):
|
|||
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),
|
||||
]
|
||||
|
@ -359,7 +299,7 @@ def test_defenses(db, bootstrap):
|
|||
carl.add_modifier(i)
|
||||
assert carl.immune(DamageType.fire)
|
||||
carl.apply_damage(5, DamageType.fire)
|
||||
assert carl.hit_points == 7 # no damage
|
||||
assert carl.hit_points == 8 # no damage
|
||||
|
||||
vulnerability = [
|
||||
schema.Modifier("Fire Vulnerability", target=DamageType.fire, new_value=Defenses.vulnerable),
|
||||
|
@ -369,7 +309,7 @@ def test_defenses(db, bootstrap):
|
|||
assert carl.vulnerable(DamageType.fire)
|
||||
assert not carl.immune(DamageType.fire)
|
||||
carl.apply_damage(2, DamageType.fire)
|
||||
assert carl.hit_points == 3 # double damage
|
||||
assert carl.hit_points == 4 # double damage
|
||||
|
||||
absorbs = [schema.Modifier("Absorbs Non-Magical Fire", target=DamageType.fire, new_value=Defenses.absorbs)]
|
||||
carl.add_modifier(absorbs[0])
|
||||
|
@ -384,85 +324,3 @@ 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)
|
||||
|
|
Loading…
Reference in New Issue
Block a user