tabletop-frog/src/ttfrog/db/schema/character.py
2024-05-12 11:20:52 -07:00

426 lines
17 KiB
Python

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, SlugMixin
from ttfrog.db.schema.classes import CharacterClass, ClassAttribute
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.skill import Skill
__all__ = [
"Ancestry",
"AncestryTrait",
"AncestryTraitMap",
"CharacterClassMap",
"CharacterClassAttributeMap",
"Character",
"Modifier",
]
def class_map_creator(fields):
if isinstance(fields, CharacterClassMap):
return 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
return CharacterClassAttributeMap(**fields)
class AncestryTraitMap(BaseObject):
__tablename__ = "trait_map"
__table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"))
ancestry_trait_id: Mapped[int] = mapped_column(ForeignKey("ancestry_trait.id"), init=False)
trait: Mapped["AncestryTrait"] = relationship(uselist=False, lazy="immediate")
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20})
class Ancestry(BaseObject, ModifierMixin):
"""
A character ancestry ("race"), which has zero or more AncestryTraits and Modifiers.
"""
__tablename__ = "ancestry"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
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")
speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99})
_fly_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
_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
def traits(self):
return [mapping.trait for mapping in self._traits]
@property
def climb_speed(self):
return self._climb_speed or int(self.speed / 2)
@property
def swim_speed(self):
return self._swim_speed or int(self.speed / 2)
def add_trait(self, trait, level=1):
if not self._traits or trait not in self._traits:
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 False
def __repr__(self):
return self.name
class AncestryTrait(BaseObject, ModifierMixin):
"""
A trait granted to a character via its Ancestry.
"""
__tablename__ = "ancestry_trait"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
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"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character: Mapped["Character"] = relationship(uselist=False, viewonly=True)
character_class: Mapped["CharacterClass"] = relationship(lazy="immediate")
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), init=False, nullable=False)
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):
return f"{self.character.name}, {self.character_class.name}, level {self.level}"
class CharacterClassAttributeMap(BaseObject):
__tablename__ = "character_class_attribute_map"
__table_args__ = (UniqueConstraint("character_id", "class_attribute_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=False)
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=False)
option_id: Mapped[int] = mapped_column(ForeignKey("class_attribute_option.id"), nullable=False)
class_attribute: Mapped["ClassAttribute"] = relationship(lazy="immediate")
option = relationship("ClassAttributeOption", lazy="immediate")
character_class = relationship(
"CharacterClass",
secondary="class_map",
primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id",
secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id",
viewonly=True,
uselist=False,
)
class Character(BaseObject, SlugMixin, ModifierMixin):
__tablename__ = "character"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
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_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 expertise_bonus(self):
return 2 * self.proficiency_bonus
@property
def proficiencies(self):
unified = {}
unified.update(**self._proficiencies)
@property
def modifiers(self):
unified = {}
unified.update(**self.ancestry.modifiers)
for trait in self.traits:
unified.update(**trait.modifiers)
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])
@property
def traits(self):
return self.ancestry.traits
@property
def speed(self):
return self._apply_modifiers("speed", self.ancestry.speed)
@property
def climb_speed(self):
return self._apply_modifiers("climb_speed", self.ancestry._climb_speed)
@property
def swim_speed(self):
return self._apply_modifiers("swim_speed", self.ancestry._swim_speed)
@property
def fly_speed(self):
return self._apply_modifiers("fly_speed", self.ancestry._fly_speed)
@property
def size(self):
return self._apply_modifiers("size", self.ancestry.size)
@property
def vision_in_darkness(self):
return self.apply_modifiers("vision_in_darkness", self.vision if self.vision is not None else 0)
@property
def level(self):
return sum(mapping.level for mapping in self.class_map)
@property
def levels(self):
return dict([(mapping.character_class.name, mapping.level) for mapping in self.class_map])
@property
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 the skill is not assigned, but we have modifiers, apply them to zero.
if skill not in self.skills:
target = f"{skill.name.lower()}_{'save' if save else 'check'}"
if self.has_modifier(target):
modified = self._apply_modifiers(target, 0)
return modified
# if the skill is a stat, start with the bonus value
attr = skill.name.lower()
stat = getattr(self, attr, None)
initial = getattr(stat, "bonus", None)
# if the skill isn't a stat, try the parent.
if initial is None and skill.parent:
stat = getattr(self, skill.parent.name.lower(), None)
initial = getattr(stat, "bonus", initial)
# if the skill is a proficiency, apply the bonus to the initial value
if skill in self.skills:
mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][-1]
print(f"Found mapping: {mapping}")
if mapping.expert and not save:
initial += 2 * self.proficiency_bonus
elif mapping.proficient:
initial += self.proficiency_bonus
# return the initial value plus any modifiers.
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)
# 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):
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, proficient=True, expert=False, 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 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.id:
raise Exception("Cannot add a skill before the character has been persisted.")
skillmap = None
exists = False
if skill in self.skills:
for mapping in self._skills:
if mapping.skill_id != skill.id:
continue
if character_class is None and mapping.character_class_id:
continue
if (character_class is None and mapping.character_class_id is None) or (
mapping.character_class_id == character_class.id
):
skillmap = mapping
exists = True
break
if not skillmap:
skillmap = CharacterSkillMap(skill_id=skill.id, character_id=self.id)
skillmap.proficient = proficient
skillmap.expert = expert
if character_class:
skillmap.character_class_id = character_class.id
if not exists:
self._skills.append(skillmap)
return True
return False
def remove_skill(self, skill, proficient, expert, character_class):
to_delete = [
mapping
for mapping in self._skills
if (
mapping.skill_id == skill.id
and mapping.proficient == proficient
and mapping.expert == expert
and (
(mapping.character_class_id is None and character_class is None)
or (character_class and mapping.character_class_id == character_class.id)
)
)
]
if not to_delete:
return False
self._skills = [m for m in self._skills if m not in to_delete]
return True
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)