add support for skills
This commit is contained in:
parent
b574dacfa1
commit
9a2d28ae75
|
@ -54,36 +54,6 @@ class BaseObject(MappedAsDataclass, DeclarativeBase):
|
|||
return str(dict(self))
|
||||
|
||||
|
||||
def multivalue_string_factory(name, column=Column(String), separator=";"):
|
||||
"""
|
||||
Generate a mixin class that adds a string column with getters and setters
|
||||
that convert list values to strings and back again. Equivalent to:
|
||||
|
||||
class MultiValueString:
|
||||
_name = column
|
||||
|
||||
@property
|
||||
def name_property(self):
|
||||
return self._name.split(';')
|
||||
|
||||
@name.setter
|
||||
def name(self, val):
|
||||
return ';'.join(val)
|
||||
"""
|
||||
attr = f"_{name}"
|
||||
prop = property(lambda self: getattr(self, attr).split(separator))
|
||||
setter = prop.setter(lambda self, val: setattr(self, attr, separator.join(val)))
|
||||
return type(
|
||||
"MultiValueString",
|
||||
(object,),
|
||||
{
|
||||
attr: column,
|
||||
f"{name}_property": prop,
|
||||
name: setter,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class EnumField(enum.Enum):
|
||||
"""
|
||||
A serializable enum.
|
||||
|
@ -93,9 +63,6 @@ class EnumField(enum.Enum):
|
|||
return self.value
|
||||
|
||||
|
||||
SavingThrowsMixin = multivalue_string_factory("saving_throws")
|
||||
SkillsMixin = multivalue_string_factory("skills")
|
||||
|
||||
STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
|
||||
CREATURE_TYPES = [
|
||||
"aberation",
|
||||
|
|
|
@ -7,7 +7,7 @@ from functools import cached_property
|
|||
|
||||
import transaction
|
||||
from pyramid_sqlalchemy.meta import Session
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
|
||||
import ttfrog.db.schema
|
||||
from ttfrog.path import database
|
||||
|
@ -92,3 +92,15 @@ class SQLDatabaseManager:
|
|||
|
||||
|
||||
db = SQLDatabaseManager()
|
||||
|
||||
|
||||
@event.listens_for(db.session, "after_flush")
|
||||
def session_after_flush(session, flush_context):
|
||||
"""
|
||||
Listen to flush events looking for newly-created objects. For each one, if the
|
||||
obj has a __after_insert__ method, call it.
|
||||
"""
|
||||
for obj in session.new:
|
||||
callback = getattr(obj, "__after_insert__", None)
|
||||
if callback:
|
||||
callback(session)
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
from sqlalchemy import ForeignKey, Text, UniqueConstraint
|
||||
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin, SlugMixin
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
"Ancestry",
|
||||
|
@ -23,6 +24,12 @@ def class_map_creator(fields):
|
|||
return CharacterClassMap(**fields)
|
||||
|
||||
|
||||
def skill_creator(fields):
|
||||
if isinstance(fields, CharacterSkillMap):
|
||||
return fields
|
||||
return CharacterSkillMap(**fields)
|
||||
|
||||
|
||||
def attr_map_creator(fields):
|
||||
if isinstance(fields, CharacterClassAttributeMap):
|
||||
return fields
|
||||
|
@ -46,7 +53,7 @@ class Ancestry(BaseObject, ModifierMixin):
|
|||
|
||||
__tablename__ = "ancestry"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(unique=True, nullable=False)
|
||||
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")
|
||||
|
@ -97,13 +104,26 @@ class AncestryTrait(BaseObject, ModifierMixin):
|
|||
|
||||
__tablename__ = "ancestry_trait"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
|
||||
description: Mapped[Text] = mapped_column(Text, default="")
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = "class_map"
|
||||
__table_args__ = (UniqueConstraint("character_id", "character_class_id"),)
|
||||
|
@ -141,12 +161,12 @@ class CharacterClassAttributeMap(BaseObject):
|
|||
)
|
||||
|
||||
|
||||
class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierMixin):
|
||||
class Character(BaseObject, SlugMixin, ModifierMixin):
|
||||
__tablename__ = "character"
|
||||
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
|
||||
name: Mapped[str] = mapped_column(default="New Character", nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, default="New Character")
|
||||
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})
|
||||
|
||||
|
@ -177,17 +197,27 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
|
|||
|
||||
_vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
|
||||
|
||||
_proficiencies: Mapped[str] = mapped_column(nullable=False, default="")
|
||||
|
||||
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
|
||||
class_list = association_proxy("class_map", "id", creator=class_map_creator)
|
||||
|
||||
_skills = relationship("CharacterSkillMap", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
skills = association_proxy("_skills", "skill", creator=skill_creator)
|
||||
|
||||
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan")
|
||||
attribute_list = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator)
|
||||
|
||||
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
|
||||
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
|
||||
|
||||
@property
|
||||
def proficiency_bonus(self):
|
||||
return 1 + int(0.5 + self.level / 4)
|
||||
|
||||
@property
|
||||
def proficiencies(self):
|
||||
unified = {}
|
||||
unified.update(**self._proficiencies)
|
||||
|
||||
@property
|
||||
def modifiers(self):
|
||||
unified = {}
|
||||
|
@ -197,6 +227,10 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
|
|||
unified.update(**super().modifiers)
|
||||
return unified
|
||||
|
||||
@property
|
||||
def check_modifiers(self):
|
||||
return [self.check_modifier(skill) for skill in self.skills]
|
||||
|
||||
@property
|
||||
def classes(self):
|
||||
return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map])
|
||||
|
@ -241,39 +275,73 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
|
|||
def class_attributes(self):
|
||||
return dict([(mapping.class_attribute.name, mapping.option) for mapping in self.character_class_attribute_map])
|
||||
|
||||
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]
|
||||
|
||||
def check_modifier(self, skill: Skill, save: bool = False):
|
||||
if skill not in self.skills:
|
||||
return self.check_modifier(skill.parent, save=save) if skill.parent else 0
|
||||
|
||||
attr = skill.parent.name.lower() if skill.parent else skill.name.lower()
|
||||
stat = getattr(self, attr, None)
|
||||
initial = stat.bonus if stat else 0
|
||||
|
||||
mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][0]
|
||||
|
||||
if mapping.expert and not save:
|
||||
initial += 2 * self.proficiency_bonus
|
||||
elif mapping.proficient:
|
||||
initial += self.proficiency_bonus
|
||||
|
||||
return self._apply_modifiers(f"{attr}_{'save' if save else 'check'}", initial)
|
||||
|
||||
def add_class(self, newclass, level=1):
|
||||
if level == 0:
|
||||
return self.remove_class(newclass)
|
||||
level_in_class = [mapping for mapping in self.class_map if mapping.character_class_id == newclass.id]
|
||||
if level_in_class:
|
||||
level_in_class = level_in_class[0]
|
||||
level_in_class.level = level
|
||||
else:
|
||||
|
||||
# add the class mapping and/or set the character's level in the class
|
||||
mapping = self.level_in_class(newclass)
|
||||
if not mapping:
|
||||
self.class_list.append(CharacterClassMap(character=self, character_class=newclass, level=level))
|
||||
else:
|
||||
mapping.level = level
|
||||
|
||||
# add class attributes with default values
|
||||
for lvl in range(1, level + 1):
|
||||
if not newclass.attributes_by_level[lvl]:
|
||||
continue
|
||||
for attr_name, attr in newclass.attributes_by_level[lvl].items():
|
||||
self.add_class_attribute(attr, attr.options[0])
|
||||
for attr in newclass.attributes_at_level(lvl):
|
||||
self.add_class_attribute(newclass, attr, attr.options[0])
|
||||
|
||||
# add default class skills
|
||||
for skill in newclass.skills[: newclass.starting_skills]:
|
||||
self.add_skill(skill, proficient=True, character_class=newclass)
|
||||
|
||||
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:
|
||||
self.remove_class_attribute(mapping.class_attribute)
|
||||
for skill in target.skills:
|
||||
self.remove_skill(skill, character_class=target)
|
||||
|
||||
def remove_class_attribute(self, attribute):
|
||||
self.character_class_attribute_map = [
|
||||
m for m in self.character_class_attribute_map if m.class_attribute.id != attribute.id
|
||||
]
|
||||
|
||||
def add_class_attribute(self, attribute, option):
|
||||
for thisclass in self.classes.values():
|
||||
current_level = self.levels[thisclass.name]
|
||||
current_attributes = thisclass.attributes_by_level.get(current_level, {})
|
||||
if attribute.name in current_attributes:
|
||||
if attribute.name in self.class_attributes:
|
||||
return True
|
||||
def has_class_attribute(self, attribute):
|
||||
return attribute in [m.class_attribute for m in self.character_class_attribute_map]
|
||||
|
||||
def add_class_attribute(self, character_class, attribute, option):
|
||||
if self.has_class_attribute(attribute):
|
||||
return False
|
||||
mapping = self.level_in_class(character_class)
|
||||
if not mapping:
|
||||
return False
|
||||
if attribute not in mapping.character_class.attributes_at_level(mapping.level):
|
||||
return False
|
||||
self.attribute_list.append(
|
||||
CharacterClassAttributeMap(
|
||||
character_id=self.id,
|
||||
|
@ -283,4 +351,30 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
|
|||
)
|
||||
)
|
||||
return True
|
||||
|
||||
def add_skill(self, skill, proficient=False, expert=False, character_class=None):
|
||||
if not self.skills or skill not in self.skills:
|
||||
if not self.id:
|
||||
raise Exception(f"Cannot add a skill before the character has been persisted.")
|
||||
mapping = CharacterSkillMap(skill_id=skill.id, character_id=self.id, proficient=proficient, expert=expert)
|
||||
if character_class:
|
||||
mapping.character_class_id = character_class.id
|
||||
self._skills.append(mapping)
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_skill(self, skill, character_class=None):
|
||||
self._skills = [
|
||||
mapping
|
||||
for mapping in self._skills
|
||||
if mapping.skill_id != skill.id and mapping.character_class_id != character_class.id
|
||||
]
|
||||
|
||||
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)
|
||||
|
|
|
@ -1,18 +1,41 @@
|
|||
import itertools
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin
|
||||
from ttfrog.db.base import BaseObject
|
||||
from ttfrog.db.schema.property import Skill
|
||||
|
||||
__all__ = [
|
||||
"ClassAttributeMap",
|
||||
"ClassAttribute",
|
||||
"ClassAttributeOption",
|
||||
"CharacterClass",
|
||||
"Skill",
|
||||
"ClassSkillMap",
|
||||
]
|
||||
|
||||
|
||||
def skill_creator(fields):
|
||||
if isinstance(fields, ClassSkillMap):
|
||||
return fields
|
||||
return ClassSkillMap(**fields)
|
||||
|
||||
|
||||
class ClassSkillMap(BaseObject):
|
||||
__tablename__ = "class_skill_map"
|
||||
__table_args__ = (UniqueConstraint("skill_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_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
|
||||
proficient: Mapped[bool] = mapped_column(default=True)
|
||||
expert: Mapped[bool] = mapped_column(default=False)
|
||||
skill = relationship("Skill", lazy="immediate")
|
||||
|
||||
|
||||
class ClassAttributeMap(BaseObject):
|
||||
__tablename__ = "class_attribute_map"
|
||||
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), primary_key=True)
|
||||
|
@ -49,15 +72,28 @@ class ClassAttributeOption(BaseObject):
|
|||
attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=True)
|
||||
|
||||
|
||||
class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin):
|
||||
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="")
|
||||
proficiencies: Mapped[str] = mapped_column(default="")
|
||||
starting_skills: int = mapped_column(nullable=False, default=0)
|
||||
|
||||
attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
|
||||
_skills = relationship("ClassSkillMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
skills = association_proxy("_skills", "skill", creator=skill_creator)
|
||||
|
||||
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.")
|
||||
mapping = ClassSkillMap(character_class_id=self.id, skill_id=skill.id, proficient=True, expert=expert)
|
||||
self.skills.append(mapping)
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_attribute(self, attribute, level=1):
|
||||
if not self.attributes or attribute not in self.attributes:
|
||||
mapping = ClassAttributeMap(character_class_id=self.id, class_attribute_id=attribute.id, level=level)
|
||||
|
@ -72,5 +108,14 @@ class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin):
|
|||
def attributes_by_level(self):
|
||||
by_level = defaultdict(list)
|
||||
for mapping in self.attributes:
|
||||
by_level[mapping.level] = {mapping.attribute.name: mapping.attribute}
|
||||
by_level[mapping.level].append(mapping.attribute)
|
||||
return by_level
|
||||
|
||||
def attribute(self, name: str):
|
||||
for mapping in self.attributes:
|
||||
if mapping.attribute.name.lower() == name.lower():
|
||||
return mapping.attribute
|
||||
return None
|
||||
|
||||
def attributes_at_level(self, level: int):
|
||||
return list(itertools.chain(*[attrs for lvl, attrs in self.attributes_by_level.items() if lvl <= level]))
|
||||
|
|
|
@ -256,4 +256,4 @@ class ModifierMixin:
|
|||
self._get_modifiable_base(col.info.get("modifiable_base", col.name)),
|
||||
modifiable_class=col.info.get("modifiable_class", None),
|
||||
)
|
||||
return super().__getattr__(attr_name)
|
||||
raise AttributeError(f"No such attribute: {attr_name}.")
|
||||
|
|
|
@ -1,27 +1,17 @@
|
|||
from sqlalchemy import Column, Integer, String, Text
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject
|
||||
|
||||
__all__ = [
|
||||
"Skill",
|
||||
"Proficiency",
|
||||
]
|
||||
|
||||
|
||||
class Skill(BaseObject):
|
||||
__tablename__ = "skill"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
description = Column(Text)
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=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)
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.name)
|
||||
|
||||
|
||||
class Proficiency(BaseObject):
|
||||
__tablename__ = "proficiency"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.name)
|
||||
parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False, lazy="immediate")
|
||||
|
|
|
@ -50,20 +50,39 @@ def bootstrap(db):
|
|||
|
||||
db.add_or_update([human, dragonborn, tiefling])
|
||||
|
||||
# skills
|
||||
skills = {
|
||||
name: schema.Skill(name=name)
|
||||
for name in ("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma")
|
||||
}
|
||||
db.add_or_update(list(skills.values()))
|
||||
acrobatics = schema.Skill(name="Acrobatics", base_id=skills["dexterity"].id)
|
||||
athletics = schema.Skill(name="Athletics", base_id=skills["strength"].id)
|
||||
db.add_or_update([acrobatics, athletics])
|
||||
|
||||
# classes
|
||||
fighting_style = schema.ClassAttribute("Fighting Style")
|
||||
fighting_style.add_option(name="Archery")
|
||||
fighting_style.add_option(name="Defense")
|
||||
db.add_or_update(fighting_style)
|
||||
|
||||
fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON")
|
||||
fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON", starting_skills=2)
|
||||
db.add_or_update(fighter)
|
||||
|
||||
# add skills
|
||||
fighter.add_skill(acrobatics)
|
||||
fighter.add_skill(athletics)
|
||||
fighter.add_attribute(fighting_style, level=2)
|
||||
db.add_or_update(fighter)
|
||||
assert acrobatics in fighter.skills
|
||||
assert athletics in fighter.skills
|
||||
|
||||
rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX")
|
||||
db.add_or_update([rogue, fighter])
|
||||
|
||||
# characters
|
||||
foo = schema.Character("Foo", ancestry=tiefling, _intelligence=14)
|
||||
db.add_or_update(foo)
|
||||
foo.add_class(fighter, level=2)
|
||||
foo.add_class(rogue, level=3)
|
||||
|
||||
|
|
|
@ -24,6 +24,12 @@ def test_manage_character(db, bootstrap):
|
|||
assert char.charisma == 10
|
||||
assert darkvision not in char.traits
|
||||
|
||||
# verify basic skills were added at creation time
|
||||
for skill in db.Skill.filter(
|
||||
schema.Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
|
||||
):
|
||||
assert char.check_modifier(skill) == 0
|
||||
|
||||
# switch ancestry to tiefling
|
||||
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
||||
char.ancestry = tiefling
|
||||
|
@ -33,11 +39,21 @@ def test_manage_character(db, bootstrap):
|
|||
assert char.ancestry.name == "tiefling"
|
||||
assert darkvision in char.traits
|
||||
|
||||
# tiefling ancestry adds INT and CHA modifiers
|
||||
assert char.intelligence == 11
|
||||
assert char.intelligence.base == 10
|
||||
assert char.charisma == 12
|
||||
assert char.charisma.base == 10
|
||||
|
||||
# switch ancestry to dragonborn and assert darkvision persists
|
||||
char.ancestry = db.Ancestry.filter_by(name="dragonborn").one()
|
||||
db.add_or_update(char)
|
||||
assert darkvision in char.traits
|
||||
|
||||
# verify tiefling modifiers were removed
|
||||
assert char.intelligence == 10
|
||||
assert char.charisma == 10
|
||||
|
||||
# switch ancestry to human and assert darkvision is removed
|
||||
char.ancestry = human
|
||||
db.add_or_update(char)
|
||||
|
@ -54,33 +70,43 @@ def test_manage_character(db, bootstrap):
|
|||
assert char.class_attributes == {}
|
||||
|
||||
# 'fighting style' is available, but not at this level
|
||||
fighting_style = fighter.attributes_by_level[2]["Fighting Style"]
|
||||
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is False
|
||||
fighting_style = fighter.attribute("Fighting Style")
|
||||
assert char.has_class_attribute(fighting_style) is False
|
||||
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
|
||||
db.add_or_update(char)
|
||||
assert char.class_attributes == {}
|
||||
|
||||
# level up
|
||||
char.add_class(fighter, level=2)
|
||||
char.add_class(fighter, level=7)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {"fighter": 2}
|
||||
assert char.level == 2
|
||||
assert char.levels == {"fighter": 7}
|
||||
assert char.level == 7
|
||||
|
||||
# Assert the fighting style is added automatically and idempotent...ly?
|
||||
assert char.has_class_attribute(fighting_style)
|
||||
assert char.class_attributes[fighting_style.name] == fighting_style.options[0]
|
||||
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is True
|
||||
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
|
||||
assert char.has_class_attribute(fighting_style)
|
||||
db.add_or_update(char)
|
||||
|
||||
# classes
|
||||
athletics = db.Skill.filter_by(name="athletics").one()
|
||||
acrobatics = db.Skill.filter_by(name="acrobatics").one()
|
||||
assert athletics in char.skills
|
||||
assert acrobatics in char.skills
|
||||
assert char.check_modifier(athletics) == char.proficiency_bonus + char.strength.bonus == 3
|
||||
assert char.check_modifier(acrobatics) == char.proficiency_bonus + char.dexterity.bonus == 3
|
||||
|
||||
# multiclass
|
||||
char.add_class(rogue, level=1)
|
||||
db.add_or_update(char)
|
||||
assert char.level == 3
|
||||
assert char.levels == {"fighter": 2, "rogue": 1}
|
||||
assert char.level == 8
|
||||
assert char.levels == {"fighter": 7, "rogue": 1}
|
||||
|
||||
# remove a class
|
||||
char.remove_class(rogue)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {"fighter": 2}
|
||||
assert char.level == 2
|
||||
assert char.levels == {"fighter": 7}
|
||||
assert char.level == 7
|
||||
|
||||
# remove remaining class by setting level to zero
|
||||
char.add_class(fighter, level=0)
|
||||
|
@ -88,13 +114,19 @@ def test_manage_character(db, bootstrap):
|
|||
assert char.levels == {}
|
||||
assert char.class_attributes == {}
|
||||
|
||||
# verify the proficiencies added by the classes have been removed
|
||||
assert athletics not in char.skills
|
||||
assert acrobatics not in char.skills
|
||||
assert char.check_modifier(athletics) == 0
|
||||
assert char.check_modifier(acrobatics) == 0
|
||||
|
||||
# ensure we're not persisting any orphan records in the map tables
|
||||
dump = json.loads(db.dump())
|
||||
assert not [m for m in dump["character_class_attribute_map"] if m["character_id"] == char.id]
|
||||
assert not [m for m in dump["class_map"] if m["character_id"] == char.id]
|
||||
|
||||
|
||||
def test_ancestries(db):
|
||||
def test_ancestries(db, bootstrap):
|
||||
with db.transaction():
|
||||
# create the Pygmy Orc ancestry
|
||||
porc = schema.Ancestry(
|
||||
|
@ -102,7 +134,6 @@ def test_ancestries(db):
|
|||
size="Small",
|
||||
walk_speed=25,
|
||||
)
|
||||
db.add_or_update(porc)
|
||||
assert porc.name == "Pygmy Orc"
|
||||
assert porc.creature_type == "humanoid"
|
||||
assert porc.size == "Small"
|
||||
|
@ -115,26 +146,33 @@ def test_ancestries(db):
|
|||
db.add_or_update(porc)
|
||||
assert endurance in porc.traits
|
||||
|
||||
# add a +1 STR modifier
|
||||
str_plus_one = schema.Modifier(
|
||||
name="STR+1 (Pygmy Orc)",
|
||||
# add a +3 STR modifier
|
||||
str_bonus = schema.Modifier(
|
||||
name="STR+3 (Pygmy Orc)",
|
||||
target="strength",
|
||||
relative_value=1,
|
||||
description="Your Strength score is increased by 1.",
|
||||
relative_value=3,
|
||||
description="Your Strength score is increased by 3.",
|
||||
)
|
||||
assert porc.add_modifier(str_plus_one) is True
|
||||
assert porc.add_modifier(str_plus_one) is False # test idempotency
|
||||
assert str_plus_one in porc.modifiers["strength"]
|
||||
assert porc.add_modifier(str_bonus) is True
|
||||
assert porc.add_modifier(str_bonus) is False # test idempotency
|
||||
assert str_bonus in porc.modifiers["strength"]
|
||||
|
||||
# now create an orc character and assert it gets traits and modifiers
|
||||
grognak = schema.Character(name="Grognak the Mighty", ancestry=porc)
|
||||
db.add_or_update(grognak)
|
||||
|
||||
assert endurance in grognak.traits
|
||||
|
||||
# verify the strength bonus is applied
|
||||
assert grognak.strength.base == 10
|
||||
assert str_plus_one in grognak.modifiers["strength"]
|
||||
assert grognak.strength == 11
|
||||
assert grognak.strength == 13
|
||||
assert grognak.strength.bonus == 1
|
||||
assert str_bonus in grognak.modifiers["strength"]
|
||||
|
||||
# make sure bonuses are applied to checks and saves
|
||||
strength = db.Skill.filter_by(name="strength").one()
|
||||
assert grognak.check_modifier(strength) == 1
|
||||
assert grognak.check_modifier(strength, save=True) == 1
|
||||
|
||||
|
||||
def test_modifiers(db, bootstrap):
|
||||
|
|
Loading…
Reference in New Issue
Block a user