from collections import defaultdict from sqlalchemy import ForeignKey, UniqueConstraint from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import Mapped, mapped_column, 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. """ __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") primary_table_name: Mapped[str] = mapped_column(nullable=False) primary_table_id: Mapped[int] = mapped_column(nullable=False) class Modifier(BaseObject): """ Modifiers modify the base value of an existing attribute on another table. Modifiers are applied by the Character class, but may be associated with any model via the ModifierMixIn model; refer to the Ancestry class for an example. """ __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="") class ModifierMixin: """ Add modifiers to an existing class. Attributes: modifier_map - get/set a list of Modifier records associated with the parent modifiers - read-only dict of lists of modifiers keyed on Modifier.target Methods: add_modifier - Add a Modifier association to the modifier_map remove_modifier - Remove a modifier association from the modifier_map Example: >>> class Item(BaseObject, ModifierMixin): 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 ... ]} """ @declared_attr def modifier_map(cls): return relationship( "ModifierMap", primaryjoin=( "and_(" f"foreign(ModifierMap.primary_table_name)=='{cls.__tablename__}', " f"foreign(ModifierMap.primary_table_id)=={cls.__name__}.id" ")" ), cascade="all,delete,delete-orphan", overlaps="modifier_map,modifier_map", single_parent=True, uselist=True, lazy="immediate", ) @property def modifiers(self): 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): 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}.") if [mod for mod in self.modifier_map if mod.modifier == modifier]: return False self.modifier_map.append( ModifierMap( primary_table_name=self.__tablename__, primary_table_id=self.id, modifier=modifier, ) ) return True def remove_modifier(self, modifier): 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"] # 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 modify_class(base=initial, modified=modified) 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}?") 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): return self._apply_modifiers( attr_name, getattr(self, col.name), modify_class=col.info.get("modify_class", None) ) return super().__getattr__(attr_name)