Debug
-
+
{{ c }}
-
+
{% endblock %}
diff --git a/ttfrog/assets/templates/list.html b/ttfrog/assets/templates/list.html
index 0032986..75978bd 100644
--- a/ttfrog/assets/templates/list.html
+++ b/ttfrog/assets/templates/list.html
@@ -1,10 +1,8 @@
{% macro build_list(c) %}
-
+
{% endmacro %}
diff --git a/ttfrog/cli.py b/ttfrog/cli.py
index 4d3e750..e272464 100644
--- a/ttfrog/cli.py
+++ b/ttfrog/cli.py
@@ -75,7 +75,6 @@ def setup(context: typer.Context):
bootstrap()
-
@app.command()
def serve(
context: typer.Context,
@@ -98,8 +97,10 @@ def serve(
# delay loading the app until we have configured our environment
from ttfrog.webserver import application
+ from ttfrog.db.bootstrap import bootstrap
print("Starting TableTop Frog server...")
+ bootstrap()
application.start(host=host, port=port, debug=debug)
diff --git a/ttfrog/db/base.py b/ttfrog/db/base.py
index f38e295..ac66fab 100644
--- a/ttfrog/db/base.py
+++ b/ttfrog/db/base.py
@@ -1,12 +1,10 @@
import nanoid
from nanoid_dictionary import human_alphabet
-
-from pyramid_sqlalchemy import BaseObject
-from wtforms import validators
-from slugify import slugify
-
from sqlalchemy import Column
from sqlalchemy import String
+from sqlalchemy import String
+from pyramid_sqlalchemy import BaseObject
+from slugify import slugify
def genslug():
@@ -38,5 +36,31 @@ class IterableMixin:
return f"{self.__class__.__name__}: {str(dict(self))}"
+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:
+
+ class MultiValueString:
+ _name = column
+
+ @property
+ def name_property(self):
+ return self._name.split(';')
+
+ @name.setter
+ def name(self, val):
+ return ';'.join(val)
+ """
+ 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,
+ })
+
+
# class Table(*Bases):
Bases = [BaseObject, IterableMixin, SlugMixin]
diff --git a/ttfrog/db/bootstrap.py b/ttfrog/db/bootstrap.py
index ef38a83..fb38c6f 100644
--- a/ttfrog/db/bootstrap.py
+++ b/ttfrog/db/bootstrap.py
@@ -7,13 +7,70 @@ from sqlalchemy.exc import IntegrityError
# move this to json or whatever
data = {
+ 'CharacterClass': [
+ {
+ 'name': 'fighter',
+ 'hit_dice': '1d10',
+ 'hit_dice_stat': 'CON',
+ 'proficiencies': 'all armor, all shields, simple weapons, martial weapons',
+ 'saving_throws': ['STR, CON'],
+ 'skills': ['Acrobatics', 'Animal Handling', 'Athletics', 'History', 'Insight', 'Intimidation', 'Perception', 'Survival'],
+ },
+ {
+ 'name': 'rogue',
+ 'hit_dice': '1d8',
+ 'hit_dice_stat': 'DEX',
+ 'proficiencies': 'simple weapons, hand crossbows, longswords, rapiers, shortswords',
+ 'saving_throws': ['DEX', 'INT'],
+ 'skills': ['Acrobatics', 'Athletics', 'Deception', 'Insight', 'Intimidation', 'Investigation', 'Perception', 'Performance', 'Persuasion', 'Sleight of Hand', 'Stealth'],
+ },
+ ],
+ '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': [
- {'name': 'human'},
- {'name': 'dragonborn'},
- {'name': 'tiefling'},
+ {'name': 'human', 'creature_type': 'humanoid'},
+ {'name': 'dragonborn', 'creature_type': 'humanoid'},
+ {'name': 'tiefling', 'creature_type': 'humanoid'},
],
'Character': [
- {'id': 1, 'name': 'Sabetha', 'ancestry': 'tiefling', 'level': 10, 'str': 10, 'dex': 10, 'con': 10, 'int': 10, 'wis': 10, 'cha': 10},
+ {
+ 'id': 1,
+ 'name': 'Sabetha',
+ 'ancestry': 'tiefling',
+ 'character_class': ['fighter', 'rogue'],
+ 'level': 1,
+ 'armor_class': 10,
+ 'max_hit_points': 14,
+ 'hit_points': 14,
+ 'temp_hit_points': 0,
+ 'passive_perception': 10,
+ 'passive_insight': 10,
+ 'passive_investigation': 10,
+ 'speed': '30 ft.',
+ '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'],
+ },
]
}
diff --git a/ttfrog/db/schema.py b/ttfrog/db/schema.py
index 63f32b5..8dad933 100644
--- a/ttfrog/db/schema.py
+++ b/ttfrog/db/schema.py
@@ -1,32 +1,97 @@
+import enum
+
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import ForeignKey
-# from sqlalchemy import PrimaryKeyConstraint
-# from sqlalchemy import DateTime
+from sqlalchemy import Enum
+from sqlalchemy import Text
-from ttfrog.db.base import Bases
+from ttfrog.db.base import Bases, BaseObject, IterableMixin
+from ttfrog.db.base import multivalue_string_factory
-class Ancestry(*Bases):
- __tablename__ = "ancestry"
+STATS = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']
- name = Column(String, primary_key=True, unique=True)
+CREATURE_TYPES = ['aberation', 'beast', 'celestial', 'construct', 'dragon', 'elemental', 'fey', 'fiend', 'Giant',
+ 'humanoid', 'monstrosity', 'ooze', 'plant', 'undead']
+
+# enums for db schemas
+StatsEnum = enum.Enum("StatsEnum", ((k, k) for k in STATS))
+CreatureTypesEnum = enum.Enum("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
+
+CharacterClassMixin = multivalue_string_factory('character_class', Column(String, nullable=False))
+SavingThrowsMixin = multivalue_string_factory('saving_throws')
+SkillsMixin = multivalue_string_factory('skills')
+
+
+class Skill(*Bases):
+ __tablename__ = "skill"
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String, index=True, unique=True)
+ description = Column(Text)
def __repr__(self):
return str(self.name)
-class Character(*Bases):
- __tablename__ = "character"
+class Proficiency(*Bases):
+ __tablename__ = "proficiency"
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String, index=True, unique=True)
+ def __repr__(self):
+ return str(self.name)
+
+
+class Ancestry(*Bases):
+ __tablename__ = "ancestry"
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String, index=True, unique=True)
+ creature_type = Column(Enum(CreatureTypesEnum))
+
+ def __repr__(self):
+ return str(self.name)
+
+
+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_stat = Column(Enum(StatsEnum))
+ proficiencies = Column(String)
+
+ def __repr__(self):
+ return str(self.name)
+
+
+class Character(*Bases, CharacterClassMixin, SavingThrowsMixin, SkillsMixin):
+ __tablename__ = "character"
id = Column(Integer, primary_key=True, autoincrement=True)
ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False)
name = Column(String(255), nullable=False)
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
+ armor_class = Column(Integer, nullable=False, info={'min': 1, 'max': 99})
+ hit_points = Column(Integer, nullable=False, info={'min': 0, 'max': 999})
+ max_hit_points = Column(Integer, nullable=False, info={'min': 0, 'max': 999})
+ temp_hit_points = Column(Integer, nullable=False, info={'min': 0})
+ passive_perception = Column(Integer, nullable=False)
+ passive_insight = Column(Integer, nullable=False)
+ passive_investigation = Column(Integer, nullable=False)
+ speed = Column(String, nullable=False, default="30 ft.")
str = Column(Integer, info={'min': 0, 'max': 30})
dex = Column(Integer, info={'min': 0, 'max': 30})
con = Column(Integer, info={'min': 0, 'max': 30})
int = Column(Integer, info={'min': 0, 'max': 30})
wis = Column(Integer, info={'min': 0, 'max': 30})
cha = Column(Integer, info={'min': 0, 'max': 30})
+ proficiencies = Column(String)
+
+
+class TransactionLog(BaseObject, IterableMixin):
+ __tablename__ = "transaction_log"
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ source_table_name = Column(String, index=True, nullable=False)
+ primary_key = Column(Integer, index=True)
+ diff = Column(Text)
diff --git a/ttfrog/db/transaction_log.py b/ttfrog/db/transaction_log.py
new file mode 100644
index 0000000..4bbfd28
--- /dev/null
+++ b/ttfrog/db/transaction_log.py
@@ -0,0 +1,36 @@
+import json
+import logging
+
+from ttfrog.db.manager import db
+from ttfrog.db.schema import TransactionLog
+
+
+def record(previous, new):
+ diff = list((set(previous.items()) ^ set(dict(new).items())))
+ if not diff:
+ return
+
+ rec = TransactionLog(
+ source_table_name=new.__tablename__,
+ primary_key=new.id,
+ diff=json.dumps(diff),
+ )
+ with db.transaction():
+ db.add(rec)
+ logging.debug(f"Saved restore point: {dict(rec)}")
+ return rec
+
+
+def restore(rec, log_id=None):
+ if log_id:
+ log = db.query(TransactionLog).filter_by(id=log_id).one()
+ else:
+ log = db.query(TransactionLog).filter_by(source_table_name=rec.__tablename__, primary_key=rec.id).one()
+ logging.debug(f"Located restore point {log = }")
+ diff = json.loads(log.diff)
+ updates = dict(diff[::2])
+ if not updates:
+ return
+ logging.debug(f"{updates = }")
+ with db.transaction():
+ db.query(db.tables[log.source_table_name]).update(updates)
diff --git a/ttfrog/webserver/controllers/base.py b/ttfrog/webserver/controllers/base.py
index e74e7a4..18c45b2 100644
--- a/ttfrog/webserver/controllers/base.py
+++ b/ttfrog/webserver/controllers/base.py
@@ -5,10 +5,10 @@ from collections import defaultdict
from pyramid.httpexceptions import HTTPFound
from pyramid.interfaces import IRoutesMapper
-from wtforms.fields import SelectField
from ttfrog.attribute_map import AttributeMap
from ttfrog.db.manager import db
+from ttfrog.db import transaction_log
def get_all_routes(request):
@@ -26,12 +26,6 @@ def get_all_routes(request):
return routes
-class DeferredSelectField(SelectField):
- def __init__(self, *args, model=None, **kwargs):
- super().__init__(*args, **kwargs)
- self.choices = db.query(model).all()
-
-
class BaseController:
model = None
model_form = None
@@ -77,6 +71,12 @@ class BaseController:
self._form = self.model_form(obj=self.record)
return self._form
+ @property
+ def resources(self):
+ return [
+ {'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()
@@ -89,6 +89,7 @@ class BaseController:
form=self.form,
record=self.record,
routes=get_all_routes(self.request),
+ resources=self.resources,
**self.attrs,
**kwargs,
)
@@ -99,7 +100,9 @@ class BaseController:
return
if not self.form.validate():
return
+ previous = dict(self.record)
self.form.populate_obj(self.record)
+ transaction_log.record(previous, self.record)
if self.record.id:
return
with db.transaction():
diff --git a/ttfrog/webserver/controllers/character_sheet.py b/ttfrog/webserver/controllers/character_sheet.py
index 57380cc..02bcffd 100644
--- a/ttfrog/webserver/controllers/character_sheet.py
+++ b/ttfrog/webserver/controllers/character_sheet.py
@@ -1,7 +1,9 @@
-from ttfrog.webserver.controllers.base import BaseController, DeferredSelectField
-from ttfrog.db.schema import Character, Ancestry
+from ttfrog.webserver.controllers.base import BaseController
+from ttfrog.webserver.forms import DeferredSelectField, DeferredSelectMultipleField
+from ttfrog.db.schema import Character, Ancestry, CharacterClass, STATS
from wtforms_alchemy import ModelForm
-from wtforms.fields import SubmitField
+from wtforms.fields import SubmitField, SelectMultipleField
+from wtforms.widgets import Select
class CharacterForm(ModelForm):
@@ -11,9 +13,25 @@ class CharacterForm(ModelForm):
save = SubmitField()
delete = SubmitField()
- ancestry = DeferredSelectField('Ancestry', model=Ancestry, coerce=str, validate_choice=True)
+
+ ancestry = DeferredSelectField('Ancestry', model=Ancestry, validate_choice=True, widget=Select())
+
+ character_class = DeferredSelectMultipleField(
+ 'CharacterClass',
+ model=CharacterClass,
+ validate_choice=True,
+ # option_widget=Select(multiple=True)
+ )
+
+ saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS)
class CharacterSheet(BaseController):
model = CharacterForm.Meta.model
model_form = CharacterForm
+
+ @property
+ def resources(self):
+ return super().resources + [
+ {'type': 'script', 'uri': 'js/character_sheet.js'},
+ ]
diff --git a/ttfrog/webserver/forms.py b/ttfrog/webserver/forms.py
index c0c5c41..b817b79 100644
--- a/ttfrog/webserver/forms.py
+++ b/ttfrog/webserver/forms.py
@@ -1,7 +1,12 @@
-from wtforms_alchemy import ModelForm
-from db.schema import Character
+from ttfrog.db.manager import db
+from wtforms.fields import SelectField, SelectMultipleField
+class DeferredSelectMultipleField(SelectMultipleField):
+ def __init__(self, *args, model=None, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.choices = db.query(model).all()
-class CharacterForm(ModelForm):
- class Meta:
- model = Character
+class DeferredSelectField(SelectField):
+ def __init__(self, *args, model=None, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.choices = db.query(model).all()
diff --git a/ttfrog/webserver/widgets.py b/ttfrog/webserver/widgets.py
deleted file mode 100644
index 61126f4..0000000
--- a/ttfrog/webserver/widgets.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import tw2.core as twc
-import tw2.forms
-from ttfrog.db import db
-
-
-class CharacterSheet(tw2.forms.Form):
- action = ''
-
- class child(tw2.forms.TableLayout):
- name = tw2.forms.TextField(validator=twc.Required)
- level = tw2.forms.SingleSelectField(
- prompt_text=None,
- options=range(1, 21),
- validator=twc.validation.IntValidator(min=1, max=20)
- )
- ancestry_name = tw2.forms.SingleSelectField(
- label='Ancestry',
- prompt_text=None,
- options=twc.Deferred(lambda: [a.name for a in db.query(db.ancestry)]),
- validator=twc.Required,
- )