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">
<head>
<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 }}">
<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 %}
</head>
<body>
<div id='content'>
{% block content %}{% endblock %}
</div>
{% 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>
</html>

View File

@ -5,27 +5,71 @@
{{ build_list(c) }}
<div style='float:left;'>
<h1>{{ c['record'].name }}</h1>
<div id='character_sheet'>
<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>
<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 %}
{% 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>
{% endif %}
{% endfor %}
</ul>
{{ c.form.save }} &nbsp; {{ c.form.delete }}
</form>
</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 %}
{% block debug %}
<div style='clear:both;display:block;'>
<h2>Debug</h2>
<pre>
<code>
{{ c }}
</pre>
</code>
{% endblock %}

View File

@ -1,10 +1,8 @@
{% macro build_list(c) %}
<div style='float:left; min-height: 90%; margin-right:5em;'>
<ul>
<ul class='nav'>
<li><a href="{{ c.routes.sheet }}">Create a Character</a></li>
{% 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 %}
</ul>
</div>
</ul>
{% endmacro %}

View File

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

View File

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

View File

@ -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'],
},
]
}

View File

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

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.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():

View File

@ -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'},
]

View File

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

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