2024-04-21 21:30:24 -07:00
|
|
|
from collections import defaultdict
|
2024-05-04 13:15:54 -07:00
|
|
|
from typing import Any, Union
|
2024-04-21 21:30:24 -07:00
|
|
|
|
2024-04-28 14:30:47 -07:00
|
|
|
from sqlalchemy import ForeignKey, UniqueConstraint
|
2024-04-21 21:30:24 -07:00
|
|
|
from sqlalchemy.ext.declarative import declared_attr
|
2024-04-28 14:30:47 -07:00
|
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
2024-04-21 21:30:24 -07:00
|
|
|
|
|
|
|
from ttfrog.db.base import BaseObject
|
|
|
|
|
|
|
|
|
2024-04-29 01:09:58 -07:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
2024-04-21 21:30:24 -07:00
|
|
|
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"),)
|
2024-04-28 14:30:47 -07:00
|
|
|
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")
|
2024-04-21 21:30:24 -07:00
|
|
|
|
2024-04-28 14:30:47 -07:00
|
|
|
primary_table_name: Mapped[str] = mapped_column(nullable=False)
|
|
|
|
primary_table_id: Mapped[int] = mapped_column(nullable=False)
|
2024-04-23 00:15:13 -07:00
|
|
|
|
2024-04-21 21:30:24 -07:00
|
|
|
|
|
|
|
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"
|
2024-04-28 14:30:47 -07:00
|
|
|
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)
|
2024-05-08 01:40:19 -07:00
|
|
|
stacks: Mapped[bool] = mapped_column(nullable=False, default=False)
|
2024-04-28 14:30:47 -07:00
|
|
|
absolute_value: Mapped[int] = mapped_column(nullable=True, default=None)
|
|
|
|
multiply_value: Mapped[float] = mapped_column(nullable=True, default=None)
|
2024-05-08 01:40:19 -07:00
|
|
|
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)
|
2024-04-28 14:30:47 -07:00
|
|
|
new_value: Mapped[str] = mapped_column(nullable=True, default=None)
|
|
|
|
description: Mapped[str] = mapped_column(default="")
|
2024-04-21 21:30:24 -07:00
|
|
|
|
|
|
|
|
|
|
|
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):
|
2024-04-28 14:30:47 -07:00
|
|
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
|
|
|
name: Mapped[str] = mapped_column(nullable=False)
|
2024-04-21 21:30:24 -07:00
|
|
|
>>> 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):
|
2024-05-04 13:15:54 -07:00
|
|
|
"""
|
|
|
|
Create the join between the current model and the ModifierMap table.
|
|
|
|
"""
|
2024-04-21 21:30:24 -07:00
|
|
|
return relationship(
|
|
|
|
"ModifierMap",
|
2024-04-23 00:15:13 -07:00
|
|
|
primaryjoin=(
|
|
|
|
"and_("
|
|
|
|
f"foreign(ModifierMap.primary_table_name)=='{cls.__tablename__}', "
|
|
|
|
f"foreign(ModifierMap.primary_table_id)=={cls.__name__}.id"
|
|
|
|
")"
|
|
|
|
),
|
2024-04-21 21:30:24 -07:00
|
|
|
cascade="all,delete,delete-orphan",
|
2024-04-23 00:15:13 -07:00
|
|
|
overlaps="modifier_map,modifier_map",
|
2024-04-21 21:30:24 -07:00
|
|
|
single_parent=True,
|
|
|
|
uselist=True,
|
|
|
|
lazy="immediate",
|
|
|
|
)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def modifiers(self):
|
2024-05-04 13:15:54 -07:00
|
|
|
"""
|
|
|
|
Return all modifiers for the current instance as a dict keyed on target attribute name.
|
|
|
|
"""
|
2024-04-21 21:30:24 -07:00
|
|
|
all_modifiers = defaultdict(list)
|
|
|
|
for mapping in self.modifier_map:
|
|
|
|
all_modifiers[mapping.modifier.target].append(mapping.modifier)
|
|
|
|
return all_modifiers
|
|
|
|
|
2024-05-08 01:40:19 -07:00
|
|
|
def has_modifier(self, name: str):
|
|
|
|
return True if self.modifiers.get(name, None) else False
|
|
|
|
|
2024-05-04 13:15:54 -07:00
|
|
|
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.
|
|
|
|
"""
|
2024-04-21 21:30:24 -07:00
|
|
|
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}.")
|
2024-04-23 00:15:13 -07:00
|
|
|
|
2024-04-21 21:30:24 -07:00
|
|
|
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
|
|
|
|
|
2024-05-04 13:15:54 -07:00
|
|
|
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.
|
|
|
|
"""
|
2024-04-28 14:30:47 -07:00
|
|
|
if modifier not in self.modifiers[modifier.target]:
|
2024-04-21 21:30:24 -07:00
|
|
|
return False
|
|
|
|
self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier]
|
|
|
|
return True
|
2024-04-28 14:30:47 -07:00
|
|
|
|
2024-05-04 13:15:54 -07:00
|
|
|
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("."))
|
|
|
|
|
2024-05-08 01:40:19 -07:00
|
|
|
def _apply_one_modifier(self, modifier, initial, modified):
|
|
|
|
print(f"Trying to apply {modifier}")
|
|
|
|
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 = }")
|
|
|
|
|
2024-05-04 13:15:54 -07:00
|
|
|
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"]
|
2024-04-28 14:30:47 -07:00
|
|
|
|
2024-05-08 01:40:19 -07:00
|
|
|
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)
|
2024-04-29 01:09:58 -07:00
|
|
|
|
|
|
|
def __setattr__(self, attr_name, value):
|
2024-05-04 13:15:54 -07:00
|
|
|
"""
|
|
|
|
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}?")
|
2024-04-29 01:09:58 -07:00
|
|
|
return super().__setattr__(attr_name, value)
|
2024-04-28 14:30:47 -07:00
|
|
|
|
|
|
|
def __getattr__(self, attr_name):
|
2024-05-04 13:15:54 -07:00
|
|
|
"""
|
|
|
|
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:
|
2024-04-29 01:09:58 -07:00
|
|
|
return self._apply_modifiers(
|
2024-05-04 13:15:54 -07:00
|
|
|
attr_name,
|
|
|
|
self._get_modifiable_base(col.info.get("modifiable_base", col.name)),
|
|
|
|
modifiable_class=col.info.get("modifiable_class", None),
|
2024-04-29 01:09:58 -07:00
|
|
|
)
|
2024-05-06 00:13:52 -07:00
|
|
|
raise AttributeError(f"No such attribute: {attr_name}.")
|