from sqlalchemy import ForeignKey, 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.schema.classes import CharacterClass, ClassAttribute from ttfrog.db.schema.modifiers import Modifier, ModifierMixin __all__ = [ "Ancestry", "AncestryTrait", "AncestryTraitMap", "CharacterClassMap", "CharacterClassAttributeMap", "Character", "Modifier", ] def class_map_creator(fields): if isinstance(fields, CharacterClassMap): return fields return CharacterClassMap(**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(unique=True, nullable=False) creature_type: Mapped[str] = mapped_column(nullable=False, default="humanoid") size: Mapped[str] = mapped_column(nullable=False, default="medium") walk_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 speed(self): return self.walk_speed @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(nullable=False) description: Mapped[Text] = mapped_column(Text, default="") def __repr__(self): return self.name 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 "{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, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsMixin): __tablename__ = "character" id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(default="New Character", nullable=False) armor_class: Mapped[int] = mapped_column(default=10, nullable=False, info={"min": 1, "max": 99}) hit_points: Mapped[int] = mapped_column(default=1, nullable=False, info={"min": 0, "max": 999}) max_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}) strength: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) dexterity: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) constitution: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) intelligence: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) wisdom: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) charisma: Mapped[int] = mapped_column(nullable=False, default=10, info={"min": 0, "max": 30}) _vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0}) 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) 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 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 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 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 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(self): return self.apply_modifiers("vision", self._vision) @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 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: self.class_list.append(CharacterClassMap(character=self, character_class=newclass, level=level)) 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]) def remove_class(self, target): self.class_map = [m for m in self.class_map if m.id != target.id] for mapping in self.character_class_attribute_map: if mapping.character_class.id == target.id: self.remove_class_attribute(mapping.class_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] 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=attribute, option=option) ) return True return False def apply_modifiers(self, target, initial): modifiers = list(reversed(self.modifiers.get(target, []))) if initial is None: return initial if isinstance(initial, int): 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] if new: return new[0].new_value return initial