Compare commits

..

2 Commits

Author SHA1 Message Date
evilchili
a8bb6de008 adding hit dice and defenses 2024-05-14 20:15:42 -07:00
evilchili
d2bed7c859 rename property module 2024-05-12 11:20:52 -07:00
7 changed files with 164 additions and 15 deletions

View File

@ -2,4 +2,4 @@ from .character import *
from .classes import * from .classes import *
from .log import * from .log import *
from .modifiers import * from .modifiers import *
from .property import * from .skill import *

View File

@ -1,3 +1,5 @@
from collections import defaultdict
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -5,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject, SlugMixin from ttfrog.db.base import BaseObject, SlugMixin
from ttfrog.db.schema.classes import CharacterClass, ClassAttribute from ttfrog.db.schema.classes import CharacterClass, ClassAttribute
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.property import Skill from ttfrog.db.schema.skill import Skill
__all__ = [ __all__ = [
"Ancestry", "Ancestry",
@ -36,6 +38,24 @@ def attr_map_creator(fields):
return CharacterClassAttributeMap(**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): class AncestryTraitMap(BaseObject):
__tablename__ = "trait_map" __tablename__ = "trait_map"
__table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"),) __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_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None) 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 @property
def proficiency_bonus(self): def proficiency_bonus(self):
return 1 + int(0.5 + self.level / 4) return 1 + int(0.5 + self.level / 4)
@ -281,6 +314,24 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
return None return None
return mapping[0] 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): def check_modifier(self, skill: Skill, save: bool = False):
# if the skill is not assigned, but we have modifiers, apply them to zero. # if the skill is not assigned, but we have modifiers, apply them to zero.
if skill not in self.skills: 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 the skill is a proficiency, apply the bonus to the initial value
if skill in self.skills: if skill in self.skills:
mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][-1] 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: if mapping.expert and not save:
initial += 2 * self.proficiency_bonus initial += 2 * self.proficiency_bonus
elif mapping.proficient: elif mapping.proficient:
@ -331,13 +381,19 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
for skill in newclass.skills[: newclass.starting_skills]: for skill in newclass.skills[: newclass.starting_skills]:
self.add_skill(skill, proficient=True, character_class=newclass) 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): 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: 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) self.remove_class_attribute(mapping.class_attribute)
for skill in target.skills: 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): def remove_class_attribute(self, attribute):
self.character_class_attribute_map = [ 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] self._skills = [m for m in self._skills if m not in to_delete]
return True 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): def __after_insert__(self, session):
""" """
Called by the session after_flush event listener to add default joins in other tables. Called by the session after_flush event listener to add default joins in other tables.

View File

@ -6,7 +6,7 @@ from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject from ttfrog.db.base import BaseObject
from ttfrog.db.schema.property import Skill from ttfrog.db.schema.skill import Skill
__all__ = [ __all__ = [
"ClassAttributeMap", "ClassAttributeMap",
@ -76,8 +76,8 @@ class CharacterClass(BaseObject):
__tablename__ = "character_class" __tablename__ = "character_class"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(index=True, unique=True) name: Mapped[str] = mapped_column(index=True, unique=True)
hit_dice: Mapped[str] = mapped_column(default="1d6") hit_die_name: Mapped[str] = mapped_column(default="1d6")
hit_dice_stat: Mapped[str] = mapped_column(default="") hit_die_stat_name: Mapped[str] = mapped_column(default="")
starting_skills: int = mapped_column(nullable=False, default=0) starting_skills: int = mapped_column(nullable=False, default=0)
attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate") attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate")
@ -88,7 +88,7 @@ class CharacterClass(BaseObject):
def add_skill(self, skill, expert=False): def add_skill(self, skill, expert=False):
if not self.skills or skill not in self.skills: if not self.skills or skill not in self.skills:
if not self.id: 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) mapping = ClassSkillMap(character_class_id=self.id, skill_id=skill.id, proficient=True, expert=expert)
self.skills.append(mapping) self.skills.append(mapping)
return True return True

View File

@ -198,7 +198,6 @@ class ModifierMixin:
return get_attr(self, attr_name.split(".")) return get_attr(self, attr_name.split("."))
def _apply_one_modifier(self, modifier, initial, modified): def _apply_one_modifier(self, modifier, initial, modified):
print(f"Trying to apply {modifier}")
if modifier.new_value is not None: if modifier.new_value is not None:
return modifier.new_value return modifier.new_value
elif modifier.absolute_value is not None: elif modifier.absolute_value is not None:
@ -275,4 +274,4 @@ class ModifierMixin:
self._get_modifiable_base(col.info.get("modifiable_base", col.name)), self._get_modifiable_base(col.info.get("modifiable_base", col.name)),
modifiable_class=col.info.get("modifiable_class", None), 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

@ -14,4 +14,4 @@ class Skill(BaseObject):
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
base_id: Mapped[int] = mapped_column(ForeignKey("skill.id"), nullable=True, default=None) base_id: Mapped[int] = mapped_column(ForeignKey("skill.id"), nullable=True, default=None)
parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False, lazy="immediate") parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False)

View File

@ -49,6 +49,15 @@ def bootstrap(db):
darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120)) darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision) 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 = schema.Ancestry("dragonborn")
dragonborn.add_trait(darkvision) dragonborn.add_trait(darkvision)
@ -70,7 +79,9 @@ def bootstrap(db):
fighting_style.add_option(name="Defense") fighting_style.add_option(name="Defense")
db.add_or_update(fighting_style) 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) db.add_or_update(fighter)
# add skills # add skills
@ -81,7 +92,7 @@ def bootstrap(db):
assert acrobatics in fighter.skills assert acrobatics in fighter.skills
assert athletics 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]) db.add_or_update([rogue, fighter])
# characters # characters

View File

@ -101,12 +101,19 @@ def test_manage_character(db, bootstrap):
db.add_or_update(char) db.add_or_update(char)
assert char.level == 8 assert char.level == 8
assert char.levels == {"fighter": 7, "rogue": 1} assert char.levels == {"fighter": 7, "rogue": 1}
assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 8
# remove a class # remove a class
char.remove_class(rogue) char.remove_class(rogue)
db.add_or_update(char) db.add_or_update(char)
assert char.levels == {"fighter": 7} assert char.levels == {"fighter": 7}
assert char.level == 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 # remove remaining class by setting level to zero
char.add_class(fighter, level=0) 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) carl.remove_skill(athletics, proficient=True, expert=False, character_class=None)
assert len([s for s in carl.skills if s == athletics]) == 1 assert len([s for s in carl.skills if s == athletics]) == 1
assert carl.check_modifier(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