Compare commits

...

2 Commits

Author SHA1 Message Date
evilchili
551140b5bc Add json import/export 2024-06-30 23:21:23 -07:00
evilchili
68251ff4e9 fix tests 2024-06-30 16:09:20 -07:00
13 changed files with 393 additions and 96 deletions

View File

@ -9,7 +9,7 @@ packages = [
]
[tool.poetry.dependencies]
python = "^3.10"
python = "^3.11"
python-dotenv = "^0.21.0"
typer = "^0.9.0"
rich = "^13.7.0"

View File

@ -80,11 +80,11 @@ def serve(
"""
# delay loading the app until we have configured our environment
from ttfrog.db.bootstrap import bootstrap
from ttfrog.db.bootstrap import loader
from ttfrog.webserver import application
print("Starting TableTop Frog server...")
bootstrap()
loader.load()
application.start(host=host, port=port, debug=debug)
@ -93,13 +93,13 @@ def setup(context: typer.Context):
"""
(Re)Initialize TableTop Frog. Idempotent; will preserve any existing configuration.
"""
from ttfrog.db.bootstrap import bootstrap
from ttfrog.db.bootstrap import loader
if not os.path.exists(app_state["env"]):
app_state["env"].parent.mkdir(parents=True, exist_ok=True)
app_state["env"].write_text(dedent(SETUP_HELP))
print(f"Wrote defaults file {app_state['env']}.")
bootstrap()
loader.load()
@db_app.command()

View File

@ -1,36 +0,0 @@
from ttfrog.db import schema
from ttfrog.db.manager import db
def bootstrap():
db.metadata.drop_all(bind=db.engine)
db.init()
with db.transaction():
# ancestries
human = schema.Ancestry("human")
tiefling = schema.Ancestry("tiefling")
tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="intelligence", relative_value=1))
tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="charisma", relative_value=2))
darkvision = schema.AncestryTrait(
"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("Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision)
# classes
fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON")
rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX")
# characters
sabetha = schema.Character("Sabetha", ancestry=tiefling, _intelligence=14)
sabetha.add_class(fighter, level=2)
sabetha.add_class(rogue, level=3)
bob = schema.Character("Bob", ancestry=human)
# persist all the records we've created
db.add_or_update([sabetha, bob])

View File

View File

@ -0,0 +1,208 @@
{
"ancestry": [
{
"id": 1,
"name": "human",
"creature_type": "humanoid",
"size": "medium",
"speed": 30,
"_fly_speed": null,
"_climb_speed": null,
"_swim_speed": null
},
{
"id": 2,
"name": "tiefling",
"creature_type": "humanoid",
"size": "medium",
"speed": 30,
"_fly_speed": null,
"_climb_speed": null,
"_swim_speed": null
}
],
"ancestry_trait": [
{
"id": 1,
"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 can\u2019t discern color in darkness, only shades of gray."
}
],
"character_class": [
{
"id": 1,
"name": "fighter",
"hit_die_name": "1d10",
"hit_die_stat_name": "constitution",
"starting_skills": 0
},
{
"id": 2,
"name": "rogue",
"hit_die_name": "1d8",
"hit_die_stat_name": "dexterity",
"starting_skills": 0
}
],
"class_attribute": [],
"modifier": [
{
"id": 1,
"name": "Darkvision",
"target": "vision_in_darkness",
"stacks": false,
"absolute_value": 120,
"multiply_value": null,
"multiply_attribute": null,
"relative_value": null,
"relative_attribute": null,
"new_value": null,
"description": ""
},
{
"id": 2,
"name": "Ability Score Increase",
"target": "intelligence",
"stacks": false,
"absolute_value": null,
"multiply_value": null,
"multiply_attribute": null,
"relative_value": 1,
"relative_attribute": null,
"new_value": null,
"description": ""
},
{
"id": 3,
"name": "Ability Score Increase",
"target": "charisma",
"stacks": false,
"absolute_value": null,
"multiply_value": null,
"multiply_attribute": null,
"relative_value": 2,
"relative_attribute": null,
"new_value": null,
"description": ""
}
],
"skill": [],
"transaction_log": [],
"character": [
{
"id": 1,
"name": "Sabetha",
"hit_points": 10,
"temp_hit_points": 0,
"_max_hit_points": 10,
"_armor_class": 10,
"_strength": 10,
"_dexterity": 10,
"_constitution": 10,
"_intelligence": 14,
"_wisdom": 10,
"_charisma": 10,
"_vision": null,
"exhaustion": 0,
"ancestry_id": 2,
"slug": "nMnWu"
},
{
"id": 2,
"name": "Bob",
"hit_points": 10,
"temp_hit_points": 0,
"_max_hit_points": 10,
"_armor_class": 10,
"_strength": 10,
"_dexterity": 10,
"_constitution": 10,
"_intelligence": 10,
"_wisdom": 10,
"_charisma": 10,
"_vision": null,
"exhaustion": 0,
"ancestry_id": 1,
"slug": "PjPdM"
}
],
"class_attribute_map": [],
"class_attribute_option": [],
"class_skill_map": [],
"modifier_map": [
{
"id": 1,
"modifier_id": 1,
"primary_table_name": "ancestry_trait",
"primary_table_id": 1
},
{
"id": 2,
"modifier_id": 2,
"primary_table_name": "ancestry",
"primary_table_id": 2
},
{
"id": 3,
"modifier_id": 3,
"primary_table_name": "ancestry",
"primary_table_id": 2
}
],
"trait_map": [
{
"id": 1,
"ancestry_id": 2,
"ancestry_trait_id": 1,
"level": 1
}
],
"character_class_attribute_map": [],
"character_skill_map": [],
"class_map": [
{
"id": 1,
"character_id": 1,
"character_class_id": 1,
"level": 2
},
{
"id": 2,
"character_id": 1,
"character_class_id": 2,
"level": 3
}
],
"hit_die": [
{
"id": 1,
"character_id": 1,
"character_class_id": 1,
"spent": false
},
{
"id": 2,
"character_id": 1,
"character_class_id": 1,
"spent": false
},
{
"id": 3,
"character_id": 1,
"character_class_id": 2,
"spent": false
},
{
"id": 4,
"character_id": 1,
"character_class_id": 2,
"spent": false
},
{
"id": 5,
"character_id": 1,
"character_class_id": 2,
"spent": false
}
]
}

View File

@ -0,0 +1,29 @@
import json
from pathlib import Path
from ttfrog.db import schema
from ttfrog.db.manager import db
DATA_PATH = Path(__file__).parent
def load(data: str = ""):
db.metadata.drop_all(bind=db.engine)
db.init()
with db.transaction():
if not data:
data = (DATA_PATH / "bootstrap.json").read_text()
db.load(json.loads(data))
tiefling = db.Ancestry.filter_by(name="tiefling").one()
human = db.Ancestry.filter_by(name="human").one()
fighter = db.CharacterClass.filter_by(name="fighter").one()
rogue = db.CharacterClass.filter_by(name="rogue").one()
sabetha = schema.Character("Sabetha", ancestry=tiefling, _intelligence=14)
sabetha.add_class(fighter, level=2)
sabetha.add_class(rogue, level=3)
bob = schema.Character("Bob", ancestry=human)
# persist all the records we've created
db.add_or_update([sabetha, bob])

View File

@ -4,16 +4,15 @@ import json
import os
from contextlib import contextmanager
from functools import cached_property
from sqlite3 import IntegrityError
import transaction
from pyramid_sqlalchemy.meta import Session
from sqlalchemy import create_engine, event
from sqlalchemy import create_engine, event, insert
import ttfrog.db.schema
from ttfrog.db import schema
from ttfrog.path import database
assert ttfrog.db.schema
class AlchemyEncoder(json.JSONEncoder):
def default(self, obj):
@ -42,7 +41,7 @@ class SQLDatabaseManager:
@cached_property
def metadata(self):
return ttfrog.db.schema.BaseObject.metadata
return schema.BaseObject.metadata
@cached_property
def tables(self):
@ -87,8 +86,18 @@ class SQLDatabaseManager:
results[table_name] = [dict(row._mapping) for row in self.query(table).all()]
return json.dumps(results, indent=2, cls=AlchemyEncoder)
def load(self, data: dict):
for table_name, rows in data.items():
table = self.tables.get(table_name, None)
if table is None:
raise IntegrityError(f"Table {table_name} not found in database.")
if not rows:
continue
query = insert(table), rows
self.session.execute(*query)
def __getattr__(self, name: str):
return self.query(getattr(ttfrog.db.schema, name))
return self.query(getattr(schema, name))
db = SQLDatabaseManager()

View File

@ -1,5 +1,6 @@
from .character import *
from .classes import *
from .constants import *
from .log import *
from .modifiers import *
from .skill import *

View File

@ -6,6 +6,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.constants import Conditions, DamageType, Defenses
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.skill import Skill
@ -212,6 +213,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
)
_vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
exhaustion: Mapped[int] = mapped_column(nullable=False, default=0, info={"min": 0, "max": 5})
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
class_list = association_proxy("class_map", "id", creator=class_map_creator)
@ -272,6 +274,10 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
def traits(self):
return self.ancestry.traits
@property
def initiative(self):
return self._apply_modifiers("initiative", self.dexterity.bonus)
@property
def speed(self):
return self._apply_modifiers("speed", self.ancestry.speed)
@ -294,7 +300,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
@property
def vision_in_darkness(self):
return self.apply_modifiers("vision_in_darkness", self.vision if self.vision is not None else 0)
return self._apply_modifiers("vision_in_darkness", self.vision if self.vision is not None else 0)
@property
def level(self):
@ -314,23 +320,26 @@ 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 immune(self, damage_type: DamageType):
return self.defense(damage_type) == Defenses.immune
def resistant(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'resistant'
def resistant(self, damage_type: DamageType):
return self.defense(damage_type) == Defenses.resistant.value
def vulnerable(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'vulnerable'
def vulnerable(self, damage_type: DamageType):
return self.defense(damage_type) == Defenses.vulnerable
def absorbs(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'absorbs'
def absorbs(self, damage_type: DamageType):
return self.defense(damage_type) == Defenses.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 conditions(self):
return [self._apply_modifiers(f"conditions.{name}") for name in Conditions]
def condition(self, condition_name: str):
return self._apply_modifiers(f"conditions.{condition_name}", False)
def defense(self, damage_type: DamageType):
return self._apply_modifiers(damage_type, None)
def check_modifier(self, skill: Skill, save: bool = False):
# if the skill is not assigned, but we have modifiers, apply them to zero.
@ -382,18 +391,18 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
self.add_skill(skill, proficient=True, character_class=newclass)
# add hit dice
existing = len(self.hit_dice[newclass.name])
existing = len([die for die in self._hit_dice if die.character_class_id == newclass.id])
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 == 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._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 = [
@ -422,8 +431,8 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
return True
def add_skill(self, skill, proficient=False, expert=False, character_class=None):
if not self.id:
raise Exception("Cannot add a skill before the character has been persisted.")
# if not self.id:
# raise Exception("Cannot add a skill before the character has been persisted.")
skillmap = None
exists = False
if skill in self.skills:
@ -474,15 +483,15 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
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):
def apply_damage(self, value: int, damage_type: DamageType):
total = value
if self.absorbs(damage_type, magical):
if self.absorbs(damage_type):
return self.apply_healing(total)
if self.immune(damage_type, magical):
if self.immune(damage_type):
return
if self.resistant(damage_type, magical):
if self.resistant(damage_type):
total = int(value / 2)
elif self.vulnerable(damage_type, magical):
elif self.vulnerable(damage_type):
total = value * 2
if total <= self.temp_hit_points:

View File

@ -0,0 +1,52 @@
from enum import StrEnum, auto
class Conditions(StrEnum):
blinded = auto()
charmed = auto()
deafened = auto()
frightened = auto()
grappled = auto()
incapacitated = auto()
invisible = auto()
paralyzed = auto()
petrified = auto()
poisoned = auto()
prone = auto()
restrained = auto()
stunned = auto()
unconscious = auto()
dead = auto()
class DamageType(StrEnum):
piercing = auto()
slashing = auto()
bludgeoning = auto()
fire = auto()
cold = auto()
lightning = auto()
thunder = auto()
acid = auto()
poison = auto()
radiant = auto()
necrotic = auto()
psychic = auto()
force = auto()
magical = auto()
magical_piercing = auto()
magical_slashing = auto()
magical_bludgeoning = auto()
silvered_piercing = auto()
silvered_slashing = auto()
silvered_bludgeoning = auto()
adamantium_piercing = auto()
adamantium_slashing = auto()
adamantium_bludgeoning = auto()
class Defenses(StrEnum):
vulnerable = auto()
resistant = auto()
immune = auto()
absorbs = auto()

View File

@ -6,6 +6,7 @@ import pytest
from ttfrog.db import schema
from ttfrog.db.manager import db as _db
from ttfrog.db.schema.constants import DamageType, Defenses
FIXTURE_PATH = Path(__file__).parent / "fixtures"
@ -49,14 +50,12 @@ 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
# resistant to 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")
schema.Modifier("Infernal Origin", target=DamageType.fire, new_value=Defenses.resistant)
)
tiefling.add_trait(infernal_origin)
db.add_or_update(tiefling)
dragonborn = schema.Ancestry("dragonborn")
dragonborn.add_trait(darkvision)

23
test/test_bootstrap.py Normal file
View File

@ -0,0 +1,23 @@
import json
from ttfrog.db.bootstrap import loader
def test_dump_load(db, bootstrap):
# dump the bootstrapped data
data = json.loads(db.dump())
# clear the database and reinitialize
db.metadata.drop_all(bind=db.engine)
db.init()
# load the dump
db.load(data)
# dump again and compare to the initial data
assert data == json.loads(db.dump())
def test_loader(db, bootstrap):
loader.load(db.dump())
assert len(db.Ancestry.all()) > 0

View File

@ -1,6 +1,7 @@
import json
from ttfrog.db import schema
from ttfrog.db.schema.constants import DamageType, Defenses
def test_manage_character(db, bootstrap):
@ -96,6 +97,14 @@ def test_manage_character(db, bootstrap):
assert char.check_modifier(athletics) == char.proficiency_bonus + char.strength.bonus == 3
assert char.check_modifier(acrobatics) == char.proficiency_bonus + char.dexterity.bonus == 3
# assert dexterity bonus apply to initiative
char._dexterity = 17
assert char.dexterity.bonus == 3
assert char.initiative == char.dexterity.bonus == 3
char.add_modifier(schema.Modifier("+1 initiative", target="initiative", relative_value=1))
assert char.initiative == 4
char._dexterity = 10
# multiclass
char.add_class(rogue, level=1)
db.add_or_update(char)
@ -279,45 +288,39 @@ 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.resistant(DamageType.fire)
carl.apply_damage(5, DamageType.fire)
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")
schema.Modifier("Fire Immunity", target=DamageType.fire, new_value=Defenses.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.immune(DamageType.fire)
carl.apply_damage(5, DamageType.fire)
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")
schema.Modifier("Fire Vulnerability", target=DamageType.fire, new_value=Defenses.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.vulnerable(DamageType.fire)
assert not carl.immune(DamageType.fire)
carl.apply_damage(2, DamageType.fire)
assert carl.hit_points == 4 # double damage
absorbs = [
schema.Modifier("Absorbs Non-Magical Fire", target="defenses.fire", new_value="absorbs"),
]
absorbs = [schema.Modifier("Absorbs Non-Magical Fire", target=DamageType.fire, new_value=Defenses.absorbs)]
carl.add_modifier(absorbs[0])
carl.apply_damage(20, "fire", magical=False)
carl.apply_damage(20, DamageType.fire)
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")
carl.apply_damage(5, DamageType.fire)
assert carl.resistant(DamageType.fire)
assert not carl.immune(DamageType.fire)
assert not carl.vulnerable(DamageType.fire)
assert not carl.absorbs(DamageType.fire)
assert carl.hit_points == 8 # half damage