diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index 5f874fc..4163e0c 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -80,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" @@ -92,16 +92,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,23 +102,16 @@ class Ancestry(BaseObject, ModifierMixin): return True return False - def __repr__(self): - return self.name - 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) description: Mapped[Text] = mapped_column(Text, default="") - def __repr__(self): - return self.name - class CharacterSkillMap(BaseObject): __tablename__ = "character_skill_map" @@ -153,9 +138,6 @@ 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" @@ -212,7 +194,7 @@ 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}) + 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") @@ -248,11 +230,6 @@ 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 = {} @@ -262,10 +239,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin): 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 +253,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): @@ -332,11 +308,13 @@ 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): + if not self.immune(condition): + return self._apply_modifiers(condition, False) + return False - def condition(self, condition_name: str): - return self._apply_modifiers(f"conditions.{condition_name}", 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) @@ -431,8 +409,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin): 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 +476,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. diff --git a/src/ttfrog/db/schema/modifiers.py b/src/ttfrog/db/schema/modifiers.py index 61f023e..76c71b1 100644 --- a/src/ttfrog/db/schema/modifiers.py +++ b/src/ttfrog/db/schema/modifiers.py @@ -136,7 +136,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 +157,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 +177,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 +219,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 +260,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): diff --git a/test/test_bootstrap.py b/test/test_bootstrap.py index bf3d280..fbdafc1 100644 --- a/test/test_bootstrap.py +++ b/test/test_bootstrap.py @@ -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 diff --git a/test/test_schema.py b/test/test_schema.py index 54ee099..739c87a 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -1,7 +1,7 @@ import json from ttfrog.db import schema -from ttfrog.db.schema.constants import DamageType, Defenses +from ttfrog.db.schema.constants import DamageType, Defenses, Conditions def test_manage_character(db, bootstrap): @@ -23,7 +23,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 +41,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 @@ -110,8 +113,22 @@ 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 + # 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 + # remove a class char.remove_class(rogue) db.add_or_update(char) @@ -130,11 +147,12 @@ def test_manage_character(db, bootstrap): assert char.levels == {} assert char.class_attributes == {} - # 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()) @@ -162,6 +180,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 +230,18 @@ 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") # 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 @@ -243,6 +273,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 +316,8 @@ 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 +327,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 +344,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 +354,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])