restructuring for poetry-slam
This commit is contained in:
1 parent
b1d7639a62
commit
78115023bb
38 files changed
+482
-464
No files matched your search
+29
-2
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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()
|
||||
File renamed without changes.
@@ -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, ), {
|
||||
attr: column,
|
||||
f"{name}_property": prop,
|
||||
name: setter,
|
||||
})
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
|
||||
File renamed without changes.
@@ -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 sqlalchemy import Column, Integer, String, Text
|
||||
|
||||
from ttfrog.db.base import BaseObject, IterableMixin
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import Integer
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import Text
|
||||
|
||||
__all__ = ["TransactionLog"]
|
||||
|
||||
__all__ = ['TransactionLog']
|
||||
|
||||
class TransactionLog(BaseObject, IterableMixin):
|
||||
__tablename__ = "transaction_log"
|
||||
File renamed without changes.
@@ -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"
|
||||
File renamed without changes.
@@ -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()
|
||||
File renamed without changes.
+4
-3
@@ -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,27 +1,25 @@
|
||||
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:
|
||||
routes[route.name] = m .group(0)
|
||||
routes[route.name] = m.group(0)
|
||||
return routes
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+46
-58
@@ -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)
|
||||
+4
-8
@@ -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):
|
||||
super().__init__(*args, model=model, **kwargs)
|
||||
def __init__(self, *args, model=None, label="---", **kwargs):
|
||||
super().__init__(*args, model=model, **kwargs)
|
||||
self.choices = [(0, label)] + self.choices
|
||||
@@ -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)
|
||||
+6
-7
@@ -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())
|
||||
+16
-16
@@ -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')
|
||||
Reference in new issue
Block a user