fixing modifier bugs, fixing traits, adding speed attrs

This commit is contained in:
evilchili 2024-04-23 00:15:13 -07:00
parent 5db6e40eae
commit 1ff0e5ca7d
5 changed files with 96 additions and 213 deletions

View File

@ -116,7 +116,7 @@ def dump(context: typer.Context):
"""
from ttfrog.db.manager import db
db.init()
setup(context)
print(db.dump(context.args))

View File

@ -1,192 +1,36 @@
import logging
from sqlalchemy.exc import IntegrityError
from ttfrog.db import schema
from ttfrog.db.manager import db
# move this to json or whatever
data = {
"CharacterClass": [
{
"id": 1,
"name": "fighter",
"hit_dice": "1d10",
"hit_dice_stat": "CON",
"proficiencies": "all armor, all shields, simple weapons, martial weapons",
"saving_throws": ["STR, CON"],
"skills": [
"Acrobatics",
"Animal Handling",
"Athletics",
"History",
"Insight",
"Intimidation",
"Perception",
"Survival",
],
},
{
"id": 2,
"name": "rogue",
"hit_dice": "1d8",
"hit_dice_stat": "DEX",
"proficiencies": "simple weapons, hand crossbows, longswords, rapiers, shortswords",
"saving_throws": ["DEX", "INT"],
"skills": [
"Acrobatics",
"Athletics",
"Deception",
"Insight",
"Intimidation",
"Investigation",
"Perception",
"Performance",
"Persuasion",
"Sleight of Hand",
"Stealth",
],
},
],
"Skill": [
{"name": "Acrobatics"},
{"name": "Animal Handling"},
{"name": "Athletics"},
{"name": "Deception"},
{"name": "History"},
{"name": "Insight"},
{"name": "Intimidation"},
{"name": "Investigation"},
{"name": "Perception"},
{"name": "Performance"},
{"name": "Persuasion"},
{"name": "Sleight of Hand"},
{"name": "Stealth"},
{"name": "Survival"},
],
"Ancestry": [
{"id": 1, "name": "human", "creature_type": "humanoid"},
{"id": 2, "name": "dragonborn", "creature_type": "humanoid"},
{"id": 3, "name": "tiefling", "creature_type": "humanoid"},
{"id": 4, "name": "elf", "creature_type": "humanoid"},
],
"AncestryTrait": [
{
"id": 1,
"name": "+1 to All Ability Scores",
},
{
"id": 2,
"name": "Breath Weapon",
},
{
"id": 3,
"name": "Darkvision",
},
],
"AncestryTraitMap": [
{"ancestry_id": 1, "ancestry_trait_id": 1, "level": 1}, # human +1 to scores
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1}, # dragonborn breath weapon
{"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1}, # tiefling darkvision
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1}, # elf darkvision
],
"CharacterClassMap": [
{
"character_id": 1,
"character_class_id": 1,
"level": 2,
},
{
"character_id": 1,
"character_class_id": 2,
"level": 3,
},
],
"Character": [
{
"id": 1,
"name": "Sabetha",
"ancestry_id": 1,
"armor_class": 10,
"max_hit_points": 14,
"hit_points": 14,
"temp_hit_points": 0,
"speed": 30,
"str": 16,
"dex": 12,
"con": 18,
"int": 11,
"wis": 12,
"cha": 8,
"proficiencies": "all armor, all shields, simple weapons, martial weapons",
"saving_throws": ["STR", "CON"],
"skills": ["Acrobatics", "Animal Handling"],
},
],
"ClassAttribute": [
{"id": 1, "name": "Fighting Style"},
{"id": 2, "name": "Another Attribute"},
],
"ClassAttributeOption": [
{"id": 1, "attribute_id": 1, "name": "Archery"},
{"id": 2, "attribute_id": 1, "name": "Battlemaster"},
{"id": 3, "attribute_id": 2, "name": "Another Option 1"},
{"id": 4, "attribute_id": 2, "name": "Another Option 2"},
],
"ClassAttributeMap": [
{"class_attribute_id": 1, "character_class_id": 1, "level": 2}, # Fighter: Fighting Style
{"class_attribute_id": 2, "character_class_id": 1, "level": 1}, # Fighter: Another Attr
],
"CharacterClassAttributeMap": [
{"character_id": 1, "class_attribute_id": 2, "option_id": 4}, # Sabetha, another option, option 2
{"character_id": 1, "class_attribute_id": 1, "option_id": 1}, # Sabetha, fighting style, archery
],
"Modifier": [
# Humans
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "str"},
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "dex"},
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "con"},
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "int"},
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "wis"},
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "cha"},
# Dragonborn
{
"source_table_name": "ancestry_trait",
"source_table_id": 2,
"value": "60",
"type": "attribute ",
"target": "Darkvision",
},
{"source_table_name": "ancestry_trait", "source_table_id": 2, "value": "+1", "type": "stat", "target": ""},
{"source_table_name": "ancestry_trait", "source_table_id": 2, "value": "+1", "type": "stat", "target": ""},
# Fighting Style: Archery
{
"source_table_name": "class_attribute",
"source_table_id": 1,
"value": "+2",
"type": "weapon ",
"target": "ranged",
},
],
}
def bootstrap():
"""
Initialize the database with source data. Idempotent; will skip anything that already exists.
"""
db.metadata.drop_all(bind=db.engine)
db.init()
for table, records in data.items():
model = getattr(schema, table)
for rec in records:
obj = model(**rec)
try:
with db.transaction():
db.session.add(obj)
logging.info(f"Created {table} {obj}")
except IntegrityError as e:
if "UNIQUE constraint failed" in str(e):
logging.info(f"Skipping existing {table} {obj}")
continue
raise
# ancestries
human = schema.Ancestry(name="human")
tiefling = schema.Ancestry(name="tiefling")
tiefling.add_modifier(schema.Modifier(name="Ability Score Increase", target="intelligence", relative_value=1))
tiefling.add_modifier(schema.Modifier(name="Ability Score Increase", target="charisma", relative_value=2))
darkvision = schema.AncestryTrait(
name="Darkvision",
description=(
"You can see in dim light within 60 feet of you as if it were bright light, and in darkness as if it "
"were dim light. You cant discern color in darkness, only shades of gray."
),
)
darkvision.add_modifier(schema.Modifier(name="Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision)
# classes
fighter = schema.CharacterClass(name="fighter", hit_dice="1d10", hit_dice_stat="CON")
rogue = schema.CharacterClass(name="rogue", hit_dice="1d8", hit_dice_stat="DEX")
# characters
sabetha = schema.Character(name="Sabetha", ancestry=tiefling)
sabetha.add_class(fighter, level=2)
sabetha.add_class(rogue, level=3)
bob = schema.Character(name="Bob", ancestry=human)
# persist all the records we've created
db.add_or_update([sabetha, bob])

View File

@ -34,7 +34,7 @@ class AncestryTraitMap(BaseObject):
id = Column(Integer, primary_key=True, autoincrement=True)
ancestry_id = Column(Integer, ForeignKey("ancestry.id"))
ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id"))
trait = relationship("AncestryTrait", lazy="immediate")
trait = relationship("AncestryTrait", uselist=False, lazy="immediate")
level = Column(Integer, nullable=False, info={"min": 1, "max": 20})
@ -48,16 +48,31 @@ class Ancestry(BaseObject, ModifierMixin):
name = Column(String, index=True, unique=True)
creature_type = Column(Enum(CreatureTypesEnum), nullable=False, default="humanoid")
size = Column(Enum(SizesEnum), nullable=False, default="Medium")
speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99})
walk_speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99})
_fly_speed = Column(Integer, info={"min": 0, "max": 99})
_climb_speed = Column(Integer, info={"min": 0, "max": 99})
_swim_speed = Column(Integer, info={"min": 0, "max": 99})
_traits = relationship("AncestryTraitMap", cascade="all,delete,delete-orphan", lazy="immediate")
@property
def traits(self):
return [mapping.trait for mapping in self._traits]
@property
def speed(self):
return self.walk_speed
@property
def climb_speed(self):
return self._climb_speed or int(self.speed / 2)
@property
def swim_speed(self):
return self._swim_speed or int(self.speed / 2)
def add_trait(self, trait, level=1):
if trait not in self.traits:
self._traits.append(AncestryTraitMap(ancestry_id=self.id, ancestry_trait_id=trait.id, level=level))
if trait not in self._traits:
self._traits.append(AncestryTraitMap(ancestry_id=self.id, trait=trait, level=level))
return True
return False
@ -65,7 +80,7 @@ class Ancestry(BaseObject, ModifierMixin):
return self.name
class AncestryTrait(BaseObject):
class AncestryTrait(BaseObject, ModifierMixin):
"""
A trait granted to a character via its Ancestry.
"""
@ -129,24 +144,14 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM
intelligence = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
wisdom = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
charisma = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
_vision = Column(Integer, info={"min": 0})
proficiencies = Column(String)
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
class_list = association_proxy("class_map", "id", creator=class_map_creator)
_modify_ok = [
"armor_class",
"max_hit_points",
"strength",
"dexterity",
"constitution",
"intelligence",
"wisdom",
"charisma",
"speed",
"size",
]
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan")
attribute_list = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator)
@ -157,6 +162,8 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM
def modifiers(self):
unified = {}
unified.update(**self.ancestry.modifiers)
for trait in self.traits:
unified.update(**trait.modifiers)
unified.update(**super().modifiers)
return unified
@ -204,10 +211,30 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM
def speed(self):
return self.apply_modifiers("speed", self.ancestry.speed)
@property
def climb_speed(self):
return self.apply_modifiers("climb_speed", self.ancestry.climb_speed)
@property
def swim_speed(self):
return self.apply_modifiers("swim_speed", self.ancestry.swim_speed)
@property
def fly_speed(self):
return self.apply_modifiers("fly_speed", self.ancestry._fly_speed)
@property
def size(self):
return self.apply_modifiers("size", self.ancestry.size)
@property
def vision(self):
return self.apply_modifiers("vision", self._vision)
@property
def vision_in_darkness(self):
return self.apply_modifiers("vision_in_darkness", self.vision if self.vision is not None else 0)
@property
def level(self):
return sum(mapping.level for mapping in self.class_map)
@ -228,7 +255,7 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM
level_in_class = level_in_class[0]
level_in_class.level = level
else:
self.class_list.append(CharacterClassMap(character_id=self.id, character_class_id=newclass.id, level=level))
self.class_list.append(CharacterClassMap(character_id=self.id, character_class=newclass, level=level))
for lvl in range(1, level + 1):
if not newclass.attributes_by_level[lvl]:
continue
@ -252,15 +279,15 @@ class Character(BaseObject, ModifierMixin, SlugMixin, SavingThrowsMixin, SkillsM
if attribute.name in self.class_attributes:
return True
self.attribute_list.append(
CharacterClassAttributeMap(
character_id=self.id, class_attribute_id=attribute.id, option_id=option.id
)
CharacterClassAttributeMap(character_id=self.id, class_attribute=attribute, option=option)
)
return True
return False
def apply_modifiers(self, target, initial):
modifiers = list(reversed(self.modifiers.get(target, [])))
if initial is None:
return initial
if isinstance(initial, int):
absolute = [mod for mod in modifiers if mod.absolute_value is not None]
if absolute:

View File

@ -15,11 +15,12 @@ class ModifierMap(BaseObject):
__tablename__ = "modifier_map"
__table_args__ = (UniqueConstraint("primary_table_name", "primary_table_id", "modifier_id"),)
id = Column(Integer, primary_key=True, autoincrement=True)
primary_table_name = Column(String, nullable=False)
primary_table_id = Column(Integer, nullable=False)
modifier_id = Column(Integer, ForeignKey("modifier.id"), nullable=False)
modifier = relationship("Modifier", uselist=False, lazy="immediate")
primary_table_name = Column(String, nullable=False)
primary_table_id = Column(Integer, nullable=False)
class Modifier(BaseObject):
"""
@ -67,8 +68,14 @@ class ModifierMixin:
def modifier_map(cls):
return relationship(
"ModifierMap",
primaryjoin=f"ModifierMap.primary_table_id == foreign({cls.__name__}.id)",
primaryjoin=(
"and_("
f"foreign(ModifierMap.primary_table_name)=='{cls.__tablename__}', "
f"foreign(ModifierMap.primary_table_id)=={cls.__name__}.id"
")"
),
cascade="all,delete,delete-orphan",
overlaps="modifier_map,modifier_map",
single_parent=True,
uselist=True,
lazy="immediate",
@ -84,6 +91,7 @@ class ModifierMixin:
def add_modifier(self, modifier):
if modifier.absolute_value is not None and modifier.relative_value is not None and modifier.multiple_value:
raise AttributeError(f"You must provide only one of absolute, relative, and multiple values {modifier}.")
if [mod for mod in self.modifier_map if mod.modifier == modifier]:
return False
self.modifier_map.append(

View File

@ -97,7 +97,7 @@ def test_ancestries(db):
porc = schema.Ancestry(
name="Pygmy Orc",
size="Small",
speed=25,
walk_speed=25,
)
db.add_or_update(porc)
assert porc.name == "Pygmy Orc"
@ -141,7 +141,8 @@ def test_modifiers(db, classes_factory, ancestries_factory):
# no modifiers; speed is ancestry speed
carl = schema.Character(name="Carl", ancestry=ancestries["elf"])
db.add_or_update(carl)
marx = schema.Character(name="Marx", ancestry=ancestries["human"])
db.add_or_update([carl, marx])
assert carl.speed == carl.ancestry.speed == 30
cold = schema.Modifier(target="speed", relative_value=-10, name="Cold")
@ -154,6 +155,9 @@ def test_modifiers(db, classes_factory, ancestries_factory):
assert carl.add_modifier(cold)
assert carl.speed == 20
# make sure modifiers only apply to carl. Carl is having a bad day.
assert marx.speed == 30
# speed is doubled
assert carl.remove_modifier(cold)
assert carl.add_modifier(hasted)