restructuring for poetry-slam

This commit is contained in:
evilchili 2024-03-26 00:53:21 -07:00
parent b1d7639a62
commit 78115023bb
38 changed files with 483 additions and 465 deletions

View File

@ -5,7 +5,7 @@ description = ""
authors = ["evilchili <evilchili@gmail.com>"] authors = ["evilchili <evilchili@gmail.com>"]
readme = "README.md" readme = "README.md"
packages = [ packages = [
{ include = 'ttfrog' }, {include = "*", from = "src"},
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
@ -26,9 +26,9 @@ nanoid-dictionary = "^2.4.0"
wtforms-alchemy = "^0.18.0" wtforms-alchemy = "^0.18.0"
sqlalchemy-serializer = "^1.4.1" sqlalchemy-serializer = "^1.4.1"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^8.1.1" pytest = "^8.1.1"
pytest-cov = "^5.0.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
@ -37,3 +37,30 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts] [tool.poetry.scripts]
ttfrog = "ttfrog.cli:app" ttfrog = "ttfrog.cli:app"
### SLAM
[tool.black]
line-length = 120
target-version = ['py310']
[tool.isort]
multi_line_output = 3
line_length = 120
include_trailing_comma = true
[tool.autoflake]
check = false # return error code if changes are needed
in-place = true # make changes to files instead of printing diffs
recursive = true # drill down directories recursively
remove-all-unused-imports = true # remove all unused imports (not just those from the standard library)
ignore-init-module-imports = true # exclude __init__.py when removing unused imports
remove-duplicate-keys = true # remove all duplicate keys in objects
remove-unused-variables = true # remove unused variables
[tool.pytest.ini_options]
log_cli_level = "DEBUG"
addopts = "--cov=src --cov-report=term-missing"
### ENDSLAM

View File

@ -41,6 +41,7 @@ class AttributeMap(Mapping):
""" """
attributes: field(default_factory=dict) attributes: field(default_factory=dict)
def __getattr__(self, attr): def __getattr__(self, attr):

View File

@ -2,8 +2,8 @@ import io
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional
from textwrap import dedent from textwrap import dedent
from typing import Optional
import typer import typer
from dotenv import load_dotenv from dotenv import load_dotenv
@ -12,9 +12,8 @@ from rich.logging import RichHandler
from ttfrog.path import assets from ttfrog.path import assets
default_data_path = Path("~/.dnd/ttfrog") default_data_path = Path("~/.dnd/ttfrog")
default_host = '127.0.0.1' default_host = "127.0.0.1"
default_port = 2323 default_port = 2323
SETUP_HELP = f""" SETUP_HELP = f"""
@ -47,18 +46,16 @@ def main(
root: Optional[Path] = typer.Option( root: Optional[Path] = typer.Option(
default_data_path, default_data_path,
help="Path to the TableTop Frog environment", help="Path to the TableTop Frog environment",
) ),
): ):
app_state['env'] = root.expanduser() / Path('defaults') app_state["env"] = root.expanduser() / Path("defaults")
load_dotenv(stream=io.StringIO(SETUP_HELP)) load_dotenv(stream=io.StringIO(SETUP_HELP))
load_dotenv(app_state['env']) load_dotenv(app_state["env"])
debug = os.getenv('DEBUG', None) debug = os.getenv("DEBUG", None)
logging.basicConfig( logging.basicConfig(
format='%(message)s', format="%(message)s",
level=logging.DEBUG if debug else logging.INFO, level=logging.DEBUG if debug else logging.INFO,
handlers=[ handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])
]
) )
@ -68,9 +65,10 @@ def setup(context: typer.Context):
(Re)Initialize TableTop Frog. Idempotent; will preserve any existing configuration. (Re)Initialize TableTop Frog. Idempotent; will preserve any existing configuration.
""" """
from ttfrog.db.bootstrap import bootstrap from ttfrog.db.bootstrap import bootstrap
if not os.path.exists(app_state['env']):
app_state['env'].parent.mkdir(parents=True, exist_ok=True) if not os.path.exists(app_state["env"]):
app_state['env'].write_text(dedent(SETUP_HELP)) 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']}.") print(f"Wrote defaults file {app_state['env']}.")
bootstrap() bootstrap()
@ -86,23 +84,20 @@ def serve(
default_port, default_port,
help="bind port", help="bind port",
), ),
debug: bool = typer.Option( debug: bool = typer.Option(False, help="Enable debugging output"),
False,
help='Enable debugging output'
),
): ):
""" """
Start the TableTop Frog server. Start the TableTop Frog server.
""" """
# delay loading the app until we have configured our environment # delay loading the app until we have configured our environment
from ttfrog.webserver import application
from ttfrog.db.bootstrap import bootstrap from ttfrog.db.bootstrap import bootstrap
from ttfrog.webserver import application
print("Starting TableTop Frog server...") print("Starting TableTop Frog server...")
bootstrap() bootstrap()
application.start(host=host, port=port, debug=debug) application.start(host=host, port=port, debug=debug)
if __name__ == '__main__': if __name__ == "__main__":
app() app()

View File

@ -1,11 +1,10 @@
import enum import enum
import logging
import nanoid import nanoid
from nanoid_dictionary import human_alphabet from nanoid_dictionary import human_alphabet
from sqlalchemy import Column
from sqlalchemy import String
from pyramid_sqlalchemy import BaseObject from pyramid_sqlalchemy import BaseObject
from slugify import slugify from slugify import slugify
from sqlalchemy import Column, String
def genslug(): def genslug():
@ -17,16 +16,14 @@ class SlugMixin:
@property @property
def uri(self): def uri(self):
return '-'.join([ return "-".join([self.slug, slugify(self.name.title().replace(" ", ""), ok="", only_ascii=True, lower=False)])
self.slug,
slugify(self.name.title().replace(' ', ''), ok='', only_ascii=True, lower=False)
])
class IterableMixin: class IterableMixin:
""" """
Allows for iterating over Model objects' column names and values Allows for iterating over Model objects' column names and values
""" """
def __iter__(self): def __iter__(self):
values = vars(self) values = vars(self)
for attr in self.__mapper__.columns.keys(): for attr in self.__mapper__.columns.keys():
@ -40,16 +37,16 @@ class IterableMixin:
continue continue
for rel in reliter: for rel in reliter:
try: try:
relvals.append({k: v for k, v in vars(rel).items() if not k.startswith('_')}) relvals.append({k: v for k, v in vars(rel).items() if not k.startswith("_")})
except TypeError: except TypeError:
relvals.append(rel) relvals.append(rel)
yield relname, relvals yield relname, relvals
def __json__(self, request): def __json__(self, request):
serialized = dict() serialized = dict()
for (key, value) in self: for key, value in self:
try: try:
serialized[key] = getattr(self.value, '__json__')(request) serialized[key] = getattr(self.value, "__json__")(request)
except AttributeError: except AttributeError:
serialized[key] = value serialized[key] = value
return serialized return serialized
@ -58,7 +55,7 @@ class IterableMixin:
return str(dict(self)) return str(dict(self))
def multivalue_string_factory(name, column=Column(String), separator=';'): def multivalue_string_factory(name, column=Column(String), separator=";"):
""" """
Generate a mixin class that adds a string column with getters and setters Generate a mixin class that adds a string column with getters and setters
that convert list values to strings and back again. Equivalent to: that convert list values to strings and back again. Equivalent to:
@ -77,27 +74,46 @@ def multivalue_string_factory(name, column=Column(String), separator=';'):
attr = f"_{name}" attr = f"_{name}"
prop = property(lambda self: getattr(self, attr).split(separator)) prop = property(lambda self: getattr(self, attr).split(separator))
setter = prop.setter(lambda self, val: setattr(self, attr, separator.join(val))) setter = prop.setter(lambda self, val: setattr(self, attr, separator.join(val)))
return type('MultiValueString', (object, ), { return type(
"MultiValueString",
(object,),
{
attr: column, attr: column,
f"{name}_property": prop, f"{name}_property": prop,
name: setter, name: setter,
}) },
)
class EnumField(enum.Enum): class EnumField(enum.Enum):
""" """
A serializable enum. A serializable enum.
""" """
def __json__(self, request): def __json__(self, request):
return self.value return self.value
SavingThrowsMixin = multivalue_string_factory('saving_throws') SavingThrowsMixin = multivalue_string_factory("saving_throws")
SkillsMixin = multivalue_string_factory('skills') SkillsMixin = multivalue_string_factory("skills")
STATS = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'] STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
CREATURE_TYPES = ['aberation', 'beast', 'celestial', 'construct', 'dragon', 'elemental', 'fey', 'fiend', 'Giant', CREATURE_TYPES = [
'humanoid', 'monstrosity', 'ooze', 'plant', 'undead'] "aberation",
"beast",
"celestial",
"construct",
"dragon",
"elemental",
"fey",
"fiend",
"Giant",
"humanoid",
"monstrosity",
"ooze",
"plant",
"undead",
]
CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES)) CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS)) StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS))

192
src/ttfrog/db/bootstrap.py Normal file
View File

@ -0,0 +1,192 @@
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.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

View File

@ -1,20 +1,19 @@
import os
import transaction
import base64 import base64
import hashlib import hashlib
import os
from contextlib import contextmanager from contextlib import contextmanager
from functools import cached_property from functools import cached_property
from pyramid_sqlalchemy import Session import transaction
from pyramid_sqlalchemy import init_sqlalchemy from pyramid_sqlalchemy import Session, init_sqlalchemy
from pyramid_sqlalchemy import metadata as _metadata from pyramid_sqlalchemy import metadata as _metadata
from sqlalchemy import create_engine from sqlalchemy import create_engine
import ttfrog.db.schema
from ttfrog.path import database
# from sqlalchemy.exc import IntegrityError # from sqlalchemy.exc import IntegrityError
from ttfrog.path import database
import ttfrog.db.schema
ttfrog.db.schema ttfrog.db.schema
@ -23,9 +22,10 @@ class SQLDatabaseManager:
""" """
A context manager for working with sqllite database. A context manager for working with sqllite database.
""" """
@cached_property @cached_property
def url(self): def url(self):
return os.environ.get('DATABASE_URL', f"sqlite:///{database()}") return os.environ.get("DATABASE_URL", f"sqlite:///{database()}")
@cached_property @cached_property
def engine(self): def engine(self):
@ -64,7 +64,7 @@ class SQLDatabaseManager:
""" """
Create a uniquish slug from a dictionary. Create a uniquish slug from a dictionary.
""" """
sha1bytes = hashlib.sha1(str(rec['id']).encode()) sha1bytes = hashlib.sha1(str(rec["id"]).encode())
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10] return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
def init(self): def init(self):
@ -73,7 +73,7 @@ class SQLDatabaseManager:
def dump(self): def dump(self):
results = {} results = {}
for (table_name, table) in self.tables.items(): for table_name, table in self.tables.items():
results[table_name] = [row for row in self.query(table).all()] results[table_name] = [row for row in self.query(table).all()]
return results return results

View File

@ -1,24 +1,16 @@
from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Text, UniqueConstraint
from ttfrog.db.base import CreatureTypesEnum
from sqlalchemy import Column
from sqlalchemy import Enum
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
from sqlalchemy import ForeignKey
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import relationship
from ttfrog.db.base import BaseObject, Bases, CreatureTypesEnum, IterableMixin, SavingThrowsMixin, SkillsMixin
__all__ = [ __all__ = [
'Ancestry', "Ancestry",
'AncestryTrait', "AncestryTrait",
'AncestryTraitMap', "AncestryTraitMap",
'CharacterClassMap', "CharacterClassMap",
'CharacterClassAttributeMap', "CharacterClassAttributeMap",
'Character', "Character",
] ]
@ -39,19 +31,20 @@ class AncestryTraitMap(BaseObject):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
ancestry_id = Column(Integer, ForeignKey("ancestry.id")) ancestry_id = Column(Integer, ForeignKey("ancestry.id"))
ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id")) ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id"))
trait = relationship("AncestryTrait", lazy='immediate') trait = relationship("AncestryTrait", lazy="immediate")
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}) level = Column(Integer, nullable=False, info={"min": 1, "max": 20})
class Ancestry(*Bases): class Ancestry(*Bases):
""" """
A character ancestry ("race"), which has zero or more AncestryTraits. A character ancestry ("race"), which has zero or more AncestryTraits.
""" """
__tablename__ = "ancestry" __tablename__ = "ancestry"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True) name = Column(String, index=True, unique=True)
creature_type = Column(Enum(CreatureTypesEnum)) creature_type = Column(Enum(CreatureTypesEnum))
traits = relationship("AncestryTraitMap", lazy='immediate') traits = relationship("AncestryTraitMap", lazy="immediate")
def __repr__(self): def __repr__(self):
return self.name return self.name
@ -61,6 +54,7 @@ class AncestryTrait(BaseObject, IterableMixin):
""" """
A trait granted to a character via its Ancestry. A trait granted to a character via its Ancestry.
""" """
__tablename__ = "ancestry_trait" __tablename__ = "ancestry_trait"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
@ -76,9 +70,9 @@ class CharacterClassMap(BaseObject, IterableMixin):
character_id = Column(Integer, ForeignKey("character.id")) character_id = Column(Integer, ForeignKey("character.id"))
character_class_id = Column(Integer, ForeignKey("character_class.id")) character_class_id = Column(Integer, ForeignKey("character_class.id"))
mapping = UniqueConstraint(character_id, character_class_id) mapping = UniqueConstraint(character_id, character_class_id)
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1) level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
character_class = relationship("CharacterClass", lazy='immediate') character_class = relationship("CharacterClass", lazy="immediate")
character = relationship("Character", uselist=False, viewonly=True) character = relationship("Character", uselist=False, viewonly=True)
def __repr__(self): def __repr__(self):
@ -93,42 +87,42 @@ class CharacterClassAttributeMap(BaseObject, IterableMixin):
option_id = Column(Integer, ForeignKey("class_attribute_option.id"), nullable=False) option_id = Column(Integer, ForeignKey("class_attribute_option.id"), nullable=False)
mapping = UniqueConstraint(character_id, class_attribute_id) mapping = UniqueConstraint(character_id, class_attribute_id)
class_attribute = relationship("ClassAttribute", lazy='immediate') class_attribute = relationship("ClassAttribute", lazy="immediate")
option = relationship("ClassAttributeOption", lazy='immediate') option = relationship("ClassAttributeOption", lazy="immediate")
character_class = relationship( character_class = relationship(
"CharacterClass", "CharacterClass",
secondary="class_map", secondary="class_map",
primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id", primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id",
secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id", secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id",
viewonly=True viewonly=True,
) )
class Character(*Bases, SavingThrowsMixin, SkillsMixin): class Character(*Bases, SavingThrowsMixin, SkillsMixin):
__tablename__ = "character" __tablename__ = "character"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, default='New Character', nullable=False) name = Column(String, default="New Character", nullable=False)
armor_class = Column(Integer, default=10, nullable=False, info={'min': 1, 'max': 99}) armor_class = Column(Integer, default=10, nullable=False, info={"min": 1, "max": 99})
hit_points = Column(Integer, default=1, nullable=False, info={'min': 0, 'max': 999}) hit_points = Column(Integer, default=1, nullable=False, info={"min": 0, "max": 999})
max_hit_points = Column(Integer, default=1, nullable=False, info={'min': 0, 'max': 999}) max_hit_points = Column(Integer, default=1, nullable=False, info={"min": 0, "max": 999})
temp_hit_points = Column(Integer, default=0, nullable=False, info={'min': 0, 'max': 999}) temp_hit_points = Column(Integer, default=0, nullable=False, info={"min": 0, "max": 999})
speed = Column(Integer, nullable=False, default=30, info={'min': 0, 'max': 99}) speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99})
str = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) str = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
dex = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) dex = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
con = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) con = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
int = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) int = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
wis = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) wis = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
cha = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) cha = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
proficiencies = Column(String) proficiencies = Column(String)
class_map = relationship("CharacterClassMap", cascade='all,delete,delete-orphan') class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
classes = association_proxy('class_map', 'id', creator=class_map_creator) classes = association_proxy("class_map", "id", creator=class_map_creator)
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade='all,delete,delete-orphan') character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan")
class_attributes = association_proxy('character_class_attribute_map', 'id', creator=attr_map_creator) class_attributes = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator)
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1') ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry = relationship("Ancestry", uselist=False) ancestry = relationship("Ancestry", uselist=False)
@property @property
@ -151,11 +145,7 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin):
level_in_class = level_in_class[0] level_in_class = level_in_class[0]
level_in_class.level = level level_in_class.level = level
return return
self.classes.append(CharacterClassMap( self.classes.append(CharacterClassMap(character_id=self.id, character_class_id=newclass.id, level=level))
character_id=self.id,
character_class_id=newclass.id,
level=level
))
def remove_class(self, target): def remove_class(self, target):
self.class_map = [m for m in self.class_map if m.id != target.id] self.class_map = [m for m in self.class_map if m.id != target.id]

View File

@ -1,20 +1,13 @@
from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin from sqlalchemy import Column, Enum, ForeignKey, Integer, String
from ttfrog.db.base import StatsEnum
from sqlalchemy import Column
from sqlalchemy import Enum
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from ttfrog.db.base import BaseObject, Bases, IterableMixin, SavingThrowsMixin, SkillsMixin, StatsEnum
__all__ = [ __all__ = [
'ClassAttributeMap', "ClassAttributeMap",
'ClassAttribute', "ClassAttribute",
'ClassAttributeOption', "ClassAttributeOption",
'CharacterClass', "CharacterClass",
] ]
@ -22,7 +15,7 @@ class ClassAttributeMap(BaseObject, IterableMixin):
__tablename__ = "class_attribute_map" __tablename__ = "class_attribute_map"
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), primary_key=True) class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), primary_key=True)
character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=True) character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=True)
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1) level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
class ClassAttribute(BaseObject, IterableMixin): class ClassAttribute(BaseObject, IterableMixin):
@ -46,7 +39,7 @@ class CharacterClass(*Bases, SavingThrowsMixin, SkillsMixin):
__tablename__ = "character_class" __tablename__ = "character_class"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True) name = Column(String, index=True, unique=True)
hit_dice = Column(String, default='1d6') hit_dice = Column(String, default="1d6")
hit_dice_stat = Column(Enum(StatsEnum)) hit_dice_stat = Column(Enum(StatsEnum))
proficiencies = Column(String) proficiencies = Column(String)
attributes = relationship("ClassAttributeMap") attributes = relationship("ClassAttributeMap")

View File

@ -1,16 +1,11 @@
from ttfrog.db.base import Bases, BaseObject, IterableMixin from sqlalchemy import Column, Integer, String, Text, UniqueConstraint
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
from sqlalchemy import UniqueConstraint
from ttfrog.db.base import BaseObject, Bases, IterableMixin
__all__ = [ __all__ = [
'Skill', "Skill",
'Proficiency', "Proficiency",
'Modifier', "Modifier",
] ]
@ -35,9 +30,7 @@ class Proficiency(*Bases):
class Modifier(BaseObject, IterableMixin): class Modifier(BaseObject, IterableMixin):
__tablename__ = "modifier" __tablename__ = "modifier"
__table_args__ = ( __table_args__ = (UniqueConstraint("source_table_name", "source_table_id", "value", "type", "target"),)
UniqueConstraint('source_table_name', 'source_table_id', 'value', 'type', 'target'),
)
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
source_table_name = Column(String, index=True, nullable=False) source_table_name = Column(String, index=True, nullable=False)
source_table_id = Column(Integer, index=True, nullable=False) source_table_id = Column(Integer, index=True, nullable=False)

View File

@ -1,10 +1,9 @@
from ttfrog.db.base import BaseObject, IterableMixin from sqlalchemy import Column, Integer, String, Text
from sqlalchemy import Column
from sqlalchemy import Integer from ttfrog.db.base import BaseObject, IterableMixin
from sqlalchemy import String
from sqlalchemy import Text __all__ = ["TransactionLog"]
__all__ = ['TransactionLog']
class TransactionLog(BaseObject, IterableMixin): class TransactionLog(BaseObject, IterableMixin):
__tablename__ = "transaction_log" __tablename__ = "transaction_log"

29
src/ttfrog/path.py Normal file
View File

@ -0,0 +1,29 @@
import os
from pathlib import Path
_setup_hint = "You may be able to solve this error by running 'ttfrog setup' or specifying the --root parameter."
def database():
path = Path(os.environ["DATA_PATH"]).expanduser()
if not path.exists() or not path.is_dir():
raise RuntimeError(f"DATA_PATH {path} doesn't exist or isn't a directory.\n\n{_setup_hint}")
return path / Path("tabletop-frog.db")
def assets():
return Path(__file__).parent / "assets"
def templates():
try:
return Path(os.environ["TEMPLATES_PATH"])
except KeyError:
return assets() / "templates"
def static_files():
try:
return Path(os.environ["STATIC_FILES_PATH"])
except KeyError:
return assets() / "public"

View File

@ -1,6 +1,6 @@
import logging import logging
from wsgiref.simple_server import make_server from wsgiref.simple_server import make_server
from pyramid.config import Configurator from pyramid.config import Configurator
from ttfrog.db.manager import db from ttfrog.db.manager import db
@ -8,15 +8,12 @@ from ttfrog.webserver.routes import routes
def configuration(): def configuration():
config = Configurator(settings={ config = Configurator(settings={"sqlalchemy.url": db.url, "jinja2.directories": "ttfrog.assets:templates/"})
'sqlalchemy.url': db.url, config.include("pyramid_tm")
'jinja2.directories': 'ttfrog.assets:templates/' config.include("pyramid_sqlalchemy")
}) config.include("pyramid_jinja2")
config.include('pyramid_tm') config.add_static_view(name="/static", path="ttfrog.assets:static/")
config.include('pyramid_sqlalchemy') config.add_jinja2_renderer(".html", settings_prefix="jinja2.")
config.include('pyramid_jinja2')
config.add_static_view(name='/static', path='ttfrog.assets:static/')
config.add_jinja2_renderer('.html', settings_prefix='jinja2.')
return config return config
@ -25,5 +22,5 @@ def start(host: str, port: int, debug: bool = False) -> None:
logging.debug(f"Configuring webserver with {host=}, {port=}, {debug=}") logging.debug(f"Configuring webserver with {host=}, {port=}, {debug=}")
config = configuration() config = configuration()
config.include(routes) config.include(routes)
config.scan('ttfrog.webserver.views') config.scan("ttfrog.webserver.views")
make_server(host, int(port), config.make_wsgi_app()).serve_forever() make_server(host, int(port), config.make_wsgi_app()).serve_forever()

View File

@ -1,12 +1,13 @@
from ttfrog.db.schema import Ancestry
from ttfrog.db.manager import db
from wtforms_alchemy import ModelForm from wtforms_alchemy import ModelForm
from ttfrog.db.manager import db
from ttfrog.db.schema import Ancestry
class AncestryForm(ModelForm): class AncestryForm(ModelForm):
class Meta: class Meta:
model = Ancestry model = Ancestry
exclude = ['slug'] exclude = ["slug"]
def get_session(): def get_session():
return db.session return db.session

View File

@ -1,27 +1,25 @@
import logging import logging
import re import re
from collections import defaultdict from collections import defaultdict
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from pyramid.interfaces import IRoutesMapper from pyramid.interfaces import IRoutesMapper
from ttfrog.db.manager import db from ttfrog.db.manager import db
from ttfrog.db import transaction_log
def get_all_routes(request): def get_all_routes(request):
routes = { routes = {
'static': '/static', "static": "/static",
} }
uri_pattern = re.compile(r"^([^\{\*]+)") uri_pattern = re.compile(r"^([^\{\*]+)")
mapper = request.registry.queryUtility(IRoutesMapper) mapper = request.registry.queryUtility(IRoutesMapper)
for route in mapper.get_routes(): for route in mapper.get_routes():
if route.name.startswith('__'): if route.name.startswith("__"):
continue continue
m = uri_pattern.search(route.pattern) m = uri_pattern.search(route.pattern)
if m: if m:
routes[route.name] = m .group(0) routes[route.name] = m.group(0)
return routes return routes
@ -36,17 +34,14 @@ class BaseController:
self._record = None self._record = None
self._form = None self._form = None
self.config = { self.config = {"static_url": "/static", "project_name": "TTFROG"}
'static_url': '/static',
'project_name': 'TTFROG'
}
self.configure_for_model() self.configure_for_model()
@property @property
def slug(self): def slug(self):
if not self._slug: if not self._slug:
parts = self.request.matchdict.get('uri', '').split('-') parts = self.request.matchdict.get("uri", "").split("-")
self._slug = parts[0].replace('/', '') self._slug = parts[0].replace("/", "")
return self._slug return self._slug
@property @property
@ -78,12 +73,12 @@ class BaseController:
@property @property
def resources(self): def resources(self):
return [ return [
{'type': 'style', 'uri': 'css/styles.css'}, {"type": "style", "uri": "css/styles.css"},
] ]
def configure_for_model(self): def configure_for_model(self):
if 'all_records' not in self.attrs: if "all_records" not in self.attrs:
self.attrs['all_records'] = db.query(self.model).all() self.attrs["all_records"] = db.query(self.model).all()
def template_context(self, **kwargs) -> dict: def template_context(self, **kwargs) -> dict:
return dict( return dict(
@ -103,14 +98,14 @@ class BaseController:
def populate_association(self, key, formdata): def populate_association(self, key, formdata):
populated = [] populated = []
for field in formdata: for field in formdata:
map_id = field.pop('id') map_id = field.pop("id")
map_id = int(map_id) if map_id else 0 map_id = int(map_id) if map_id else 0
if not field[key]: if not field[key]:
continue continue
elif not map_id: elif not map_id:
populated.append(field) populated.append(field)
else: else:
field['id'] = map_id field["id"] = map_id
populated.append(field) populated.append(field)
return populated return populated

View File

@ -1,29 +1,25 @@
import logging import logging
from ttfrog.webserver.controllers.base import BaseController from markupsafe import Markup
from ttfrog.webserver.forms import DeferredSelectField from wtforms import ValidationError
from ttfrog.webserver.forms import NullableDeferredSelectField from wtforms.fields import FieldList, FormField, HiddenField, SelectField, SelectMultipleField, SubmitField
from ttfrog.db.schema import ( from wtforms.validators import Optional
Character, from wtforms.widgets import ListWidget, Select
Ancestry, from wtforms.widgets.core import html_params
CharacterClass, from wtforms_alchemy import ModelForm
CharacterClassMap,
ClassAttributeOption,
CharacterClassAttributeMap
)
from ttfrog.db.base import STATS from ttfrog.db.base import STATS
from ttfrog.db.manager import db from ttfrog.db.manager import db
from ttfrog.db.schema import (
from wtforms_alchemy import ModelForm Ancestry,
from wtforms.fields import SubmitField, SelectField, SelectMultipleField, FieldList, FormField, HiddenField Character,
from wtforms.widgets import Select, ListWidget CharacterClass,
from wtforms import ValidationError CharacterClassAttributeMap,
from wtforms.validators import Optional CharacterClassMap,
from wtforms.widgets.core import html_params ClassAttributeOption,
)
from markupsafe import Markup from ttfrog.webserver.controllers.base import BaseController
from ttfrog.webserver.forms import DeferredSelectField, NullableDeferredSelectField
VALID_LEVELS = range(1, 21) VALID_LEVELS = range(1, 21)
@ -48,7 +44,7 @@ class ClassAttributesFormField(FormField):
def process(self, *args, **kwargs): def process(self, *args, **kwargs):
super().process(*args, **kwargs) super().process(*args, **kwargs)
self.character_class_map = db.query(CharacterClassAttributeMap).get(self.data['id']) self.character_class_map = db.query(CharacterClassAttributeMap).get(self.data["id"])
self.label.text = self.character_class_map.character_class[0].name self.label.text = self.character_class_map.character_class[0].name
@ -56,12 +52,7 @@ class ClassAttributesForm(ModelForm):
id = HiddenField() id = HiddenField()
class_attribute_id = HiddenField() class_attribute_id = HiddenField()
option_id = SelectField( option_id = SelectField(widget=Select(), choices=[], validators=[Optional()], coerce=int)
widget=Select(),
choices=[],
validators=[Optional()],
coerce=int
)
def __init__(self, formdata=None, obj=None, prefix=None): def __init__(self, formdata=None, obj=None, prefix=None):
if obj: if obj:
@ -74,13 +65,9 @@ class ClassAttributesForm(ModelForm):
class MulticlassForm(ModelForm): class MulticlassForm(ModelForm):
id = HiddenField() id = HiddenField()
character_class_id = NullableDeferredSelectField( character_class_id = NullableDeferredSelectField(
model=CharacterClass, model=CharacterClass, validate_choice=True, widget=Select(), coerce=int
validate_choice=True,
widget=Select(),
coerce=int
) )
level = SelectField(choices=VALID_LEVELS, default=1, coerce=int, validate_choice=True, widget=Select()) level = SelectField(choices=VALID_LEVELS, default=1, coerce=int, validate_choice=True, widget=Select())
@ -98,20 +85,19 @@ class MulticlassForm(ModelForm):
class CharacterForm(ModelForm): class CharacterForm(ModelForm):
class Meta: class Meta:
model = Character model = Character
exclude = ['slug'] exclude = ["slug"]
save = SubmitField() save = SubmitField()
delete = SubmitField() delete = SubmitField()
ancestry_id = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select()) ancestry_id = DeferredSelectField("Ancestry", model=Ancestry, default=1, validate_choice=True, widget=Select())
classes = FieldList(FormField(MulticlassForm, label=None, widget=ListWidget()), min_entries=0) classes = FieldList(FormField(MulticlassForm, label=None, widget=ListWidget()), min_entries=0)
newclass = FormField(MulticlassForm, widget=ListWidget()) newclass = FormField(MulticlassForm, widget=ListWidget())
class_attributes = FieldList( class_attributes = FieldList(
ClassAttributesFormField(ClassAttributesForm, widget=ClassAttributeWidget()), ClassAttributesFormField(ClassAttributesForm, widget=ClassAttributeWidget()), min_entries=1
min_entries=1
) )
saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS) saving_throws = SelectMultipleField("Saving Throws", validate_choice=True, choices=STATS)
class CharacterSheet(BaseController): class CharacterSheet(BaseController):
@ -121,7 +107,7 @@ class CharacterSheet(BaseController):
@property @property
def resources(self): def resources(self):
return super().resources + [ return super().resources + [
{'type': 'script', 'uri': 'js/character_sheet.js'}, {"type": "script", "uri": "js/character_sheet.js"},
] ]
def validate_callback(self): def validate_callback(self):
@ -129,13 +115,13 @@ class CharacterSheet(BaseController):
Validate multiclass fields in form data. Validate multiclass fields in form data.
""" """
ret = super().validate() ret = super().validate()
if not self.form.data['classes']: if not self.form.data["classes"]:
return ret return ret
err = "" err = ""
total_level = 0 total_level = 0
for field in self.form.data['classes']: for field in self.form.data["classes"]:
level = field.get('level') level = field.get("level")
total_level += level total_level += level
if level not in VALID_LEVELS: if level not in VALID_LEVELS:
err = f"Multiclass form field {field = } level is outside possible range." err = f"Multiclass form field {field = } level is outside possible range."
@ -150,9 +136,10 @@ class CharacterSheet(BaseController):
def add_class_attributes(self): def add_class_attributes(self):
# prefetch the records for each of the character's classes # prefetch the records for each of the character's classes
classes_by_id = { classes_by_id = {
c.id: c for c in db.query(CharacterClass).filter(CharacterClass.id.in_( c.id: c
c.character_class_id for c in self.record.class_map for c in db.query(CharacterClass)
)).all() .filter(CharacterClass.id.in_(c.character_class_id for c in self.record.class_map))
.all()
} }
assigned = [int(m.class_attribute_id) for m in self.record.character_class_attribute_map] assigned = [int(m.class_attribute_id) for m in self.record.character_class_attribute_map]
@ -165,18 +152,19 @@ class CharacterSheet(BaseController):
# assign each class attribute available at the character's current # assign each class attribute available at the character's current
# level to the list of the character's class attributes # level to the list of the character's class attributes
for attr_map in [a for a in thisclass.attributes if a.level <= class_map.level]: for attr_map in [a for a in thisclass.attributes if a.level <= class_map.level]:
# when creating a record, assign the first of the available # when creating a record, assign the first of the available
# options to the character's class attribute. # options to the character's class attribute.
default_option = db.query(ClassAttributeOption).filter_by( default_option = (
attribute_id=attr_map.class_attribute_id db.query(ClassAttributeOption).filter_by(attribute_id=attr_map.class_attribute_id).first()
).first() )
if attr_map.class_attribute_id not in assigned: if attr_map.class_attribute_id not in assigned:
self.record.class_attributes.append({ self.record.class_attributes.append(
'class_attribute_id': attr_map.class_attribute_id, {
'option_id': default_option.id, "class_attribute_id": attr_map.class_attribute_id,
}) "option_id": default_option.id,
}
)
def save_callback(self): def save_callback(self):
self.add_class_attributes() self.add_class_attributes()
@ -188,16 +176,16 @@ class CharacterSheet(BaseController):
""" """
# multiclass form # multiclass form
classes_formdata = self.form.data['classes'] classes_formdata = self.form.data["classes"]
classes_formdata.append(self.form.data['newclass']) classes_formdata.append(self.form.data["newclass"])
del self.form.classes del self.form.classes
del self.form.newclass del self.form.newclass
# class attributes # class attributes
attrs_formdata = self.form.data['class_attributes'] attrs_formdata = self.form.data["class_attributes"]
del self.form.class_attributes del self.form.class_attributes
super().populate() super().populate()
self.record.classes = self.populate_association('character_class_id', classes_formdata) self.record.classes = self.populate_association("character_class_id", classes_formdata)
self.record.class_attributes = self.populate_association('class_attribute_id', attrs_formdata) self.record.class_attributes = self.populate_association("class_attribute_id", attrs_formdata)

View File

@ -1,10 +1,9 @@
import logging from pyramid.httpexceptions import exception_response
from ttfrog.db import schema from ttfrog.db import schema
from ttfrog.db.manager import db from ttfrog.db.manager import db
from .base import BaseController
from pyramid.httpexceptions import exception_response from .base import BaseController
class JsonData(BaseController): class JsonData(BaseController):
@ -13,13 +12,10 @@ class JsonData(BaseController):
def configure_for_model(self): def configure_for_model(self):
try: try:
self.model = getattr(schema, self.request.matchdict.get('table_name')) self.model = getattr(schema, self.request.matchdict.get("table_name"))
except AttributeError: except AttributeError:
raise exception_response(404) raise exception_response(404)
def response(self): def response(self):
query = db.query(self.model).filter_by(**self.request.params) query = db.query(self.model).filter_by(**self.request.params)
return { return {"table_name": self.model.__tablename__, "records": query.all()}
'table_name': self.model.__tablename__,
'records': query.all()
}

View File

@ -1,6 +1,7 @@
from ttfrog.db.manager import db
from wtforms.fields import SelectField, SelectMultipleField from wtforms.fields import SelectField, SelectMultipleField
from ttfrog.db.manager import db
class DeferredSelectMultipleField(SelectMultipleField): class DeferredSelectMultipleField(SelectMultipleField):
def __init__(self, *args, model=None, **kwargs): def __init__(self, *args, model=None, **kwargs):
@ -11,10 +12,10 @@ class DeferredSelectMultipleField(SelectMultipleField):
class DeferredSelectField(SelectField): class DeferredSelectField(SelectField):
def __init__(self, *args, model=None, **kwargs): def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.choices = [(rec.id, getattr(rec, 'name', str(rec))) for rec in db.query(model).all()] self.choices = [(rec.id, getattr(rec, "name", str(rec))) for rec in db.query(model).all()]
class NullableDeferredSelectField(DeferredSelectField): class NullableDeferredSelectField(DeferredSelectField):
def __init__(self, *args, model=None, label='---', **kwargs): def __init__(self, *args, model=None, label="---", **kwargs):
super().__init__(*args, model=model, **kwargs) super().__init__(*args, model=model, **kwargs)
self.choices = [(0, label)] + self.choices self.choices = [(0, label)] + self.choices

View File

@ -0,0 +1,4 @@
def routes(config):
config.add_route("index", "/")
config.add_route("sheet", "/c{uri:.*}", factory="ttfrog.webserver.controllers.CharacterSheet")
config.add_route("data", "/_/{table_name}{uri:.*}", factory="ttfrog.webserver.controllers.JsonData")

View File

@ -1,23 +1,26 @@
from pyramid.response import Response from pyramid.response import Response
from pyramid.view import view_config from pyramid.view import view_config
from ttfrog.attribute_map import AttributeMap
from ttfrog.db.manager import db from ttfrog.db.manager import db
from ttfrog.db.schema import Ancestry from ttfrog.db.schema import Ancestry
from ttfrog.attribute_map import AttributeMap
def response_from(controller): def response_from(controller):
return controller.response() or AttributeMap.from_dict({'c': controller.template_context()}) return controller.response() or AttributeMap.from_dict({"c": controller.template_context()})
@view_config(route_name='index') @view_config(route_name="index")
def index(request): def index(request):
ancestries = [a.name for a in db.session.query(Ancestry).all()] ancestries = [a.name for a in db.session.query(Ancestry).all()]
return Response(','.join(ancestries)) return Response(",".join(ancestries))
@view_config(route_name='sheet', renderer='character_sheet.html') @view_config(route_name="sheet", renderer="character_sheet.html")
def sheet(request): def sheet(request):
return response_from(request.context) return response_from(request.context)
@view_config(route_name='data', renderer='json')
@view_config(route_name="data", renderer="json")
def data(request): def data(request):
return response_from(request.context) return response_from(request.context)

View File

@ -7,8 +7,7 @@ import pytest
from ttfrog.db import schema from ttfrog.db import schema
from ttfrog.db.manager import db as _db from ttfrog.db.manager import db as _db
FIXTURE_PATH = Path(__file__).parent / "fixtures"
FIXTURE_PATH = Path(__file__).parent / 'fixtures'
def load_fixture(db, fixture_name): def load_fixture(db, fixture_name):
@ -23,20 +22,20 @@ def load_fixture(db, fixture_name):
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def db(monkeypatch): def db(monkeypatch):
monkeypatch.setattr('ttfrog.db.manager.database', MagicMock(return_value="")) monkeypatch.setattr("ttfrog.db.manager.database", MagicMock(return_value=""))
monkeypatch.setenv('DATABASE_URL', "sqlite:///:memory:") monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv('DEBUG', '1') monkeypatch.setenv("DEBUG", "1")
_db.init() _db.init()
return _db return _db
@pytest.fixture @pytest.fixture
def classes(db): def classes(db):
load_fixture(db, 'classes') load_fixture(db, "classes")
return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all()) return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all())
@pytest.fixture @pytest.fixture
def ancestries(db): def ancestries(db):
load_fixture(db, 'ancestry') load_fixture(db, "ancestry")
return dict((rec.name, rec) for rec in db.session.query(schema.Ancestry).all()) return dict((rec.name, rec) for rec in db.session.query(schema.Ancestry).all())

View File

@ -3,54 +3,54 @@ from ttfrog.db import schema
def test_create_character(db, classes, ancestries): def test_create_character(db, classes, ancestries):
with db.transaction(): with db.transaction():
darkvision = db.session.query(schema.AncestryTrait).filter_by(name='Darkvision')[0] darkvision = db.session.query(schema.AncestryTrait).filter_by(name="Darkvision")[0]
# create a human character (the default) # create a human character (the default)
char = schema.Character(name='Test Character') char = schema.Character(name="Test Character")
db.add(char) db.add(char)
assert char.id == 1 assert char.id == 1
assert char.armor_class == 10 assert char.armor_class == 10
assert char.name == 'Test Character' assert char.name == "Test Character"
assert char.ancestry.name == 'human' assert char.ancestry.name == "human"
assert darkvision not in char.traits assert darkvision not in char.traits
# switch ancestry to tiefling # switch ancestry to tiefling
char.ancestry = ancestries['tiefling'] char.ancestry = ancestries["tiefling"]
db.add(char) db.add(char)
char = db.session.get(schema.Character, 1) char = db.session.get(schema.Character, 1)
assert char.ancestry.name == 'tiefling' assert char.ancestry.name == "tiefling"
assert darkvision in char.traits assert darkvision in char.traits
# assign a class and level # assign a class and level
char.add_class(classes['fighter'], level=1) char.add_class(classes["fighter"], level=1)
db.add(char) db.add(char)
assert char.levels == {'fighter': 1} assert char.levels == {"fighter": 1}
assert char.level == 1 assert char.level == 1
assert char.class_attributes == [] assert char.class_attributes == []
# level up # level up
char.add_class(classes['fighter'], level=2) char.add_class(classes["fighter"], level=2)
db.add(char) db.add(char)
assert char.levels == {'fighter': 2} assert char.levels == {"fighter": 2}
assert char.level == 2 assert char.level == 2
assert char.class_attributes == [] assert char.class_attributes == []
# multiclass # multiclass
char.add_class(classes['rogue'], level=1) char.add_class(classes["rogue"], level=1)
db.add(char) db.add(char)
assert char.level == 3 assert char.level == 3
assert char.levels == {'fighter': 2, 'rogue': 1} assert char.levels == {"fighter": 2, "rogue": 1}
# remove a class # remove a class
char.remove_class(classes['rogue']) char.remove_class(classes["rogue"])
db.add(char) db.add(char)
assert char.levels == {'fighter': 2} assert char.levels == {"fighter": 2}
assert char.level == 2 assert char.level == 2
# remove all remaining classes # remove all remaining classes
char.remove_class(classes['fighter']) char.remove_class(classes["fighter"])
db.add(char) db.add(char)
# ensure we're not persisting any orphan records in the map table # ensure we're not persisting any orphan records in the map table
dump = db.dump() dump = db.dump()
assert dump['class_map'] == [] assert dump["class_map"] == []

View File

@ -1,166 +0,0 @@
import logging
from ttfrog.db.manager import db
from ttfrog.db import schema
from sqlalchemy.exc import IntegrityError
# 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.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

View File

@ -1,31 +0,0 @@
import os
from pathlib import Path
_setup_hint = "You may be able to solve this error by running 'ttfrog setup' or specifying the --root parameter."
def database():
path = Path(os.environ['DATA_PATH']).expanduser()
if not path.exists() or not path.is_dir():
raise RuntimeError(
f"DATA_PATH {path} doesn't exist or isn't a directory.\n\n{_setup_hint}"
)
return path / Path('tabletop-frog.db')
def assets():
return Path(__file__).parent / 'assets'
def templates():
try:
return Path(os.environ['TEMPLATES_PATH'])
except KeyError:
return assets() / 'templates'
def static_files():
try:
return Path(os.environ['STATIC_FILES_PATH'])
except KeyError:
return assets() / 'public'

View File

@ -1,4 +0,0 @@
def routes(config):
config.add_route('index', '/')
config.add_route('sheet', '/c{uri:.*}', factory='ttfrog.webserver.controllers.CharacterSheet')
config.add_route('data', '/_/{table_name}{uri:.*}', factory='ttfrog.webserver.controllers.JsonData')