From da6255a86aacf6467d0733634b2fceeca6d45f49 Mon Sep 17 00:00:00 2001 From: evilchili Date: Thu, 8 Feb 2024 01:14:35 -0800 Subject: [PATCH] added transaction log, UX scaffolding --- ttfrog/assets/static/css/styles.css | 112 ++++++++++++++++++ ttfrog/assets/static/js/character_sheet.js | 31 +++++ ttfrog/assets/static/styles.css | 0 ttfrog/assets/templates/base.html | 16 ++- ttfrog/assets/templates/character_sheet.html | 66 +++++++++-- ttfrog/assets/templates/list.html | 10 +- ttfrog/cli.py | 3 +- ttfrog/db/base.py | 34 +++++- ttfrog/db/bootstrap.py | 65 +++++++++- ttfrog/db/schema.py | 81 +++++++++++-- ttfrog/db/transaction_log.py | 36 ++++++ ttfrog/webserver/controllers/base.py | 17 +-- .../webserver/controllers/character_sheet.py | 26 +++- ttfrog/webserver/forms.py | 15 ++- ttfrog/webserver/widgets.py | 21 ---- 15 files changed, 460 insertions(+), 73 deletions(-) create mode 100644 ttfrog/assets/static/css/styles.css create mode 100644 ttfrog/assets/static/js/character_sheet.js delete mode 100644 ttfrog/assets/static/styles.css create mode 100644 ttfrog/db/transaction_log.py delete mode 100644 ttfrog/webserver/widgets.py diff --git a/ttfrog/assets/static/css/styles.css b/ttfrog/assets/static/css/styles.css new file mode 100644 index 0000000..208afc0 --- /dev/null +++ b/ttfrog/assets/static/css/styles.css @@ -0,0 +1,112 @@ +body { + width: 100%; + padding: 0; + margin: 0; +} + + +#content { + margin: 1rem auto; + max-width:1280px; +} + + +a { + text-decoration: none; + color: #000055; +} +a:visited, a:active { + color: #000055; +} + + +ul.nav { + background: #e7e7e7; + margin: 0; + padding: 0.5rem; + list-style-type: none; + margin-bottom: 0.5rem; +} + +ul.nav li { + display: inline; + text-align: center; + background: #FFF; + padding: 0 0.5rem; +} + +#character_sheet { + width:50%; + display: block; +} + +#character_sheet .statblock { + width: 100%; + margin-bottom:3rem; + display: grid; + grid-template-columns: auto 250px; + grid-gap: 1rem; +} + +#character_sheet .banner { + display: grid; + grid-template-columns: 64px 1fr; + grid-gap: 1rem; +} + +#character_sheet .banner #portrait { + width: 64px; + height: 64px; + background: #e7e7e7; +} + + +#character_sheet h1 { + margin: 0; +} + +#character_sheet .sidebar { + min-height: 300px; +} + +#character_sheet input, +#character_sheet select, +#character_sheet textarea { + font-weight: bold; + border: 0; +} + +.stats .cards { + margin-top: 1rem; + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-auto-rows: auto; + grid-gap: 1rem; +} + +.sidebar .cards { + margin-top: 1rem; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto; + grid-gap: 1rem; + text-align: center; +} + +.cards .card { + border: 2px solid #e7e7e7; + border-radius: 4px; + padding: .5rem; +} + +.card .label { + text-align: center; + text-transform: uppercase; +} + +.card input { + font-size: 1.25em; + text-align: center; + padding: 0; + margin: 0; +} diff --git a/ttfrog/assets/static/js/character_sheet.js b/ttfrog/assets/static/js/character_sheet.js new file mode 100644 index 0000000..15bdfb4 --- /dev/null +++ b/ttfrog/assets/static/js/character_sheet.js @@ -0,0 +1,31 @@ +function proficiency() { + return parseInt(document.getElementById('proficiency_bonus').innerHTML); +} + +function bonus(stat) { + return parseInt(document.getElementById(stat + '_bonus').innerHTML); +} + +function setStatBonus(stat) { + var score = document.getElementById(stat).value; + var bonus = Math.floor((score - 10) / 2); + document.getElementById(stat + '_bonus').innerHTML = bonus; +} + +function setProficiencyBonus() { + var score = document.getElementById('level').value; + var bonus = Math.ceil(1 + (0.25 * score)); + document.getElementById('proficiency_bonus').innerHTML = bonus; +} + +function setSpellSaveDC() { + var score = 8 + proficiency() + bonus('wis'); + document.getElementById('spell_save_dc').innerHTML = score; +} + +(function () { + const stats = ['str', 'dex', 'con', 'int', 'wis', 'cha']; + stats.forEach(setStatBonus); + setProficiencyBonus(); + setSpellSaveDC(); +})(); diff --git a/ttfrog/assets/static/styles.css b/ttfrog/assets/static/styles.css deleted file mode 100644 index e69de29..0000000 diff --git a/ttfrog/assets/templates/base.html b/ttfrog/assets/templates/base.html index 8f3f407..2e25d02 100644 --- a/ttfrog/assets/templates/base.html +++ b/ttfrog/assets/templates/base.html @@ -2,12 +2,26 @@ {{ c.config.project_name }}{% block title %}{% endblock %} + + - + {% for resource in c.resources %} + + {% if resource['type'] == 'style' %} + + {% endif %} + {% endfor %} {% block headers %}{% endblock %} +
{% block content %}{% endblock %} +
{% block debug %}{% endblock %} +{% for resource in c.resources %} + {% if resource['type'] == 'script' %} + + {% endif %} +{% endfor %} diff --git a/ttfrog/assets/templates/character_sheet.html b/ttfrog/assets/templates/character_sheet.html index a74d67a..baece9f 100644 --- a/ttfrog/assets/templates/character_sheet.html +++ b/ttfrog/assets/templates/character_sheet.html @@ -5,27 +5,71 @@ {{ build_list(c) }} -
-

{{ c['record'].name }}

- +
- {{ c.form.csrf_token }} -
    + + +
    +
    +
    +{% for stat in ['str', 'dex', 'con', 'int', 'wis', 'cha'] %} +
    +
    {{ c.form[stat].label }}
    + {{ c.form[stat] }} +
    +
    +{% endfor %} +
    +
    AC
    + {{ c.form.armor_class }} +
    +
    +
      +
    • Initiative: 3
    • +
    • Proficiency Bonus:
    • +
    • Spell Save DC:
    • +
    • Saving Throws: {{ c.record.saving_throws |join(', ') }}
    • +
    • Skills: {{ c.record.skills |join(', ') }}
    • {% for field in c.form %} - {% if field.name not in ['save', 'delete'] %} + {% if field.name in ['proficiencies', 'speed', 'passive_perception', 'passive_insight', 'passive_investigation'] %}
    • {{ field.label }}: {{ field }} {{ field.errors|join(',') }}
    • {% endif %} {% endfor %} -
    - {{ c.form.save }}   {{ c.form.delete }} - +
+
+
+ +
+ + {{ c.form.save }}   {{ c.form.delete }} + +{{ c.form.csrf_token }} + + {% endblock %} {% block debug %}

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, - )