diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index 2067dd4..a021cb3 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -1,6 +1,8 @@ -from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Text, UniqueConstraint +from collections import defaultdict + +from sqlalchemy import Column, Enum, ForeignKey, Integer, Float, String, Text, UniqueConstraint from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, validates from ttfrog.db.base import BaseObject, CreatureTypesEnum, SavingThrowsMixin, SizesEnum, SkillsMixin, SlugMixin @@ -8,9 +10,11 @@ __all__ = [ "Ancestry", "AncestryTrait", "AncestryTraitMap", + "AncestryModifier", "CharacterClassMap", "CharacterClassAttributeMap", "Character", + "Modifier", ] @@ -36,6 +40,7 @@ class AncestryTraitMap(BaseObject): level = Column(Integer, nullable=False, info={"min": 1, "max": 20}) +# XXX: Replace this with a many-to-many on the Modifiers table. Will need for proficiecies too. class Ancestry(BaseObject): """ A character ancestry ("race"), which has zero or more AncestryTraits. @@ -48,6 +53,7 @@ class Ancestry(BaseObject): size = Column(Enum(SizesEnum), nullable=False, default="Medium") speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99}) _traits = relationship("AncestryTraitMap", lazy="immediate") + modifiers = relationship("AncestryModifier", lazy="immediate") @property def traits(self): @@ -59,10 +65,33 @@ class Ancestry(BaseObject): return True return False + def add_modifier(self, modifier): + if modifier not in self.modifiers: + self.modifiers.append(modifier) + return True + return False + def __repr__(self): return self.name +class AncestryModifier(BaseObject): + """ + A modifier granted to a character via its Ancestry. + """ + + __tablename__ = "ancestry_modifier" + id = Column(Integer, primary_key=True, autoincrement=True) + ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False) + name = Column(String, nullable=False) + target = Column(String, nullable=False) + absolute_value = Column(Integer) + relative_value = Column(Integer) + multiply_value = Column(Float) + new_value = Column(String) + description = Column(String, nullable=False) + + class AncestryTrait(BaseObject): """ A trait granted to a character via its Ancestry. @@ -113,31 +142,67 @@ class CharacterClassAttributeMap(BaseObject): ) +class Modifier(BaseObject): + __tablename__ = "modifier" + + id = Column(Integer, primary_key=True, autoincrement=True) + character_id = Column(Integer, ForeignKey("character.id"), nullable=False) + target = Column(String, nullable=False) + absolute_value = Column(Integer) + relative_value = Column(Integer) + multiply_value = Column(Float) + new_value = Column(String) + description = Column(String, nullable=False) + + class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin): __tablename__ = "character" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String, default="New Character", nullable=False) armor_class = Column(Integer, default=10, nullable=False, info={"min": 1, "max": 99}) hit_points = Column(Integer, default=1, nullable=False, info={"min": 0, "max": 999}) - max_hit_points = Column(Integer, default=1, nullable=False, info={"min": 0, "max": 999}) + max_hit_points = Column(Integer, default=10, nullable=False, info={"min": 0, "max": 999}) temp_hit_points = Column(Integer, default=0, nullable=False, info={"min": 0, "max": 999}) - str = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) - dex = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) - con = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) - int = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) - wis = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) - cha = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + strength = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + dexterity = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + constitution = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + intelligence = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + wisdom = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) + charisma = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30}) proficiencies = Column(String) class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan") class_list = association_proxy("class_map", "id", creator=class_map_creator) + _modifiers = relationship("Modifier", cascade="all,delete,delete-orphan", lazy="immediate") + _modify_ok = [ + "armor_class", + "max_hit_points", + "strength", + "dexterity", + "constitution", + "intelligence", + "wisdom", + "charisma", + "speed", + "size", + ] + 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 = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default="1") ancestry = relationship("Ancestry", uselist=False) + @property + def modifiers(self): + all_modifiers = defaultdict(list) + for mod in self.ancestry.modifiers: + all_modifiers[mod.target].append(mod) + for mod in self._modifiers: + all_modifiers[mod.target].append(mod) + return all_modifiers + @property def classes(self): return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map]) @@ -147,12 +212,44 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin): return self.ancestry.traits @property - def size(self): - return self.ancestry.size + def AC(self): + return self.apply_modifiers("armor_class", self.armor_class) + + @property + def HP(self): + return self.apply_modifiers("max_hit_points", self.max_hit_points) + + @property + def STR(self): + return self.apply_modifiers("strength", self.strength) + + @property + def DEX(self): + return self.apply_modifiers("dexterity", self.dexterity) + + @property + def CON(self): + return self.apply_modifiers("constitution", self.constitution) + + @property + def INT(self): + return self.apply_modifiers("intelligence", self.intelligence) + + @property + def WIS(self): + return self.apply_modifiers("wisdom", self.wisdom) + + @property + def CHA(self): + return self.apply_modifiers("charisma", self.charisma) @property def speed(self): - return self.ancestry.speed + return self.apply_modifiers("speed", self.ancestry.speed) + + @property + def size(self): + return self.apply_modifiers("size", self.ancestry.size) @property def level(self): @@ -204,3 +301,30 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin): ) return True return False + + + def apply_modifiers(self, target, initial): + modifiers = list(reversed(self.modifiers.get(target, []))) + if isinstance(initial, int): + absolute = [mod for mod in modifiers if mod.absolute_value is not None] + if absolute: + return absolute[0].absolute_value + multiple = [mod for mod in modifiers if mod.multiply_value is not None] + if multiple: + return int(initial * multiple[0].multiply_value + 0.5) + return initial + sum(mod.relative_value for mod in modifiers if mod.relative_value is not None) + + new = [mod for mod in modifiers if mod.new_value is not None] + if new: + return new[0].new_value + return initial + + + + def add_modifier(self, modifier): + 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}.") + self._modifiers.append(modifier) + + def remove_modifier(self, modifier): + self._modifiers = [mod for mod in self._modifiers if mod != modifier] diff --git a/src/ttfrog/db/schema/property.py b/src/ttfrog/db/schema/property.py index 47bfd98..46b8913 100644 --- a/src/ttfrog/db/schema/property.py +++ b/src/ttfrog/db/schema/property.py @@ -1,11 +1,10 @@ -from sqlalchemy import Column, Integer, String, Text, UniqueConstraint +from sqlalchemy import Column, Integer, String, Text from ttfrog.db.base import BaseObject __all__ = [ "Skill", "Proficiency", - "Modifier", ] @@ -26,14 +25,3 @@ class Proficiency(BaseObject): def __repr__(self): return str(self.name) - - -class Modifier(BaseObject): - __tablename__ = "modifier" - __table_args__ = (UniqueConstraint("source_table_name", "source_table_id", "value", "type", "target"),) - id = Column(Integer, primary_key=True, autoincrement=True) - source_table_name = Column(String, index=True, nullable=False) - source_table_id = Column(Integer, index=True, nullable=False) - value = Column(String, nullable=False) - type = Column(String, nullable=False) - target = Column(String, nullable=False) diff --git a/test/test_schema.py b/test/test_schema.py index 7fc8ab8..ca2a5d4 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -14,9 +14,16 @@ def test_manage_character(db, classes_factory, ancestries_factory): char = schema.Character(name="Test Character") db.add_or_update(char) assert char.id == 1 - assert char.armor_class == 10 assert char.name == "Test Character" assert char.ancestry.name == "human" + assert char.AC == 10 + assert char.HP == 10 + assert char.STR == 10 + assert char.DEX == 10 + assert char.CON == 10 + assert char.INT == 10 + assert char.WIS == 10 + assert char.CHA == 10 assert darkvision not in char.traits # switch ancestry to tiefling @@ -105,6 +112,70 @@ def test_ancestries(db): db.add_or_update(porc) assert endurance in porc.traits - # now create an orc character and assert it gets Relentless Endurance + # add a +1 STR modifier + str_plus_one = schema.AncestryModifier( + name="STR+1 (Pygmy Orc)", + target="strength", + relative_value=1, + description="Your Strength score is increased by 1." + ) + assert porc.add_modifier(str_plus_one) is True + assert porc.add_modifier(str_plus_one) is False # test idempotency + db.add_or_update(porc) + assert str_plus_one in porc.modifiers + + # now create an orc character and assert it gets traits and modifiers grognak = schema.Character(name="Grognak the Mighty", ancestry=porc) + db.add_or_update(grognak) assert endurance in grognak.traits + + # verify the strength bonus is applied + assert grognak.strength == 10 + assert str_plus_one in grognak.modifiers['strength'] + assert grognak.STR == 11 + + +def test_modifiers(db, classes_factory, ancestries_factory): + with db.transaction(): + classes_factory() + ancestries = ancestries_factory() + + # no modifiers; speed is ancestry speed + carl = schema.Character(name="Carl", ancestry=ancestries["elf"]) + db.add_or_update(carl) + assert carl.speed == carl.ancestry.speed == 30 + + # reduce speed by 10 + cold = schema.Modifier(target="speed", relative_value=-10, description="Cold") + carl.add_modifier(cold) + assert carl.speed == 20 + + # speed is doubled + carl.remove_modifier(cold) + hasted = schema.Modifier(target="speed", multiply_value=2.0, description="Hasted") + carl.add_modifier(hasted) + assert carl.speed == 60 + + # speed is halved + slowed = schema.Modifier(target="speed", multiply_value=0.5, description="Slowed") + carl.remove_modifier(hasted) + carl.add_modifier(slowed) + assert carl.speed == 15 + + # speed is 0 + restrained = schema.Modifier(target="speed", absolute_value=0, description="Restrained") + carl.add_modifier(restrained) + assert carl.speed == 0 + + # no longer restrained, but still slowed + carl.remove_modifier(restrained) + assert carl.speed == 15 + + # back to normal + carl.remove_modifier(slowed) + assert carl.speed == carl.ancestry.speed + + # modifiers can modify string values too + carl.add_modifier(schema.Modifier(target="size", new_value="Tiny", description="Reduced")) + assert carl.size == "Tiny" +