add support for skills

This commit is contained in:
evilchili
2024-05-06 00:13:52 -07:00
parent b574dacfa1
commit 9a2d28ae75
8 changed files with 278 additions and 113 deletions
-33
View File
@@ -54,36 +54,6 @@ class BaseObject(MappedAsDataclass, DeclarativeBase):
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):
"""
A serializable enum.
@@ -93,9 +63,6 @@ class EnumField(enum.Enum):
return self.value
SavingThrowsMixin = multivalue_string_factory("saving_throws")
SkillsMixin = multivalue_string_factory("skills")
STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
CREATURE_TYPES = [
"aberation",
+13 -1
View File
@@ -7,7 +7,7 @@ from functools import cached_property
import transaction
from pyramid_sqlalchemy.meta import Session
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
import ttfrog.db.schema
from ttfrog.path import database
@@ -92,3 +92,15 @@ class 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)
+127 -33
View File
@@ -1,10 +1,11 @@
from sqlalchemy import ForeignKey, Text, UniqueConstraint
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin, SlugMixin
from ttfrog.db.base import BaseObject, SlugMixin
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__ = [
"Ancestry",
@@ -23,6 +24,12 @@ def class_map_creator(fields):
return CharacterClassMap(**fields)
def skill_creator(fields):
if isinstance(fields, CharacterSkillMap):
return fields
return CharacterSkillMap(**fields)
def attr_map_creator(fields):
if isinstance(fields, CharacterClassAttributeMap):
return fields
@@ -46,7 +53,7 @@ class Ancestry(BaseObject, ModifierMixin):
__tablename__ = "ancestry"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
creature_type: Mapped[str] = mapped_column(nullable=False, default="humanoid")
size: Mapped[str] = mapped_column(nullable=False, default="medium")
@@ -97,13 +104,26 @@ class AncestryTrait(BaseObject, ModifierMixin):
__tablename__ = "ancestry_trait"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[Text] = mapped_column(Text, default="")
def __repr__(self):
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):
__tablename__ = "class_map"
__table_args__ = (UniqueConstraint("character_id", "character_class_id"),)
@@ -141,12 +161,12 @@ class CharacterClassAttributeMap(BaseObject):
)
class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierMixin):
class Character(BaseObject, SlugMixin, ModifierMixin):
__tablename__ = "character"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(default="New Character", nullable=False)
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})
@@ -177,17 +197,27 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
_vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
_proficiencies: Mapped[str] = mapped_column(nullable=False, default="")
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
class_list = association_proxy("class_map", "id", creator=class_map_creator)
_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")
attribute_list = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator)
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
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
def modifiers(self):
unified = {}
@@ -197,6 +227,10 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
unified.update(**super().modifiers)
return unified
@property
def check_modifiers(self):
return [self.check_modifier(skill) for skill in self.skills]
@property
def classes(self):
return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map])
@@ -241,46 +275,106 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
def class_attributes(self):
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):
if level == 0:
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:
level_in_class = level_in_class[0]
level_in_class.level = level
else:
# add the class mapping and/or set the character's level in the class
mapping = self.level_in_class(newclass)
if not mapping:
self.class_list.append(CharacterClassMap(character=self, character_class=newclass, level=level))
else:
mapping.level = level
# add class attributes with default values
for lvl in range(1, level + 1):
if not newclass.attributes_by_level[lvl]:
continue
for attr_name, attr in newclass.attributes_by_level[lvl].items():
self.add_class_attribute(attr, attr.options[0])
for attr in newclass.attributes_at_level(lvl):
self.add_class_attribute(newclass, 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):
self.class_map = [m for m in self.class_map if m.character_class != target]
for mapping in self.character_class_attribute_map:
if mapping.character_class.id == target.id:
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):
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):
for thisclass in self.classes.values():
current_level = self.levels[thisclass.name]
current_attributes = thisclass.attributes_by_level.get(current_level, {})
if attribute.name in current_attributes:
if attribute.name in self.class_attributes:
return True
self.attribute_list.append(
CharacterClassAttributeMap(
character_id=self.id,
class_attribute_id=attribute.id,
option_id=option.id,
class_attribute=attribute,
)
)
return True
def has_class_attribute(self, attribute):
return attribute in [m.class_attribute for m in self.character_class_attribute_map]
def add_class_attribute(self, character_class, attribute, option):
if self.has_class_attribute(attribute):
return False
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(
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 False
def remove_skill(self, skill, character_class=None):
self._skills = [
mapping
for mapping in self._skills
if mapping.skill_id != skill.id and mapping.character_class_id != character_class.id
]
def __after_insert__(self, session):
"""
Called by the session after_flush event listener to add default joins in other tables.
"""
for skill in session.query(Skill).filter(
Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
):
self.add_skill(skill, proficient=False, expert=False)
+50 -5
View File
@@ -1,18 +1,41 @@
import itertools
from collections import defaultdict
from sqlalchemy import ForeignKey
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin
from ttfrog.db.base import BaseObject
from ttfrog.db.schema.property import Skill
__all__ = [
"ClassAttributeMap",
"ClassAttribute",
"ClassAttributeOption",
"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):
__tablename__ = "class_attribute_map"
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), primary_key=True)
@@ -49,15 +72,28 @@ class ClassAttributeOption(BaseObject):
attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=True)
class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin):
class CharacterClass(BaseObject):
__tablename__ = "character_class"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(index=True, unique=True)
hit_dice: Mapped[str] = mapped_column(default="1d6")
hit_dice_stat: Mapped[str] = mapped_column(default="")
proficiencies: Mapped[str] = mapped_column(default="")
starting_skills: int = mapped_column(nullable=False, default=0)
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)
@@ -72,5 +108,14 @@ class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin):
def attributes_by_level(self):
by_level = defaultdict(list)
for mapping in self.attributes:
by_level[mapping.level] = {mapping.attribute.name: mapping.attribute}
by_level[mapping.level].append(mapping.attribute)
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 -1
View File
@@ -256,4 +256,4 @@ class ModifierMixin:
self._get_modifiable_base(col.info.get("modifiable_base", col.name)),
modifiable_class=col.info.get("modifiable_class", None),
)
return super().__getattr__(attr_name)
raise AttributeError(f"No such attribute: {attr_name}.")
+6 -16
View File
@@ -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
__all__ = [
"Skill",
"Proficiency",
]
class Skill(BaseObject):
__tablename__ = "skill"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
description = Column(Text)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
base_id: Mapped[int] = mapped_column(ForeignKey("skill.id"), nullable=True, default=None)
def __repr__(self):
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)
parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False, lazy="immediate")