Compare commits
4 Commits
1ff0e5ca7d
...
9a2d28ae75
Author | SHA1 | Date | |
---|---|---|---|
|
9a2d28ae75 | ||
|
b574dacfa1 | ||
|
3292b11d89 | ||
|
3980be5f07 |
|
@ -2,9 +2,9 @@ import enum
|
||||||
|
|
||||||
import nanoid
|
import nanoid
|
||||||
from nanoid_dictionary import human_alphabet
|
from nanoid_dictionary import human_alphabet
|
||||||
from pyramid_sqlalchemy import BaseObject as _BaseObject
|
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
from sqlalchemy import Column, String
|
from sqlalchemy import Column, String
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
|
||||||
|
|
||||||
|
|
||||||
def genslug():
|
def genslug():
|
||||||
|
@ -19,7 +19,7 @@ class SlugMixin:
|
||||||
return "-".join([self.slug, slugify(self.name.title().replace(" ", ""), ok="", only_ascii=True, lower=False)])
|
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
|
Allows for iterating over Model objects' column names and values
|
||||||
"""
|
"""
|
||||||
|
@ -54,36 +54,6 @@ class BaseObject(_BaseObject):
|
||||||
return str(dict(self))
|
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):
|
class EnumField(enum.Enum):
|
||||||
"""
|
"""
|
||||||
A serializable enum.
|
A serializable enum.
|
||||||
|
@ -93,9 +63,6 @@ class EnumField(enum.Enum):
|
||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
SavingThrowsMixin = multivalue_string_factory("saving_throws")
|
|
||||||
SkillsMixin = multivalue_string_factory("skills")
|
|
||||||
|
|
||||||
STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
|
STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
|
||||||
CREATURE_TYPES = [
|
CREATURE_TYPES = [
|
||||||
"aberation",
|
"aberation",
|
||||||
|
|
|
@ -7,30 +7,30 @@ def bootstrap():
|
||||||
db.init()
|
db.init()
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
# ancestries
|
# ancestries
|
||||||
human = schema.Ancestry(name="human")
|
human = schema.Ancestry("human")
|
||||||
tiefling = schema.Ancestry(name="tiefling")
|
tiefling = schema.Ancestry("tiefling")
|
||||||
tiefling.add_modifier(schema.Modifier(name="Ability Score Increase", target="intelligence", relative_value=1))
|
tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="intelligence", relative_value=1))
|
||||||
tiefling.add_modifier(schema.Modifier(name="Ability Score Increase", target="charisma", relative_value=2))
|
tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="charisma", relative_value=2))
|
||||||
darkvision = schema.AncestryTrait(
|
darkvision = schema.AncestryTrait(
|
||||||
name="Darkvision",
|
"Darkvision",
|
||||||
description=(
|
description=(
|
||||||
"You can see in dim light within 60 feet of you as if it were bright light, and in darkness as if it "
|
"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."
|
"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)
|
tiefling.add_trait(darkvision)
|
||||||
|
|
||||||
# classes
|
# classes
|
||||||
fighter = schema.CharacterClass(name="fighter", hit_dice="1d10", hit_dice_stat="CON")
|
fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON")
|
||||||
rogue = schema.CharacterClass(name="rogue", hit_dice="1d8", hit_dice_stat="DEX")
|
rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX")
|
||||||
|
|
||||||
# characters
|
# characters
|
||||||
sabetha = schema.Character(name="Sabetha", ancestry=tiefling)
|
sabetha = schema.Character("Sabetha", ancestry=tiefling, _intelligence=14)
|
||||||
sabetha.add_class(fighter, level=2)
|
sabetha.add_class(fighter, level=2)
|
||||||
sabetha.add_class(rogue, level=3)
|
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
|
# persist all the records we've created
|
||||||
db.add_or_update([sabetha, bob])
|
db.add_or_update([sabetha, bob])
|
||||||
|
|
|
@ -6,9 +6,8 @@ from contextlib import contextmanager
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
import transaction
|
import transaction
|
||||||
from pyramid_sqlalchemy import Session, init_sqlalchemy
|
from pyramid_sqlalchemy.meta import Session
|
||||||
from pyramid_sqlalchemy import metadata as _metadata
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy import create_engine
|
|
||||||
|
|
||||||
import ttfrog.db.schema
|
import ttfrog.db.schema
|
||||||
from ttfrog.path import database
|
from ttfrog.path import database
|
||||||
|
@ -43,7 +42,7 @@ class SQLDatabaseManager:
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def metadata(self):
|
def metadata(self):
|
||||||
return _metadata
|
return ttfrog.db.schema.BaseObject.metadata
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def tables(self):
|
def tables(self):
|
||||||
|
@ -77,7 +76,8 @@ class SQLDatabaseManager:
|
||||||
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
|
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
|
||||||
|
|
||||||
def init(self):
|
def init(self):
|
||||||
init_sqlalchemy(self.engine)
|
self.session.configure(bind=self.engine)
|
||||||
|
self.metadata.bind = self.engine
|
||||||
self.metadata.create_all(self.engine)
|
self.metadata.create_all(self.engine)
|
||||||
|
|
||||||
def dump(self, names: list = []):
|
def dump(self, names: list = []):
|
||||||
|
@ -92,3 +92,15 @@ class SQLDatabaseManager:
|
||||||
|
|
||||||
|
|
||||||
db = 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)
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Text, UniqueConstraint
|
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.ext.associationproxy import association_proxy
|
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, SlugMixin
|
||||||
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin
|
from ttfrog.db.schema.classes import CharacterClass, ClassAttribute
|
||||||
|
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
|
||||||
|
from ttfrog.db.schema.property import Skill
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Ancestry",
|
"Ancestry",
|
||||||
|
@ -22,6 +24,12 @@ def class_map_creator(fields):
|
||||||
return CharacterClassMap(**fields)
|
return CharacterClassMap(**fields)
|
||||||
|
|
||||||
|
|
||||||
|
def skill_creator(fields):
|
||||||
|
if isinstance(fields, CharacterSkillMap):
|
||||||
|
return fields
|
||||||
|
return CharacterSkillMap(**fields)
|
||||||
|
|
||||||
|
|
||||||
def attr_map_creator(fields):
|
def attr_map_creator(fields):
|
||||||
if isinstance(fields, CharacterClassAttributeMap):
|
if isinstance(fields, CharacterClassAttributeMap):
|
||||||
return fields
|
return fields
|
||||||
|
@ -31,11 +39,11 @@ def attr_map_creator(fields):
|
||||||
class AncestryTraitMap(BaseObject):
|
class AncestryTraitMap(BaseObject):
|
||||||
__tablename__ = "trait_map"
|
__tablename__ = "trait_map"
|
||||||
__table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"),)
|
__table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"),)
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
ancestry_id = Column(Integer, ForeignKey("ancestry.id"))
|
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"))
|
||||||
ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id"))
|
ancestry_trait_id: Mapped[int] = mapped_column(ForeignKey("ancestry_trait.id"), init=False)
|
||||||
trait = relationship("AncestryTrait", uselist=False, lazy="immediate")
|
trait: Mapped["AncestryTrait"] = relationship(uselist=False, lazy="immediate")
|
||||||
level = Column(Integer, nullable=False, info={"min": 1, "max": 20})
|
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20})
|
||||||
|
|
||||||
|
|
||||||
class Ancestry(BaseObject, ModifierMixin):
|
class Ancestry(BaseObject, ModifierMixin):
|
||||||
|
@ -44,15 +52,20 @@ class Ancestry(BaseObject, ModifierMixin):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "ancestry"
|
__tablename__ = "ancestry"
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
name = Column(String, index=True, unique=True)
|
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
|
||||||
creature_type = Column(Enum(CreatureTypesEnum), nullable=False, default="humanoid")
|
|
||||||
size = Column(Enum(SizesEnum), nullable=False, default="Medium")
|
creature_type: Mapped[str] = mapped_column(nullable=False, default="humanoid")
|
||||||
walk_speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99})
|
size: Mapped[str] = mapped_column(nullable=False, default="medium")
|
||||||
_fly_speed = Column(Integer, info={"min": 0, "max": 99})
|
walk_speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99})
|
||||||
_climb_speed = Column(Integer, info={"min": 0, "max": 99})
|
|
||||||
_swim_speed = Column(Integer, info={"min": 0, "max": 99})
|
_fly_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
||||||
_traits = relationship("AncestryTraitMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
_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
|
@property
|
||||||
def traits(self):
|
def traits(self):
|
||||||
|
@ -71,8 +84,12 @@ class Ancestry(BaseObject, ModifierMixin):
|
||||||
return self._swim_speed or int(self.speed / 2)
|
return self._swim_speed or int(self.speed / 2)
|
||||||
|
|
||||||
def add_trait(self, trait, level=1):
|
def add_trait(self, trait, level=1):
|
||||||
if trait not in self._traits:
|
if not self._traits or trait not in self._traits:
|
||||||
self._traits.append(AncestryTraitMap(ancestry_id=self.id, trait=trait, level=level))
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -86,38 +103,52 @@ class AncestryTrait(BaseObject, ModifierMixin):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "ancestry_trait"
|
__tablename__ = "ancestry_trait"
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
name = Column(String, nullable=False)
|
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
|
||||||
description = Column(Text)
|
description: Mapped[Text] = mapped_column(Text, default="")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.name
|
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):
|
class CharacterClassMap(BaseObject):
|
||||||
__tablename__ = "class_map"
|
__tablename__ = "class_map"
|
||||||
__table_args__ = (UniqueConstraint("character_id", "character_class_id"),)
|
__table_args__ = (UniqueConstraint("character_id", "character_class_id"),)
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
character_id = Column(Integer, ForeignKey("character.id"), nullable=False)
|
character: Mapped["Character"] = relationship(uselist=False, viewonly=True)
|
||||||
character_class_id = Column(Integer, ForeignKey("character_class.id"), nullable=False)
|
character_class: Mapped["CharacterClass"] = relationship(lazy="immediate")
|
||||||
level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
|
|
||||||
|
|
||||||
character_class = relationship("CharacterClass", lazy="immediate")
|
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), init=False, nullable=False)
|
||||||
character = relationship("Character", uselist=False, viewonly=True)
|
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):
|
def __repr__(self):
|
||||||
return "{self.character.name}, {self.character_class.name}, level {self.level}"
|
return f"{self.character.name}, {self.character_class.name}, level {self.level}"
|
||||||
|
|
||||||
|
|
||||||
class CharacterClassAttributeMap(BaseObject):
|
class CharacterClassAttributeMap(BaseObject):
|
||||||
__tablename__ = "character_class_attribute_map"
|
__tablename__ = "character_class_attribute_map"
|
||||||
__table_args__ = (UniqueConstraint("character_id", "class_attribute_id"),)
|
__table_args__ = (UniqueConstraint("character_id", "class_attribute_id"),)
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
character_id = Column(Integer, ForeignKey("character.id"), nullable=False)
|
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=False)
|
||||||
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
|
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=False)
|
||||||
option_id = Column(Integer, ForeignKey("class_attribute_option.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")
|
option = relationship("ClassAttributeOption", lazy="immediate")
|
||||||
|
|
||||||
character_class = relationship(
|
character_class = relationship(
|
||||||
|
@ -130,33 +161,62 @@ class CharacterClassAttributeMap(BaseObject):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsMixin):
|
class Character(BaseObject, SlugMixin, ModifierMixin):
|
||||||
__tablename__ = "character"
|
__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})
|
|
||||||
|
|
||||||
_vision = Column(Integer, info={"min": 0})
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
proficiencies = Column(String)
|
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})
|
||||||
|
|
||||||
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
|
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
|
||||||
class_list = association_proxy("class_map", "id", creator=class_map_creator)
|
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")
|
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan")
|
||||||
attribute_list = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator)
|
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_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
|
||||||
ancestry = relationship("Ancestry", uselist=False)
|
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)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def modifiers(self):
|
def modifiers(self):
|
||||||
|
@ -167,6 +227,10 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM
|
||||||
unified.update(**super().modifiers)
|
unified.update(**super().modifiers)
|
||||||
return unified
|
return unified
|
||||||
|
|
||||||
|
@property
|
||||||
|
def check_modifiers(self):
|
||||||
|
return [self.check_modifier(skill) for skill in self.skills]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def classes(self):
|
def classes(self):
|
||||||
return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map])
|
return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map])
|
||||||
|
@ -175,61 +239,25 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM
|
||||||
def traits(self):
|
def traits(self):
|
||||||
return self.ancestry.traits
|
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
|
@property
|
||||||
def speed(self):
|
def speed(self):
|
||||||
return self.apply_modifiers("speed", self.ancestry.speed)
|
return self._apply_modifiers("speed", self.ancestry.speed)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def climb_speed(self):
|
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
|
@property
|
||||||
def swim_speed(self):
|
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
|
@property
|
||||||
def fly_speed(self):
|
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
|
@property
|
||||||
def size(self):
|
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
|
@property
|
||||||
def vision_in_darkness(self):
|
def vision_in_darkness(self):
|
||||||
|
@ -247,57 +275,106 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM
|
||||||
def class_attributes(self):
|
def class_attributes(self):
|
||||||
return dict([(mapping.class_attribute.name, mapping.option) for mapping in self.character_class_attribute_map])
|
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):
|
def add_class(self, newclass, level=1):
|
||||||
if level == 0:
|
if level == 0:
|
||||||
return self.remove_class(newclass)
|
return self.remove_class(newclass)
|
||||||
level_in_class = [mapping for mapping in self.class_map if mapping.character_class_id == newclass.id]
|
|
||||||
if level_in_class:
|
# add the class mapping and/or set the character's level in the class
|
||||||
level_in_class = level_in_class[0]
|
mapping = self.level_in_class(newclass)
|
||||||
level_in_class.level = level
|
if not mapping:
|
||||||
|
self.class_list.append(CharacterClassMap(character=self, character_class=newclass, level=level))
|
||||||
else:
|
else:
|
||||||
self.class_list.append(CharacterClassMap(character_id=self.id, character_class=newclass, level=level))
|
mapping.level = level
|
||||||
|
|
||||||
|
# add class attributes with default values
|
||||||
for lvl in range(1, level + 1):
|
for lvl in range(1, level + 1):
|
||||||
if not newclass.attributes_by_level[lvl]:
|
for attr in newclass.attributes_at_level(lvl):
|
||||||
continue
|
self.add_class_attribute(newclass, attr, attr.options[0])
|
||||||
for attr_name, attr in newclass.attributes_by_level[lvl].items():
|
|
||||||
self.add_class_attribute(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)
|
||||||
|
|
||||||
def remove_class(self, target):
|
def remove_class(self, target):
|
||||||
self.class_map = [m for m in self.class_map if m.id != target.id]
|
self.class_map = [m for m in self.class_map if m.character_class != target]
|
||||||
for mapping in self.character_class_attribute_map:
|
for mapping in self.character_class_attribute_map:
|
||||||
if mapping.character_class.id == target.id:
|
if mapping.character_class.id == target.id:
|
||||||
self.remove_class_attribute(mapping.class_attribute)
|
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):
|
def remove_class_attribute(self, attribute):
|
||||||
self.character_class_attribute_map = [m for m in self.character_class_attribute_map if m.id != attribute.id]
|
self.character_class_attribute_map = [
|
||||||
|
m for m in self.character_class_attribute_map if m.class_attribute.id != attribute.id
|
||||||
|
]
|
||||||
|
|
||||||
def add_class_attribute(self, attribute, option):
|
def has_class_attribute(self, attribute):
|
||||||
for thisclass in self.classes.values():
|
return attribute in [m.class_attribute for m in self.character_class_attribute_map]
|
||||||
current_level = self.levels[thisclass.name]
|
|
||||||
current_attributes = thisclass.attributes_by_level.get(current_level, {})
|
def add_class_attribute(self, character_class, attribute, option):
|
||||||
if attribute.name in current_attributes:
|
if self.has_class_attribute(attribute):
|
||||||
if attribute.name in self.class_attributes:
|
return False
|
||||||
return True
|
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
|
||||||
self.attribute_list.append(
|
self.attribute_list.append(
|
||||||
CharacterClassAttributeMap(character_id=self.id, class_attribute=attribute, option=option)
|
CharacterClassAttributeMap(
|
||||||
|
character_id=self.id,
|
||||||
|
class_attribute_id=attribute.id,
|
||||||
|
option_id=option.id,
|
||||||
|
class_attribute=attribute,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def apply_modifiers(self, target, initial):
|
def remove_skill(self, skill, character_class=None):
|
||||||
modifiers = list(reversed(self.modifiers.get(target, [])))
|
self._skills = [
|
||||||
if initial is None:
|
mapping
|
||||||
return initial
|
for mapping in self._skills
|
||||||
if isinstance(initial, int):
|
if mapping.skill_id != skill.id and mapping.character_class_id != character_class.id
|
||||||
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]
|
def __after_insert__(self, session):
|
||||||
if new:
|
"""
|
||||||
return new[0].new_value
|
Called by the session after_flush event listener to add default joins in other tables.
|
||||||
return initial
|
"""
|
||||||
|
for skill in session.query(Skill).filter(
|
||||||
|
Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
|
||||||
|
):
|
||||||
|
self.add_skill(skill, proficient=False, expert=False)
|
||||||
|
|
|
@ -1,55 +1,121 @@
|
||||||
|
import itertools
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
|
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin, StatsEnum
|
from ttfrog.db.base import BaseObject
|
||||||
|
from ttfrog.db.schema.property import Skill
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ClassAttributeMap",
|
"ClassAttributeMap",
|
||||||
"ClassAttribute",
|
"ClassAttribute",
|
||||||
"ClassAttributeOption",
|
"ClassAttributeOption",
|
||||||
"CharacterClass",
|
"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):
|
class ClassAttributeMap(BaseObject):
|
||||||
__tablename__ = "class_attribute_map"
|
__tablename__ = "class_attribute_map"
|
||||||
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), primary_key=True)
|
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), primary_key=True)
|
||||||
character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=True)
|
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), primary_key=True)
|
||||||
level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
|
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
|
||||||
attribute = relationship("ClassAttribute", uselist=False, viewonly=True, lazy="immediate")
|
attribute = relationship("ClassAttribute", uselist=False, viewonly=True, lazy="immediate")
|
||||||
|
|
||||||
|
|
||||||
class ClassAttribute(BaseObject):
|
class ClassAttribute(BaseObject):
|
||||||
__tablename__ = "class_attribute"
|
__tablename__ = "class_attribute"
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
name = Column(String, nullable=False)
|
name: Mapped[str] = mapped_column(nullable=False)
|
||||||
options = relationship("ClassAttributeOption", cascade="all,delete,delete-orphan", lazy="immediate")
|
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):
|
def __repr__(self):
|
||||||
return f"{self.id}: {self.name}"
|
return f"{self.id}: {self.name}"
|
||||||
|
|
||||||
|
|
||||||
class ClassAttributeOption(BaseObject):
|
class ClassAttributeOption(BaseObject):
|
||||||
__tablename__ = "class_attribute_option"
|
__tablename__ = "class_attribute_option"
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
name = Column(String, nullable=False)
|
name: Mapped[str] = mapped_column(nullable=False)
|
||||||
attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
|
attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin):
|
class CharacterClass(BaseObject):
|
||||||
__tablename__ = "character_class"
|
__tablename__ = "character_class"
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
name = Column(String, index=True, unique=True)
|
name: Mapped[str] = mapped_column(index=True, unique=True)
|
||||||
hit_dice = Column(String, default="1d6")
|
hit_dice: Mapped[str] = mapped_column(default="1d6")
|
||||||
hit_dice_stat = Column(Enum(StatsEnum))
|
hit_dice_stat: Mapped[str] = mapped_column(default="")
|
||||||
proficiencies = Column(String)
|
starting_skills: int = mapped_column(nullable=False, default=0)
|
||||||
|
|
||||||
attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
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
|
@property
|
||||||
def attributes_by_level(self):
|
def attributes_by_level(self):
|
||||||
by_level = defaultdict(list)
|
by_level = defaultdict(list)
|
||||||
for mapping in self.attributes:
|
for mapping in self.attributes:
|
||||||
by_level[mapping.level] = {mapping.attribute.name: mapping.attribute}
|
by_level[mapping.level].append(mapping.attribute)
|
||||||
return by_level
|
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]))
|
||||||
|
|
|
@ -1,12 +1,41 @@
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from typing import Any, Union
|
||||||
|
|
||||||
from sqlalchemy import Column, Float, ForeignKey, Integer, String, UniqueConstraint
|
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.ext.declarative import declared_attr
|
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
|
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):
|
class ModifierMap(BaseObject):
|
||||||
"""
|
"""
|
||||||
Creates a many-to-many between Modifier and any model inheriting from the ModifierMixin.
|
Creates a many-to-many between Modifier and any model inheriting from the ModifierMixin.
|
||||||
|
@ -14,12 +43,12 @@ class ModifierMap(BaseObject):
|
||||||
|
|
||||||
__tablename__ = "modifier_map"
|
__tablename__ = "modifier_map"
|
||||||
__table_args__ = (UniqueConstraint("primary_table_name", "primary_table_id", "modifier_id"),)
|
__table_args__ = (UniqueConstraint("primary_table_name", "primary_table_id", "modifier_id"),)
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
modifier_id = Column(Integer, ForeignKey("modifier.id"), nullable=False)
|
modifier_id: Mapped[int] = mapped_column(ForeignKey("modifier.id"), init=False)
|
||||||
modifier = relationship("Modifier", uselist=False, lazy="immediate")
|
modifier: Mapped["Modifier"] = relationship(uselist=False, lazy="immediate")
|
||||||
|
|
||||||
primary_table_name = Column(String, nullable=False)
|
primary_table_name: Mapped[str] = mapped_column(nullable=False)
|
||||||
primary_table_id = Column(Integer, nullable=False)
|
primary_table_id: Mapped[int] = mapped_column(nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Modifier(BaseObject):
|
class Modifier(BaseObject):
|
||||||
|
@ -31,14 +60,14 @@ class Modifier(BaseObject):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "modifier"
|
__tablename__ = "modifier"
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
target = Column(String, nullable=False)
|
name: Mapped[str] = mapped_column(nullable=False)
|
||||||
absolute_value = Column(Integer)
|
target: Mapped[str] = mapped_column(nullable=False)
|
||||||
relative_value = Column(Integer)
|
absolute_value: Mapped[int] = mapped_column(nullable=True, default=None)
|
||||||
multiply_value = Column(Float)
|
relative_value: Mapped[int] = mapped_column(nullable=True, default=None)
|
||||||
new_value = Column(String)
|
multiply_value: Mapped[float] = mapped_column(nullable=True, default=None)
|
||||||
name = Column(String, nullable=False)
|
new_value: Mapped[str] = mapped_column(nullable=True, default=None)
|
||||||
description = Column(String)
|
description: Mapped[str] = mapped_column(default="")
|
||||||
|
|
||||||
|
|
||||||
class ModifierMixin:
|
class ModifierMixin:
|
||||||
|
@ -56,8 +85,8 @@ class ModifierMixin:
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
>>> class Item(BaseObject, ModifierMixin):
|
>>> class Item(BaseObject, ModifierMixin):
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
name = Column(String, nullable=False)
|
name: Mapped[str] = mapped_column(nullable=False)
|
||||||
>>> dwarven_belt = Item(name="Dwarven Belt")
|
>>> dwarven_belt = Item(name="Dwarven Belt")
|
||||||
>>> dwarven_belt.add_modifier(Modifier(name="STR+1", target="strength", relative_value=1))
|
>>> dwarven_belt.add_modifier(Modifier(name="STR+1", target="strength", relative_value=1))
|
||||||
>>> dwarven_belt.modifiers
|
>>> dwarven_belt.modifiers
|
||||||
|
@ -66,6 +95,9 @@ class ModifierMixin:
|
||||||
|
|
||||||
@declared_attr
|
@declared_attr
|
||||||
def modifier_map(cls):
|
def modifier_map(cls):
|
||||||
|
"""
|
||||||
|
Create the join between the current model and the ModifierMap table.
|
||||||
|
"""
|
||||||
return relationship(
|
return relationship(
|
||||||
"ModifierMap",
|
"ModifierMap",
|
||||||
primaryjoin=(
|
primaryjoin=(
|
||||||
|
@ -83,12 +115,20 @@ class ModifierMixin:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def modifiers(self):
|
def modifiers(self):
|
||||||
|
"""
|
||||||
|
Return all modifiers for the current instance as a dict keyed on target attribute name.
|
||||||
|
"""
|
||||||
all_modifiers = defaultdict(list)
|
all_modifiers = defaultdict(list)
|
||||||
for mapping in self.modifier_map:
|
for mapping in self.modifier_map:
|
||||||
all_modifiers[mapping.modifier.target].append(mapping.modifier)
|
all_modifiers[mapping.modifier.target].append(mapping.modifier)
|
||||||
return all_modifiers
|
return all_modifiers
|
||||||
|
|
||||||
def add_modifier(self, modifier):
|
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:
|
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}.")
|
raise AttributeError(f"You must provide only one of absolute, relative, and multiple values {modifier}.")
|
||||||
|
|
||||||
|
@ -103,8 +143,117 @@ class ModifierMixin:
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def remove_modifier(self, modifier):
|
def remove_modifier(self, modifier: Modifier) -> bool:
|
||||||
if modifier.id not in [mod.modifier_id for mod in self.modifier_map]:
|
"""
|
||||||
|
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
|
return False
|
||||||
self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier]
|
self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier]
|
||||||
return True
|
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}.")
|
||||||
|
|
|
@ -1,27 +1,17 @@
|
||||||
from sqlalchemy import Column, Integer, String, Text
|
from sqlalchemy import ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from ttfrog.db.base import BaseObject
|
from ttfrog.db.base import BaseObject
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Skill",
|
"Skill",
|
||||||
"Proficiency",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class Skill(BaseObject):
|
class Skill(BaseObject):
|
||||||
__tablename__ = "skill"
|
__tablename__ = "skill"
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
name = Column(String, index=True, unique=True)
|
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
|
||||||
description = Column(Text)
|
base_id: Mapped[int] = mapped_column(ForeignKey("skill.id"), nullable=True, default=None)
|
||||||
|
|
||||||
def __repr__(self):
|
parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False, lazy="immediate")
|
||||||
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)
|
|
||||||
|
|
|
@ -31,20 +31,62 @@ def db(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def classes_factory(db):
|
def bootstrap(db):
|
||||||
load_fixture(db, "classes")
|
with db.transaction():
|
||||||
|
# ancestries
|
||||||
|
human = schema.Ancestry("human")
|
||||||
|
|
||||||
def factory():
|
tiefling = schema.Ancestry("tiefling")
|
||||||
return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all())
|
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))
|
||||||
|
|
||||||
return factory
|
# ancestry traits
|
||||||
|
darkvision = schema.AncestryTrait("Darkvision")
|
||||||
|
darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120))
|
||||||
|
tiefling.add_trait(darkvision)
|
||||||
|
|
||||||
|
dragonborn = schema.Ancestry("dragonborn")
|
||||||
|
dragonborn.add_trait(darkvision)
|
||||||
|
|
||||||
@pytest.fixture
|
db.add_or_update([human, dragonborn, tiefling])
|
||||||
def ancestries_factory(db):
|
|
||||||
load_fixture(db, "ancestry")
|
|
||||||
|
|
||||||
def factory():
|
# skills
|
||||||
return dict((rec.name, rec) for rec in db.session.query(schema.Ancestry).all())
|
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])
|
||||||
|
|
||||||
return factory
|
# 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])
|
||||||
|
|
|
@ -3,95 +3,130 @@ import json
|
||||||
from ttfrog.db import schema
|
from ttfrog.db import schema
|
||||||
|
|
||||||
|
|
||||||
def test_manage_character(db, classes_factory, ancestries_factory):
|
def test_manage_character(db, bootstrap):
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
# load the fixtures so they are bound to the current session
|
darkvision = db.AncestryTrait.filter_by(name="Darkvision").one()
|
||||||
classes = classes_factory()
|
human = db.Ancestry.filter_by(name="human").one()
|
||||||
ancestries = ancestries_factory()
|
|
||||||
darkvision = db.AncestryTrait.filter_by(name="Darkvision")[0]
|
|
||||||
|
|
||||||
# create a human character (the default)
|
# create a human character (the default)
|
||||||
char = schema.Character(name="Test Character")
|
char = schema.Character(name="Test Character", ancestry=human)
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
assert char.id == 1
|
assert char.id == 3
|
||||||
assert char.name == "Test Character"
|
assert char.name == "Test Character"
|
||||||
assert char.ancestry.name == "human"
|
assert char.ancestry.name == "human"
|
||||||
assert char.AC == 10
|
assert char.armor_class == 10
|
||||||
assert char.HP == 10
|
assert char.hit_points == 10
|
||||||
assert char.STR == 10
|
assert char.strength == 10
|
||||||
assert char.DEX == 10
|
assert char.dexterity == 10
|
||||||
assert char.CON == 10
|
assert char.constitution == 10
|
||||||
assert char.INT == 10
|
assert char.intelligence == 10
|
||||||
assert char.WIS == 10
|
assert char.wisdom == 10
|
||||||
assert char.CHA == 10
|
assert char.charisma == 10
|
||||||
assert darkvision not in char.traits
|
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
|
# switch ancestry to tiefling
|
||||||
char.ancestry = ancestries["tiefling"]
|
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
||||||
|
char.ancestry = tiefling
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
char = db.session.get(schema.Character, 1)
|
char = db.session.get(schema.Character, char.id)
|
||||||
|
assert char.ancestry_id == tiefling.id
|
||||||
assert char.ancestry.name == "tiefling"
|
assert char.ancestry.name == "tiefling"
|
||||||
assert darkvision in char.traits
|
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
|
# switch ancestry to dragonborn and assert darkvision persists
|
||||||
char.ancestry = ancestries["dragonborn"]
|
char.ancestry = db.Ancestry.filter_by(name="dragonborn").one()
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
assert darkvision in char.traits
|
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
|
# switch ancestry to human and assert darkvision is removed
|
||||||
char.ancestry = ancestries["human"]
|
char.ancestry = human
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
assert darkvision not in char.traits
|
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
|
# assign a class and level
|
||||||
char.add_class(classes["fighter"], level=1)
|
char.add_class(fighter, level=1)
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
assert char.levels == {"fighter": 1}
|
assert char.levels == {"fighter": 1}
|
||||||
assert char.level == 1
|
assert char.level == 1
|
||||||
assert char.class_attributes == {}
|
assert char.class_attributes == {}
|
||||||
|
|
||||||
# 'fighting style' is available, but not at this level
|
# 'fighting style' is available, but not at this level
|
||||||
fighter = classes["fighter"]
|
fighting_style = fighter.attribute("Fighting Style")
|
||||||
fighting_style = fighter.attributes_by_level[2]["Fighting Style"]
|
assert char.has_class_attribute(fighting_style) is False
|
||||||
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is False
|
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
assert char.class_attributes == {}
|
assert char.class_attributes == {}
|
||||||
|
|
||||||
# level up
|
# level up
|
||||||
char.add_class(classes["fighter"], level=2)
|
char.add_class(fighter, level=7)
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
assert char.levels == {"fighter": 2}
|
assert char.levels == {"fighter": 7}
|
||||||
assert char.level == 2
|
assert char.level == 7
|
||||||
|
|
||||||
# Assert the fighting style is added automatically and idempotent...ly?
|
# 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.class_attributes[fighting_style.name] == fighting_style.options[0]
|
||||||
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is True
|
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
|
||||||
|
assert char.has_class_attribute(fighting_style)
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
|
|
||||||
# classes
|
athletics = db.Skill.filter_by(name="athletics").one()
|
||||||
char.add_class(classes["rogue"], level=1)
|
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)
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
assert char.level == 3
|
assert char.level == 8
|
||||||
assert char.levels == {"fighter": 2, "rogue": 1}
|
assert char.levels == {"fighter": 7, "rogue": 1}
|
||||||
|
|
||||||
# remove a class
|
# remove a class
|
||||||
char.remove_class(classes["rogue"])
|
char.remove_class(rogue)
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
assert char.levels == {"fighter": 2}
|
assert char.levels == {"fighter": 7}
|
||||||
assert char.level == 2
|
assert char.level == 7
|
||||||
|
|
||||||
# remove remaining class by setting level to zero
|
# remove remaining class by setting level to zero
|
||||||
char.add_class(classes["fighter"], level=0)
|
char.add_class(fighter, level=0)
|
||||||
db.add_or_update(char)
|
db.add_or_update(char)
|
||||||
assert char.levels == {}
|
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
|
# ensure we're not persisting any orphan records in the map tables
|
||||||
dump = json.loads(db.dump())
|
dump = json.loads(db.dump())
|
||||||
assert dump["class_map"] == []
|
assert not [m for m in dump["character_class_attribute_map"] if m["character_id"] == char.id]
|
||||||
assert dump["character_class_attribute_map"] == []
|
assert not [m for m in dump["class_map"] if m["character_id"] == char.id]
|
||||||
|
|
||||||
|
|
||||||
def test_ancestries(db):
|
def test_ancestries(db, bootstrap):
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
# create the Pygmy Orc ancestry
|
# create the Pygmy Orc ancestry
|
||||||
porc = schema.Ancestry(
|
porc = schema.Ancestry(
|
||||||
|
@ -99,7 +134,6 @@ def test_ancestries(db):
|
||||||
size="Small",
|
size="Small",
|
||||||
walk_speed=25,
|
walk_speed=25,
|
||||||
)
|
)
|
||||||
db.add_or_update(porc)
|
|
||||||
assert porc.name == "Pygmy Orc"
|
assert porc.name == "Pygmy Orc"
|
||||||
assert porc.creature_type == "humanoid"
|
assert porc.creature_type == "humanoid"
|
||||||
assert porc.size == "Small"
|
assert porc.size == "Small"
|
||||||
|
@ -112,36 +146,43 @@ def test_ancestries(db):
|
||||||
db.add_or_update(porc)
|
db.add_or_update(porc)
|
||||||
assert endurance in porc.traits
|
assert endurance in porc.traits
|
||||||
|
|
||||||
# add a +1 STR modifier
|
# add a +3 STR modifier
|
||||||
str_plus_one = schema.Modifier(
|
str_bonus = schema.Modifier(
|
||||||
name="STR+1 (Pygmy Orc)",
|
name="STR+3 (Pygmy Orc)",
|
||||||
target="strength",
|
target="strength",
|
||||||
relative_value=1,
|
relative_value=3,
|
||||||
description="Your Strength score is increased by 1.",
|
description="Your Strength score is increased by 3.",
|
||||||
)
|
)
|
||||||
assert porc.add_modifier(str_plus_one) is True
|
assert porc.add_modifier(str_bonus) is True
|
||||||
assert porc.add_modifier(str_plus_one) is False # test idempotency
|
assert porc.add_modifier(str_bonus) is False # test idempotency
|
||||||
assert str_plus_one in porc.modifiers["strength"]
|
assert str_bonus in porc.modifiers["strength"]
|
||||||
|
|
||||||
# now create an orc character and assert it gets traits and modifiers
|
# now create an orc character and assert it gets traits and modifiers
|
||||||
grognak = schema.Character(name="Grognak the Mighty", ancestry=porc)
|
grognak = schema.Character(name="Grognak the Mighty", ancestry=porc)
|
||||||
db.add_or_update(grognak)
|
db.add_or_update(grognak)
|
||||||
|
|
||||||
assert endurance in grognak.traits
|
assert endurance in grognak.traits
|
||||||
|
|
||||||
# verify the strength bonus is applied
|
# verify the strength bonus is applied
|
||||||
assert grognak.strength == 10
|
assert grognak.strength.base == 10
|
||||||
assert str_plus_one in grognak.modifiers["strength"]
|
assert grognak.strength == 13
|
||||||
assert grognak.STR == 11
|
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
|
||||||
|
|
||||||
|
|
||||||
def test_modifiers(db, classes_factory, ancestries_factory):
|
def test_modifiers(db, bootstrap):
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
classes_factory()
|
human = db.Ancestry.filter_by(name="human").one()
|
||||||
ancestries = ancestries_factory()
|
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
||||||
|
|
||||||
# no modifiers; speed is ancestry speed
|
# no modifiers; speed is ancestry speed
|
||||||
carl = schema.Character(name="Carl", ancestry=ancestries["elf"])
|
carl = schema.Character(name="Carl", ancestry=tiefling)
|
||||||
marx = schema.Character(name="Marx", ancestry=ancestries["human"])
|
marx = schema.Character(name="Marx", ancestry=human)
|
||||||
db.add_or_update([carl, marx])
|
db.add_or_update([carl, marx])
|
||||||
assert carl.speed == carl.ancestry.speed == 30
|
assert carl.speed == carl.ancestry.speed == 30
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user