Compare commits

..

No commits in common. "9a2d28ae75f5b293072928c956e787b0e88a8ace" and "1ff0e5ca7d4899afd004d41b77c19804349b10af" have entirely different histories.

9 changed files with 301 additions and 645 deletions

View File

@ -2,9 +2,9 @@ import enum
import nanoid
from nanoid_dictionary import human_alphabet
from pyramid_sqlalchemy import BaseObject as _BaseObject
from slugify import slugify
from sqlalchemy import Column, String
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
def genslug():
@ -19,7 +19,7 @@ class SlugMixin:
return "-".join([self.slug, slugify(self.name.title().replace(" ", ""), ok="", only_ascii=True, lower=False)])
class BaseObject(MappedAsDataclass, DeclarativeBase):
class BaseObject(_BaseObject):
"""
Allows for iterating over Model objects' column names and values
"""
@ -54,6 +54,36 @@ class BaseObject(MappedAsDataclass, DeclarativeBase):
return str(dict(self))
def multivalue_string_factory(name, column=Column(String), separator=";"):
"""
Generate a mixin class that adds a string column with getters and setters
that convert list values to strings and back again. Equivalent to:
class MultiValueString:
_name = column
@property
def name_property(self):
return self._name.split(';')
@name.setter
def name(self, val):
return ';'.join(val)
"""
attr = f"_{name}"
prop = property(lambda self: getattr(self, attr).split(separator))
setter = prop.setter(lambda self, val: setattr(self, attr, separator.join(val)))
return type(
"MultiValueString",
(object,),
{
attr: column,
f"{name}_property": prop,
name: setter,
},
)
class EnumField(enum.Enum):
"""
A serializable enum.
@ -63,6 +93,9 @@ class EnumField(enum.Enum):
return self.value
SavingThrowsMixin = multivalue_string_factory("saving_throws")
SkillsMixin = multivalue_string_factory("skills")
STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
CREATURE_TYPES = [
"aberation",

View File

@ -7,30 +7,30 @@ def bootstrap():
db.init()
with db.transaction():
# ancestries
human = schema.Ancestry("human")
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))
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(
"Darkvision",
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 cant discern color in darkness, only shades of gray."
),
)
darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120))
darkvision.add_modifier(schema.Modifier(name="Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision)
# classes
fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON")
rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX")
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("Sabetha", ancestry=tiefling, _intelligence=14)
sabetha = schema.Character(name="Sabetha", ancestry=tiefling)
sabetha.add_class(fighter, level=2)
sabetha.add_class(rogue, level=3)
bob = schema.Character("Bob", ancestry=human)
bob = schema.Character(name="Bob", ancestry=human)
# persist all the records we've created
db.add_or_update([sabetha, bob])

View File

@ -6,8 +6,9 @@ from contextlib import contextmanager
from functools import cached_property
import transaction
from pyramid_sqlalchemy.meta import Session
from sqlalchemy import create_engine, event
from pyramid_sqlalchemy import Session, init_sqlalchemy
from pyramid_sqlalchemy import metadata as _metadata
from sqlalchemy import create_engine
import ttfrog.db.schema
from ttfrog.path import database
@ -42,7 +43,7 @@ class SQLDatabaseManager:
@cached_property
def metadata(self):
return ttfrog.db.schema.BaseObject.metadata
return _metadata
@cached_property
def tables(self):
@ -76,8 +77,7 @@ class SQLDatabaseManager:
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
def init(self):
self.session.configure(bind=self.engine)
self.metadata.bind = self.engine
init_sqlalchemy(self.engine)
self.metadata.create_all(self.engine)
def dump(self, names: list = []):
@ -92,15 +92,3 @@ class SQLDatabaseManager:
db = SQLDatabaseManager()
@event.listens_for(db.session, "after_flush")
def session_after_flush(session, flush_context):
"""
Listen to flush events looking for newly-created objects. For each one, if the
obj has a __after_insert__ method, call it.
"""
for obj in session.new:
callback = getattr(obj, "__after_insert__", None)
if callback:
callback(session)

View File

@ -1,11 +1,9 @@
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import relationship
from ttfrog.db.base import BaseObject, SlugMixin
from ttfrog.db.schema.classes import CharacterClass, ClassAttribute
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.property import Skill
from ttfrog.db.base import BaseObject, CreatureTypesEnum, SavingThrowsMixin, SizesEnum, SkillsMixin, SlugMixin
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin
__all__ = [
"Ancestry",
@ -24,12 +22,6 @@ def class_map_creator(fields):
return CharacterClassMap(**fields)
def skill_creator(fields):
if isinstance(fields, CharacterSkillMap):
return fields
return CharacterSkillMap(**fields)
def attr_map_creator(fields):
if isinstance(fields, CharacterClassAttributeMap):
return fields
@ -39,11 +31,11 @@ def attr_map_creator(fields):
class AncestryTraitMap(BaseObject):
__tablename__ = "trait_map"
__table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"))
ancestry_trait_id: Mapped[int] = mapped_column(ForeignKey("ancestry_trait.id"), init=False)
trait: Mapped["AncestryTrait"] = relationship(uselist=False, lazy="immediate")
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20})
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", uselist=False, lazy="immediate")
level = Column(Integer, nullable=False, info={"min": 1, "max": 20})
class Ancestry(BaseObject, ModifierMixin):
@ -52,20 +44,15 @@ class Ancestry(BaseObject, ModifierMixin):
"""
__tablename__ = "ancestry"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
creature_type: Mapped[str] = mapped_column(nullable=False, default="humanoid")
size: Mapped[str] = mapped_column(nullable=False, default="medium")
walk_speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99})
_fly_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
_climb_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
_swim_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
_traits = relationship(
"AncestryTraitMap", init=False, uselist=True, cascade="all,delete,delete-orphan", lazy="immediate"
)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
creature_type = Column(Enum(CreatureTypesEnum), nullable=False, default="humanoid")
size = Column(Enum(SizesEnum), nullable=False, default="Medium")
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):
@ -84,12 +71,8 @@ class Ancestry(BaseObject, ModifierMixin):
return self._swim_speed or int(self.speed / 2)
def add_trait(self, trait, level=1):
if not self._traits or trait not in self._traits:
mapping = AncestryTraitMap(ancestry_id=self.id, trait=trait, level=level)
if not self._traits:
self._traits = [mapping]
else:
self._traits.append(mapping)
if trait not in self._traits:
self._traits.append(AncestryTraitMap(ancestry_id=self.id, trait=trait, level=level))
return True
return False
@ -103,52 +86,38 @@ class AncestryTrait(BaseObject, ModifierMixin):
"""
__tablename__ = "ancestry_trait"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[Text] = mapped_column(Text, default="")
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
description = Column(Text)
def __repr__(self):
return self.name
class CharacterSkillMap(BaseObject):
__tablename__ = "character_skill_map"
__table_args__ = (UniqueConstraint("skill_id", "character_id", "character_class_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
skill_id: Mapped[int] = mapped_column(ForeignKey("skill.id"))
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=True, default=None)
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=True, default=None)
proficient: Mapped[bool] = mapped_column(default=True)
expert: Mapped[bool] = mapped_column(default=False)
skill = relationship("Skill", lazy="immediate")
class CharacterClassMap(BaseObject):
__tablename__ = "class_map"
__table_args__ = (UniqueConstraint("character_id", "character_class_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character: Mapped["Character"] = relationship(uselist=False, viewonly=True)
character_class: Mapped["CharacterClass"] = relationship(lazy="immediate")
id = Column(Integer, primary_key=True, autoincrement=True)
character_id = Column(Integer, ForeignKey("character.id"), nullable=False)
character_class_id = Column(Integer, ForeignKey("character_class.id"), nullable=False)
level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), init=False, nullable=False)
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), init=False, nullable=False)
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
character_class = relationship("CharacterClass", lazy="immediate")
character = relationship("Character", uselist=False, viewonly=True)
def __repr__(self):
return f"{self.character.name}, {self.character_class.name}, level {self.level}"
return "{self.character.name}, {self.character_class.name}, level {self.level}"
class CharacterClassAttributeMap(BaseObject):
__tablename__ = "character_class_attribute_map"
__table_args__ = (UniqueConstraint("character_id", "class_attribute_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=False)
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=False)
option_id: Mapped[int] = mapped_column(ForeignKey("class_attribute_option.id"), nullable=False)
id = Column(Integer, primary_key=True, autoincrement=True)
character_id = Column(Integer, ForeignKey("character.id"), nullable=False)
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
option_id = Column(Integer, ForeignKey("class_attribute_option.id"), nullable=False)
class_attribute: Mapped["ClassAttribute"] = relationship(lazy="immediate")
class_attribute = relationship("ClassAttribute", lazy="immediate")
option = relationship("ClassAttributeOption", lazy="immediate")
character_class = relationship(
@ -161,62 +130,33 @@ class CharacterClassAttributeMap(BaseObject):
)
class Character(BaseObject, SlugMixin, ModifierMixin):
class Character(BaseObject, ModifierMixin, 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=10, nullable=False, info={"min": 0, "max": 999})
temp_hit_points = Column(Integer, default=0, nullable=False, info={"min": 0, "max": 999})
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})
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
_vision = Column(Integer, info={"min": 0})
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, default="New Character")
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, "modifiable_class": Stat}
)
_dexterity: Mapped[int] = mapped_column(
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, "modifiable_class": Stat}
)
_intelligence: Mapped[int] = mapped_column(
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, "modifiable_class": Stat}
)
_charisma: Mapped[int] = mapped_column(
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
)
_vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
proficiencies = Column(String)
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
class_list = association_proxy("class_map", "id", creator=class_map_creator)
_skills = relationship("CharacterSkillMap", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
skills = association_proxy("_skills", "skill", creator=skill_creator)
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: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
@property
def proficiency_bonus(self):
return 1 + int(0.5 + self.level / 4)
@property
def proficiencies(self):
unified = {}
unified.update(**self._proficiencies)
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry = relationship("Ancestry", uselist=False)
@property
def modifiers(self):
@ -227,10 +167,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
unified.update(**super().modifiers)
return unified
@property
def check_modifiers(self):
return [self.check_modifier(skill) for skill in self.skills]
@property
def classes(self):
return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map])
@ -239,25 +175,61 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
def traits(self):
return self.ancestry.traits
@property
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._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):
return self._apply_modifiers("size", self.ancestry.size)
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):
@ -275,106 +247,57 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
def class_attributes(self):
return dict([(mapping.class_attribute.name, mapping.option) for mapping in self.character_class_attribute_map])
def level_in_class(self, charclass):
mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id]
if not mapping:
return None
return mapping[0]
def check_modifier(self, skill: Skill, save: bool = False):
if skill not in self.skills:
return self.check_modifier(skill.parent, save=save) if skill.parent else 0
attr = skill.parent.name.lower() if skill.parent else skill.name.lower()
stat = getattr(self, attr, None)
initial = stat.bonus if stat else 0
mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][0]
if mapping.expert and not save:
initial += 2 * self.proficiency_bonus
elif mapping.proficient:
initial += self.proficiency_bonus
return self._apply_modifiers(f"{attr}_{'save' if save else 'check'}", initial)
def add_class(self, newclass, level=1):
if level == 0:
return self.remove_class(newclass)
# add the class mapping and/or set the character's level in the class
mapping = self.level_in_class(newclass)
if not mapping:
self.class_list.append(CharacterClassMap(character=self, character_class=newclass, level=level))
level_in_class = [mapping for mapping in self.class_map if mapping.character_class_id == newclass.id]
if level_in_class:
level_in_class = level_in_class[0]
level_in_class.level = level
else:
mapping.level = level
# add class attributes with default values
self.class_list.append(CharacterClassMap(character_id=self.id, character_class=newclass, level=level))
for lvl in range(1, level + 1):
for attr in newclass.attributes_at_level(lvl):
self.add_class_attribute(newclass, attr, attr.options[0])
# add default class skills
for skill in newclass.skills[: newclass.starting_skills]:
self.add_skill(skill, proficient=True, character_class=newclass)
if not newclass.attributes_by_level[lvl]:
continue
for attr_name, attr in newclass.attributes_by_level[lvl].items():
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.character_class != target]
self.class_map = [m for m in self.class_map if m.id != target.id]
for mapping in self.character_class_attribute_map:
if mapping.character_class.id == target.id:
self.remove_class_attribute(mapping.class_attribute)
for skill in target.skills:
self.remove_skill(skill, character_class=target)
def remove_class_attribute(self, attribute):
self.character_class_attribute_map = [
m for m in self.character_class_attribute_map if m.class_attribute.id != attribute.id
]
self.character_class_attribute_map = [m for m in self.character_class_attribute_map if m.id != attribute.id]
def has_class_attribute(self, attribute):
return attribute in [m.class_attribute for m in self.character_class_attribute_map]
def add_class_attribute(self, character_class, attribute, option):
if self.has_class_attribute(attribute):
return False
mapping = self.level_in_class(character_class)
if not mapping:
return False
if attribute not in mapping.character_class.attributes_at_level(mapping.level):
return False
def add_class_attribute(self, attribute, option):
for thisclass in self.classes.values():
current_level = self.levels[thisclass.name]
current_attributes = thisclass.attributes_by_level.get(current_level, {})
if attribute.name in current_attributes:
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,
class_attribute=attribute,
CharacterClassAttributeMap(character_id=self.id, class_attribute=attribute, option=option)
)
)
return True
def add_skill(self, skill, proficient=False, expert=False, character_class=None):
if not self.skills or skill not in self.skills:
if not self.id:
raise Exception(f"Cannot add a skill before the character has been persisted.")
mapping = CharacterSkillMap(skill_id=skill.id, character_id=self.id, proficient=proficient, expert=expert)
if character_class:
mapping.character_class_id = character_class.id
self._skills.append(mapping)
return True
return False
def remove_skill(self, skill, character_class=None):
self._skills = [
mapping
for mapping in self._skills
if mapping.skill_id != skill.id and mapping.character_class_id != character_class.id
]
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:
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)
def __after_insert__(self, session):
"""
Called by the session after_flush event listener to add default joins in other tables.
"""
for skill in session.query(Skill).filter(
Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
):
self.add_skill(skill, proficient=False, expert=False)
new = [mod for mod in modifiers if mod.new_value is not None]
if new:
return new[0].new_value
return initial

View File

@ -1,121 +1,55 @@
import itertools
from collections import defaultdict
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from ttfrog.db.base import BaseObject
from ttfrog.db.schema.property import Skill
from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin, StatsEnum
__all__ = [
"ClassAttributeMap",
"ClassAttribute",
"ClassAttributeOption",
"CharacterClass",
"Skill",
"ClassSkillMap",
]
def skill_creator(fields):
if isinstance(fields, ClassSkillMap):
return fields
return ClassSkillMap(**fields)
class ClassSkillMap(BaseObject):
__tablename__ = "class_skill_map"
__table_args__ = (UniqueConstraint("skill_id", "character_class_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
skill_id: Mapped[int] = mapped_column(ForeignKey("skill.id"))
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
proficient: Mapped[bool] = mapped_column(default=True)
expert: Mapped[bool] = mapped_column(default=False)
skill = relationship("Skill", lazy="immediate")
class ClassAttributeMap(BaseObject):
__tablename__ = "class_attribute_map"
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), primary_key=True)
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), primary_key=True)
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), primary_key=True)
character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=True)
level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
attribute = relationship("ClassAttribute", uselist=False, viewonly=True, lazy="immediate")
class ClassAttribute(BaseObject):
__tablename__ = "class_attribute"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, 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}"
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=True)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
class CharacterClass(BaseObject):
class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin):
__tablename__ = "character_class"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(index=True, unique=True)
hit_dice: Mapped[str] = mapped_column(default="1d6")
hit_dice_stat: Mapped[str] = mapped_column(default="")
starting_skills: int = mapped_column(nullable=False, default=0)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
hit_dice = Column(String, default="1d6")
hit_dice_stat = Column(Enum(StatsEnum))
proficiencies = Column(String)
attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate")
_skills = relationship("ClassSkillMap", cascade="all,delete,delete-orphan", lazy="immediate")
skills = association_proxy("_skills", "skill", creator=skill_creator)
def add_skill(self, skill, expert=False):
if not self.skills or skill not in self.skills:
if not self.id:
raise Exception(f"Cannot add a skill before the class has been persisted.")
mapping = ClassSkillMap(character_class_id=self.id, skill_id=skill.id, proficient=True, expert=expert)
self.skills.append(mapping)
return True
return False
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)
for mapping in self.attributes:
by_level[mapping.level].append(mapping.attribute)
by_level[mapping.level] = {mapping.attribute.name: mapping.attribute}
return by_level
def attribute(self, name: str):
for mapping in self.attributes:
if mapping.attribute.name.lower() == name.lower():
return mapping.attribute
return None
def attributes_at_level(self, level: int):
return list(itertools.chain(*[attrs for lvl, attrs in self.attributes_by_level.items() if lvl <= level]))

View File

@ -1,41 +1,12 @@
from collections import defaultdict
from typing import Any, Union
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy import Column, Float, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import relationship
from ttfrog.db.base import BaseObject
class Modifiable:
def __new__(cls, base, modified=None):
cls.base = base
return super().__new__(cls, modified)
class ModifiableStr(Modifiable, str):
"""
A string that also has a '.base' property.
"""
class ModifiableInt(Modifiable, int):
"""
An integer that also has a '.base' property
"""
class Stat(ModifiableInt):
"""
Same as a Score except it also has a bonus for STR, DEX, CON, etc.
"""
@property
def bonus(self):
return int((self - 10) / 2)
class ModifierMap(BaseObject):
"""
Creates a many-to-many between Modifier and any model inheriting from the ModifierMixin.
@ -43,12 +14,12 @@ class ModifierMap(BaseObject):
__tablename__ = "modifier_map"
__table_args__ = (UniqueConstraint("primary_table_name", "primary_table_id", "modifier_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
modifier_id: Mapped[int] = mapped_column(ForeignKey("modifier.id"), init=False)
modifier: Mapped["Modifier"] = relationship(uselist=False, lazy="immediate")
id = Column(Integer, primary_key=True, autoincrement=True)
modifier_id = Column(Integer, ForeignKey("modifier.id"), nullable=False)
modifier = relationship("Modifier", uselist=False, lazy="immediate")
primary_table_name: Mapped[str] = mapped_column(nullable=False)
primary_table_id: Mapped[int] = mapped_column(nullable=False)
primary_table_name = Column(String, nullable=False)
primary_table_id = Column(Integer, nullable=False)
class Modifier(BaseObject):
@ -60,14 +31,14 @@ class Modifier(BaseObject):
"""
__tablename__ = "modifier"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False)
target: Mapped[str] = mapped_column(nullable=False)
absolute_value: Mapped[int] = mapped_column(nullable=True, default=None)
relative_value: Mapped[int] = mapped_column(nullable=True, default=None)
multiply_value: Mapped[float] = mapped_column(nullable=True, default=None)
new_value: Mapped[str] = mapped_column(nullable=True, default=None)
description: Mapped[str] = mapped_column(default="")
id = Column(Integer, primary_key=True, autoincrement=True)
target = Column(String, nullable=False)
absolute_value = Column(Integer)
relative_value = Column(Integer)
multiply_value = Column(Float)
new_value = Column(String)
name = Column(String, nullable=False)
description = Column(String)
class ModifierMixin:
@ -85,8 +56,8 @@ class ModifierMixin:
Example:
>>> class Item(BaseObject, ModifierMixin):
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
>>> dwarven_belt = Item(name="Dwarven Belt")
>>> dwarven_belt.add_modifier(Modifier(name="STR+1", target="strength", relative_value=1))
>>> dwarven_belt.modifiers
@ -95,9 +66,6 @@ class ModifierMixin:
@declared_attr
def modifier_map(cls):
"""
Create the join between the current model and the ModifierMap table.
"""
return relationship(
"ModifierMap",
primaryjoin=(
@ -115,20 +83,12 @@ 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: 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.
"""
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}.")
@ -143,117 +103,8 @@ class ModifierMixin:
)
return True
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]:
def remove_modifier(self, modifier):
if modifier.id not in [mod.modifier_id for mod in self.modifier_map]:
return False
self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier]
return True
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, [])))
if isinstance(initial, int):
absolute = [mod for mod in modifiers if mod.absolute_value is not None]
if absolute:
modified = absolute[0].absolute_value
else:
multiple = [mod for mod in modifiers if mod.multiply_value is not None]
if multiple:
modified = int(initial * multiple[0].multiply_value + 0.5)
else:
modified = initial + sum(mod.relative_value for mod in modifiers if mod.relative_value is not None)
else:
new = [mod for mod in modifiers if mod.new_value is not None]
if new:
modified = new[0].new_value
else:
modified = initial
return modifiable_class(base=initial, modified=modified) if modified is not None else None
def __setattr__(self, attr_name, value):
"""
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):
"""
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,
self._get_modifiable_base(col.info.get("modifiable_base", col.name)),
modifiable_class=col.info.get("modifiable_class", None),
)
raise AttributeError(f"No such attribute: {attr_name}.")

View File

@ -1,17 +1,27 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Column, Integer, String, Text
from ttfrog.db.base import BaseObject
__all__ = [
"Skill",
"Proficiency",
]
class Skill(BaseObject):
__tablename__ = "skill"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
base_id: Mapped[int] = mapped_column(ForeignKey("skill.id"), nullable=True, default=None)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
description = Column(Text)
parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False, lazy="immediate")
def __repr__(self):
return str(self.name)
class Proficiency(BaseObject):
__tablename__ = "proficiency"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
def __repr__(self):
return str(self.name)

View File

@ -31,62 +31,20 @@ def db(monkeypatch):
@pytest.fixture
def bootstrap(db):
with db.transaction():
# ancestries
human = schema.Ancestry("human")
def classes_factory(db):
load_fixture(db, "classes")
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))
def factory():
return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all())
# ancestry traits
darkvision = schema.AncestryTrait("Darkvision")
darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision)
return factory
dragonborn = schema.Ancestry("dragonborn")
dragonborn.add_trait(darkvision)
db.add_or_update([human, dragonborn, tiefling])
@pytest.fixture
def ancestries_factory(db):
load_fixture(db, "ancestry")
# skills
skills = {
name: schema.Skill(name=name)
for name in ("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma")
}
db.add_or_update(list(skills.values()))
acrobatics = schema.Skill(name="Acrobatics", base_id=skills["dexterity"].id)
athletics = schema.Skill(name="Athletics", base_id=skills["strength"].id)
db.add_or_update([acrobatics, athletics])
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)
fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON", starting_skills=2)
db.add_or_update(fighter)
# add skills
fighter.add_skill(acrobatics)
fighter.add_skill(athletics)
fighter.add_attribute(fighting_style, level=2)
db.add_or_update(fighter)
assert acrobatics in fighter.skills
assert athletics in fighter.skills
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)
db.add_or_update(foo)
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])
return factory

View File

@ -3,130 +3,95 @@ import json
from ttfrog.db import schema
def test_manage_character(db, bootstrap):
def test_manage_character(db, classes_factory, ancestries_factory):
with db.transaction():
darkvision = db.AncestryTrait.filter_by(name="Darkvision").one()
human = db.Ancestry.filter_by(name="human").one()
# 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]
# create a human character (the default)
char = schema.Character(name="Test Character", ancestry=human)
char = schema.Character(name="Test Character")
db.add_or_update(char)
assert char.id == 3
assert char.id == 1
assert char.name == "Test Character"
assert char.ancestry.name == "human"
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 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
# verify basic skills were added at creation time
for skill in db.Skill.filter(
schema.Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
):
assert char.check_modifier(skill) == 0
# switch ancestry to tiefling
tiefling = db.Ancestry.filter_by(name="tiefling").one()
char.ancestry = tiefling
char.ancestry = ancestries["tiefling"]
db.add_or_update(char)
char = db.session.get(schema.Character, char.id)
assert char.ancestry_id == tiefling.id
char = db.session.get(schema.Character, 1)
assert char.ancestry.name == "tiefling"
assert darkvision in char.traits
# tiefling ancestry adds INT and CHA modifiers
assert char.intelligence == 11
assert char.intelligence.base == 10
assert char.charisma == 12
assert char.charisma.base == 10
# switch ancestry to dragonborn and assert darkvision persists
char.ancestry = db.Ancestry.filter_by(name="dragonborn").one()
char.ancestry = ancestries["dragonborn"]
db.add_or_update(char)
assert darkvision in char.traits
# verify tiefling modifiers were removed
assert char.intelligence == 10
assert char.charisma == 10
# switch ancestry to human and assert darkvision is removed
char.ancestry = human
char.ancestry = ancestries["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(fighter, level=1)
char.add_class(classes["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
fighting_style = fighter.attribute("Fighting Style")
assert char.has_class_attribute(fighting_style) is False
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
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(fighter, level=7)
char.add_class(classes["fighter"], level=2)
db.add_or_update(char)
assert char.levels == {"fighter": 7}
assert char.level == 7
assert char.levels == {"fighter": 2}
assert char.level == 2
# Assert the fighting style is added automatically and idempotent...ly?
assert char.has_class_attribute(fighting_style)
assert char.class_attributes[fighting_style.name] == fighting_style.options[0]
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
assert char.has_class_attribute(fighting_style)
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is True
db.add_or_update(char)
athletics = db.Skill.filter_by(name="athletics").one()
acrobatics = db.Skill.filter_by(name="acrobatics").one()
assert athletics in char.skills
assert acrobatics in char.skills
assert char.check_modifier(athletics) == char.proficiency_bonus + char.strength.bonus == 3
assert char.check_modifier(acrobatics) == char.proficiency_bonus + char.dexterity.bonus == 3
# multiclass
char.add_class(rogue, level=1)
# classes
char.add_class(classes["rogue"], level=1)
db.add_or_update(char)
assert char.level == 8
assert char.levels == {"fighter": 7, "rogue": 1}
assert char.level == 3
assert char.levels == {"fighter": 2, "rogue": 1}
# remove a class
char.remove_class(rogue)
char.remove_class(classes["rogue"])
db.add_or_update(char)
assert char.levels == {"fighter": 7}
assert char.level == 7
assert char.levels == {"fighter": 2}
assert char.level == 2
# remove remaining class by setting level to zero
char.add_class(fighter, level=0)
char.add_class(classes["fighter"], level=0)
db.add_or_update(char)
assert char.levels == {}
assert char.class_attributes == {}
# verify the proficiencies added by the classes have been removed
assert athletics not in char.skills
assert acrobatics not in char.skills
assert char.check_modifier(athletics) == 0
assert char.check_modifier(acrobatics) == 0
# ensure we're not persisting any orphan records in the map tables
dump = json.loads(db.dump())
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]
assert dump["class_map"] == []
assert dump["character_class_attribute_map"] == []
def test_ancestries(db, bootstrap):
def test_ancestries(db):
with db.transaction():
# create the Pygmy Orc ancestry
porc = schema.Ancestry(
@ -134,6 +99,7 @@ def test_ancestries(db, bootstrap):
size="Small",
walk_speed=25,
)
db.add_or_update(porc)
assert porc.name == "Pygmy Orc"
assert porc.creature_type == "humanoid"
assert porc.size == "Small"
@ -146,43 +112,36 @@ def test_ancestries(db, bootstrap):
db.add_or_update(porc)
assert endurance in porc.traits
# add a +3 STR modifier
str_bonus = schema.Modifier(
name="STR+3 (Pygmy Orc)",
# add a +1 STR modifier
str_plus_one = schema.Modifier(
name="STR+1 (Pygmy Orc)",
target="strength",
relative_value=3,
description="Your Strength score is increased by 3.",
relative_value=1,
description="Your Strength score is increased by 1.",
)
assert porc.add_modifier(str_bonus) is True
assert porc.add_modifier(str_bonus) is False # test idempotency
assert str_bonus in porc.modifiers["strength"]
assert porc.add_modifier(str_plus_one) is True
assert porc.add_modifier(str_plus_one) is False # test idempotency
assert str_plus_one in porc.modifiers["strength"]
# 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.base == 10
assert grognak.strength == 13
assert grognak.strength.bonus == 1
assert str_bonus in grognak.modifiers["strength"]
# make sure bonuses are applied to checks and saves
strength = db.Skill.filter_by(name="strength").one()
assert grognak.check_modifier(strength) == 1
assert grognak.check_modifier(strength, save=True) == 1
assert grognak.strength == 10
assert str_plus_one in grognak.modifiers["strength"]
assert grognak.STR == 11
def test_modifiers(db, bootstrap):
def test_modifiers(db, classes_factory, ancestries_factory):
with db.transaction():
human = db.Ancestry.filter_by(name="human").one()
tiefling = db.Ancestry.filter_by(name="tiefling").one()
classes_factory()
ancestries = ancestries_factory()
# no modifiers; speed is ancestry speed
carl = schema.Character(name="Carl", ancestry=tiefling)
marx = schema.Character(name="Marx", ancestry=human)
carl = schema.Character(name="Carl", ancestry=ancestries["elf"])
marx = schema.Character(name="Marx", ancestry=ancestries["human"])
db.add_or_update([carl, marx])
assert carl.speed == carl.ancestry.speed == 30