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
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)
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

View File

@ -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)

View File

@ -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)

View File

@ -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])

View File

@ -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