Compare commits
2 Commits
09549bf68c
...
a8bb6de008
Author | SHA1 | Date | |
---|---|---|---|
|
a8bb6de008 | ||
|
d2bed7c859 |
|
@ -2,4 +2,4 @@ from .character import *
|
|||
from .classes import *
|
||||
from .log 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.ext.associationproxy import association_proxy
|
||||
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.schema.classes import CharacterClass, ClassAttribute
|
||||
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
|
||||
from ttfrog.db.schema.property import Skill
|
||||
from ttfrog.db.schema.skill import Skill
|
||||
|
||||
__all__ = [
|
||||
"Ancestry",
|
||||
|
@ -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.
|
||||
|
|
|
@ -6,7 +6,7 @@ from sqlalchemy.ext.associationproxy import association_proxy
|
|||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject
|
||||
from ttfrog.db.schema.property import Skill
|
||||
from ttfrog.db.schema.skill import Skill
|
||||
|
||||
__all__ = [
|
||||
"ClassAttributeMap",
|
||||
|
@ -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
|
||||
|
|
|
@ -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}.")
|
||||
|
|
|
@ -14,4 +14,4 @@ class Skill(BaseObject):
|
|||
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)
|
||||
|
||||
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))
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue
Block a user