diff --git a/src/ttfrog/db/base.py b/src/ttfrog/db/base.py index c15ced3..a3e9ebe 100644 --- a/src/ttfrog/db/base.py +++ b/src/ttfrog/db/base.py @@ -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(_BaseObject): +class BaseObject(MappedAsDataclass, DeclarativeBase): """ Allows for iterating over Model objects' column names and values """ diff --git a/src/ttfrog/db/bootstrap.py b/src/ttfrog/db/bootstrap.py index cd962f1..33c10e7 100644 --- a/src/ttfrog/db/bootstrap.py +++ b/src/ttfrog/db/bootstrap.py @@ -7,30 +7,30 @@ def bootstrap(): db.init() with db.transaction(): # ancestries - human = schema.Ancestry(name="human") - tiefling = schema.Ancestry(name="tiefling") - tiefling.add_modifier(schema.Modifier(name="Ability Score Increase", target="intelligence", relative_value=1)) - tiefling.add_modifier(schema.Modifier(name="Ability Score Increase", target="charisma", relative_value=2)) + 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)) darkvision = schema.AncestryTrait( - name="Darkvision", + "Darkvision", description=( "You can see in dim light within 60 feet of you as if it were bright light, and in darkness as if it " "were dim light. You can’t discern color in darkness, only shades of gray." ), ) - darkvision.add_modifier(schema.Modifier(name="Darkvision", target="vision_in_darkness", absolute_value=120)) + darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120)) tiefling.add_trait(darkvision) # classes - fighter = schema.CharacterClass(name="fighter", hit_dice="1d10", hit_dice_stat="CON") - rogue = schema.CharacterClass(name="rogue", hit_dice="1d8", hit_dice_stat="DEX") + fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON") + rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX") # characters - sabetha = schema.Character(name="Sabetha", ancestry=tiefling) + sabetha = schema.Character("Sabetha", ancestry=tiefling) sabetha.add_class(fighter, level=2) sabetha.add_class(rogue, level=3) - bob = schema.Character(name="Bob", ancestry=human) + bob = schema.Character("Bob", ancestry=human) # persist all the records we've created db.add_or_update([sabetha, bob]) diff --git a/src/ttfrog/db/manager.py b/src/ttfrog/db/manager.py index 00d8e54..1a3e507 100644 --- a/src/ttfrog/db/manager.py +++ b/src/ttfrog/db/manager.py @@ -6,8 +6,7 @@ from contextlib import contextmanager from functools import cached_property import transaction -from pyramid_sqlalchemy import Session, init_sqlalchemy -from pyramid_sqlalchemy import metadata as _metadata +from pyramid_sqlalchemy.meta import Session from sqlalchemy import create_engine import ttfrog.db.schema @@ -43,7 +42,7 @@ class SQLDatabaseManager: @cached_property def metadata(self): - return _metadata + return ttfrog.db.schema.BaseObject.metadata @cached_property def tables(self): @@ -77,7 +76,8 @@ class SQLDatabaseManager: return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10] def init(self): - init_sqlalchemy(self.engine) + self.session.configure(bind=self.engine, autoflush=False) + self.metadata.bind = self.engine self.metadata.create_all(self.engine) def dump(self, names: list = []): diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index ab7e7bf..0910364 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -1,8 +1,9 @@ -from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import ForeignKey, Text, UniqueConstraint from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Mapped, mapped_column, relationship -from ttfrog.db.base import BaseObject, CreatureTypesEnum, SavingThrowsMixin, SizesEnum, SkillsMixin, SlugMixin +from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin, SlugMixin +from ttfrog.db.schema.classes import CharacterClass, ClassAttribute from ttfrog.db.schema.modifiers import Modifier, ModifierMixin __all__ = [ @@ -31,11 +32,11 @@ def attr_map_creator(fields): class AncestryTraitMap(BaseObject): __tablename__ = "trait_map" __table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"),) - 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}) + 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}) class Ancestry(BaseObject, ModifierMixin): @@ -44,15 +45,20 @@ class Ancestry(BaseObject, ModifierMixin): """ __tablename__ = "ancestry" - 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") + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(unique=True, nullable=False) + + 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" + ) @property def traits(self): @@ -71,8 +77,12 @@ class Ancestry(BaseObject, ModifierMixin): return self._swim_speed or int(self.speed / 2) def add_trait(self, trait, level=1): - if trait not in self._traits: - self._traits.append(AncestryTraitMap(ancestry_id=self.id, trait=trait, level=level)) + 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) return True return False @@ -86,9 +96,9 @@ class AncestryTrait(BaseObject, ModifierMixin): """ __tablename__ = "ancestry_trait" - id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String, nullable=False) - description = Column(Text) + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(nullable=False) + description: Mapped[Text] = mapped_column(Text, default="") def __repr__(self): return self.name @@ -97,13 +107,14 @@ class AncestryTrait(BaseObject, ModifierMixin): class CharacterClassMap(BaseObject): __tablename__ = "class_map" __table_args__ = (UniqueConstraint("character_id", "character_class_id"),) - 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) + 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") - character_class = relationship("CharacterClass", lazy="immediate") - character = relationship("Character", uselist=False, viewonly=True) + 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) def __repr__(self): return "{self.character.name}, {self.character_class.name}, level {self.level}" @@ -112,12 +123,12 @@ class CharacterClassMap(BaseObject): class CharacterClassAttributeMap(BaseObject): __tablename__ = "character_class_attribute_map" __table_args__ = (UniqueConstraint("character_id", "class_attribute_id"),) - 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) + 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) - class_attribute = relationship("ClassAttribute", lazy="immediate") + class_attribute: Mapped["ClassAttribute"] = relationship(lazy="immediate") option = relationship("ClassAttributeOption", lazy="immediate") character_class = relationship( @@ -132,22 +143,23 @@ class CharacterClassAttributeMap(BaseObject): 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(default="New Character", nullable=False) + armor_class: Mapped[int] = mapped_column(default=10, nullable=False, info={"min": 1, "max": 99}) + hit_points: Mapped[int] = mapped_column(default=1, nullable=False, info={"min": 0, "max": 999}) + max_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}) + strength: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) + dexterity: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) + constitution: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) + intelligence: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) + wisdom: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) + charisma: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) - proficiencies = Column(String) + _vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0}) + + 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) @@ -155,8 +167,8 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM 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 = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default="1") - ancestry = relationship("Ancestry", uselist=False) + ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1") + ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None) @property def modifiers(self): @@ -255,7 +267,7 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM level_in_class = level_in_class[0] level_in_class.level = level else: - self.class_list.append(CharacterClassMap(character_id=self.id, character_class=newclass, level=level)) + self.class_list.append(CharacterClassMap(character=self, character_class=newclass, level=level)) for lvl in range(1, level + 1): if not newclass.attributes_by_level[lvl]: continue diff --git a/src/ttfrog/db/schema/classes.py b/src/ttfrog/db/schema/classes.py index 6c66ecc..b5c5a11 100644 --- a/src/ttfrog/db/schema/classes.py +++ b/src/ttfrog/db/schema/classes.py @@ -1,9 +1,9 @@ from collections import defaultdict -from sqlalchemy import Column, Enum, ForeignKey, Integer, String -from sqlalchemy.orm import relationship +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship -from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin, StatsEnum +from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin __all__ = [ "ClassAttributeMap", @@ -15,16 +15,16 @@ __all__ = [ class ClassAttributeMap(BaseObject): __tablename__ = "class_attribute_map" - 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) + 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) attribute = relationship("ClassAttribute", uselist=False, viewonly=True, lazy="immediate") class ClassAttribute(BaseObject): __tablename__ = "class_attribute" - id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String, nullable=False) + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(nullable=False) options = relationship("ClassAttributeOption", cascade="all,delete,delete-orphan", lazy="immediate") def __repr__(self): @@ -33,18 +33,18 @@ class ClassAttribute(BaseObject): class ClassAttributeOption(BaseObject): __tablename__ = "class_attribute_option" - id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String, nullable=False) - attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False) + 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) class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin): __tablename__ = "character_class" - 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) + 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="") + proficiencies: Mapped[str] = mapped_column(default="") attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate") @property diff --git a/src/ttfrog/db/schema/modifiers.py b/src/ttfrog/db/schema/modifiers.py index dc6af55..0ca62de 100644 --- a/src/ttfrog/db/schema/modifiers.py +++ b/src/ttfrog/db/schema/modifiers.py @@ -1,8 +1,8 @@ from collections import defaultdict -from sqlalchemy import Column, Float, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy import ForeignKey, UniqueConstraint from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Mapped, mapped_column, relationship from ttfrog.db.base import BaseObject @@ -14,12 +14,12 @@ class ModifierMap(BaseObject): __tablename__ = "modifier_map" __table_args__ = (UniqueConstraint("primary_table_name", "primary_table_id", "modifier_id"),) - id = Column(Integer, primary_key=True, autoincrement=True) - modifier_id = Column(Integer, ForeignKey("modifier.id"), nullable=False) - modifier = relationship("Modifier", uselist=False, lazy="immediate") + 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") - primary_table_name = Column(String, nullable=False) - primary_table_id = Column(Integer, nullable=False) + primary_table_name: Mapped[str] = mapped_column(nullable=False) + primary_table_id: Mapped[int] = mapped_column(nullable=False) class Modifier(BaseObject): @@ -31,14 +31,14 @@ class Modifier(BaseObject): """ __tablename__ = "modifier" - 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) + 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="") class ModifierMixin: @@ -56,14 +56,16 @@ class ModifierMixin: Example: >>> class Item(BaseObject, ModifierMixin): - id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String, nullable=False) + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(nullable=False) >>> dwarven_belt = Item(name="Dwarven Belt") >>> dwarven_belt.add_modifier(Modifier(name="STR+1", target="strength", relative_value=1)) >>> dwarven_belt.modifiers {'strength': [Modifier(id=1, target='strength', name='STR+1', relative_value=1 ... ]} """ + _modifiable_attributes = dict() + @declared_attr def modifier_map(cls): return relationship( @@ -104,7 +106,36 @@ class ModifierMixin: return True def remove_modifier(self, modifier): - if modifier.id not in [mod.modifier_id for mod in self.modifier_map]: + 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): + if not self._modifiable_attributes: + raise NotImplementedError( + f"You must define the '_modifiable_attributes' property on {self.__class__.__name__}." + ) + + 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) + + new = [mod for mod in modifiers if mod.new_value is not None] + if new: + return new[0].new_value + return initial + + def __getattr__(self, attr_name): + prop = self._modifiable_attributes.get(attr_name, None) + if not prop: + raise AttributeError(f"Attribute not found: {attr_name}") + return self.apply_modifiers(attr_name, getattr(self, prop))