From b574dacfa1c8db915c3a6428f814a64becb651e6 Mon Sep 17 00:00:00 2001 From: evilchili Date: Sat, 4 May 2024 13:15:54 -0700 Subject: [PATCH] fix schemas --- src/ttfrog/db/bootstrap.py | 2 - src/ttfrog/db/schema/character.py | 52 +++++++++------ src/ttfrog/db/schema/classes.py | 23 ++++++- src/ttfrog/db/schema/modifiers.py | 105 ++++++++++++++++++++++++++---- test/conftest.py | 45 +++++++++---- test/test_schema.py | 71 ++++++++++---------- 6 files changed, 217 insertions(+), 81 deletions(-) diff --git a/src/ttfrog/db/bootstrap.py b/src/ttfrog/db/bootstrap.py index 6fbda6d..ef0f8c6 100644 --- a/src/ttfrog/db/bootstrap.py +++ b/src/ttfrog/db/bootstrap.py @@ -34,5 +34,3 @@ def bootstrap(): # persist all the records we've created db.add_or_update([sabetha, bob]) - - print(f"{sabetha.intelligence.bonus = }, {sabetha.size = }") diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index 7c23e50..f4dadad 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -117,7 +117,7 @@ class CharacterClassMap(BaseObject): level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1) def __repr__(self): - return "{self.character.name}, {self.character_class.name}, level {self.level}" + return f"{self.character.name}, {self.character_class.name}, level {self.level}" class CharacterClassAttributeMap(BaseObject): @@ -147,34 +147,37 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(default="New Character", nullable=False) - _armor_class: Mapped[int] = mapped_column(default=10, nullable=False, info={"min": 1, "max": 99, "modify": True}) - _hit_points: Mapped[int] = mapped_column(default=1, nullable=False, info={"min": 0, "max": 999, "modify": True}) - _max_hit_points: Mapped[int] = mapped_column( - default=10, nullable=False, info={"min": 0, "max": 999, "modify": True} - ) + hit_points: Mapped[int] = mapped_column(default=10, nullable=False, info={"min": 0, "max": 999}) temp_hit_points: Mapped[int] = mapped_column(default=0, nullable=False, info={"min": 0, "max": 999}) + + _max_hit_points: Mapped[int] = mapped_column( + default=10, nullable=False, info={"min": 0, "max": 999, "modifiable": True} + ) + _armor_class: Mapped[int] = mapped_column( + default=10, nullable=False, info={"min": 1, "max": 99, "modifiable": True} + ) _strength: Mapped[int] = mapped_column( - nullable=False, default=10, info={"min": 0, "max": 30, "modify": True, "modify_class": Stat} + nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat} ) _dexterity: Mapped[int] = mapped_column( - nullable=False, default=10, info={"min": 0, "max": 30, "modify": True, "modify_class": Stat} + nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat} ) _constitution: Mapped[int] = mapped_column( - nullable=False, default=10, info={"min": 0, "max": 30, "modify": True, "modify_class": Stat} + nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat} ) _intelligence: Mapped[int] = mapped_column( - nullable=False, default=10, info={"min": 0, "max": 30, "modify": True, "modify_class": Stat} + nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat} ) _wisdom: Mapped[int] = mapped_column( - nullable=False, default=10, info={"min": 0, "max": 30, "modify": True, "modify_class": Stat} + nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat} ) _charisma: Mapped[int] = mapped_column( - nullable=False, default=10, info={"min": 0, "max": 30, "modify": True, "modify_class": Stat} + nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat} ) - _vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0}) + _vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True}) - proficiencies: Mapped[str] = mapped_column(nullable=False, default="") + _proficiencies: Mapped[str] = mapped_column(nullable=False, default="") class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan") class_list = association_proxy("class_map", "id", creator=class_map_creator) @@ -204,19 +207,19 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM @property def speed(self): - return self.apply_modifiers("speed", self.ancestry.speed) + return self._apply_modifiers("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) @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) @property def fly_speed(self): - return self.apply_modifiers("fly_speed", self.ancestry.fly_speed) + return self._apply_modifiers("fly_speed", self.ancestry._fly_speed) @property def size(self): @@ -254,13 +257,15 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM self.add_class_attribute(attr, attr.options[0]) def remove_class(self, target): - self.class_map = [m for m in self.class_map if m.id != target.id] + 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: self.remove_class_attribute(mapping.class_attribute) def remove_class_attribute(self, attribute): - self.character_class_attribute_map = [m for m in self.character_class_attribute_map if m.id != attribute.id] + self.character_class_attribute_map = [ + m for m in self.character_class_attribute_map if m.class_attribute.id != attribute.id + ] def add_class_attribute(self, attribute, option): for thisclass in self.classes.values(): @@ -270,7 +275,12 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM if attribute.name in self.class_attributes: return True self.attribute_list.append( - CharacterClassAttributeMap(character_id=self.id, class_attribute=attribute, option=option) + CharacterClassAttributeMap( + character_id=self.id, + class_attribute_id=attribute.id, + option_id=option.id, + class_attribute=attribute, + ) ) return True return False diff --git a/src/ttfrog/db/schema/classes.py b/src/ttfrog/db/schema/classes.py index b5c5a11..76ff809 100644 --- a/src/ttfrog/db/schema/classes.py +++ b/src/ttfrog/db/schema/classes.py @@ -27,6 +27,17 @@ class ClassAttribute(BaseObject): name: Mapped[str] = mapped_column(nullable=False) options = relationship("ClassAttributeOption", cascade="all,delete,delete-orphan", lazy="immediate") + def add_option(self, **kwargs): + option = ClassAttributeOption(attribute_id=self.id, **kwargs) + if not self.options or option not in self.options: + option.attribute_id = self.id + if not self.options: + self.options = [option] + else: + self.options.append(option) + return True + return False + def __repr__(self): return f"{self.id}: {self.name}" @@ -35,7 +46,7 @@ class ClassAttributeOption(BaseObject): __tablename__ = "class_attribute_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=False) + attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=True) class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin): @@ -47,6 +58,16 @@ class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin): proficiencies: Mapped[str] = mapped_column(default="") attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate") + 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] + else: + self.attributes.append(mapping) + return True + return False + @property def attributes_by_level(self): by_level = defaultdict(list) diff --git a/src/ttfrog/db/schema/modifiers.py b/src/ttfrog/db/schema/modifiers.py index f915a8f..01b2d9a 100644 --- a/src/ttfrog/db/schema/modifiers.py +++ b/src/ttfrog/db/schema/modifiers.py @@ -1,4 +1,5 @@ from collections import defaultdict +from typing import Any, Union from sqlalchemy import ForeignKey, UniqueConstraint from sqlalchemy.ext.declarative import declared_attr @@ -94,6 +95,9 @@ class ModifierMixin: @declared_attr def modifier_map(cls): + """ + Create the join between the current model and the ModifierMap table. + """ return relationship( "ModifierMap", primaryjoin=( @@ -111,12 +115,20 @@ class ModifierMixin: @property def modifiers(self): + """ + Return all modifiers for the current instance as a dict keyed on target attribute name. + """ all_modifiers = defaultdict(list) for mapping in self.modifier_map: all_modifiers[mapping.modifier.target].append(mapping.modifier) return all_modifiers - def add_modifier(self, modifier): + def add_modifier(self, modifier: Modifier) -> bool: + """ + Associate a modifier to the current instance if it isn't already. + + 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}.") @@ -131,15 +143,74 @@ class ModifierMixin: ) return True - def remove_modifier(self, modifier): + def remove_modifier(self, modifier: Modifier) -> bool: + """ + Remove a modifier from the map. + + Returns True if it was removed and False if it wasn't present. + """ if modifier not in self.modifiers[modifier.target]: return False self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier] return True - def _apply_modifiers(self, target, initial, modify_class=None): - if not modify_class: - modify_class = globals()["ModifiableInt"] if isinstance(initial, int) else globals()["ModifiableStr"] + def _modifiable_column(self, attr_name: str) -> Union[mapped_column, None]: + """ + Given an atttribute name, look for a column attribute with the same + name but with an underscore prefix. If that column exists, and it + has one or more of the expected "modifiable" keys in its info, the + column is modifiable. + + Returns the matching column if it was found, or None. + """ + col = getattr(self.__table__.columns, f"_{attr_name}", None) + if col is None: + return None + for key in col.info.keys(): + if key.startswith("modifiable"): + return col + return None + + def _get_modifiable_base(self, attr_name: str) -> object: + """ + Resolve a dottted string "foo.bar.baz" as its corresponding nested attribute. + + This is useful for cases where a column definition includes a modifiable_base + that is some other attribute. For example: + + foo[int] = mapped_column(default=0, info={"modifiable_base": "ancestry.bar") + + This will create an initial value for self.foo equal to self.ancesetry.bar. + """ + + def get_attr(obj, parts): + if parts: + name, *parts = parts + return get_attr(getattr(obj, name), parts) + return obj + + return get_attr(self, attr_name.split(".")) + + def _apply_modifiers(self, target: str, initial: Any, modifiable_class: type = None) -> Modifiable: + """ + Apply all the modifiers for a given target and return the modified value. + + This is mostly called from __getattr__() below to handle cases where a + column is named self._foo but the modified value is accessible as + self.foo. It can also be invoked directly, as, say from a property: + + @property + def speed(self): + return self._apply_modifiers("speed", self.ancestry.speed) + + Args: + target - The name of the attribute to modify + initial - The initial value for the target + modifiable_class - The object type to return; inferred from the + target attribute's type if not specified. + """ + if not modifiable_class: + modifiable_class = globals()["ModifiableInt"] if isinstance(initial, int) else globals()["ModifiableStr"] # get the modifiers in order from most to least recent modifiers = list(reversed(self.modifiers.get(target, []))) @@ -161,18 +232,28 @@ class ModifierMixin: else: modified = initial - return modify_class(base=initial, modified=modified) + return modifiable_class(base=initial, modified=modified) if modified is not None else None def __setattr__(self, attr_name, value): - col = getattr(self.__table__.columns, f"_{attr_name}", None) - if col is not None and col.info.get("modify", False): - raise AttributeError(f"You cannot set .{attr_name}. Did you mean ._{attr_name}?") + """ + Prevent callers from setting the value of a Modifiable directly. + """ + col = self._modifiable_column(attr_name) + if col is not None: + raise AttributeError(f"You cannot modify .{attr_name}. Did you mean ._{attr_name}?") return super().__setattr__(attr_name, value) def __getattr__(self, attr_name): - col = getattr(self.__table__.columns, f"_{attr_name}", None) - if col is not None and col.info.get("modify", False): + """ + If the instance has an attribute equal to attr_name but prefixed with an + underscore, check to see if that attribute is a column, and modifiable. + If it is, return a Modifiable instance corresponding to that column's value. + """ + col = self._modifiable_column(attr_name) + if col is not None: return self._apply_modifiers( - attr_name, getattr(self, col.name), modify_class=col.info.get("modify_class", None) + attr_name, + self._get_modifiable_base(col.info.get("modifiable_base", col.name)), + modifiable_class=col.info.get("modifiable_class", None), ) return super().__getattr__(attr_name) diff --git a/test/conftest.py b/test/conftest.py index eb8cbcc..8c801a7 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -31,20 +31,43 @@ def db(monkeypatch): @pytest.fixture -def classes_factory(db): - load_fixture(db, "classes") +def bootstrap(db): + with db.transaction(): + # ancestries + human = schema.Ancestry("human") - def factory(): - return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all()) + tiefling = schema.Ancestry("tiefling") + tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="intelligence", relative_value=1)) + tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="charisma", relative_value=2)) - return factory + # ancestry traits + darkvision = schema.AncestryTrait("Darkvision") + darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120)) + tiefling.add_trait(darkvision) + dragonborn = schema.Ancestry("dragonborn") + dragonborn.add_trait(darkvision) -@pytest.fixture -def ancestries_factory(db): - load_fixture(db, "ancestry") + db.add_or_update([human, dragonborn, tiefling]) - def factory(): - return dict((rec.name, rec) for rec in db.session.query(schema.Ancestry).all()) + # classes + fighting_style = schema.ClassAttribute("Fighting Style") + fighting_style.add_option(name="Archery") + fighting_style.add_option(name="Defense") + db.add_or_update(fighting_style) - return factory + fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON") + fighter.add_attribute(fighting_style, level=2) + + rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX") + db.add_or_update([rogue, fighter]) + + # characters + foo = schema.Character("Foo", ancestry=tiefling, _intelligence=14) + foo.add_class(fighter, level=2) + foo.add_class(rogue, level=3) + + bar = schema.Character("Bar", ancestry=human) + + # persist all the records we've created + db.add_or_update([foo, bar]) diff --git a/test/test_schema.py b/test/test_schema.py index 6c035a6..94790d3 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -3,62 +3,64 @@ import json from ttfrog.db import schema -def test_manage_character(db, classes_factory, ancestries_factory): +def test_manage_character(db, bootstrap): with db.transaction(): - # load the fixtures so they are bound to the current session - classes = classes_factory() - ancestries = ancestries_factory() - darkvision = db.AncestryTrait.filter_by(name="Darkvision")[0] + darkvision = db.AncestryTrait.filter_by(name="Darkvision").one() + human = db.Ancestry.filter_by(name="human").one() # create a human character (the default) - char = schema.Character(name="Test Character") + char = schema.Character(name="Test Character", ancestry=human) db.add_or_update(char) - assert char.id == 1 + assert char.id == 3 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 char.armor_class == 10 + assert char.hit_points == 10 + assert char.strength == 10 + assert char.dexterity == 10 + assert char.constitution == 10 + assert char.intelligence == 10 + assert char.wisdom == 10 + assert char.charisma == 10 assert darkvision not in char.traits # switch ancestry to tiefling - char.ancestry = ancestries["tiefling"] + tiefling = db.Ancestry.filter_by(name="tiefling").one() + char.ancestry = tiefling db.add_or_update(char) - char = db.session.get(schema.Character, 1) + char = db.session.get(schema.Character, char.id) + assert char.ancestry_id == tiefling.id assert char.ancestry.name == "tiefling" assert darkvision in char.traits # switch ancestry to dragonborn and assert darkvision persists - char.ancestry = ancestries["dragonborn"] + char.ancestry = db.Ancestry.filter_by(name="dragonborn").one() db.add_or_update(char) assert darkvision in char.traits # switch ancestry to human and assert darkvision is removed - char.ancestry = ancestries["human"] + char.ancestry = human db.add_or_update(char) assert darkvision not in char.traits + fighter = db.CharacterClass.filter_by(name="fighter").one() + rogue = db.CharacterClass.filter_by(name="rogue").one() + # assign a class and level - char.add_class(classes["fighter"], level=1) + char.add_class(fighter, level=1) db.add_or_update(char) assert char.levels == {"fighter": 1} assert char.level == 1 assert char.class_attributes == {} # 'fighting style' is available, but not at this level - fighter = classes["fighter"] fighting_style = fighter.attributes_by_level[2]["Fighting Style"] assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is False db.add_or_update(char) assert char.class_attributes == {} # level up - char.add_class(classes["fighter"], level=2) + char.add_class(fighter, level=2) db.add_or_update(char) assert char.levels == {"fighter": 2} assert char.level == 2 @@ -69,26 +71,27 @@ def test_manage_character(db, classes_factory, ancestries_factory): db.add_or_update(char) # classes - char.add_class(classes["rogue"], level=1) + char.add_class(rogue, level=1) db.add_or_update(char) assert char.level == 3 assert char.levels == {"fighter": 2, "rogue": 1} # remove a class - char.remove_class(classes["rogue"]) + char.remove_class(rogue) db.add_or_update(char) assert char.levels == {"fighter": 2} assert char.level == 2 # remove remaining class by setting level to zero - char.add_class(classes["fighter"], level=0) + char.add_class(fighter, level=0) db.add_or_update(char) assert char.levels == {} + assert char.class_attributes == {} # ensure we're not persisting any orphan records in the map tables dump = json.loads(db.dump()) - assert dump["class_map"] == [] - assert dump["character_class_attribute_map"] == [] + assert not [m for m in dump["character_class_attribute_map"] if m["character_id"] == char.id] + assert not [m for m in dump["class_map"] if m["character_id"] == char.id] def test_ancestries(db): @@ -129,19 +132,19 @@ def test_ancestries(db): assert endurance in grognak.traits # verify the strength bonus is applied - assert grognak.strength == 10 + assert grognak.strength.base == 10 assert str_plus_one in grognak.modifiers["strength"] - assert grognak.STR == 11 + assert grognak.strength == 11 -def test_modifiers(db, classes_factory, ancestries_factory): +def test_modifiers(db, bootstrap): with db.transaction(): - classes_factory() - ancestries = ancestries_factory() + human = db.Ancestry.filter_by(name="human").one() + tiefling = db.Ancestry.filter_by(name="tiefling").one() # no modifiers; speed is ancestry speed - carl = schema.Character(name="Carl", ancestry=ancestries["elf"]) - marx = schema.Character(name="Marx", ancestry=ancestries["human"]) + carl = schema.Character(name="Carl", ancestry=tiefling) + marx = schema.Character(name="Marx", ancestry=human) db.add_or_update([carl, marx]) assert carl.speed == carl.ancestry.speed == 30