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)