From 1ff0e5ca7d4899afd004d41b77c19804349b10af Mon Sep 17 00:00:00 2001 From: evilchili Date: Tue, 23 Apr 2024 00:15:13 -0700 Subject: [PATCH] fixing modifier bugs, fixing traits, adding speed attrs --- src/ttfrog/cli.py | 2 +- src/ttfrog/db/bootstrap.py | 214 ++++-------------------------- src/ttfrog/db/schema/character.py | 71 +++++++--- src/ttfrog/db/schema/modifiers.py | 14 +- test/test_schema.py | 8 +- 5 files changed, 96 insertions(+), 213 deletions(-) diff --git a/src/ttfrog/cli.py b/src/ttfrog/cli.py index a8ba015..03f5de3 100644 --- a/src/ttfrog/cli.py +++ b/src/ttfrog/cli.py @@ -116,7 +116,7 @@ def dump(context: typer.Context): """ from ttfrog.db.manager import db - db.init() + setup(context) print(db.dump(context.args)) diff --git a/src/ttfrog/db/bootstrap.py b/src/ttfrog/db/bootstrap.py index 4ed1b43..cd962f1 100644 --- a/src/ttfrog/db/bootstrap.py +++ b/src/ttfrog/db/bootstrap.py @@ -1,192 +1,36 @@ -import logging - -from sqlalchemy.exc import IntegrityError - from ttfrog.db import schema from ttfrog.db.manager import db -# move this to json or whatever -data = { - "CharacterClass": [ - { - "id": 1, - "name": "fighter", - "hit_dice": "1d10", - "hit_dice_stat": "CON", - "proficiencies": "all armor, all shields, simple weapons, martial weapons", - "saving_throws": ["STR, CON"], - "skills": [ - "Acrobatics", - "Animal Handling", - "Athletics", - "History", - "Insight", - "Intimidation", - "Perception", - "Survival", - ], - }, - { - "id": 2, - "name": "rogue", - "hit_dice": "1d8", - "hit_dice_stat": "DEX", - "proficiencies": "simple weapons, hand crossbows, longswords, rapiers, shortswords", - "saving_throws": ["DEX", "INT"], - "skills": [ - "Acrobatics", - "Athletics", - "Deception", - "Insight", - "Intimidation", - "Investigation", - "Perception", - "Performance", - "Persuasion", - "Sleight of Hand", - "Stealth", - ], - }, - ], - "Skill": [ - {"name": "Acrobatics"}, - {"name": "Animal Handling"}, - {"name": "Athletics"}, - {"name": "Deception"}, - {"name": "History"}, - {"name": "Insight"}, - {"name": "Intimidation"}, - {"name": "Investigation"}, - {"name": "Perception"}, - {"name": "Performance"}, - {"name": "Persuasion"}, - {"name": "Sleight of Hand"}, - {"name": "Stealth"}, - {"name": "Survival"}, - ], - "Ancestry": [ - {"id": 1, "name": "human", "creature_type": "humanoid"}, - {"id": 2, "name": "dragonborn", "creature_type": "humanoid"}, - {"id": 3, "name": "tiefling", "creature_type": "humanoid"}, - {"id": 4, "name": "elf", "creature_type": "humanoid"}, - ], - "AncestryTrait": [ - { - "id": 1, - "name": "+1 to All Ability Scores", - }, - { - "id": 2, - "name": "Breath Weapon", - }, - { - "id": 3, - "name": "Darkvision", - }, - ], - "AncestryTraitMap": [ - {"ancestry_id": 1, "ancestry_trait_id": 1, "level": 1}, # human +1 to scores - {"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1}, # dragonborn breath weapon - {"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1}, # tiefling darkvision - {"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1}, # elf darkvision - ], - "CharacterClassMap": [ - { - "character_id": 1, - "character_class_id": 1, - "level": 2, - }, - { - "character_id": 1, - "character_class_id": 2, - "level": 3, - }, - ], - "Character": [ - { - "id": 1, - "name": "Sabetha", - "ancestry_id": 1, - "armor_class": 10, - "max_hit_points": 14, - "hit_points": 14, - "temp_hit_points": 0, - "speed": 30, - "str": 16, - "dex": 12, - "con": 18, - "int": 11, - "wis": 12, - "cha": 8, - "proficiencies": "all armor, all shields, simple weapons, martial weapons", - "saving_throws": ["STR", "CON"], - "skills": ["Acrobatics", "Animal Handling"], - }, - ], - "ClassAttribute": [ - {"id": 1, "name": "Fighting Style"}, - {"id": 2, "name": "Another Attribute"}, - ], - "ClassAttributeOption": [ - {"id": 1, "attribute_id": 1, "name": "Archery"}, - {"id": 2, "attribute_id": 1, "name": "Battlemaster"}, - {"id": 3, "attribute_id": 2, "name": "Another Option 1"}, - {"id": 4, "attribute_id": 2, "name": "Another Option 2"}, - ], - "ClassAttributeMap": [ - {"class_attribute_id": 1, "character_class_id": 1, "level": 2}, # Fighter: Fighting Style - {"class_attribute_id": 2, "character_class_id": 1, "level": 1}, # Fighter: Another Attr - ], - "CharacterClassAttributeMap": [ - {"character_id": 1, "class_attribute_id": 2, "option_id": 4}, # Sabetha, another option, option 2 - {"character_id": 1, "class_attribute_id": 1, "option_id": 1}, # Sabetha, fighting style, archery - ], - "Modifier": [ - # Humans - {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "str"}, - {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "dex"}, - {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "con"}, - {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "int"}, - {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "wis"}, - {"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "cha"}, - # Dragonborn - { - "source_table_name": "ancestry_trait", - "source_table_id": 2, - "value": "60", - "type": "attribute ", - "target": "Darkvision", - }, - {"source_table_name": "ancestry_trait", "source_table_id": 2, "value": "+1", "type": "stat", "target": ""}, - {"source_table_name": "ancestry_trait", "source_table_id": 2, "value": "+1", "type": "stat", "target": ""}, - # Fighting Style: Archery - { - "source_table_name": "class_attribute", - "source_table_id": 1, - "value": "+2", - "type": "weapon ", - "target": "ranged", - }, - ], -} - def bootstrap(): - """ - Initialize the database with source data. Idempotent; will skip anything that already exists. - """ + db.metadata.drop_all(bind=db.engine) db.init() - for table, records in data.items(): - model = getattr(schema, table) + with db.transaction(): + # ancestries + human = schema.Ancestry(name="human") + tiefling = schema.Ancestry(name="tiefling") + tiefling.add_modifier(schema.Modifier(name="Ability Score Increase", target="intelligence", relative_value=1)) + tiefling.add_modifier(schema.Modifier(name="Ability Score Increase", target="charisma", relative_value=2)) + darkvision = schema.AncestryTrait( + name="Darkvision", + description=( + "You can see in dim light within 60 feet of you as if it were bright light, and in darkness as if it " + "were dim light. You can’t discern color in darkness, only shades of gray." + ), + ) + darkvision.add_modifier(schema.Modifier(name="Darkvision", target="vision_in_darkness", absolute_value=120)) + tiefling.add_trait(darkvision) - for rec in records: - obj = model(**rec) - try: - with db.transaction(): - db.session.add(obj) - logging.info(f"Created {table} {obj}") - except IntegrityError as e: - if "UNIQUE constraint failed" in str(e): - logging.info(f"Skipping existing {table} {obj}") - continue - raise + # classes + fighter = schema.CharacterClass(name="fighter", hit_dice="1d10", hit_dice_stat="CON") + rogue = schema.CharacterClass(name="rogue", hit_dice="1d8", hit_dice_stat="DEX") + + # characters + sabetha = schema.Character(name="Sabetha", ancestry=tiefling) + sabetha.add_class(fighter, level=2) + sabetha.add_class(rogue, level=3) + + bob = schema.Character(name="Bob", ancestry=human) + + # persist all the records we've created + db.add_or_update([sabetha, bob]) diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index b3d7773..ab7e7bf 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -34,7 +34,7 @@ class AncestryTraitMap(BaseObject): id = Column(Integer, primary_key=True, autoincrement=True) ancestry_id = Column(Integer, ForeignKey("ancestry.id")) ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id")) - trait = relationship("AncestryTrait", lazy="immediate") + trait = relationship("AncestryTrait", uselist=False, lazy="immediate") level = Column(Integer, nullable=False, info={"min": 1, "max": 20}) @@ -48,16 +48,31 @@ class Ancestry(BaseObject, ModifierMixin): name = Column(String, index=True, unique=True) creature_type = Column(Enum(CreatureTypesEnum), nullable=False, default="humanoid") size = Column(Enum(SizesEnum), nullable=False, default="Medium") - speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99}) + walk_speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99}) + _fly_speed = Column(Integer, info={"min": 0, "max": 99}) + _climb_speed = Column(Integer, info={"min": 0, "max": 99}) + _swim_speed = Column(Integer, info={"min": 0, "max": 99}) _traits = relationship("AncestryTraitMap", cascade="all,delete,delete-orphan", lazy="immediate") @property def traits(self): return [mapping.trait for mapping in self._traits] + @property + def speed(self): + return self.walk_speed + + @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: - self._traits.append(AncestryTraitMap(ancestry_id=self.id, ancestry_trait_id=trait.id, level=level)) + if trait not in self._traits: + self._traits.append(AncestryTraitMap(ancestry_id=self.id, trait=trait, level=level)) return True return False @@ -65,7 +80,7 @@ class Ancestry(BaseObject, ModifierMixin): return self.name -class AncestryTrait(BaseObject): +class AncestryTrait(BaseObject, ModifierMixin): """ A trait granted to a character via its Ancestry. """ @@ -129,24 +144,14 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM 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}) + + _vision = Column(Integer, info={"min": 0}) + proficiencies = Column(String) class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan") class_list = association_proxy("class_map", "id", creator=class_map_creator) - _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) @@ -157,6 +162,8 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM def modifiers(self): unified = {} unified.update(**self.ancestry.modifiers) + for trait in self.traits: + unified.update(**trait.modifiers) unified.update(**super().modifiers) return unified @@ -204,10 +211,30 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM def speed(self): return self.apply_modifiers("speed", self.ancestry.speed) + @property + def climb_speed(self): + 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) + + @property + def fly_speed(self): + return self.apply_modifiers("fly_speed", self.ancestry._fly_speed) + @property def size(self): return self.apply_modifiers("size", self.ancestry.size) + @property + def vision(self): + return self.apply_modifiers("vision", self._vision) + + @property + def vision_in_darkness(self): + return self.apply_modifiers("vision_in_darkness", self.vision if self.vision is not None else 0) + @property def level(self): return sum(mapping.level for mapping in self.class_map) @@ -228,7 +255,7 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM level_in_class = level_in_class[0] level_in_class.level = level else: - self.class_list.append(CharacterClassMap(character_id=self.id, character_class_id=newclass.id, level=level)) + self.class_list.append(CharacterClassMap(character_id=self.id, character_class=newclass, level=level)) for lvl in range(1, level + 1): if not newclass.attributes_by_level[lvl]: continue @@ -252,15 +279,15 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM if attribute.name in self.class_attributes: return True self.attribute_list.append( - CharacterClassAttributeMap( - character_id=self.id, class_attribute_id=attribute.id, option_id=option.id - ) + CharacterClassAttributeMap(character_id=self.id, class_attribute=attribute, option=option) ) return True return False def apply_modifiers(self, target, initial): modifiers = list(reversed(self.modifiers.get(target, []))) + if initial is None: + return initial if isinstance(initial, int): absolute = [mod for mod in modifiers if mod.absolute_value is not None] if absolute: diff --git a/src/ttfrog/db/schema/modifiers.py b/src/ttfrog/db/schema/modifiers.py index 832da88..dc6af55 100644 --- a/src/ttfrog/db/schema/modifiers.py +++ b/src/ttfrog/db/schema/modifiers.py @@ -15,11 +15,12 @@ class ModifierMap(BaseObject): __tablename__ = "modifier_map" __table_args__ = (UniqueConstraint("primary_table_name", "primary_table_id", "modifier_id"),) id = Column(Integer, primary_key=True, autoincrement=True) - primary_table_name = Column(String, nullable=False) - primary_table_id = Column(Integer, nullable=False) modifier_id = Column(Integer, ForeignKey("modifier.id"), nullable=False) modifier = relationship("Modifier", uselist=False, lazy="immediate") + primary_table_name = Column(String, nullable=False) + primary_table_id = Column(Integer, nullable=False) + class Modifier(BaseObject): """ @@ -67,8 +68,14 @@ class ModifierMixin: def modifier_map(cls): return relationship( "ModifierMap", - primaryjoin=f"ModifierMap.primary_table_id == foreign({cls.__name__}.id)", + primaryjoin=( + "and_(" + f"foreign(ModifierMap.primary_table_name)=='{cls.__tablename__}', " + f"foreign(ModifierMap.primary_table_id)=={cls.__name__}.id" + ")" + ), cascade="all,delete,delete-orphan", + overlaps="modifier_map,modifier_map", single_parent=True, uselist=True, lazy="immediate", @@ -84,6 +91,7 @@ class ModifierMixin: 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}.") + if [mod for mod in self.modifier_map if mod.modifier == modifier]: return False self.modifier_map.append( diff --git a/test/test_schema.py b/test/test_schema.py index 4fb9326..6c035a6 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -97,7 +97,7 @@ def test_ancestries(db): porc = schema.Ancestry( name="Pygmy Orc", size="Small", - speed=25, + walk_speed=25, ) db.add_or_update(porc) assert porc.name == "Pygmy Orc" @@ -141,7 +141,8 @@ def test_modifiers(db, classes_factory, ancestries_factory): # no modifiers; speed is ancestry speed carl = schema.Character(name="Carl", ancestry=ancestries["elf"]) - db.add_or_update(carl) + marx = schema.Character(name="Marx", ancestry=ancestries["human"]) + db.add_or_update([carl, marx]) assert carl.speed == carl.ancestry.speed == 30 cold = schema.Modifier(target="speed", relative_value=-10, name="Cold") @@ -154,6 +155,9 @@ def test_modifiers(db, classes_factory, ancestries_factory): assert carl.add_modifier(cold) assert carl.speed == 20 + # make sure modifiers only apply to carl. Carl is having a bad day. + assert marx.speed == 30 + # speed is doubled assert carl.remove_modifier(cold) assert carl.add_modifier(hasted)