diff --git a/pyproject.toml b/pyproject.toml index dcbad63..91bf65a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ wtforms-alchemy = "^0.18.0" sqlalchemy-serializer = "^1.4.1" +[tool.poetry.group.dev.dependencies] +pytest = "^8.1.1" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..d3c8d3a --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from ttfrog.db import schema +from ttfrog.db.manager import db as _db + + +FIXTURE_PATH = Path(__file__).parent / 'fixtures' + + +def load_fixture(db, fixture_name): + with db.transaction(): + data = json.loads((FIXTURE_PATH / f"{fixture_name}.json").read_text()) + for schema_name in data: + for record in data[schema_name]: + print(f"Loading {schema_name} {record = }") + obj = getattr(schema, schema_name)(**record) + db.session.add(obj) + + +@pytest.fixture(autouse=True) +def db(monkeypatch): + monkeypatch.setattr('ttfrog.db.manager.database', MagicMock(return_value="")) + monkeypatch.setenv('DATABASE_URL', "sqlite:///:memory:") + monkeypatch.setenv('DEBUG', '1') + _db.init() + return _db + + +@pytest.fixture +def classes(db): + load_fixture(db, 'classes') + return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all()) + + +@pytest.fixture +def ancestries(db): + load_fixture(db, 'ancestry') + return dict((rec.name, rec) for rec in db.session.query(schema.Ancestry).all()) diff --git a/test/fixtures/ancestry.json b/test/fixtures/ancestry.json new file mode 100644 index 0000000..df91106 --- /dev/null +++ b/test/fixtures/ancestry.json @@ -0,0 +1,19 @@ +{ + "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}, + {"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1}, + {"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1}, + {"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1} + ] +} diff --git a/test/fixtures/ancestry_trait.json b/test/fixtures/ancestry_trait.json new file mode 100644 index 0000000..d5cd214 --- /dev/null +++ b/test/fixtures/ancestry_trait.json @@ -0,0 +1,7 @@ +{ + "Ancestry": [ + {"id": 1, "name": "+1 to All Ability Scores"}, + {"id": 2, "name": "Breath Weapon"}, + {"id": 3, "name": "Darkvision"} + ] +} diff --git a/test/fixtures/classes.json b/test/fixtures/classes.json new file mode 100644 index 0000000..3d1abc3 --- /dev/null +++ b/test/fixtures/classes.json @@ -0,0 +1,20 @@ +{ + "CharacterClass": [ + { + "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"] + }, + { + "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"] + } + ] +} diff --git a/test/fixtures/multiclass.json b/test/fixtures/multiclass.json new file mode 100644 index 0000000..52493a3 --- /dev/null +++ b/test/fixtures/multiclass.json @@ -0,0 +1,7 @@ +{ + "name": "multiclass_pc", + "classes": [ + "fighter": 5, + "rogue": 3 + ] +} diff --git a/test/test_schema.py b/test/test_schema.py new file mode 100644 index 0000000..77092a3 --- /dev/null +++ b/test/test_schema.py @@ -0,0 +1,56 @@ +from ttfrog.db import schema + + +def test_create_character(db, classes, ancestries): + with db.transaction(): + darkvision = db.session.query(schema.AncestryTrait).filter_by(name='Darkvision')[0] + + # create a human character (the default) + char = schema.Character(name='Test Character') + db.add(char) + assert char.id == 1 + assert char.armor_class == 10 + assert char.name == 'Test Character' + assert char.ancestry.name == 'human' + assert darkvision not in char.traits + + # switch ancestry to tiefling + char.ancestry = ancestries['tiefling'] + db.add(char) + char = db.session.get(schema.Character, 1) + assert char.ancestry.name == 'tiefling' + assert darkvision in char.traits + + # assign a class and level + char.add_class(classes['fighter'], level=1) + db.add(char) + assert char.levels == {'fighter': 1} + assert char.level == 1 + assert char.class_attributes == [] + + # level up + char.add_class(classes['fighter'], level=2) + db.add(char) + assert char.levels == {'fighter': 2} + assert char.level == 2 + assert char.class_attributes == [] + + # multiclass + char.add_class(classes['rogue'], level=1) + db.add(char) + assert char.level == 3 + assert char.levels == {'fighter': 2, 'rogue': 1} + + # remove a class + char.remove_class(classes['rogue']) + db.add(char) + assert char.levels == {'fighter': 2} + assert char.level == 2 + + # remove all remaining classes + char.remove_class(classes['fighter']) + db.add(char) + + # ensure we're not persisting any orphan records in the map table + dump = db.dump() + assert dump['class_map'] == [] diff --git a/ttfrog/db/manager.py b/ttfrog/db/manager.py index 2baa1b6..ecc223d 100644 --- a/ttfrog/db/manager.py +++ b/ttfrog/db/manager.py @@ -1,3 +1,4 @@ +import os import transaction import base64 import hashlib @@ -24,7 +25,7 @@ class SQLDatabaseManager: """ @cached_property def url(self): - return f"sqlite:///{database()}" + return os.environ.get('DATABASE_URL', f"sqlite:///{database()}") @cached_property def engine(self): @@ -70,6 +71,12 @@ class SQLDatabaseManager: init_sqlalchemy(self.engine) self.metadata.create_all(self.engine) + def dump(self): + results = {} + for (table_name, table) in self.tables.items(): + results[table_name] = [row for row in self.query(table).all()] + return results + def __getattr__(self, name: str): try: return self.tables[name] diff --git a/ttfrog/db/schema/character.py b/ttfrog/db/schema/character.py index dc03dca..e6a942a 100644 --- a/ttfrog/db/schema/character.py +++ b/ttfrog/db/schema/character.py @@ -27,6 +27,7 @@ def class_map_creator(fields): return fields return CharacterClassMap(**fields) + def attr_map_creator(fields): if isinstance(fields, CharacterClassAttributeMap): return fields @@ -35,8 +36,9 @@ def attr_map_creator(fields): class AncestryTraitMap(BaseObject): __tablename__ = "trait_map" - ancestry_id = Column(Integer, ForeignKey("ancestry.id"), primary_key=True) - ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id"), primary_key=True) + 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') level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}) @@ -64,6 +66,9 @@ class AncestryTrait(BaseObject, IterableMixin): name = Column(String, nullable=False) description = Column(Text) + def __repr__(self): + return self.name + class CharacterClassMap(BaseObject, IterableMixin): __tablename__ = "class_map" @@ -71,9 +76,13 @@ class CharacterClassMap(BaseObject, IterableMixin): character_id = Column(Integer, ForeignKey("character.id")) character_class_id = Column(Integer, ForeignKey("character_class.id")) mapping = UniqueConstraint(character_id, character_class_id) + level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1) character_class = relationship("CharacterClass", lazy='immediate') - level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1) + character = relationship("Character", uselist=False, viewonly=True) + + def __repr__(self): + return f"{self.character.name}, {self.character_class.name}, level {self.level}" class CharacterClassAttributeMap(BaseObject, IterableMixin): @@ -87,6 +96,14 @@ class CharacterClassAttributeMap(BaseObject, IterableMixin): class_attribute = relationship("ClassAttribute", lazy='immediate') option = relationship("ClassAttributeOption", lazy='immediate') + character_class = relationship( + "CharacterClass", + secondary="class_map", + primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id", + secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id", + viewonly=True + ) + class Character(*Bases, SavingThrowsMixin, SkillsMixin): __tablename__ = "character" @@ -113,3 +130,32 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin): ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1') ancestry = relationship("Ancestry", uselist=False) + + @property + def traits(self): + return [mapping.trait for mapping in self.ancestry.traits] + + @property + def level(self): + return sum(mapping.level for mapping in self.class_map) + + @property + def levels(self): + return dict([(mapping.character_class.name, mapping.level) for mapping in self.class_map]) + + 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 + return + self.classes.append(CharacterClassMap( + character_id=self.id, + character_class_id=newclass.id, + level=level + )) + + def remove_class(self, target): + self.class_map = [m for m in self.class_map if m.id != target.id]