fix schemas

This commit is contained in:
evilchili 2024-05-04 13:15:54 -07:00
parent 3292b11d89
commit b574dacfa1
6 changed files with 217 additions and 81 deletions

View File

@ -34,5 +34,3 @@ def bootstrap():
# persist all the records we've created # persist all the records we've created
db.add_or_update([sabetha, bob]) db.add_or_update([sabetha, bob])
print(f"{sabetha.intelligence.bonus = }, {sabetha.size = }")

View File

@ -117,7 +117,7 @@ class CharacterClassMap(BaseObject):
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1) level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
def __repr__(self): 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): 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) id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(default="New Character", nullable=False) 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=10, nullable=False, info={"min": 0, "max": 999})
_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}
)
temp_hit_points: Mapped[int] = mapped_column(default=0, 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( _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( _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( _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( _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( _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( _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_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
class_list = association_proxy("class_map", "id", creator=class_map_creator) class_list = association_proxy("class_map", "id", creator=class_map_creator)
@ -204,19 +207,19 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
@property @property
def speed(self): def speed(self):
return self.apply_modifiers("speed", self.ancestry.speed) return self._apply_modifiers("speed", self.ancestry.speed)
@property @property
def climb_speed(self): 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 @property
def swim_speed(self): 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 @property
def fly_speed(self): 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 @property
def size(self): def size(self):
@ -254,13 +257,15 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
self.add_class_attribute(attr, attr.options[0]) self.add_class_attribute(attr, attr.options[0])
def remove_class(self, target): 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: for mapping in self.character_class_attribute_map:
if mapping.character_class.id == target.id: if mapping.character_class.id == target.id:
self.remove_class_attribute(mapping.class_attribute) self.remove_class_attribute(mapping.class_attribute)
def remove_class_attribute(self, 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): def add_class_attribute(self, attribute, option):
for thisclass in self.classes.values(): for thisclass in self.classes.values():
@ -270,7 +275,12 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
if attribute.name in self.class_attributes: if attribute.name in self.class_attributes:
return True return True
self.attribute_list.append( 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 True
return False return False

View File

@ -27,6 +27,17 @@ class ClassAttribute(BaseObject):
name: Mapped[str] = mapped_column(nullable=False) name: Mapped[str] = mapped_column(nullable=False)
options = relationship("ClassAttributeOption", cascade="all,delete,delete-orphan", lazy="immediate") 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): def __repr__(self):
return f"{self.id}: {self.name}" return f"{self.id}: {self.name}"
@ -35,7 +46,7 @@ class ClassAttributeOption(BaseObject):
__tablename__ = "class_attribute_option" __tablename__ = "class_attribute_option"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False) 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): class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin):
@ -47,6 +58,16 @@ class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin):
proficiencies: Mapped[str] = mapped_column(default="") proficiencies: Mapped[str] = mapped_column(default="")
attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate") 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 @property
def attributes_by_level(self): def attributes_by_level(self):
by_level = defaultdict(list) by_level = defaultdict(list)

View File

@ -1,4 +1,5 @@
from collections import defaultdict from collections import defaultdict
from typing import Any, Union
from sqlalchemy import ForeignKey, UniqueConstraint from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.declarative import declared_attr
@ -94,6 +95,9 @@ class ModifierMixin:
@declared_attr @declared_attr
def modifier_map(cls): def modifier_map(cls):
"""
Create the join between the current model and the ModifierMap table.
"""
return relationship( return relationship(
"ModifierMap", "ModifierMap",
primaryjoin=( primaryjoin=(
@ -111,12 +115,20 @@ class ModifierMixin:
@property @property
def modifiers(self): def modifiers(self):
"""
Return all modifiers for the current instance as a dict keyed on target attribute name.
"""
all_modifiers = defaultdict(list) all_modifiers = defaultdict(list)
for mapping in self.modifier_map: for mapping in self.modifier_map:
all_modifiers[mapping.modifier.target].append(mapping.modifier) all_modifiers[mapping.modifier.target].append(mapping.modifier)
return all_modifiers 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: 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}.")
@ -131,15 +143,74 @@ class ModifierMixin:
) )
return True 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]: if modifier not in self.modifiers[modifier.target]:
return False return False
self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier] self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier]
return True return True
def _apply_modifiers(self, target, initial, modify_class=None): def _modifiable_column(self, attr_name: str) -> Union[mapped_column, None]:
if not modify_class: """
modify_class = globals()["ModifiableInt"] if isinstance(initial, int) else globals()["ModifiableStr"] 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 # get the modifiers in order from most to least recent
modifiers = list(reversed(self.modifiers.get(target, []))) modifiers = list(reversed(self.modifiers.get(target, [])))
@ -161,18 +232,28 @@ class ModifierMixin:
else: else:
modified = initial 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): 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): Prevent callers from setting the value of a Modifiable directly.
raise AttributeError(f"You cannot set .{attr_name}. Did you mean ._{attr_name}?") """
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) return super().__setattr__(attr_name, value)
def __getattr__(self, attr_name): 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( 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) return super().__getattr__(attr_name)

View File

@ -31,20 +31,43 @@ def db(monkeypatch):
@pytest.fixture @pytest.fixture
def classes_factory(db): def bootstrap(db):
load_fixture(db, "classes") with db.transaction():
# ancestries
human = schema.Ancestry("human")
def factory(): tiefling = schema.Ancestry("tiefling")
return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all()) 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 db.add_or_update([human, dragonborn, tiefling])
def ancestries_factory(db):
load_fixture(db, "ancestry")
def factory(): # classes
return dict((rec.name, rec) for rec in db.session.query(schema.Ancestry).all()) 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])

View File

@ -3,62 +3,64 @@ import json
from ttfrog.db import schema from ttfrog.db import schema
def test_manage_character(db, classes_factory, ancestries_factory): def test_manage_character(db, bootstrap):
with db.transaction(): with db.transaction():
# load the fixtures so they are bound to the current session darkvision = db.AncestryTrait.filter_by(name="Darkvision").one()
classes = classes_factory() human = db.Ancestry.filter_by(name="human").one()
ancestries = ancestries_factory()
darkvision = db.AncestryTrait.filter_by(name="Darkvision")[0]
# create a human character (the default) # 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) db.add_or_update(char)
assert char.id == 1 assert char.id == 3
assert char.name == "Test Character" assert char.name == "Test Character"
assert char.ancestry.name == "human" assert char.ancestry.name == "human"
assert char.AC == 10 assert char.armor_class == 10
assert char.HP == 10 assert char.hit_points == 10
assert char.STR == 10 assert char.strength == 10
assert char.DEX == 10 assert char.dexterity == 10
assert char.CON == 10 assert char.constitution == 10
assert char.INT == 10 assert char.intelligence == 10
assert char.WIS == 10 assert char.wisdom == 10
assert char.CHA == 10 assert char.charisma == 10
assert darkvision not in char.traits assert darkvision not in char.traits
# switch ancestry to tiefling # switch ancestry to tiefling
char.ancestry = ancestries["tiefling"] tiefling = db.Ancestry.filter_by(name="tiefling").one()
char.ancestry = tiefling
db.add_or_update(char) 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 char.ancestry.name == "tiefling"
assert darkvision in char.traits assert darkvision in char.traits
# switch ancestry to dragonborn and assert darkvision persists # 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) db.add_or_update(char)
assert darkvision in char.traits assert darkvision in char.traits
# switch ancestry to human and assert darkvision is removed # switch ancestry to human and assert darkvision is removed
char.ancestry = ancestries["human"] char.ancestry = human
db.add_or_update(char) db.add_or_update(char)
assert darkvision not in char.traits 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 # assign a class and level
char.add_class(classes["fighter"], level=1) char.add_class(fighter, level=1)
db.add_or_update(char) db.add_or_update(char)
assert char.levels == {"fighter": 1} assert char.levels == {"fighter": 1}
assert char.level == 1 assert char.level == 1
assert char.class_attributes == {} assert char.class_attributes == {}
# 'fighting style' is available, but not at this level # 'fighting style' is available, but not at this level
fighter = classes["fighter"]
fighting_style = fighter.attributes_by_level[2]["Fighting Style"] fighting_style = fighter.attributes_by_level[2]["Fighting Style"]
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is False assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is False
db.add_or_update(char) db.add_or_update(char)
assert char.class_attributes == {} assert char.class_attributes == {}
# level up # level up
char.add_class(classes["fighter"], level=2) char.add_class(fighter, level=2)
db.add_or_update(char) db.add_or_update(char)
assert char.levels == {"fighter": 2} assert char.levels == {"fighter": 2}
assert char.level == 2 assert char.level == 2
@ -69,26 +71,27 @@ def test_manage_character(db, classes_factory, ancestries_factory):
db.add_or_update(char) db.add_or_update(char)
# classes # classes
char.add_class(classes["rogue"], level=1) char.add_class(rogue, level=1)
db.add_or_update(char) db.add_or_update(char)
assert char.level == 3 assert char.level == 3
assert char.levels == {"fighter": 2, "rogue": 1} assert char.levels == {"fighter": 2, "rogue": 1}
# remove a class # remove a class
char.remove_class(classes["rogue"]) char.remove_class(rogue)
db.add_or_update(char) db.add_or_update(char)
assert char.levels == {"fighter": 2} assert char.levels == {"fighter": 2}
assert char.level == 2 assert char.level == 2
# remove remaining class by setting level to zero # 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) db.add_or_update(char)
assert char.levels == {} assert char.levels == {}
assert char.class_attributes == {}
# ensure we're not persisting any orphan records in the map tables # ensure we're not persisting any orphan records in the map tables
dump = json.loads(db.dump()) dump = json.loads(db.dump())
assert dump["class_map"] == [] assert not [m for m in dump["character_class_attribute_map"] if m["character_id"] == char.id]
assert dump["character_class_attribute_map"] == [] assert not [m for m in dump["class_map"] if m["character_id"] == char.id]
def test_ancestries(db): def test_ancestries(db):
@ -129,19 +132,19 @@ def test_ancestries(db):
assert endurance in grognak.traits assert endurance in grognak.traits
# verify the strength bonus is applied # verify the strength bonus is applied
assert grognak.strength == 10 assert grognak.strength.base == 10
assert str_plus_one in grognak.modifiers["strength"] 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(): with db.transaction():
classes_factory() human = db.Ancestry.filter_by(name="human").one()
ancestries = ancestries_factory() tiefling = db.Ancestry.filter_by(name="tiefling").one()
# no modifiers; speed is ancestry speed # no modifiers; speed is ancestry speed
carl = schema.Character(name="Carl", ancestry=ancestries["elf"]) carl = schema.Character(name="Carl", ancestry=tiefling)
marx = schema.Character(name="Marx", ancestry=ancestries["human"]) marx = schema.Character(name="Marx", ancestry=human)
db.add_or_update([carl, marx]) db.add_or_update([carl, marx])
assert carl.speed == carl.ancestry.speed == 30 assert carl.speed == carl.ancestry.speed == 30