Compare commits

..

3 Commits

Author SHA1 Message Date
evilchili
e2ff1eb027 Implement conditions 2024-07-13 12:30:43 -07:00
evilchili
da1b4223ea rename ClassAttribute ClassFeature 2024-07-05 17:45:27 -07:00
evilchili
4dd72d47d0 adding tests 2024-07-05 14:42:11 -07:00
7 changed files with 371 additions and 140 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

@ -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, ClassAttribute
from ttfrog.db.schema.constants import Conditions, DamageType, Defenses
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
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.skill import Skill
__all__ = [
@ -15,7 +15,7 @@ __all__ = [
"AncestryTrait",
"AncestryTraitMap",
"CharacterClassMap",
"CharacterClassAttributeMap",
"CharacterClassFeatureMap",
"Character",
"Modifier",
]
@ -32,11 +32,16 @@ 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, CharacterClassAttributeMap):
if isinstance(fields, CharacterClassFeatureMap):
return fields
return CharacterClassAttributeMap(**fields)
return CharacterClassFeatureMap(**fields)
class HitDie(BaseObject):
@ -80,9 +85,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"
@ -92,16 +97,8 @@ 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 not self._traits or trait not in self._traits:
if trait not in self.traits:
mapping = AncestryTraitMap(ancestry_id=self.id, trait=trait, level=level)
if not self._traits:
self._traits = [mapping]
@ -110,9 +107,6 @@ class Ancestry(BaseObject, ModifierMixin):
return True
return False
def __repr__(self):
return self.name
class AncestryTrait(BaseObject, ModifierMixin):
"""
@ -124,9 +118,6 @@ 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"
@ -153,30 +144,34 @@ 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 CharacterClassAttributeMap(BaseObject):
__tablename__ = "character_class_attribute_map"
__table_args__ = (UniqueConstraint("character_id", "class_attribute_id"),)
class CharacterClassFeatureMap(BaseObject):
__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["ClassAttribute"] = relationship(lazy="immediate")
option = relationship("ClassAttributeOption", lazy="immediate")
class_feature: Mapped["ClassFeature"] = relationship(lazy="immediate")
option = relationship("ClassFeatureOption", lazy="immediate")
character_class = relationship(
"CharacterClass",
secondary="class_map",
primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id",
primaryjoin="CharacterClassFeatureMap.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"
@ -212,7 +207,11 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
)
_vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
_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})
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
@ -221,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("CharacterClassAttributeMap", 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)
@ -248,24 +250,17 @@ 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])
@ -280,19 +275,22 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
@property
def speed(self):
return self._apply_modifiers("speed", self.ancestry.speed)
return self._apply_modifiers("speed", self._apply_modifiers("walking_speed", self.ancestry.speed))
@property
def climb_speed(self):
return self._apply_modifiers("climb_speed", self.ancestry._climb_speed)
return self._apply_modifiers("climb_speed", self.ancestry.climb_speed or int(self.speed / 2))
@property
def swim_speed(self):
return self._apply_modifiers("swim_speed", self.ancestry._swim_speed)
return self._apply_modifiers("swim_speed", self.ancestry.swim_speed or int(self.speed / 2))
@property
def fly_speed(self):
return self._apply_modifiers("fly_speed", self.ancestry._fly_speed)
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)
@property
def size(self):
@ -311,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]
@ -332,12 +330,6 @@ 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)
@ -381,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]:
@ -397,42 +389,75 @@ 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(
CharacterClassAttributeMap(
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):
# 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:
@ -500,14 +525,10 @@ 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.

View File

@ -9,9 +9,9 @@ from ttfrog.db.base import BaseObject
from ttfrog.db.schema.skill import Skill
__all__ = [
"ClassAttributeMap",
"ClassAttribute",
"ClassAttributeOption",
"ClassFeatureMap",
"ClassFeature",
"ClassFeatureOption",
"CharacterClass",
"Skill",
"ClassSkillMap",
@ -36,24 +36,24 @@ class ClassSkillMap(BaseObject):
skill = relationship("Skill", lazy="immediate")
class ClassAttributeMap(BaseObject):
__tablename__ = "class_attribute_map"
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), primary_key=True)
class ClassFeatureMap(BaseObject):
__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("ClassAttribute", uselist=False, viewonly=True, lazy="immediate")
feature = relationship("ClassFeature", uselist=False, viewonly=True, lazy="immediate")
class ClassAttribute(BaseObject):
__tablename__ = "class_attribute"
class ClassFeature(BaseObject):
__tablename__ = "class_feature"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False)
options = relationship("ClassAttributeOption", cascade="all,delete,delete-orphan", lazy="immediate")
options = relationship("ClassFeatureOption", cascade="all,delete,delete-orphan", lazy="immediate")
def add_option(self, **kwargs):
option = ClassAttributeOption(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:
@ -65,11 +65,11 @@ class ClassAttribute(BaseObject):
return f"{self.id}: {self.name}"
class ClassAttributeOption(BaseObject):
__tablename__ = "class_attribute_option"
class ClassFeatureOption(BaseObject):
__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("ClassAttributeMap", 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 = ClassAttributeMap(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:
@ -136,7 +138,9 @@ 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}.")
raise AttributeError(
f"You must provide only one of absolute, relative, and multiple values {modifier}."
) # pragma: no cover
if [mod for mod in self.modifier_map if mod.modifier == modifier]:
return False
@ -155,7 +159,7 @@ class ModifierMixin:
Returns True if it was removed and False if it wasn't present.
"""
if modifier not in self.modifiers[modifier.target]:
if modifier not in self.modifiers.get(modifier.target, []):
return False
self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier]
return True
@ -175,7 +179,7 @@ class ModifierMixin:
for key in col.info.keys():
if key.startswith("modifiable"):
return col
return None
return None # pragma: no cover
def _get_modifiable_base(self, attr_name: str) -> object:
"""
@ -217,7 +221,7 @@ class ModifierMixin:
if modifier.relative_value is not None:
return base_value + modifier.relative_value
raise Exception(f"Cannot apply modifier: {modifier = }")
raise Exception(f"Cannot apply modifier: {modifier = }") # pragma: no cover
def _apply_modifiers(self, target: str, initial: Any, modifiable_class: type = None) -> Modifiable:
"""
@ -258,7 +262,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}?")
raise AttributeError(f"You cannot modify .{attr_name}. Did you mean ._{attr_name}?") # pragma: no cover
return super().__setattr__(attr_name, value)
def __getattr__(self, attr_name):
@ -275,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

@ -73,7 +73,7 @@ def bootstrap(db):
db.add_or_update([acrobatics, athletics])
# classes
fighting_style = schema.ClassAttribute("Fighting Style")
fighting_style = schema.ClassFeature("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_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

@ -21,3 +21,8 @@ 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

View File

@ -1,7 +1,8 @@
import json
from ttfrog.db import schema
from ttfrog.db.schema.constants import DamageType, Defenses
from ttfrog.db.schema.conditions import conditions
from ttfrog.db.schema.constants import Conditions, DamageType, Defenses
def test_manage_character(db, bootstrap):
@ -23,7 +24,9 @@ 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(
@ -39,6 +42,7 @@ 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
@ -68,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)
@ -84,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()
@ -110,8 +114,13 @@ 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)
@ -128,17 +137,18 @@ 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 added by the classes have been removed
# verify the proficiencies etc. added by the classes have been removed
assert athletics not in char.skills
assert acrobatics not in char.skills
assert char.check_modifier(athletics) == 0
assert char.check_modifier(acrobatics) == 0
assert char.hit_dice == {}
# ensure we're not persisting any orphan records in the map tables
dump = json.loads(db.dump())
assert not [m for m in dump["character_class_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]
@ -162,6 +172,10 @@ 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)",
@ -208,10 +222,39 @@ 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
@ -219,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
@ -243,6 +287,9 @@ 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
@ -283,6 +330,9 @@ 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():
@ -292,6 +342,16 @@ 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),
]
@ -299,7 +359,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 == 8 # no damage
assert carl.hit_points == 7 # no damage
vulnerability = [
schema.Modifier("Fire Vulnerability", target=DamageType.fire, new_value=Defenses.vulnerable),
@ -309,7 +369,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 == 4 # double damage
assert carl.hit_points == 3 # double damage
absorbs = [schema.Modifier("Absorbs Non-Magical Fire", target=DamageType.fire, new_value=Defenses.absorbs)]
carl.add_modifier(absorbs[0])
@ -324,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)