added transaction log, UX scaffolding

This commit is contained in:
evilchili 2024-02-08 01:14:35 -08:00
parent 2dcaa3fac6
commit da6255a86a
15 changed files with 460 additions and 73 deletions

View File

@ -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;
}

View File

@ -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();
})();

View File

@ -2,12 +2,26 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ c.config.project_name }}{% block title %}{% endblock %}</title> <title>{{ c.config.project_name }}{% block title %}{% endblock %}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="og:provider_name" content="{{ c.config.project_name }}"> <meta name="og:provider_name" content="{{ c.config.project_name }}">
<link rel='stylesheet' href="{{c.routes.static}}/styles.css" /> {% for resource in c.resources %}
<link rel='preload' href="{{c.routes.static}}/{{resource['uri']}}" as="{{resource['type']}}"/>
{% if resource['type'] == 'style' %}
<link rel='stylesheet' href="{{c.routes.static}}/{{resource['uri']}}" />
{% endif %}
{% endfor %}
{% block headers %}{% endblock %} {% block headers %}{% endblock %}
</head> </head>
<body> <body>
<div id='content'>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div>
{% block debug %}{% endblock %} {% block debug %}{% endblock %}
{% for resource in c.resources %}
{% if resource['type'] == 'script' %}
<script type="text/javascript" src="{{c.routes.static}}/{{resource['uri']}}"></script>
{% endif %}
{% endfor %}
</body> </body>
</html> </html>

View File

@ -5,27 +5,71 @@
{{ build_list(c) }} {{ build_list(c) }}
<div style='float:left;'> <div id='character_sheet'>
<h1>{{ c['record'].name }}</h1>
<form name="character_sheet" method="post" novalidate class="form"> <form name="character_sheet" method="post" novalidate class="form">
{{ c.form.csrf_token }}
<div class='banner'>
<div><img id='portrait' /></div>
<div>
<h1>{{ c.record.name }}</h1>
{{ c.form.ancestry }} {{ c.record.character_class|join(' / ') }} &nbsp; Level {{ c.form.level }}
</div>
</div>
<div class='statblock'>
<div class='stats'>
<div class='cards'>
{% for stat in ['str', 'dex', 'con', 'int', 'wis', 'cha'] %}
<div class='card'>
<div class='label'>{{ c.form[stat].label }}</div>
{{ c.form[stat] }}
<div id='{{stat}}_bonus'></div>
</div>
{% endfor %}
<div class='card'>
<div class='label'>AC</div>
{{ c.form.armor_class }}
</div>
</div>
<ul> <ul>
<li>Initiative: <span id='initiative'>3</span></li>
<li>Proficiency Bonus: <span id='proficiency_bonus'></span></li>
<li>Spell Save DC: <span id='spell_save_dc'></span></li>
<li>Saving Throws: <span id='saving_throws'>{{ c.record.saving_throws |join(', ') }}</span></li>
<li>Skills: <span id='skills'>{{ c.record.skills |join(', ') }}</span></li>
{% for field in c.form %} {% for field in c.form %}
{% if field.name not in ['save', 'delete'] %} {% if field.name in ['proficiencies', 'speed', 'passive_perception', 'passive_insight', 'passive_investigation'] %}
<li>{{ field.label }}: {{ field }} {{ field.errors|join(',') }}</li> <li>{{ field.label }}: {{ field }} {{ field.errors|join(',') }}</li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
{{ c.form.save }} &nbsp; {{ c.form.delete }}
</form>
</div> </div>
<div class='sidebar'>
<div class='cards'>
<div class='card'>
<div class='label'>HP</div>
{{ c.form.max_hit_points }} / {{ c.form.hit_points }}
</div>
<div class='card'>
<div class='label'>TEMP HP</div>
{{ c.form.temp_hit_points }}
</div>
</div>
</div>
</div>
<hr>
{{ c.form.save }} &nbsp; {{ c.form.delete }}
</div>
{{ c.form.csrf_token }}
</form>
{% endblock %} {% endblock %}
{% block debug %} {% block debug %}
<div style='clear:both;display:block;'> <div style='clear:both;display:block;'>
<h2>Debug</h2> <h2>Debug</h2>
<pre> <code>
{{ c }} {{ c }}
</pre> </code>
{% endblock %} {% endblock %}

View File

@ -1,10 +1,8 @@
{% macro build_list(c) %} {% macro build_list(c) %}
<div style='float:left; min-height: 90%; margin-right:5em;'> <ul class='nav'>
<ul>
<li><a href="{{ c.routes.sheet }}">Create a Character</a></li> <li><a href="{{ c.routes.sheet }}">Create a Character</a></li>
{% for rec in c.all_records %} {% for rec in c.all_records %}
<li><a href="{{ c.routes.sheet }}/{{ rec.uri }}">{{ rec.uri }}</a></li> <li><a href="{{ c.routes.sheet }}/{{ rec.uri }}">{{ rec.name }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div>
{% endmacro %} {% endmacro %}

View File

@ -75,7 +75,6 @@ def setup(context: typer.Context):
bootstrap() bootstrap()
@app.command() @app.command()
def serve( def serve(
context: typer.Context, context: typer.Context,
@ -98,8 +97,10 @@ def serve(
# delay loading the app until we have configured our environment # delay loading the app until we have configured our environment
from ttfrog.webserver import application from ttfrog.webserver import application
from ttfrog.db.bootstrap import bootstrap
print("Starting TableTop Frog server...") print("Starting TableTop Frog server...")
bootstrap()
application.start(host=host, port=port, debug=debug) application.start(host=host, port=port, debug=debug)

View File

@ -1,12 +1,10 @@
import nanoid import nanoid
from nanoid_dictionary import human_alphabet 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 Column
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy import String
from pyramid_sqlalchemy import BaseObject
from slugify import slugify
def genslug(): def genslug():
@ -38,5 +36,31 @@ class IterableMixin:
return f"{self.__class__.__name__}: {str(dict(self))}" 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): # class Table(*Bases):
Bases = [BaseObject, IterableMixin, SlugMixin] Bases = [BaseObject, IterableMixin, SlugMixin]

View File

@ -7,13 +7,70 @@ from sqlalchemy.exc import IntegrityError
# move this to json or whatever # move this to json or whatever
data = { 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': [ 'Ancestry': [
{'name': 'human'}, {'name': 'human', 'creature_type': 'humanoid'},
{'name': 'dragonborn'}, {'name': 'dragonborn', 'creature_type': 'humanoid'},
{'name': 'tiefling'}, {'name': 'tiefling', 'creature_type': 'humanoid'},
], ],
'Character': [ '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'],
},
] ]
} }

View File

@ -1,32 +1,97 @@
import enum
from sqlalchemy import Column from sqlalchemy import Column
from sqlalchemy import Integer from sqlalchemy import Integer
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
# from sqlalchemy import PrimaryKeyConstraint from sqlalchemy import Enum
# from sqlalchemy import DateTime 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): STATS = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']
__tablename__ = "ancestry"
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): def __repr__(self):
return str(self.name) return str(self.name)
class Character(*Bases): class Proficiency(*Bases):
__tablename__ = "character" __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) id = Column(Integer, primary_key=True, autoincrement=True)
ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False) ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False)
name = Column(String(255), nullable=False) name = Column(String(255), nullable=False)
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}) 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}) str = Column(Integer, info={'min': 0, 'max': 30})
dex = Column(Integer, info={'min': 0, 'max': 30}) dex = Column(Integer, info={'min': 0, 'max': 30})
con = Column(Integer, info={'min': 0, 'max': 30}) con = Column(Integer, info={'min': 0, 'max': 30})
int = Column(Integer, info={'min': 0, 'max': 30}) int = Column(Integer, info={'min': 0, 'max': 30})
wis = Column(Integer, info={'min': 0, 'max': 30}) wis = Column(Integer, info={'min': 0, 'max': 30})
cha = 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)

View File

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

View File

@ -5,10 +5,10 @@ from collections import defaultdict
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from pyramid.interfaces import IRoutesMapper from pyramid.interfaces import IRoutesMapper
from wtforms.fields import SelectField
from ttfrog.attribute_map import AttributeMap from ttfrog.attribute_map import AttributeMap
from ttfrog.db.manager import db from ttfrog.db.manager import db
from ttfrog.db import transaction_log
def get_all_routes(request): def get_all_routes(request):
@ -26,12 +26,6 @@ def get_all_routes(request):
return routes return routes
class DeferredSelectField(SelectField):
def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs)
self.choices = db.query(model).all()
class BaseController: class BaseController:
model = None model = None
model_form = None model_form = None
@ -77,6 +71,12 @@ class BaseController:
self._form = self.model_form(obj=self.record) self._form = self.model_form(obj=self.record)
return self._form return self._form
@property
def resources(self):
return [
{'type': 'style', 'uri': 'css/styles.css'},
]
def configure_for_model(self): def configure_for_model(self):
if 'all_records' not in self.attrs: if 'all_records' not in self.attrs:
self.attrs['all_records'] = db.query(self.model).all() self.attrs['all_records'] = db.query(self.model).all()
@ -89,6 +89,7 @@ class BaseController:
form=self.form, form=self.form,
record=self.record, record=self.record,
routes=get_all_routes(self.request), routes=get_all_routes(self.request),
resources=self.resources,
**self.attrs, **self.attrs,
**kwargs, **kwargs,
) )
@ -99,7 +100,9 @@ class BaseController:
return return
if not self.form.validate(): if not self.form.validate():
return return
previous = dict(self.record)
self.form.populate_obj(self.record) self.form.populate_obj(self.record)
transaction_log.record(previous, self.record)
if self.record.id: if self.record.id:
return return
with db.transaction(): with db.transaction():

View File

@ -1,7 +1,9 @@
from ttfrog.webserver.controllers.base import BaseController, DeferredSelectField from ttfrog.webserver.controllers.base import BaseController
from ttfrog.db.schema import Character, Ancestry from ttfrog.webserver.forms import DeferredSelectField, DeferredSelectMultipleField
from ttfrog.db.schema import Character, Ancestry, CharacterClass, STATS
from wtforms_alchemy import ModelForm from wtforms_alchemy import ModelForm
from wtforms.fields import SubmitField from wtforms.fields import SubmitField, SelectMultipleField
from wtforms.widgets import Select
class CharacterForm(ModelForm): class CharacterForm(ModelForm):
@ -11,9 +13,25 @@ class CharacterForm(ModelForm):
save = SubmitField() save = SubmitField()
delete = 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): class CharacterSheet(BaseController):
model = CharacterForm.Meta.model model = CharacterForm.Meta.model
model_form = CharacterForm model_form = CharacterForm
@property
def resources(self):
return super().resources + [
{'type': 'script', 'uri': 'js/character_sheet.js'},
]

View File

@ -1,7 +1,12 @@
from wtforms_alchemy import ModelForm from ttfrog.db.manager import db
from db.schema import Character 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 DeferredSelectField(SelectField):
class Meta: def __init__(self, *args, model=None, **kwargs):
model = Character super().__init__(*args, **kwargs)
self.choices = db.query(model).all()

View File

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