diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index 37e6ee0..2d707d6 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from sqlalchemy import ForeignKey, String, Text, UniqueConstraint from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -36,6 +38,24 @@ def attr_map_creator(fields): return CharacterClassAttributeMap(**fields) +class HitDie(BaseObject): + __tablename__ = "hit_die" + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + character_id: Mapped[int] = mapped_column(ForeignKey("character.id")) + character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id")) + character_class = relationship("CharacterClass", lazy="immediate") + + spent: Mapped[bool] = mapped_column(nullable=False, default=False) + + @property + def name(self): + return self.character_class.hit_die_name + + @property + def stat(self): + return self.character_class.hit_die_stat_name + + class AncestryTraitMap(BaseObject): __tablename__ = "trait_map" __table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"),) @@ -205,6 +225,19 @@ class Character(BaseObject, SlugMixin, ModifierMixin): ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1") ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None) + _hit_dice = relationship("HitDie", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate") + + @property + def hit_dice(self): + pool = defaultdict(list) + for die in self._hit_dice: + pool[die.character_class.name].append(die) + return pool + + @property + def hit_dice_available(self): + return [die for die in self._hit_dice if die.spent is False] + @property def proficiency_bonus(self): return 1 + int(0.5 + self.level / 4) @@ -281,6 +314,24 @@ class Character(BaseObject, SlugMixin, ModifierMixin): return None return mapping[0] + def immune(self, damage_type: str, magical: bool = False): + return self.defense(damage_type, magical) == 'immune' + + def resistant(self, damage_type: str, magical: bool = False): + return self.defense(damage_type, magical) == 'resistant' + + def vulnerable(self, damage_type: str, magical: bool = False): + return self.defense(damage_type, magical) == 'vulnerable' + + def absorbs(self, damage_type: str, magical: bool = False): + return self.defense(damage_type, magical) == 'absorbs' + + def defense(self, damage_type: str, magical: bool = False): + attr_name = damage_type + if magical: + attr_name = f"magical_{attr_name}" + return self._apply_modifiers(f"defenses.{attr_name}", None) + def check_modifier(self, skill: Skill, save: bool = False): # if the skill is not assigned, but we have modifiers, apply them to zero. if skill not in self.skills: @@ -302,7 +353,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin): # if the skill is a proficiency, apply the bonus to the initial value if skill in self.skills: mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][-1] - print(f"Found mapping: {mapping}") if mapping.expert and not save: initial += 2 * self.proficiency_bonus elif mapping.proficient: @@ -331,13 +381,19 @@ class Character(BaseObject, SlugMixin, ModifierMixin): for skill in newclass.skills[: newclass.starting_skills]: self.add_skill(skill, proficient=True, character_class=newclass) + # add hit dice + existing = len(self.hit_dice[newclass.name]) + for lvl in range(level - existing): + self._hit_dice.append(HitDie(character_id=self.id, character_class_id=newclass.id)) + 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: - if mapping.character_class.id == target.id: + if mapping.character_class == target: self.remove_class_attribute(mapping.class_attribute) for skill in target.skills: - self.remove_skill(skill, proficient=True, expert=False, character_class=target) + 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] + self.class_map = [m for m in self.class_map if m.character_class != target] def remove_class_attribute(self, attribute): self.character_class_attribute_map = [ @@ -415,6 +471,34 @@ class Character(BaseObject, SlugMixin, ModifierMixin): self._skills = [m for m in self._skills if m not in to_delete] return True + def apply_healing(self, value: int): + self.hit_points = min(self.hit_points + value, self._max_hit_points) + + def apply_damage(self, value: int, damage_type: str, magical=False): + total = value + if self.absorbs(damage_type, magical): + return self.apply_healing(total) + if self.immune(damage_type, magical): + return + if self.resistant(damage_type, magical): + total = int(value / 2) + elif self.vulnerable(damage_type, magical): + total = value * 2 + + if total <= self.temp_hit_points: + self.temp_hit_points -= total + return + + 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/classes.py b/src/ttfrog/db/schema/classes.py index 512be04..91d99b3 100644 --- a/src/ttfrog/db/schema/classes.py +++ b/src/ttfrog/db/schema/classes.py @@ -76,8 +76,8 @@ class CharacterClass(BaseObject): __tablename__ = "character_class" id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(index=True, unique=True) - hit_dice: Mapped[str] = mapped_column(default="1d6") - hit_dice_stat: Mapped[str] = mapped_column(default="") + hit_die_name: Mapped[str] = mapped_column(default="1d6") + 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") @@ -88,7 +88,7 @@ class CharacterClass(BaseObject): def add_skill(self, skill, expert=False): if not self.skills or skill not in self.skills: if not self.id: - raise Exception(f"Cannot add a skill before the class has been persisted.") + raise Exception("Cannot add a skill before the class has been persisted.") mapping = ClassSkillMap(character_class_id=self.id, skill_id=skill.id, proficient=True, expert=expert) self.skills.append(mapping) return True diff --git a/src/ttfrog/db/schema/modifiers.py b/src/ttfrog/db/schema/modifiers.py index 2fc607f..61f023e 100644 --- a/src/ttfrog/db/schema/modifiers.py +++ b/src/ttfrog/db/schema/modifiers.py @@ -198,7 +198,6 @@ class ModifierMixin: return get_attr(self, attr_name.split(".")) def _apply_one_modifier(self, modifier, initial, modified): - print(f"Trying to apply {modifier}") if modifier.new_value is not None: return modifier.new_value elif modifier.absolute_value is not None: @@ -275,4 +274,4 @@ class ModifierMixin: self._get_modifiable_base(col.info.get("modifiable_base", col.name)), modifiable_class=col.info.get("modifiable_class", None), ) - raise AttributeError(f"No such attribute: {attr_name}.") + raise AttributeError(f"No such attribute on {self.__class__.__name__} object: {attr_name}.") diff --git a/test/conftest.py b/test/conftest.py index 522b097..489e592 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -49,6 +49,15 @@ def bootstrap(db): darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120)) tiefling.add_trait(darkvision) + # resistant to both magical and non-magical sources of fire + infernal_origin = schema.AncestryTrait("Infernal Origin") + infernal_origin.add_modifier(schema.Modifier("Infernal Origin", target="defenses.fire", new_value="resistant")) + infernal_origin.add_modifier( + schema.Modifier("Infernal Origin", target="defenses.magical_fire", new_value="resistant") + ) + tiefling.add_trait(infernal_origin) + db.add_or_update(tiefling) + dragonborn = schema.Ancestry("dragonborn") dragonborn.add_trait(darkvision) @@ -70,7 +79,9 @@ def bootstrap(db): fighting_style.add_option(name="Defense") db.add_or_update(fighting_style) - fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON", starting_skills=2) + fighter = schema.CharacterClass( + "fighter", hit_die_name="1d10", hit_die_stat_name="_constitution", starting_skills=2 + ) db.add_or_update(fighter) # add skills @@ -81,7 +92,7 @@ def bootstrap(db): assert acrobatics in fighter.skills assert athletics in fighter.skills - rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX") + rogue = schema.CharacterClass("rogue", hit_die_name="1d8", hit_die_stat_name="_dexterity") db.add_or_update([rogue, fighter]) # characters diff --git a/test/test_schema.py b/test/test_schema.py index f6e0cb2..721dab0 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -101,12 +101,19 @@ def test_manage_character(db, bootstrap): db.add_or_update(char) assert char.level == 8 assert char.levels == {"fighter": 7, "rogue": 1} + assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 8 # remove a class char.remove_class(rogue) db.add_or_update(char) assert char.levels == {"fighter": 7} assert char.level == 7 + assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 7 + + # verify hit dice are added and removed correctly + assert len(char.hit_dice["fighter"]) == char.level_in_class(fighter).level == 7 + assert char.hit_dice["fighter"][0].name == fighter.hit_die_name == "1d10" + assert char.hit_dice["fighter"][0].stat == fighter.hit_die_stat_name == "_constitution" # remove remaining class by setting level to zero char.add_class(fighter, level=0) @@ -266,3 +273,51 @@ def test_modifiers(db, bootstrap): carl.remove_skill(athletics, proficient=True, expert=False, character_class=None) assert len([s for s in carl.skills if s == athletics]) == 1 assert carl.check_modifier(athletics) == 1 + + +def test_defenses(db, bootstrap): + with db.transaction(): + tiefling = db.Ancestry.filter_by(name="tiefling").one() + carl = schema.Character(name="Carl", ancestry=tiefling) + assert carl.resistant("fire", magical=False) + assert carl.resistant("fire", magical=True) + carl.apply_damage(5, "fire", magical=True) + assert carl.hit_points == 8 # half damage + + immunity = [ + schema.Modifier("Fire Immunity", target="defenses.fire", new_value="immune"), + schema.Modifier("Fire Immunity", target="defenses.magical_fire", new_value="immune") + ] + for i in immunity: + carl.add_modifier(i) + assert carl.immune("fire") + carl.apply_damage(5, "fire", magical=True) + carl.apply_damage(5, "fire", magical=False) + assert carl.hit_points == 8 # no damage + + vulnerability = [ + schema.Modifier("Fire Vulnerability", target="defenses.fire", new_value="vulnerable"), + schema.Modifier("Fire Vulnerability", target="defenses.magical_fire", new_value="vulnerable") + ] + for i in vulnerability: + carl.add_modifier(i) + assert carl.vulnerable("fire") + assert not carl.immune("fire") + carl.apply_damage(2, "fire", magical=True) + assert carl.hit_points == 4 # double damage + + absorbs = [ + schema.Modifier("Absorbs Non-Magical Fire", target="defenses.fire", new_value="absorbs"), + ] + carl.add_modifier(absorbs[0]) + carl.apply_damage(20, "fire", magical=False) + assert carl.hit_points == carl._max_hit_points == 10 + + for i in immunity + vulnerability + absorbs: + carl.remove_modifier(i) + carl.apply_damage(5, "fire", magical=True) + assert carl.resistant("fire") + assert not carl.immune("fire") + assert not carl.vulnerable("fire") + assert not carl.absorbs("fire") + assert carl.hit_points == 8 # half damage