adding hit dice and defenses

This commit is contained in:
evilchili 2024-05-14 20:15:42 -07:00
parent d2bed7c859
commit a8bb6de008
5 changed files with 160 additions and 11 deletions

View File

@ -1,3 +1,5 @@
from collections import defaultdict
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -36,6 +38,24 @@ def attr_map_creator(fields):
return CharacterClassAttributeMap(**fields)
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"),)
@ -205,6 +225,19 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
_hit_dice = relationship("HitDie", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
@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)
@ -281,6 +314,24 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
return None
return mapping[0]
def immune(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'immune'
def resistant(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'resistant'
def vulnerable(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'vulnerable'
def absorbs(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'absorbs'
def defense(self, damage_type: str, magical: bool = False):
attr_name = damage_type
if magical:
attr_name = f"magical_{attr_name}"
return self._apply_modifiers(f"defenses.{attr_name}", 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:
@ -302,7 +353,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
# 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:
@ -331,13 +381,19 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
for skill in newclass.skills[: newclass.starting_skills]:
self.add_skill(skill, proficient=True, character_class=newclass)
# add hit dice
existing = len(self.hit_dice[newclass.name])
for lvl in range(level - existing):
self._hit_dice.append(HitDie(character_id=self.id, character_class_id=newclass.id))
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:
if mapping.character_class == target:
self.remove_class_attribute(mapping.class_attribute)
for skill in target.skills:
self.remove_skill(skill, proficient=True, expert=False, character_class=target)
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.class_map = [m for m in self.class_map if m.character_class != target]
def remove_class_attribute(self, attribute):
self.character_class_attribute_map = [
@ -415,6 +471,34 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
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: str, magical=False):
total = value
if self.absorbs(damage_type, magical):
return self.apply_healing(total)
if self.immune(damage_type, magical):
return
if self.resistant(damage_type, magical):
total = int(value / 2)
elif self.vulnerable(damage_type, magical):
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
return
def spend_hit_die(self, die):
die.spent = True
def reset_hit_die(self, die):
die.spent = False
def __after_insert__(self, session):
"""
Called by the session after_flush event listener to add default joins in other tables.

View File

@ -76,8 +76,8 @@ class CharacterClass(BaseObject):
__tablename__ = "character_class"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(index=True, unique=True)
hit_dice: Mapped[str] = mapped_column(default="1d6")
hit_dice_stat: Mapped[str] = mapped_column(default="")
hit_die_name: Mapped[str] = mapped_column(default="1d6")
hit_die_stat_name: Mapped[str] = mapped_column(default="")
starting_skills: int = mapped_column(nullable=False, default=0)
attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate")
@ -88,7 +88,7 @@ class CharacterClass(BaseObject):
def add_skill(self, skill, expert=False):
if not self.skills or skill not in self.skills:
if not self.id:
raise Exception(f"Cannot add a skill before the class has been persisted.")
raise Exception("Cannot add a skill before the class has been persisted.")
mapping = ClassSkillMap(character_class_id=self.id, skill_id=skill.id, proficient=True, expert=expert)
self.skills.append(mapping)
return True

View File

@ -198,7 +198,6 @@ class ModifierMixin:
return get_attr(self, attr_name.split("."))
def _apply_one_modifier(self, modifier, initial, modified):
print(f"Trying to apply {modifier}")
if modifier.new_value is not None:
return modifier.new_value
elif modifier.absolute_value is not None:
@ -275,4 +274,4 @@ class ModifierMixin:
self._get_modifiable_base(col.info.get("modifiable_base", col.name)),
modifiable_class=col.info.get("modifiable_class", None),
)
raise AttributeError(f"No such attribute: {attr_name}.")
raise AttributeError(f"No such attribute on {self.__class__.__name__} object: {attr_name}.")

View File

@ -49,6 +49,15 @@ def bootstrap(db):
darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision)
# resistant to both magical and non-magical sources of fire
infernal_origin = schema.AncestryTrait("Infernal Origin")
infernal_origin.add_modifier(schema.Modifier("Infernal Origin", target="defenses.fire", new_value="resistant"))
infernal_origin.add_modifier(
schema.Modifier("Infernal Origin", target="defenses.magical_fire", new_value="resistant")
)
tiefling.add_trait(infernal_origin)
db.add_or_update(tiefling)
dragonborn = schema.Ancestry("dragonborn")
dragonborn.add_trait(darkvision)
@ -70,7 +79,9 @@ def bootstrap(db):
fighting_style.add_option(name="Defense")
db.add_or_update(fighting_style)
fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON", starting_skills=2)
fighter = schema.CharacterClass(
"fighter", hit_die_name="1d10", hit_die_stat_name="_constitution", starting_skills=2
)
db.add_or_update(fighter)
# add skills
@ -81,7 +92,7 @@ def bootstrap(db):
assert acrobatics in fighter.skills
assert athletics in fighter.skills
rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX")
rogue = schema.CharacterClass("rogue", hit_die_name="1d8", hit_die_stat_name="_dexterity")
db.add_or_update([rogue, fighter])
# characters

View File

@ -101,12 +101,19 @@ def test_manage_character(db, bootstrap):
db.add_or_update(char)
assert char.level == 8
assert char.levels == {"fighter": 7, "rogue": 1}
assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 8
# remove a class
char.remove_class(rogue)
db.add_or_update(char)
assert char.levels == {"fighter": 7}
assert char.level == 7
assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 7
# verify hit dice are added and removed correctly
assert len(char.hit_dice["fighter"]) == char.level_in_class(fighter).level == 7
assert char.hit_dice["fighter"][0].name == fighter.hit_die_name == "1d10"
assert char.hit_dice["fighter"][0].stat == fighter.hit_die_stat_name == "_constitution"
# remove remaining class by setting level to zero
char.add_class(fighter, level=0)
@ -266,3 +273,51 @@ def test_modifiers(db, bootstrap):
carl.remove_skill(athletics, proficient=True, expert=False, character_class=None)
assert len([s for s in carl.skills if s == athletics]) == 1
assert carl.check_modifier(athletics) == 1
def test_defenses(db, bootstrap):
with db.transaction():
tiefling = db.Ancestry.filter_by(name="tiefling").one()
carl = schema.Character(name="Carl", ancestry=tiefling)
assert carl.resistant("fire", magical=False)
assert carl.resistant("fire", magical=True)
carl.apply_damage(5, "fire", magical=True)
assert carl.hit_points == 8 # half damage
immunity = [
schema.Modifier("Fire Immunity", target="defenses.fire", new_value="immune"),
schema.Modifier("Fire Immunity", target="defenses.magical_fire", new_value="immune")
]
for i in immunity:
carl.add_modifier(i)
assert carl.immune("fire")
carl.apply_damage(5, "fire", magical=True)
carl.apply_damage(5, "fire", magical=False)
assert carl.hit_points == 8 # no damage
vulnerability = [
schema.Modifier("Fire Vulnerability", target="defenses.fire", new_value="vulnerable"),
schema.Modifier("Fire Vulnerability", target="defenses.magical_fire", new_value="vulnerable")
]
for i in vulnerability:
carl.add_modifier(i)
assert carl.vulnerable("fire")
assert not carl.immune("fire")
carl.apply_damage(2, "fire", magical=True)
assert carl.hit_points == 4 # double damage
absorbs = [
schema.Modifier("Absorbs Non-Magical Fire", target="defenses.fire", new_value="absorbs"),
]
carl.add_modifier(absorbs[0])
carl.apply_damage(20, "fire", magical=False)
assert carl.hit_points == carl._max_hit_points == 10
for i in immunity + vulnerability + absorbs:
carl.remove_modifier(i)
carl.apply_damage(5, "fire", magical=True)
assert carl.resistant("fire")
assert not carl.immune("fire")
assert not carl.vulnerable("fire")
assert not carl.absorbs("fire")
assert carl.hit_points == 8 # half damage