restructuring for poetry-slam
This commit is contained in:
parent
b1d7639a62
commit
78115023bb
|
@ -5,7 +5,7 @@ description = ""
|
|||
authors = ["evilchili <evilchili@gmail.com>"]
|
||||
readme = "README.md"
|
||||
packages = [
|
||||
{ include = 'ttfrog' },
|
||||
{include = "*", from = "src"},
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
|
@ -26,9 +26,9 @@ nanoid-dictionary = "^2.4.0"
|
|||
wtforms-alchemy = "^0.18.0"
|
||||
sqlalchemy-serializer = "^1.4.1"
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.1.1"
|
||||
pytest-cov = "^5.0.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
|
@ -37,3 +37,30 @@ build-backend = "poetry.core.masonry.api"
|
|||
|
||||
[tool.poetry.scripts]
|
||||
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)
|
||||
|
||||
def __getattr__(self, attr):
|
|
@ -2,8 +2,8 @@ import io
|
|||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from dotenv import load_dotenv
|
||||
|
@ -12,9 +12,8 @@ from rich.logging import RichHandler
|
|||
|
||||
from ttfrog.path import assets
|
||||
|
||||
|
||||
default_data_path = Path("~/.dnd/ttfrog")
|
||||
default_host = '127.0.0.1'
|
||||
default_host = "127.0.0.1"
|
||||
default_port = 2323
|
||||
|
||||
SETUP_HELP = f"""
|
||||
|
@ -47,18 +46,16 @@ def main(
|
|||
root: Optional[Path] = typer.Option(
|
||||
default_data_path,
|
||||
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(app_state['env'])
|
||||
debug = os.getenv('DEBUG', None)
|
||||
load_dotenv(app_state["env"])
|
||||
debug = os.getenv("DEBUG", None)
|
||||
logging.basicConfig(
|
||||
format='%(message)s',
|
||||
format="%(message)s",
|
||||
level=logging.DEBUG if debug else logging.INFO,
|
||||
handlers=[
|
||||
RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])
|
||||
]
|
||||
handlers=[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.
|
||||
"""
|
||||
from ttfrog.db.bootstrap import bootstrap
|
||||
if not os.path.exists(app_state['env']):
|
||||
app_state['env'].parent.mkdir(parents=True, exist_ok=True)
|
||||
app_state['env'].write_text(dedent(SETUP_HELP))
|
||||
|
||||
if not os.path.exists(app_state["env"]):
|
||||
app_state["env"].parent.mkdir(parents=True, exist_ok=True)
|
||||
app_state["env"].write_text(dedent(SETUP_HELP))
|
||||
print(f"Wrote defaults file {app_state['env']}.")
|
||||
bootstrap()
|
||||
|
||||
|
@ -86,23 +84,20 @@ def serve(
|
|||
default_port,
|
||||
help="bind port",
|
||||
),
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
help='Enable debugging output'
|
||||
),
|
||||
debug: bool = typer.Option(False, help="Enable debugging output"),
|
||||
):
|
||||
"""
|
||||
Start the TableTop Frog server.
|
||||
"""
|
||||
|
||||
# delay loading the app until we have configured our environment
|
||||
from ttfrog.webserver import application
|
||||
from ttfrog.db.bootstrap import bootstrap
|
||||
from ttfrog.webserver import application
|
||||
|
||||
print("Starting TableTop Frog server...")
|
||||
bootstrap()
|
||||
application.start(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
app()
|
|
@ -1,11 +1,10 @@
|
|||
import enum
|
||||
import logging
|
||||
|
||||
import nanoid
|
||||
from nanoid_dictionary import human_alphabet
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import String
|
||||
from pyramid_sqlalchemy import BaseObject
|
||||
from slugify import slugify
|
||||
from sqlalchemy import Column, String
|
||||
|
||||
|
||||
def genslug():
|
||||
|
@ -17,16 +16,14 @@ class SlugMixin:
|
|||
|
||||
@property
|
||||
def uri(self):
|
||||
return '-'.join([
|
||||
self.slug,
|
||||
slugify(self.name.title().replace(' ', ''), ok='', only_ascii=True, lower=False)
|
||||
])
|
||||
return "-".join([self.slug, slugify(self.name.title().replace(" ", ""), ok="", only_ascii=True, lower=False)])
|
||||
|
||||
|
||||
class IterableMixin:
|
||||
"""
|
||||
Allows for iterating over Model objects' column names and values
|
||||
"""
|
||||
|
||||
def __iter__(self):
|
||||
values = vars(self)
|
||||
for attr in self.__mapper__.columns.keys():
|
||||
|
@ -40,16 +37,16 @@ class IterableMixin:
|
|||
continue
|
||||
for rel in reliter:
|
||||
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:
|
||||
relvals.append(rel)
|
||||
yield relname, relvals
|
||||
|
||||
def __json__(self, request):
|
||||
serialized = dict()
|
||||
for (key, value) in self:
|
||||
for key, value in self:
|
||||
try:
|
||||
serialized[key] = getattr(self.value, '__json__')(request)
|
||||
serialized[key] = getattr(self.value, "__json__")(request)
|
||||
except AttributeError:
|
||||
serialized[key] = value
|
||||
return serialized
|
||||
|
@ -58,7 +55,7 @@ class IterableMixin:
|
|||
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
|
||||
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}"
|
||||
prop = property(lambda self: getattr(self, attr).split(separator))
|
||||
setter = prop.setter(lambda self, val: setattr(self, attr, separator.join(val)))
|
||||
return type('MultiValueString', (object, ), {
|
||||
return type(
|
||||
"MultiValueString",
|
||||
(object,),
|
||||
{
|
||||
attr: column,
|
||||
f"{name}_property": prop,
|
||||
name: setter,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class EnumField(enum.Enum):
|
||||
"""
|
||||
A serializable enum.
|
||||
"""
|
||||
|
||||
def __json__(self, request):
|
||||
return self.value
|
||||
|
||||
|
||||
SavingThrowsMixin = multivalue_string_factory('saving_throws')
|
||||
SkillsMixin = multivalue_string_factory('skills')
|
||||
SavingThrowsMixin = multivalue_string_factory("saving_throws")
|
||||
SkillsMixin = multivalue_string_factory("skills")
|
||||
|
||||
STATS = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']
|
||||
CREATURE_TYPES = ['aberation', 'beast', 'celestial', 'construct', 'dragon', 'elemental', 'fey', 'fiend', 'Giant',
|
||||
'humanoid', 'monstrosity', 'ooze', 'plant', 'undead']
|
||||
STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
|
||||
CREATURE_TYPES = [
|
||||
"aberation",
|
||||
"beast",
|
||||
"celestial",
|
||||
"construct",
|
||||
"dragon",
|
||||
"elemental",
|
||||
"fey",
|
||||
"fiend",
|
||||
"Giant",
|
||||
"humanoid",
|
||||
"monstrosity",
|
||||
"ooze",
|
||||
"plant",
|
||||
"undead",
|
||||
]
|
||||
CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
|
||||
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 hashlib
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from functools import cached_property
|
||||
|
||||
from pyramid_sqlalchemy import Session
|
||||
from pyramid_sqlalchemy import init_sqlalchemy
|
||||
import transaction
|
||||
from pyramid_sqlalchemy import Session, init_sqlalchemy
|
||||
from pyramid_sqlalchemy import metadata as _metadata
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
import ttfrog.db.schema
|
||||
from ttfrog.path import database
|
||||
|
||||
# from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ttfrog.path import database
|
||||
import ttfrog.db.schema
|
||||
|
||||
ttfrog.db.schema
|
||||
|
||||
|
@ -23,9 +22,10 @@ class SQLDatabaseManager:
|
|||
"""
|
||||
A context manager for working with sqllite database.
|
||||
"""
|
||||
|
||||
@cached_property
|
||||
def url(self):
|
||||
return os.environ.get('DATABASE_URL', f"sqlite:///{database()}")
|
||||
return os.environ.get("DATABASE_URL", f"sqlite:///{database()}")
|
||||
|
||||
@cached_property
|
||||
def engine(self):
|
||||
|
@ -64,7 +64,7 @@ class SQLDatabaseManager:
|
|||
"""
|
||||
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]
|
||||
|
||||
def init(self):
|
||||
|
@ -73,7 +73,7 @@ class SQLDatabaseManager:
|
|||
|
||||
def dump(self):
|
||||
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()]
|
||||
return results
|
||||
|
|
@ -1,24 +1,16 @@
|
|||
from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin
|
||||
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 import Column, Enum, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject, Bases, CreatureTypesEnum, IterableMixin, SavingThrowsMixin, SkillsMixin
|
||||
|
||||
__all__ = [
|
||||
'Ancestry',
|
||||
'AncestryTrait',
|
||||
'AncestryTraitMap',
|
||||
'CharacterClassMap',
|
||||
'CharacterClassAttributeMap',
|
||||
'Character',
|
||||
"Ancestry",
|
||||
"AncestryTrait",
|
||||
"AncestryTraitMap",
|
||||
"CharacterClassMap",
|
||||
"CharacterClassAttributeMap",
|
||||
"Character",
|
||||
]
|
||||
|
||||
|
||||
|
@ -39,19 +31,20 @@ class AncestryTraitMap(BaseObject):
|
|||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
ancestry_id = Column(Integer, ForeignKey("ancestry.id"))
|
||||
ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id"))
|
||||
trait = relationship("AncestryTrait", lazy='immediate')
|
||||
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
|
||||
trait = relationship("AncestryTrait", lazy="immediate")
|
||||
level = Column(Integer, nullable=False, info={"min": 1, "max": 20})
|
||||
|
||||
|
||||
class Ancestry(*Bases):
|
||||
"""
|
||||
A character ancestry ("race"), which has zero or more AncestryTraits.
|
||||
"""
|
||||
|
||||
__tablename__ = "ancestry"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
creature_type = Column(Enum(CreatureTypesEnum))
|
||||
traits = relationship("AncestryTraitMap", lazy='immediate')
|
||||
traits = relationship("AncestryTraitMap", lazy="immediate")
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
@ -61,6 +54,7 @@ class AncestryTrait(BaseObject, IterableMixin):
|
|||
"""
|
||||
A trait granted to a character via its Ancestry.
|
||||
"""
|
||||
|
||||
__tablename__ = "ancestry_trait"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, nullable=False)
|
||||
|
@ -76,9 +70,9 @@ class CharacterClassMap(BaseObject, IterableMixin):
|
|||
character_id = Column(Integer, ForeignKey("character.id"))
|
||||
character_class_id = Column(Integer, ForeignKey("character_class.id"))
|
||||
mapping = UniqueConstraint(character_id, character_class_id)
|
||||
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1)
|
||||
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)
|
||||
|
||||
def __repr__(self):
|
||||
|
@ -93,42 +87,42 @@ class CharacterClassAttributeMap(BaseObject, IterableMixin):
|
|||
option_id = Column(Integer, ForeignKey("class_attribute_option.id"), nullable=False)
|
||||
mapping = UniqueConstraint(character_id, class_attribute_id)
|
||||
|
||||
class_attribute = relationship("ClassAttribute", lazy='immediate')
|
||||
option = relationship("ClassAttributeOption", lazy='immediate')
|
||||
class_attribute = relationship("ClassAttribute", lazy="immediate")
|
||||
option = relationship("ClassAttributeOption", lazy="immediate")
|
||||
|
||||
character_class = relationship(
|
||||
"CharacterClass",
|
||||
secondary="class_map",
|
||||
primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id",
|
||||
secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id",
|
||||
viewonly=True
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
|
||||
class Character(*Bases, SavingThrowsMixin, SkillsMixin):
|
||||
__tablename__ = "character"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, default='New Character', nullable=False)
|
||||
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})
|
||||
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})
|
||||
speed = Column(Integer, nullable=False, default=30, info={'min': 0, 'max': 99})
|
||||
str = 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})
|
||||
int = 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})
|
||||
name = Column(String, default="New Character", nullable=False)
|
||||
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})
|
||||
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})
|
||||
speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99})
|
||||
str = 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})
|
||||
int = 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})
|
||||
proficiencies = Column(String)
|
||||
|
||||
class_map = relationship("CharacterClassMap", cascade='all,delete,delete-orphan')
|
||||
classes = association_proxy('class_map', 'id', creator=class_map_creator)
|
||||
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
|
||||
classes = association_proxy("class_map", "id", creator=class_map_creator)
|
||||
|
||||
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade='all,delete,delete-orphan')
|
||||
class_attributes = association_proxy('character_class_attribute_map', 'id', creator=attr_map_creator)
|
||||
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan")
|
||||
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)
|
||||
|
||||
@property
|
||||
|
@ -151,11 +145,7 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin):
|
|||
level_in_class = level_in_class[0]
|
||||
level_in_class.level = level
|
||||
return
|
||||
self.classes.append(CharacterClassMap(
|
||||
character_id=self.id,
|
||||
character_class_id=newclass.id,
|
||||
level=level
|
||||
))
|
||||
self.classes.append(CharacterClassMap(character_id=self.id, character_class_id=newclass.id, level=level))
|
||||
|
||||
def remove_class(self, target):
|
||||
self.class_map = [m for m in self.class_map if m.id != target.id]
|
|
@ -1,20 +1,13 @@
|
|||
from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin
|
||||
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 import Column, Enum, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject, Bases, IterableMixin, SavingThrowsMixin, SkillsMixin, StatsEnum
|
||||
|
||||
__all__ = [
|
||||
'ClassAttributeMap',
|
||||
'ClassAttribute',
|
||||
'ClassAttributeOption',
|
||||
'CharacterClass',
|
||||
"ClassAttributeMap",
|
||||
"ClassAttribute",
|
||||
"ClassAttributeOption",
|
||||
"CharacterClass",
|
||||
]
|
||||
|
||||
|
||||
|
@ -22,7 +15,7 @@ class ClassAttributeMap(BaseObject, IterableMixin):
|
|||
__tablename__ = "class_attribute_map"
|
||||
class_attribute_id = Column(Integer, ForeignKey("class_attribute.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):
|
||||
|
@ -46,7 +39,7 @@ class CharacterClass(*Bases, SavingThrowsMixin, SkillsMixin):
|
|||
__tablename__ = "character_class"
|
||||
id = Column(Integer, primary_key=True, autoincrement=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))
|
||||
proficiencies = Column(String)
|
||||
attributes = relationship("ClassAttributeMap")
|
|
@ -1,16 +1,11 @@
|
|||
from ttfrog.db.base import Bases, BaseObject, IterableMixin
|
||||
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import Integer
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy import Column, Integer, String, Text, UniqueConstraint
|
||||
|
||||
from ttfrog.db.base import BaseObject, Bases, IterableMixin
|
||||
|
||||
__all__ = [
|
||||
'Skill',
|
||||
'Proficiency',
|
||||
'Modifier',
|
||||
"Skill",
|
||||
"Proficiency",
|
||||
"Modifier",
|
||||
]
|
||||
|
||||
|
||||
|
@ -35,9 +30,7 @@ class Proficiency(*Bases):
|
|||
|
||||
class Modifier(BaseObject, IterableMixin):
|
||||
__tablename__ = "modifier"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('source_table_name', 'source_table_id', 'value', 'type', 'target'),
|
||||
)
|
||||
__table_args__ = (UniqueConstraint("source_table_name", "source_table_id", "value", "type", "target"),)
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source_table_name = Column(String, 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
|
||||
from sqlalchemy import Integer
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy import Column, Integer, String, Text
|
||||
|
||||
from ttfrog.db.base import BaseObject, IterableMixin
|
||||
|
||||
__all__ = ["TransactionLog"]
|
||||
|
||||
__all__ = ['TransactionLog']
|
||||
|
||||
class TransactionLog(BaseObject, IterableMixin):
|
||||
__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
|
||||
|
||||
from wsgiref.simple_server import make_server
|
||||
|
||||
from pyramid.config import Configurator
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
|
@ -8,15 +8,12 @@ from ttfrog.webserver.routes import routes
|
|||
|
||||
|
||||
def configuration():
|
||||
config = Configurator(settings={
|
||||
'sqlalchemy.url': db.url,
|
||||
'jinja2.directories': 'ttfrog.assets:templates/'
|
||||
})
|
||||
config.include('pyramid_tm')
|
||||
config.include('pyramid_sqlalchemy')
|
||||
config.include('pyramid_jinja2')
|
||||
config.add_static_view(name='/static', path='ttfrog.assets:static/')
|
||||
config.add_jinja2_renderer('.html', settings_prefix='jinja2.')
|
||||
config = Configurator(settings={"sqlalchemy.url": db.url, "jinja2.directories": "ttfrog.assets:templates/"})
|
||||
config.include("pyramid_tm")
|
||||
config.include("pyramid_sqlalchemy")
|
||||
config.include("pyramid_jinja2")
|
||||
config.add_static_view(name="/static", path="ttfrog.assets:static/")
|
||||
config.add_jinja2_renderer(".html", settings_prefix="jinja2.")
|
||||
|
||||
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=}")
|
||||
config = configuration()
|
||||
config.include(routes)
|
||||
config.scan('ttfrog.webserver.views')
|
||||
config.scan("ttfrog.webserver.views")
|
||||
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 ttfrog.db.manager import db
|
||||
from ttfrog.db.schema import Ancestry
|
||||
|
||||
|
||||
class AncestryForm(ModelForm):
|
||||
class Meta:
|
||||
model = Ancestry
|
||||
exclude = ['slug']
|
||||
exclude = ["slug"]
|
||||
|
||||
def get_session():
|
||||
return db.session
|
|
@ -1,23 +1,21 @@
|
|||
import logging
|
||||
import re
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
from pyramid.interfaces import IRoutesMapper
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
from ttfrog.db import transaction_log
|
||||
|
||||
|
||||
def get_all_routes(request):
|
||||
routes = {
|
||||
'static': '/static',
|
||||
"static": "/static",
|
||||
}
|
||||
uri_pattern = re.compile(r"^([^\{\*]+)")
|
||||
mapper = request.registry.queryUtility(IRoutesMapper)
|
||||
for route in mapper.get_routes():
|
||||
if route.name.startswith('__'):
|
||||
if route.name.startswith("__"):
|
||||
continue
|
||||
m = uri_pattern.search(route.pattern)
|
||||
if m:
|
||||
|
@ -36,17 +34,14 @@ class BaseController:
|
|||
self._record = None
|
||||
self._form = None
|
||||
|
||||
self.config = {
|
||||
'static_url': '/static',
|
||||
'project_name': 'TTFROG'
|
||||
}
|
||||
self.config = {"static_url": "/static", "project_name": "TTFROG"}
|
||||
self.configure_for_model()
|
||||
|
||||
@property
|
||||
def slug(self):
|
||||
if not self._slug:
|
||||
parts = self.request.matchdict.get('uri', '').split('-')
|
||||
self._slug = parts[0].replace('/', '')
|
||||
parts = self.request.matchdict.get("uri", "").split("-")
|
||||
self._slug = parts[0].replace("/", "")
|
||||
return self._slug
|
||||
|
||||
@property
|
||||
|
@ -78,12 +73,12 @@ class BaseController:
|
|||
@property
|
||||
def resources(self):
|
||||
return [
|
||||
{'type': 'style', 'uri': 'css/styles.css'},
|
||||
{"type": "style", "uri": "css/styles.css"},
|
||||
]
|
||||
|
||||
def configure_for_model(self):
|
||||
if 'all_records' not in self.attrs:
|
||||
self.attrs['all_records'] = db.query(self.model).all()
|
||||
if "all_records" not in self.attrs:
|
||||
self.attrs["all_records"] = db.query(self.model).all()
|
||||
|
||||
def template_context(self, **kwargs) -> dict:
|
||||
return dict(
|
||||
|
@ -103,14 +98,14 @@ class BaseController:
|
|||
def populate_association(self, key, formdata):
|
||||
populated = []
|
||||
for field in formdata:
|
||||
map_id = field.pop('id')
|
||||
map_id = field.pop("id")
|
||||
map_id = int(map_id) if map_id else 0
|
||||
if not field[key]:
|
||||
continue
|
||||
elif not map_id:
|
||||
populated.append(field)
|
||||
else:
|
||||
field['id'] = map_id
|
||||
field["id"] = map_id
|
||||
populated.append(field)
|
||||
return populated
|
||||
|
|
@ -1,29 +1,25 @@
|
|||
import logging
|
||||
|
||||
from ttfrog.webserver.controllers.base import BaseController
|
||||
from ttfrog.webserver.forms import DeferredSelectField
|
||||
from ttfrog.webserver.forms import NullableDeferredSelectField
|
||||
from ttfrog.db.schema import (
|
||||
Character,
|
||||
Ancestry,
|
||||
CharacterClass,
|
||||
CharacterClassMap,
|
||||
ClassAttributeOption,
|
||||
CharacterClassAttributeMap
|
||||
)
|
||||
from markupsafe import Markup
|
||||
from wtforms import ValidationError
|
||||
from wtforms.fields import FieldList, FormField, HiddenField, SelectField, SelectMultipleField, SubmitField
|
||||
from wtforms.validators import Optional
|
||||
from wtforms.widgets import ListWidget, Select
|
||||
from wtforms.widgets.core import html_params
|
||||
from wtforms_alchemy import ModelForm
|
||||
|
||||
from ttfrog.db.base import STATS
|
||||
from ttfrog.db.manager import db
|
||||
|
||||
from wtforms_alchemy import ModelForm
|
||||
from wtforms.fields import SubmitField, SelectField, SelectMultipleField, FieldList, FormField, HiddenField
|
||||
from wtforms.widgets import Select, ListWidget
|
||||
from wtforms import ValidationError
|
||||
from wtforms.validators import Optional
|
||||
from wtforms.widgets.core import html_params
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from ttfrog.db.schema import (
|
||||
Ancestry,
|
||||
Character,
|
||||
CharacterClass,
|
||||
CharacterClassAttributeMap,
|
||||
CharacterClassMap,
|
||||
ClassAttributeOption,
|
||||
)
|
||||
from ttfrog.webserver.controllers.base import BaseController
|
||||
from ttfrog.webserver.forms import DeferredSelectField, NullableDeferredSelectField
|
||||
|
||||
VALID_LEVELS = range(1, 21)
|
||||
|
||||
|
@ -48,7 +44,7 @@ class ClassAttributesFormField(FormField):
|
|||
|
||||
def process(self, *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
|
||||
|
||||
|
||||
|
@ -56,12 +52,7 @@ class ClassAttributesForm(ModelForm):
|
|||
id = HiddenField()
|
||||
class_attribute_id = HiddenField()
|
||||
|
||||
option_id = SelectField(
|
||||
widget=Select(),
|
||||
choices=[],
|
||||
validators=[Optional()],
|
||||
coerce=int
|
||||
)
|
||||
option_id = SelectField(widget=Select(), choices=[], validators=[Optional()], coerce=int)
|
||||
|
||||
def __init__(self, formdata=None, obj=None, prefix=None):
|
||||
if obj:
|
||||
|
@ -74,13 +65,9 @@ class ClassAttributesForm(ModelForm):
|
|||
|
||||
|
||||
class MulticlassForm(ModelForm):
|
||||
|
||||
id = HiddenField()
|
||||
character_class_id = NullableDeferredSelectField(
|
||||
model=CharacterClass,
|
||||
validate_choice=True,
|
||||
widget=Select(),
|
||||
coerce=int
|
||||
model=CharacterClass, validate_choice=True, widget=Select(), coerce=int
|
||||
)
|
||||
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 Meta:
|
||||
model = Character
|
||||
exclude = ['slug']
|
||||
exclude = ["slug"]
|
||||
|
||||
save = 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)
|
||||
newclass = FormField(MulticlassForm, widget=ListWidget())
|
||||
|
||||
class_attributes = FieldList(
|
||||
ClassAttributesFormField(ClassAttributesForm, widget=ClassAttributeWidget()),
|
||||
min_entries=1
|
||||
ClassAttributesFormField(ClassAttributesForm, widget=ClassAttributeWidget()), 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):
|
||||
|
@ -121,7 +107,7 @@ class CharacterSheet(BaseController):
|
|||
@property
|
||||
def resources(self):
|
||||
return super().resources + [
|
||||
{'type': 'script', 'uri': 'js/character_sheet.js'},
|
||||
{"type": "script", "uri": "js/character_sheet.js"},
|
||||
]
|
||||
|
||||
def validate_callback(self):
|
||||
|
@ -129,13 +115,13 @@ class CharacterSheet(BaseController):
|
|||
Validate multiclass fields in form data.
|
||||
"""
|
||||
ret = super().validate()
|
||||
if not self.form.data['classes']:
|
||||
if not self.form.data["classes"]:
|
||||
return ret
|
||||
|
||||
err = ""
|
||||
total_level = 0
|
||||
for field in self.form.data['classes']:
|
||||
level = field.get('level')
|
||||
for field in self.form.data["classes"]:
|
||||
level = field.get("level")
|
||||
total_level += level
|
||||
if level not in VALID_LEVELS:
|
||||
err = f"Multiclass form field {field = } level is outside possible range."
|
||||
|
@ -150,9 +136,10 @@ class CharacterSheet(BaseController):
|
|||
def add_class_attributes(self):
|
||||
# prefetch the records for each of the character's classes
|
||||
classes_by_id = {
|
||||
c.id: c for c in db.query(CharacterClass).filter(CharacterClass.id.in_(
|
||||
c.character_class_id for c in self.record.class_map
|
||||
)).all()
|
||||
c.id: c
|
||||
for c in db.query(CharacterClass)
|
||||
.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]
|
||||
|
@ -165,18 +152,19 @@ class CharacterSheet(BaseController):
|
|||
# assign each class attribute available at the character's current
|
||||
# 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]:
|
||||
|
||||
# when creating a record, assign the first of the available
|
||||
# options to the character's class attribute.
|
||||
default_option = db.query(ClassAttributeOption).filter_by(
|
||||
attribute_id=attr_map.class_attribute_id
|
||||
).first()
|
||||
default_option = (
|
||||
db.query(ClassAttributeOption).filter_by(attribute_id=attr_map.class_attribute_id).first()
|
||||
)
|
||||
|
||||
if attr_map.class_attribute_id not in assigned:
|
||||
self.record.class_attributes.append({
|
||||
'class_attribute_id': attr_map.class_attribute_id,
|
||||
'option_id': default_option.id,
|
||||
})
|
||||
self.record.class_attributes.append(
|
||||
{
|
||||
"class_attribute_id": attr_map.class_attribute_id,
|
||||
"option_id": default_option.id,
|
||||
}
|
||||
)
|
||||
|
||||
def save_callback(self):
|
||||
self.add_class_attributes()
|
||||
|
@ -188,16 +176,16 @@ class CharacterSheet(BaseController):
|
|||
"""
|
||||
|
||||
# multiclass form
|
||||
classes_formdata = self.form.data['classes']
|
||||
classes_formdata.append(self.form.data['newclass'])
|
||||
classes_formdata = self.form.data["classes"]
|
||||
classes_formdata.append(self.form.data["newclass"])
|
||||
del self.form.classes
|
||||
del self.form.newclass
|
||||
|
||||
# class attributes
|
||||
attrs_formdata = self.form.data['class_attributes']
|
||||
attrs_formdata = self.form.data["class_attributes"]
|
||||
del self.form.class_attributes
|
||||
|
||||
super().populate()
|
||||
|
||||
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.classes = self.populate_association("character_class_id", classes_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.manager import db
|
||||
from .base import BaseController
|
||||
|
||||
from pyramid.httpexceptions import exception_response
|
||||
from .base import BaseController
|
||||
|
||||
|
||||
class JsonData(BaseController):
|
||||
|
@ -13,13 +12,10 @@ class JsonData(BaseController):
|
|||
|
||||
def configure_for_model(self):
|
||||
try:
|
||||
self.model = getattr(schema, self.request.matchdict.get('table_name'))
|
||||
self.model = getattr(schema, self.request.matchdict.get("table_name"))
|
||||
except AttributeError:
|
||||
raise exception_response(404)
|
||||
|
||||
def response(self):
|
||||
query = db.query(self.model).filter_by(**self.request.params)
|
||||
return {
|
||||
'table_name': self.model.__tablename__,
|
||||
'records': query.all()
|
||||
}
|
||||
return {"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 ttfrog.db.manager import db
|
||||
|
||||
|
||||
class DeferredSelectMultipleField(SelectMultipleField):
|
||||
def __init__(self, *args, model=None, **kwargs):
|
||||
|
@ -11,10 +12,10 @@ class DeferredSelectMultipleField(SelectMultipleField):
|
|||
class DeferredSelectField(SelectField):
|
||||
def __init__(self, *args, model=None, **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):
|
||||
def __init__(self, *args, model=None, label='---', **kwargs):
|
||||
def __init__(self, *args, model=None, label="---", **kwargs):
|
||||
super().__init__(*args, model=model, **kwargs)
|
||||
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.view import view_config
|
||||
|
||||
from ttfrog.attribute_map import AttributeMap
|
||||
from ttfrog.db.manager import db
|
||||
from ttfrog.db.schema import Ancestry
|
||||
from ttfrog.attribute_map import AttributeMap
|
||||
|
||||
|
||||
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):
|
||||
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):
|
||||
return response_from(request.context)
|
||||
|
||||
@view_config(route_name='data', renderer='json')
|
||||
|
||||
@view_config(route_name="data", renderer="json")
|
||||
def data(request):
|
||||
return response_from(request.context)
|
|
@ -7,8 +7,7 @@ import pytest
|
|||
from ttfrog.db import schema
|
||||
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):
|
||||
|
@ -23,20 +22,20 @@ def load_fixture(db, fixture_name):
|
|||
|
||||
@pytest.fixture(autouse=True)
|
||||
def db(monkeypatch):
|
||||
monkeypatch.setattr('ttfrog.db.manager.database', MagicMock(return_value=""))
|
||||
monkeypatch.setenv('DATABASE_URL', "sqlite:///:memory:")
|
||||
monkeypatch.setenv('DEBUG', '1')
|
||||
monkeypatch.setattr("ttfrog.db.manager.database", MagicMock(return_value=""))
|
||||
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
|
||||
monkeypatch.setenv("DEBUG", "1")
|
||||
_db.init()
|
||||
return _db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def classes(db):
|
||||
load_fixture(db, 'classes')
|
||||
load_fixture(db, "classes")
|
||||
return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ancestries(db):
|
||||
load_fixture(db, 'ancestry')
|
||||
load_fixture(db, "ancestry")
|
||||
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):
|
||||
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)
|
||||
char = schema.Character(name='Test Character')
|
||||
char = schema.Character(name="Test Character")
|
||||
db.add(char)
|
||||
assert char.id == 1
|
||||
assert char.armor_class == 10
|
||||
assert char.name == 'Test Character'
|
||||
assert char.ancestry.name == 'human'
|
||||
assert char.name == "Test Character"
|
||||
assert char.ancestry.name == "human"
|
||||
assert darkvision not in char.traits
|
||||
|
||||
# switch ancestry to tiefling
|
||||
char.ancestry = ancestries['tiefling']
|
||||
char.ancestry = ancestries["tiefling"]
|
||||
db.add(char)
|
||||
char = db.session.get(schema.Character, 1)
|
||||
assert char.ancestry.name == 'tiefling'
|
||||
assert char.ancestry.name == "tiefling"
|
||||
assert darkvision in char.traits
|
||||
|
||||
# assign a class and level
|
||||
char.add_class(classes['fighter'], level=1)
|
||||
char.add_class(classes["fighter"], level=1)
|
||||
db.add(char)
|
||||
assert char.levels == {'fighter': 1}
|
||||
assert char.levels == {"fighter": 1}
|
||||
assert char.level == 1
|
||||
assert char.class_attributes == []
|
||||
|
||||
# level up
|
||||
char.add_class(classes['fighter'], level=2)
|
||||
char.add_class(classes["fighter"], level=2)
|
||||
db.add(char)
|
||||
assert char.levels == {'fighter': 2}
|
||||
assert char.levels == {"fighter": 2}
|
||||
assert char.level == 2
|
||||
assert char.class_attributes == []
|
||||
|
||||
# multiclass
|
||||
char.add_class(classes['rogue'], level=1)
|
||||
char.add_class(classes["rogue"], level=1)
|
||||
db.add(char)
|
||||
assert char.level == 3
|
||||
assert char.levels == {'fighter': 2, 'rogue': 1}
|
||||
assert char.levels == {"fighter": 2, "rogue": 1}
|
||||
|
||||
# remove a class
|
||||
char.remove_class(classes['rogue'])
|
||||
char.remove_class(classes["rogue"])
|
||||
db.add(char)
|
||||
assert char.levels == {'fighter': 2}
|
||||
assert char.levels == {"fighter": 2}
|
||||
assert char.level == 2
|
||||
|
||||
# remove all remaining classes
|
||||
char.remove_class(classes['fighter'])
|
||||
char.remove_class(classes["fighter"])
|
||||
db.add(char)
|
||||
|
||||
# ensure we're not persisting any orphan records in the map table
|
||||
dump = db.dump()
|
||||
assert dump['class_map'] == []
|
||||
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