tabletop-frog/src/ttfrog/db/schema/character.py

753 lines
28 KiB
Python
Raw Normal View History

import itertools
2024-05-14 20:15:42 -07:00
from collections import defaultdict
2024-09-21 16:00:17 -07:00
from dataclasses import dataclass
2024-05-14 20:15:42 -07:00
2024-05-06 00:13:52 -07:00
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
2024-02-23 10:45:38 -08:00
from sqlalchemy.ext.associationproxy import association_proxy
2024-09-21 15:59:40 -07:00
from sqlalchemy.ext.declarative import declared_attr
2024-09-21 16:00:17 -07:00
from sqlalchemy.orm import Mapped, mapped_column, relationship
2024-02-18 19:30:41 -08:00
2024-05-06 00:13:52 -07:00
from ttfrog.db.base import BaseObject, SlugMixin
2024-07-05 17:45:27 -07:00
from ttfrog.db.schema.classes import CharacterClass, ClassFeature
2024-09-21 15:59:40 -07:00
from ttfrog.db.schema.constants import DamageType, Defenses, InventoryType
2024-09-21 16:00:17 -07:00
from ttfrog.db.schema.inventory import InventoryMixin
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
2024-05-12 11:20:52 -07:00
from ttfrog.db.schema.skill import Skill
2024-02-18 19:30:41 -08:00
__all__ = [
2024-03-26 00:53:21 -07:00
"Ancestry",
"AncestryTrait",
"AncestryTraitMap",
"CharacterClassMap",
2024-07-05 17:45:27 -07:00
"CharacterClassFeatureMap",
2024-03-26 00:53:21 -07:00
"Character",
2024-04-21 02:17:47 -07:00
"Modifier",
2024-02-18 19:30:41 -08:00
]
2024-02-23 10:45:38 -08:00
def class_map_creator(fields):
if isinstance(fields, CharacterClassMap):
return fields
return CharacterClassMap(**fields)
2024-03-24 16:56:13 -07:00
2024-05-06 00:13:52 -07:00
def skill_creator(fields):
if isinstance(fields, CharacterSkillMap):
return fields
return CharacterSkillMap(**fields)
2024-07-13 13:08:47 -07:00
2024-07-13 12:30:43 -07:00
def condition_creator(fields):
if isinstance(fields, CharacterConditionMap):
return fields
return CharacterConditionMap(**fields)
2024-05-06 00:13:52 -07:00
def attr_map_creator(fields):
2024-07-05 17:45:27 -07:00
if isinstance(fields, CharacterClassFeatureMap):
return fields
2024-07-05 17:45:27 -07:00
return CharacterClassFeatureMap(**fields)
2024-02-18 19:30:41 -08:00
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)
2024-05-14 20:15:42 -07:00
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
2024-02-18 19:30:41 -08:00
class AncestryTraitMap(BaseObject):
__tablename__ = "trait_map"
2024-04-20 20:35:24 -07:00
__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})
2024-02-18 19:30:41 -08:00
2024-04-21 21:30:24 -07:00
class Ancestry(BaseObject, ModifierMixin):
2024-02-18 19:30:41 -08:00
"""
2024-04-21 21:30:24 -07:00
A character ancestry ("race"), which has zero or more AncestryTraits and Modifiers.
2024-02-18 19:30:41 -08:00
"""
2024-03-26 00:53:21 -07:00
2024-02-18 19:30:41 -08:00
__tablename__ = "ancestry"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
2024-05-06 00:13:52 -07:00
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"
)
2024-02-18 19:30:41 -08:00
2024-04-20 23:27:47 -07:00
@property
def traits(self):
return [mapping.trait for mapping in self._traits]
def add_trait(self, trait, level=1):
2024-07-05 14:42:11 -07:00
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)
2024-04-20 23:27:47 -07:00
return True
return False
2024-02-18 19:30:41 -08:00
class AncestryTrait(BaseObject, ModifierMixin):
2024-02-18 19:30:41 -08:00
"""
A trait granted to a character via its Ancestry.
"""
2024-07-13 12:30:43 -07:00
2024-02-18 19:30:41 -08:00
__tablename__ = "ancestry_trait"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
2024-05-06 00:13:52 -07:00
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[Text] = mapped_column(Text, default="")
2024-02-18 19:30:41 -08:00
2024-05-06 00:13:52 -07:00
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):
2024-02-18 19:30:41 -08:00
__tablename__ = "class_map"
2024-04-20 20:35:24 -07:00
__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)
2024-03-24 16:56:13 -07:00
character_class: Mapped["CharacterClass"] = relationship(lazy="immediate", init=False, viewonly=True)
2024-02-18 19:30:41 -08:00
2024-07-05 17:45:27 -07:00
class CharacterClassFeatureMap(BaseObject):
2024-07-13 12:30:43 -07:00
__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)
2024-07-13 12:30:43 -07:00
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)
2024-07-13 12:30:43 -07:00
class_feature: Mapped["ClassFeature"] = relationship(lazy="immediate")
2024-07-05 17:45:27 -07:00
option = relationship("ClassFeatureOption", lazy="immediate")
2024-02-18 19:30:41 -08:00
2024-03-24 16:56:13 -07:00
character_class = relationship(
"CharacterClass",
secondary="class_map",
2024-07-05 17:45:27 -07:00
primaryjoin="CharacterClassFeatureMap.character_id == CharacterClassMap.character_id",
2024-03-24 16:56:13 -07:00
secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id",
2024-03-26 00:53:21 -07:00
viewonly=True,
2024-03-26 21:58:04 -07:00
uselist=False,
2024-03-24 16:56:13 -07:00
)
2024-07-13 13:08:47 -07:00
2024-07-13 12:30:43 -07:00
class CharacterConditionMap(BaseObject):
__tablename__ = "character_condition_map"
2024-07-13 13:08:47 -07:00
__table_args__ = (UniqueConstraint("condition_id", "character_id"),)
2024-07-13 12:30:43 -07:00
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")
2024-02-18 19:30:41 -08:00
2024-07-13 13:08:47 -07:00
2024-09-21 15:59:40 -07:00
@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):
2024-10-13 00:15:41 -07:00
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
2024-09-21 15:59:40 -07:00
2024-05-06 00:13:52 -07:00
class Character(BaseObject, SlugMixin, ModifierMixin):
2024-02-18 19:30:41 -08:00
__tablename__ = "character"
2024-04-29 01:09:58 -07:00
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
2024-05-06 00:13:52 -07:00
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, default="New Character")
2024-05-04 13:15:54 -07:00
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})
2024-04-29 01:09:58 -07:00
_max_hit_points: Mapped[int] = mapped_column(
2024-05-04 13:15:54 -07:00
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}
2024-04-29 01:09:58 -07:00
)
_strength: Mapped[int] = mapped_column(
2024-05-04 13:15:54 -07:00
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
2024-04-29 01:09:58 -07:00
)
_dexterity: Mapped[int] = mapped_column(
2024-05-04 13:15:54 -07:00
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
2024-04-29 01:09:58 -07:00
)
_constitution: Mapped[int] = mapped_column(
2024-05-04 13:15:54 -07:00
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
2024-04-29 01:09:58 -07:00
)
_intelligence: Mapped[int] = mapped_column(
2024-05-04 13:15:54 -07:00
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
2024-04-29 01:09:58 -07:00
)
_wisdom: Mapped[int] = mapped_column(
2024-05-04 13:15:54 -07:00
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
2024-04-29 01:09:58 -07:00
)
_charisma: Mapped[int] = mapped_column(
2024-05-04 13:15:54 -07:00
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
2024-04-29 01:09:58 -07:00
)
2024-07-13 13:08:47 -07:00
_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}
)
2024-07-28 13:55:19 -07:00
_attacks_per_action: Mapped[int] = mapped_column(
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
)
2024-07-13 12:30:43 -07:00
2024-07-05 14:42:11 -07:00
vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
2024-06-30 16:09:20 -07:00
exhaustion: Mapped[int] = mapped_column(nullable=False, default=0, info={"min": 0, "max": 5})
2024-03-26 00:53:21 -07:00
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
2024-04-14 11:37:34 -07:00
class_list = association_proxy("class_map", "id", creator=class_map_creator)
2024-02-23 10:45:38 -08:00
2024-05-06 00:13:52 -07:00
_skills = relationship("CharacterSkillMap", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
skills = association_proxy("_skills", "skill", creator=skill_creator)
2024-07-13 13:08:47 -07:00
_conditions = relationship(
"CharacterConditionMap", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate"
)
2024-07-13 12:30:43 -07:00
conditions = association_proxy("_conditions", "condition", creator=condition_creator)
character_class_feature_map = relationship("CharacterClassFeatureMap", cascade="all,delete,delete-orphan")
2024-07-28 13:55:19 -07:00
features = association_proxy("character_class_feature_map", "id", creator=attr_map_creator)
2024-02-18 19:30:41 -08:00
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
2024-03-24 16:56:13 -07:00
2024-09-21 15:59:40 -07:00
_equipment = relationship(
2024-09-21 16:00:17 -07:00
"CharacterItemInventory",
uselist=False,
cascade="all,delete,delete-orphan",
lazy="immediate",
back_populates="character",
2024-09-21 15:59:40 -07:00
)
spells = relationship(
2024-09-21 15:59:40 -07:00
"CharacterSpellInventory",
uselist=False,
cascade="all,delete,delete-orphan",
lazy="immediate",
back_populates="character",
2024-08-29 15:14:47 -07:00
)
2024-07-28 13:55:19 -07:00
2024-05-14 20:15:42 -07:00
_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")
2024-09-21 15:59:40 -07:00
@property
def equipment(self):
return self._equipment.inventory
2024-08-29 16:51:02 -07:00
@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
2024-05-14 20:15:42 -07:00
@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]
2024-05-06 00:13:52 -07:00
@property
def proficiency_bonus(self):
return 1 + int(0.5 + self.level / 4)
@property
def expertise_bonus(self):
return 2 * self.proficiency_bonus
2024-04-21 02:17:47 -07:00
@property
def modifiers(self):
2024-08-03 17:46:12 -07:00
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)
2024-09-21 15:59:40 -07:00
for item in self.equipped_items:
for target, mods in item.modifiers.items():
2024-08-03 17:46:12 -07:00
for mod in mods:
2024-09-21 15:59:40 -07:00
if mod.requires_attunement and not item.attuned:
2024-08-03 17:46:12 -07:00
continue
unified[target].append(mod)
merge_modifiers([super()])
2024-04-21 21:30:24 -07:00
return unified
2024-04-21 02:17:47 -07:00
2024-03-26 21:58:04 -07:00
@property
def classes(self):
return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map])
2024-03-24 16:56:13 -07:00
@property
def traits(self):
2024-04-20 23:27:47 -07:00
return self.ancestry.traits
2024-03-24 16:56:13 -07:00
2024-06-30 16:09:20 -07:00
@property
def initiative(self):
return self._apply_modifiers("initiative", self.dexterity.bonus)
2024-04-20 23:33:36 -07:00
@property
def speed(self):
2024-07-13 12:30:43 -07:00
return self._apply_modifiers("speed", self._apply_modifiers("walking_speed", self.ancestry.speed))
2024-04-21 02:17:47 -07:00
@property
def climb_speed(self):
2024-07-05 14:42:11 -07:00
return self._apply_modifiers("climb_speed", self.ancestry.climb_speed or int(self.speed / 2))
@property
def swim_speed(self):
2024-07-05 14:42:11 -07:00
return self._apply_modifiers("swim_speed", self.ancestry.swim_speed or int(self.speed / 2))
@property
def fly_speed(self):
2024-07-05 14:42:11 -07:00
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)
2024-04-21 02:17:47 -07:00
@property
def size(self):
2024-04-29 01:09:58 -07:00
return self._apply_modifiers("size", self.ancestry.size)
@property
def vision_in_darkness(self):
2024-06-30 16:09:20 -07:00
return self._apply_modifiers("vision_in_darkness", self.vision if self.vision is not None else 0)
2024-03-24 16:56:13 -07:00
@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)
2024-03-26 21:58:04 -07:00
@property
2024-07-13 12:30:43 -07:00
def class_features(self):
return dict([(mapping.class_feature.name, mapping.option) for mapping in self.character_class_feature_map])
2024-03-26 21:58:04 -07:00
2024-08-03 17:46:12 -07:00
@property
def equipped_items(self):
2024-09-21 15:59:40 -07:00
return [item for item in self.equipment.contents if item.equipped]
2024-08-03 17:46:12 -07:00
@property
def attuned_items(self):
2024-09-21 15:59:40 -07:00
return [item for item in self.equipment.contents if item.attuned]
2024-08-03 17:46:12 -07:00
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
2024-05-06 00:13:52 -07:00
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]
2024-06-30 16:09:20 -07:00
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
2024-05-14 20:15:42 -07:00
2024-06-30 16:09:20 -07:00
def vulnerable(self, damage_type: DamageType):
return self.defense(damage_type) == Defenses.vulnerable
2024-05-14 20:15:42 -07:00
2024-06-30 16:09:20 -07:00
def absorbs(self, damage_type: DamageType):
return self.defense(damage_type) == Defenses.absorbs
2024-05-14 20:15:42 -07:00
2024-06-30 16:09:20 -07:00
def defense(self, damage_type: DamageType):
return self._apply_modifiers(damage_type, None)
2024-05-14 20:15:42 -07:00
2024-05-06 00:13:52 -07:00
def check_modifier(self, skill: Skill, save: bool = False):
# if the skill is not assigned, but we have modifiers, apply them to zero.
2024-05-06 00:13:52 -07:00
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
2024-05-06 00:13:52 -07:00
# if the skill is a stat, start with the bonus value
attr = skill.name.lower()
2024-05-06 00:13:52 -07:00
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.
2024-05-06 00:13:52 -07:00
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)
2024-05-06 00:13:52 -07:00
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))
2024-05-06 00:13:52 -07:00
return current
2024-05-06 00:13:52 -07:00
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)
2024-05-06 00:13:52 -07:00
for skill in newclass.skills[: newclass.starting_skills]:
self.add_skill(skill, proficient=True, character_class=newclass)
return True
2024-05-14 20:15:42 -07:00
2024-03-24 16:56:13 -07:00
def remove_class(self, target):
2024-06-30 16:09:20 -07:00
self.class_map = [m for m in self.class_map if m.character_class != target]
2024-07-13 12:30:43 -07:00
for mapping in self.character_class_feature_map:
2024-05-14 20:15:42 -07:00
if mapping.character_class == target:
2024-07-13 12:30:43 -07:00
self.remove_class_feature(mapping.class_feature)
2024-05-06 00:13:52 -07:00
for skill in target.skills:
2024-05-14 20:15:42 -07:00
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]
2024-03-26 21:58:04 -07:00
2024-07-13 12:30:43 -07:00
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
2024-05-04 13:15:54 -07:00
]
2024-03-26 21:58:04 -07:00
2024-07-13 12:30:43 -07:00
def has_class_feature(self, feature):
return feature in [m.class_feature for m in self.character_class_feature_map]
2024-05-06 00:13:52 -07:00
2024-07-13 12:30:43 -07:00
def add_class_feature(self, character_class, feature, option):
if self.has_class_feature(feature):
2024-05-06 00:13:52 -07:00
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):
2024-05-06 00:13:52 -07:00
return False
2024-07-28 13:55:19 -07:00
self.features.append(
2024-07-05 17:45:27 -07:00
CharacterClassFeatureMap(
2024-05-06 00:13:52 -07:00
character_id=self.id,
2024-07-13 12:30:43 -07:00
class_feature_id=feature.id,
2024-05-06 00:13:52 -07:00
option_id=option.id,
2024-07-13 12:30:43 -07:00
class_feature=feature,
2024-05-06 00:13:52 -07:00
)
)
return True
2024-07-13 12:30:43 -07:00
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
2024-05-06 00:13:52 -07:00
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)
2024-05-06 00:13:52 -07:00
return True
2024-03-26 21:58:04 -07:00
return False
2024-05-06 00:13:52 -07:00
def remove_skill(self, skill, proficient, expert, character_class):
to_delete = [
2024-05-06 00:13:52 -07:00
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)
)
)
2024-05-06 00:13:52 -07:00
]
if not to_delete:
return False
self._skills = [m for m in self._skills if m not in to_delete]
return True
2024-05-06 00:13:52 -07:00
2024-05-14 20:15:42 -07:00
def apply_healing(self, value: int):
self.hit_points = min(self.hit_points + value, self._max_hit_points)
2024-06-30 16:09:20 -07:00
def apply_damage(self, value: int, damage_type: DamageType):
2024-05-14 20:15:42 -07:00
total = value
2024-06-30 16:09:20 -07:00
if self.absorbs(damage_type):
2024-05-14 20:15:42 -07:00
return self.apply_healing(total)
2024-06-30 16:09:20 -07:00
if self.immune(damage_type):
2024-05-14 20:15:42 -07:00
return
2024-06-30 16:09:20 -07:00
if self.resistant(damage_type):
2024-05-14 20:15:42 -07:00
total = int(value / 2)
2024-06-30 16:09:20 -07:00
elif self.vulnerable(damage_type):
2024-05-14 20:15:42 -07:00
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
2024-05-06 00:13:52 -07:00
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)
2024-07-28 13:55:19 -07:00
2024-09-21 15:59:40 -07:00
self._equipment = CharacterItemInventory(character_id=self.id)
self.spells = CharacterSpellInventory(character_id=self.id)
2024-07-28 13:55:19 -07:00
session.add(self)