tabletop-frog/src/ttfrog/db/schema/character.py
2024-10-13 00:15:41 -07:00

753 lines
28 KiB
Python

import itertools
from collections import defaultdict
from dataclasses import dataclass
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject, SlugMixin
from ttfrog.db.schema.classes import CharacterClass, ClassFeature
from ttfrog.db.schema.constants import DamageType, Defenses, InventoryType
from ttfrog.db.schema.inventory import InventoryMixin
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.skill import Skill
__all__ = [
"Ancestry",
"AncestryTrait",
"AncestryTraitMap",
"CharacterClassMap",
"CharacterClassFeatureMap",
"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 condition_creator(fields):
if isinstance(fields, CharacterConditionMap):
return fields
return CharacterConditionMap(**fields)
def attr_map_creator(fields):
if isinstance(fields, CharacterClassFeatureMap):
return fields
return CharacterClassFeatureMap(**fields)
class SpellSlot(BaseObject):
__tablename__ = "spell_slot"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"))
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
character_class = relationship("CharacterClass", lazy="immediate")
spell_level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 9})
expended: Mapped[bool] = mapped_column(nullable=False, default=False)
class HitDie(BaseObject):
__tablename__ = "hit_die"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"))
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
character_class = relationship("CharacterClass", lazy="immediate")
spent: Mapped[bool] = mapped_column(nullable=False, default=False)
@property
def name(self):
return self.character_class.hit_die_name
@property
def stat(self):
return self.character_class.hit_die_stat_name
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(nullable=True, info={"min": 0, "max": 99}, default=None)
climb_speed: Mapped[int] = mapped_column(nullable=True, info={"min": 0, "max": 99}, default=None)
swim_speed: Mapped[int] = mapped_column(nullable=True, info={"min": 0, "max": 99}, default=None)
_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]
def add_trait(self, trait, level=1):
if 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
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="")
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_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=False)
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=False)
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
character_class: Mapped["CharacterClass"] = relationship(lazy="immediate", init=False, viewonly=True)
class CharacterClassFeatureMap(BaseObject):
__tablename__ = "character_class_feature_map"
__table_args__ = (UniqueConstraint("character_id", "class_feature_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_feature_id: Mapped[int] = mapped_column(ForeignKey("class_feature.id"), nullable=False)
option_id: Mapped[int] = mapped_column(ForeignKey("class_feature_option.id"), nullable=False)
class_feature: Mapped["ClassFeature"] = relationship(lazy="immediate")
option = relationship("ClassFeatureOption", lazy="immediate")
character_class = relationship(
"CharacterClass",
secondary="class_map",
primaryjoin="CharacterClassFeatureMap.character_id == CharacterClassMap.character_id",
secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id",
viewonly=True,
uselist=False,
)
class CharacterConditionMap(BaseObject):
__tablename__ = "character_condition_map"
__table_args__ = (UniqueConstraint("condition_id", "character_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id"))
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=True, default=None)
condition = relationship("Condition", lazy="immediate")
@dataclass
class InventoryMap(InventoryMixin):
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), unique=True)
@declared_attr
def character(cls) -> Mapped["Character"]:
return relationship("Character", default=None)
@property
def contents(self):
return self.inventory.contents
class CharacterItemInventory(BaseObject, InventoryMap):
__tablename__ = "character_item_inventory"
__item_class__ = "Item"
inventory_type: InventoryType = InventoryType.EQUIPMENT
class CharacterSpellInventory(BaseObject, InventoryMap):
__tablename__ = "character_spell_inventory"
__item_class__ = "Spell"
inventory_type: InventoryType = InventoryType.SPELL
@property
def all_contents(self):
yield from self.inventory.contents
for item in self.character.equipment.all_contents:
if item.prototype.inventory_type == InventoryType.SPELL:
yield from item.inventory.contents
@property
def available(self):
yield from [spell.prototype for spell in self.all_contents]
@property
def known(self):
yield from [spell.prototype for spell in self.inventory.contents]
@property
def prepared(self):
yield from [spell.prototype for spell in self.all_contents if spell.prepared]
def get_all(self, prototype):
return [mapping for mapping in self.all_contents if mapping.prototype == prototype]
def get(self, prototype):
return self.get_all(prototype)[0]
def learn(self, prototype):
return self.inventory.add(prototype)
def forget(self, spell):
return self.inventory.remove(spell)
def prepare(self, prototype):
spell = self.get(prototype)
if spell.prototype.level > 0 and not self.character.spell_slots_by_level[spell.prototype.level]:
return False
spell._prepared = True
return True
def unprepare(self, prototype):
spell = self.get(prototype)
if spell.prepared:
spell._prepared = False
return True
return False
def cast(self, prototype, level=0):
spell = self.get(prototype)
if not spell.prepared:
return False
if not level:
level = spell.prototype.level
# cantrips
if level == 0:
return True
# expend the spell slot
avail = self.character.spell_slots_available[level]
if not avail:
return False
avail[0].expended = True
return True
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}
)
_actions_per_turn: Mapped[int] = mapped_column(
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
)
_bonus_actions_per_turn: Mapped[int] = mapped_column(
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
)
_reactions_per_turn: Mapped[int] = mapped_column(
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
)
_attacks_per_action: Mapped[int] = mapped_column(
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
)
vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
exhaustion: Mapped[int] = mapped_column(nullable=False, default=0, info={"min": 0, "max": 5})
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)
_conditions = relationship(
"CharacterConditionMap", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate"
)
conditions = association_proxy("_conditions", "condition", creator=condition_creator)
character_class_feature_map = relationship("CharacterClassFeatureMap", cascade="all,delete,delete-orphan")
features = association_proxy("character_class_feature_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)
_equipment = relationship(
"CharacterItemInventory",
uselist=False,
cascade="all,delete,delete-orphan",
lazy="immediate",
back_populates="character",
)
spells = relationship(
"CharacterSpellInventory",
uselist=False,
cascade="all,delete,delete-orphan",
lazy="immediate",
back_populates="character",
)
_hit_dice = relationship("HitDie", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
_spell_slots = relationship("SpellSlot", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
@property
def equipment(self):
return self._equipment.inventory
@property
def spell_slots(self):
return list(itertools.chain(*[slot for lvl, slot in self.spell_slots_by_level.items()]))
@property
def spell_slots_by_level(self):
pool = defaultdict(list)
for slot in self._spell_slots:
pool[slot.spell_level].append(slot)
return pool
@property
def spell_slots_available(self):
available = defaultdict(list)
for slot in self._spell_slots:
if not slot.expended:
available[slot.spell_level].append(slot)
return available
@property
def hit_dice(self):
pool = defaultdict(list)
for die in self._hit_dice:
pool[die.character_class.name].append(die)
return pool
@property
def hit_dice_available(self):
return [die for die in self._hit_dice if die.spent is False]
@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 modifiers(self):
unified = defaultdict(list)
def merge_modifiers(object_list):
for obj in object_list:
for target, mods in obj.modifiers.items():
unified[target] += mods
merge_modifiers([self.ancestry])
merge_modifiers(self.traits)
merge_modifiers(self.conditions)
for item in self.equipped_items:
for target, mods in item.modifiers.items():
for mod in mods:
if mod.requires_attunement and not item.attuned:
continue
unified[target].append(mod)
merge_modifiers([super()])
return unified
@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 initiative(self):
return self._apply_modifiers("initiative", self.dexterity.bonus)
@property
def speed(self):
return self._apply_modifiers("speed", self._apply_modifiers("walking_speed", self.ancestry.speed))
@property
def climb_speed(self):
return self._apply_modifiers("climb_speed", self.ancestry.climb_speed or int(self.speed / 2))
@property
def swim_speed(self):
return self._apply_modifiers("swim_speed", self.ancestry.swim_speed or int(self.speed / 2))
@property
def fly_speed(self):
modified = self._apply_modifiers("fly_speed", self.ancestry.fly_speed or 0)
if self.ancestry.fly_speed is None and not modified:
return None
return self._apply_modifiers("speed", modified)
@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 spellcaster_level(self):
return max(slot.spell_level for slot in self.spell_slots)
@property
def class_features(self):
return dict([(mapping.class_feature.name, mapping.option) for mapping in self.character_class_feature_map])
@property
def equipped_items(self):
return [item for item in self.equipment.contents if item.equipped]
@property
def attuned_items(self):
return [item for item in self.equipment.contents if item.attuned]
def attune(self, mapping):
if mapping.attuned:
return False
if not mapping.item.requires_attunement:
return False
if len(self.attuned_items) >= 3:
return False
mapping.attuned = True
return True
def unattune(self, mapping):
if not mapping.attuned:
return False
mapping.attuned = False
return True
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 immune(self, damage_type: DamageType):
return self.defense(damage_type) == Defenses.immune
def resistant(self, damage_type: DamageType):
return self.defense(damage_type) == Defenses.resistant.value
def vulnerable(self, damage_type: DamageType):
return self.defense(damage_type) == Defenses.vulnerable
def absorbs(self, damage_type: DamageType):
return self.defense(damage_type) == Defenses.absorbs
def defense(self, damage_type: DamageType):
return self._apply_modifiers(damage_type, None)
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]
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 level_up(self, charclass, num_levels=1):
for _ in range(num_levels):
self._level_up_once(charclass)
return self.level_in_class(charclass)
def _level_up_once(self, charclass):
current = self.level_in_class(charclass)
if not current:
return False
current.level += 1
# add new features
for feature in charclass.features_at_level(current.level):
self.add_class_feature(charclass, feature, feature.options[0])
# add new spell slots
for slot in charclass.spell_slots_by_level[current.level]:
self._spell_slots.append(
SpellSlot(character_id=self.id, character_class_id=charclass.id, spell_level=slot.spell_level)
)
# add a new hit die
self._hit_dice.append(HitDie(character_id=self.id, character_class_id=charclass.id))
return current
def add_class(self, newclass):
if self.level_in_class(newclass):
return False
self.class_list.append(CharacterClassMap(character_id=self.id, character_class_id=newclass.id, level=0))
self._level_up_once(newclass)
for skill in newclass.skills[: newclass.starting_skills]:
self.add_skill(skill, proficient=True, character_class=newclass)
return True
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_feature_map:
if mapping.character_class == target:
self.remove_class_feature(mapping.class_feature)
for skill in target.skills:
self.remove_skill(skill, proficient=True, expert=False, character_class=target)
self._hit_dice = [die for die in self._hit_dice if die.character_class != target]
self._spell_slots = [slot for slot in self._spell_slots if slot.character_class != target]
def remove_class_feature(self, feature):
self.character_class_feature_map = [
m for m in self.character_class_feature_map if m.class_feature.id != feature.id
]
def has_class_feature(self, feature):
return feature in [m.class_feature for m in self.character_class_feature_map]
def add_class_feature(self, character_class, feature, option):
if self.has_class_feature(feature):
return False
mapping = self.level_in_class(character_class)
if not mapping:
return False
if feature not in character_class.features_at_level(mapping.level):
return False
self.features.append(
CharacterClassFeatureMap(
character_id=self.id,
class_feature_id=feature.id,
option_id=option.id,
class_feature=feature,
)
)
return True
def add_modifier(self, modifier):
if not super().add_modifier(modifier):
return False
if modifier.new_value != Defenses.immune:
return True
modified_condition = None
for cond in self.conditions:
if modifier.target == cond.name:
modified_condition = cond
break
if not modified_condition:
return True
return self.remove_condition(modified_condition)
def has_condition(self, condition):
return condition in self.conditions
def add_condition(self, condition):
if self.immune(condition.name):
return False
if self.has_condition(condition):
return False
self._conditions.append(CharacterConditionMap(condition_id=condition.id, character_id=self.id))
return True
def remove_condition(self, condition):
if not self.has_condition(condition):
return False
mappings = [mapping for mapping in self._conditions if mapping.condition_id != condition.id]
self._conditions = mappings
return True
def add_skill(self, skill, proficient=False, expert=False, character_class=None):
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 apply_healing(self, value: int):
self.hit_points = min(self.hit_points + value, self._max_hit_points)
def apply_damage(self, value: int, damage_type: DamageType):
total = value
if self.absorbs(damage_type):
return self.apply_healing(total)
if self.immune(damage_type):
return
if self.resistant(damage_type):
total = int(value / 2)
elif self.vulnerable(damage_type):
total = value * 2
if total <= self.temp_hit_points:
self.temp_hit_points -= total
return
self.hit_points = max(0, self.hit_points - (total - self.temp_hit_points))
self.temp_hit_points = 0
def spend_hit_die(self, die):
die.spent = True
def expend_sell_splot(self, slot):
slot.expended = 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)
self._equipment = CharacterItemInventory(character_id=self.id)
self.spells = CharacterSpellInventory(character_id=self.id)
session.add(self)