diff --git a/src/ttfrog/cli.py b/src/ttfrog/cli.py index 03f5de3..26f7a7c 100644 --- a/src/ttfrog/cli.py +++ b/src/ttfrog/cli.py @@ -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() diff --git a/src/ttfrog/db/bootstrap.py b/src/ttfrog/db/bootstrap.py deleted file mode 100644 index ef0f8c6..0000000 --- a/src/ttfrog/db/bootstrap.py +++ /dev/null @@ -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 can’t 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]) diff --git a/src/ttfrog/db/bootstrap/__init__.py b/src/ttfrog/db/bootstrap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ttfrog/db/bootstrap/bootstrap.json b/src/ttfrog/db/bootstrap/bootstrap.json new file mode 100644 index 0000000..c3ab621 --- /dev/null +++ b/src/ttfrog/db/bootstrap/bootstrap.json @@ -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 + } + ] +} diff --git a/src/ttfrog/db/bootstrap/loader.py b/src/ttfrog/db/bootstrap/loader.py new file mode 100644 index 0000000..31c258e --- /dev/null +++ b/src/ttfrog/db/bootstrap/loader.py @@ -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]) diff --git a/src/ttfrog/db/manager.py b/src/ttfrog/db/manager.py index 5555eb7..119d4e8 100644 --- a/src/ttfrog/db/manager.py +++ b/src/ttfrog/db/manager.py @@ -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() diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index 017ec6a..5f874fc 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -391,7 +391,7 @@ 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)) @@ -431,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: diff --git a/src/ttfrog/db/schema/constants.py b/src/ttfrog/db/schema/constants.py new file mode 100644 index 0000000..e62ce8d --- /dev/null +++ b/src/ttfrog/db/schema/constants.py @@ -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() diff --git a/test/test_bootstrap.py b/test/test_bootstrap.py new file mode 100644 index 0000000..bf3d280 --- /dev/null +++ b/test/test_bootstrap.py @@ -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