restructuring for poetry-slam
This commit is contained in:
parent
b1d7639a62
commit
78115023bb
|
@ -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
|
||||||
|
|
|
@ -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):
|
|
@ -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()
|
|
@ -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
192
src/ttfrog/db/bootstrap.py
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
|
@ -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")
|
|
@ -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)
|
|
@ -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
29
src/ttfrog/path.py
Normal 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"
|
|
@ -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()
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
@ -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()
|
|
||||||
}
|
|
|
@ -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
|
4
src/ttfrog/webserver/routes.py
Normal file
4
src/ttfrog/webserver/routes.py
Normal 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")
|
|
@ -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)
|
|
@ -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())
|
||||||
|
|
|
@ -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"] == []
|
||||||
|
|
|
@ -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
|
|
|
@ -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'
|
|
|
@ -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')
|
|
Loading…
Reference in New Issue
Block a user