from collections import defaultdict from typing import Any, Union 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) stacks: Mapped[bool] = mapped_column(nullable=False, default=False) absolute_value: Mapped[int] = mapped_column(nullable=True, default=None) multiply_value: Mapped[float] = mapped_column(nullable=True, default=None) multiply_attribute: Mapped[str] = mapped_column(nullable=True, default=None) relative_value: Mapped[int] = mapped_column(nullable=True, default=None) relative_attribute: Mapped[str] = 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): """ Create the join between the current model and the ModifierMap table. """ 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): """ 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 has_modifier(self, name: str): return True if self.modifiers.get(name, None) else False 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}.") 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: 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 _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_one_modifier(self, modifier, initial, modified): if modifier.new_value is not None: return modifier.new_value elif modifier.absolute_value is not None: return modifier.absolute_value base_value = modified if modifier.stacks else initial if modifier.multiply_attribute is not None: return int(base_value * getattr(self, modifier.multiply_attribute) + 0.5) if modifier.multiply_value is not None: return int(base_value * modifier.multiply_value + 0.5) if modifier.relative_attribute is not None: return base_value + getattr(self, modifier.relative_attribute) if modifier.relative_value is not None: return base_value + modifier.relative_value raise Exception(f"Cannot apply modifier: {modifier = }") 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"] modifiers = self.modifiers.get(target, []) nonstacking = [m for m in modifiers if not m.stacks] if nonstacking: return modifiable_class(base=initial, modified=self._apply_one_modifier(nonstacking[-1], initial, initial)) modified = initial for modifier in modifiers: if modifier.stacks: modified = self._apply_one_modifier(modifier, initial, modified) return modifiable_class(base=initial, modified=modified) 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 on {self.__class__.__name__} object: {attr_name}.")