Compare commits
2 Commits
09549bf68c
...
a8bb6de008
Author | SHA1 | Date | |
---|---|---|---|
|
a8bb6de008 | ||
|
d2bed7c859 |
|
@ -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 *
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}.")
|
||||||
|
|
|
@ -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)
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user