restructuring for poetry-slam
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
body {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
position : relative;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.disabled:after {
|
||||
position :absolute;
|
||||
left : 0;
|
||||
top : 0;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
content :' ';
|
||||
}
|
||||
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
#sheet_container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#character_sheet {
|
||||
margin-bottom:3rem;
|
||||
display: grid;
|
||||
grid-gap: 1rem;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
#sheet_container .banner {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
#sheet_container .banner #portrait {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #e7e7e7;
|
||||
}
|
||||
|
||||
#controls {
|
||||
float: right;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.temp_hp input {
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
#sheet_container h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#sheet_container .sidebar {
|
||||
grid-column-start: 2;
|
||||
grid-row-start: 1;
|
||||
}
|
||||
|
||||
#sheet_container .sidebar .card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#sheet_container .sidebar ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#sheet_container .sidebar ul > li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#hp {
|
||||
grid-row-start: 1;
|
||||
grid-column: 7;
|
||||
grid-column-end: 9;
|
||||
}
|
||||
|
||||
#saves {
|
||||
grid-row-start: 2;
|
||||
grid-column: 3;
|
||||
grid-column-start: span 2;
|
||||
}
|
||||
|
||||
#proficiency {
|
||||
grid-row-start: 2;
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
#initiative {
|
||||
grid-row-start: 2;
|
||||
grid-column: 6;
|
||||
}
|
||||
#ac {
|
||||
grid-row-start: 2;
|
||||
grid-column: 7;
|
||||
}
|
||||
#speed {
|
||||
grid-row-start: 2;
|
||||
grid-column: 8;
|
||||
}
|
||||
|
||||
#skills {
|
||||
grid-row-start: 2;
|
||||
grid-row-end: 50;
|
||||
grid-column: 1;
|
||||
grid-column-start: span 2;
|
||||
text-align:left;
|
||||
}
|
||||
|
||||
#actions {
|
||||
grid-row-start: 3;
|
||||
grid-column: 4;
|
||||
grid-column-start: span 6;
|
||||
}
|
||||
|
||||
|
||||
table {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, 150px) 1fr;
|
||||
grid-gap: 0rem;
|
||||
}
|
||||
table th {
|
||||
grid-column-start: span 4;
|
||||
white-space: nowrap;
|
||||
padding-right: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table td {
|
||||
padding-right: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 0.75em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#sheet_container input,
|
||||
#sheet_container select,
|
||||
#sheet_container textarea {
|
||||
font-weight: bold;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#sheet_container input#name {
|
||||
font-size: 2.0rem;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(6rem, 1fr));
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 2px solid #e7e7e7;
|
||||
border-radius: 4px;
|
||||
padding: .5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.card input {
|
||||
font-size: 1.25em;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
ul.multiclass {
|
||||
display: inline;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.multiclass li {
|
||||
display: inline;
|
||||
}
|
||||
.multiclass label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
ul#class_attributes {
|
||||
list-style-type: none;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ul#class_attributes li {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr 1fr;
|
||||
}
|
||||
|
||||
ul#class_attributes span,
|
||||
ul#class_attributes label {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
ul#class_attributes label {
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ul#class_attributes span select {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
function getTraitModifiersForStat(stat) {
|
||||
var mods = {};
|
||||
for (const prop in TRAITS) {
|
||||
var props = [];
|
||||
for (const desc in TRAITS[prop]) {
|
||||
trait = TRAITS[prop][desc]
|
||||
if (trait.type == "stat" && trait.target == stat) {
|
||||
props.push(trait);
|
||||
}
|
||||
}
|
||||
if (props) {
|
||||
mods[prop] = props;
|
||||
}
|
||||
}
|
||||
return mods;
|
||||
}
|
||||
|
||||
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 applyStatModifiers(stat) {
|
||||
var score = parseInt(document.getElementById(stat).value);
|
||||
var modsForStat = getTraitModifiersForStat(stat);
|
||||
for (desc in modsForStat) {
|
||||
for (idx in modsForStat[desc]) {
|
||||
var value = modsForStat[desc][idx].value;
|
||||
console.log(`Ancestry Trait "${desc}" grants ${value} to ${stat}`);
|
||||
score += parseInt(value);
|
||||
}
|
||||
}
|
||||
document.getElementById(stat).value = score;
|
||||
}
|
||||
|
||||
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(applyStatModifiers);
|
||||
stats.forEach(setStatBonus);
|
||||
setProficiencyBonus();
|
||||
// setSpellSaveDC();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,30 @@
|
||||
{% from "list.html" import build_list %}
|
||||
<!doctype html>
|
||||
<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 }}">
|
||||
{% 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>
|
||||
{{ build_list(c) }}
|
||||
<div id='content'>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% block debug %}{% endblock %}
|
||||
{% block script %}{% 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>
|
||||
@@ -0,0 +1,197 @@
|
||||
{% extends "base.html" %}
|
||||
{% set DISABLED = False if c.record.id else True %}
|
||||
|
||||
{% macro field(name, disabled=False) %}
|
||||
{% set default_value = c.record[name] if c.record.id else c.form[name].default %}
|
||||
{{ c.form[name](disabled=disabled, **{'data-initial_value': default_value}) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id='sheet_container'>
|
||||
<form name="character_sheet" method="post" novalidate class="form">
|
||||
|
||||
<div class='banner'>
|
||||
<div><img id='portrait' /></div>
|
||||
<div>
|
||||
{{ field('name') }}
|
||||
{{ field('ancestry_id') }}
|
||||
{% for obj in c.form['classes'] %}
|
||||
{{ obj(class='multiclass') }}
|
||||
{% endfor %}
|
||||
<span class='label'>Add Class:</span> {{ c.form['newclass'](class='multiclass') }}
|
||||
|
||||
|
||||
<div id='controls'>
|
||||
{{ c.form.save }} {{ c.form.delete }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id='character_sheet' {% if not c.record.id %}class='disabled'{% endif %} >
|
||||
<div class='stats'>
|
||||
{% for stat in ['str', 'dex', 'con', 'int', 'wis', 'cha'] %}
|
||||
<div class='card'>
|
||||
<div class='label'>{{ c.form[stat].label }}</div>
|
||||
{{ field(stat, DISABLED) }}
|
||||
<div id='{{stat}}_bonus'></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div id='hp' class='card'>
|
||||
<div class='label'>HP</div>
|
||||
{{ field('hit_points', DISABLED) }} / {{ field('max_hit_points', DISABLED) }}
|
||||
<div id='temp_hp'>
|
||||
<span class='label'>TEMP</span> {{ field('temp_hit_points', DISABLED) }}
|
||||
</div>
|
||||
</div>
|
||||
<div id='skills'>
|
||||
<div class='label'>Skills</div>
|
||||
<table>
|
||||
{% for skill in c.record.skills %}
|
||||
<tr><td>{{ skill }}</td><td>3</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div id='saves' class='card'>
|
||||
<div class='label'>Saving Throws</div>
|
||||
{% for save in c.record.saving_throws %}
|
||||
{{ save }} 3
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id='proficiency' class='card'>
|
||||
<div class='label'>PROF</div>
|
||||
<div id='proficiency_bonus'></div>
|
||||
<div class='label'>BONUS</div>
|
||||
</div>
|
||||
<div id="ac" class='card'>
|
||||
<div class='label'>Armor</div>
|
||||
{{ field('armor_class', DISABLED) }}
|
||||
<div class='label'>Class</div>
|
||||
</div>
|
||||
<div id='initiative' class='card'>
|
||||
<div class='label'>Initiative</div>
|
||||
<span id='initiative_bonus'>3 </span>
|
||||
<div class='label'>Bonus</div>
|
||||
</div>
|
||||
<div id='speed' class='card'>
|
||||
<div class='label'>Speed</div>
|
||||
{{ field('speed', DISABLED) }}
|
||||
</div>
|
||||
<div id="actions" class='card'>
|
||||
<table>
|
||||
<tr>
|
||||
<td class='label' colspan='2'>Actions</td>
|
||||
<td class='label'>To Hit</td>
|
||||
<td class='label'>Range</td>
|
||||
<td class='label'>Targets</td>
|
||||
<td class='label'>Damage</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Attack</th>
|
||||
<td>Dagger</td>
|
||||
<td>+7</td>
|
||||
<td>5</td>
|
||||
<td>1</td>
|
||||
<td>1d4+3 slashing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Attack</th>
|
||||
<td>Sabetha's Fans</td>
|
||||
<td>+7</td>
|
||||
<td>5</td>
|
||||
<td>1</td>
|
||||
<td>2d6 slashing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Spell</th>
|
||||
<td>Eldritch Blast</td>
|
||||
<td>+5</td>
|
||||
<td>120</td>
|
||||
<td>1</td>
|
||||
<td>1d10 force</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class='label' colspan='2'>Bonus Actions</td>
|
||||
<td class='label'>To Hit</td>
|
||||
<td class='label'>Range</td>
|
||||
<td class='label'>Targets</td>
|
||||
<td class='label'>Damage</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
<span class='note'>
|
||||
Attack (1 per Action), Cast a Spell, Dash, Disengage, Dodge, Grapple,<br>Help, Hide, Improvise, Ready, Search, Shove, or Use an Object
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
|
||||
<div class='sidebar'>
|
||||
<div class='card'>
|
||||
<div class='label'>Inspiration</div>
|
||||
<ul>
|
||||
</ul>
|
||||
</div>
|
||||
<div class='card'>
|
||||
<div class='label'>Conditions</div>
|
||||
<ul>
|
||||
</ul>
|
||||
</div>
|
||||
<div class='card'>
|
||||
<div class='label'>Attributes</div>
|
||||
{% if c.record.class_attributes %}
|
||||
{{ field('class_attributes') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class='card'>
|
||||
<div class='label'>Defenses</div>
|
||||
<ul>
|
||||
<li>Vulnerable to Fire</li>
|
||||
<li>Immune to Cold</li>
|
||||
<li>Resistant to Poison</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
{{ c.form.csrf_token }}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block debug %}
|
||||
<div style='clear:both;display:block;'>
|
||||
<h2>Debug</h2>
|
||||
<code>
|
||||
{% for field, msg in c.form.errors.items() %}
|
||||
{{ field }}: {{ msg }}
|
||||
{% endfor %}
|
||||
</code>
|
||||
{{ c.record.class_attributes }}
|
||||
</code>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
<script type='text/javascript'>
|
||||
const TRAITS = {
|
||||
{% for trait_desc, traits in [] %}
|
||||
'{{ trait_desc }}': [
|
||||
{% for trait in traits %}
|
||||
{
|
||||
"type": "{{ trait['type'] }}",
|
||||
"target": "{{ trait.target }}",
|
||||
"value": "{{ trait.value }}",
|
||||
},
|
||||
{% endfor %}
|
||||
],
|
||||
{% endfor %}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ tmpl_context.project_name }}</title>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="{{ tmpl_context.project_name }}">
|
||||
|
||||
<meta name="og:title" content="{{ tmpl_context.project_name }}">
|
||||
<meta name="og:description" content="{{ tmpl_context.project_name }}">
|
||||
<meta name="og:url" content="">
|
||||
<meta name="og:type" content="text">
|
||||
<meta name="og:provider_name" content="{{ tmpl_context.project_name }}">
|
||||
<!--
|
||||
<meta name="og:image" content="/static/45.svg">
|
||||
-->
|
||||
|
||||
<link rel='stylesheet' href='/static/styles.css' />
|
||||
|
||||
<!--
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
-->
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ tmpl_context.project_name }}: {{ page }}</h1>
|
||||
<pre>
|
||||
{{ content }}
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{% macro build_list(c) %}
|
||||
<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.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,71 @@
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeMap(Mapping):
|
||||
"""
|
||||
AttributeMap is a data class that is also a mapping, converting a dict
|
||||
into an object with attributes. Example:
|
||||
|
||||
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
|
||||
>>> amap.foo
|
||||
True
|
||||
>>> amap.bar
|
||||
False
|
||||
|
||||
Instantiating an AttributeMap using the from_dict() class method will
|
||||
recursively transform dictionary members sinto AttributeMaps:
|
||||
|
||||
>>> nested_dict = {'foo': {'bar': {'baz': True}, 'boz': False}}
|
||||
>>> amap = AttributeMap.from_dict(nested_dict)
|
||||
>>> amap.foo.bar.baz
|
||||
True
|
||||
>>> amap.foo.boz
|
||||
False
|
||||
|
||||
The dictionary can be accessed directly via 'attributes':
|
||||
|
||||
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
|
||||
>>> list(amap.attributes.keys()):
|
||||
>>>['foo', 'bar']
|
||||
|
||||
Because AttributeMap is a mapping, you can use it anywhere you would use
|
||||
a regular mapping, like a dict:
|
||||
|
||||
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
|
||||
>>> 'foo' in amap
|
||||
True
|
||||
>>> "{foo}, {bar}".format(**amap)
|
||||
True, False
|
||||
|
||||
|
||||
"""
|
||||
|
||||
attributes: field(default_factory=dict)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
if attr in self.attributes:
|
||||
return self.attributes[attr]
|
||||
return self.__getattribute__(attr)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.attributes)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.attributes[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.attributes)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, kwargs: dict):
|
||||
"""
|
||||
Create a new AttributeMap object using keyword arguments. Dicts are
|
||||
recursively converted to AttributeMap objects; everything else is
|
||||
passed as-is.
|
||||
"""
|
||||
attrs = {}
|
||||
for k, v in sorted(kwargs.items()):
|
||||
attrs[k] = AttributeMap.from_dict(v) if type(v) is dict else v
|
||||
return cls(attributes=attrs)
|
||||
@@ -0,0 +1,103 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from dotenv import load_dotenv
|
||||
from rich import print
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from ttfrog.path import assets
|
||||
|
||||
default_data_path = Path("~/.dnd/ttfrog")
|
||||
default_host = "127.0.0.1"
|
||||
default_port = 2323
|
||||
|
||||
SETUP_HELP = f"""
|
||||
# Please make sure you set the SECRET_KEY in your environment. By default,
|
||||
# TableTop Frog will attempt to load these variables from:
|
||||
# {default_data_path}/defaults
|
||||
#
|
||||
# which may contain the following variables as well.
|
||||
#
|
||||
# See also the --root paramter.
|
||||
|
||||
DATA_PATH={default_data_path}
|
||||
|
||||
# Uncomment one or both of these to replace the packaged static assets and templates:
|
||||
#
|
||||
# STATIC_FILES_PATH={assets()}/public
|
||||
# TEMPLATES_PATH={assets()}/templates
|
||||
|
||||
HOST={default_host}
|
||||
PORT={default_port}
|
||||
"""
|
||||
|
||||
app = typer.Typer()
|
||||
app_state = dict()
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main(
|
||||
context: typer.Context,
|
||||
root: Optional[Path] = typer.Option(
|
||||
default_data_path,
|
||||
help="Path to the TableTop Frog environment",
|
||||
),
|
||||
):
|
||||
app_state["env"] = root.expanduser() / Path("defaults")
|
||||
load_dotenv(stream=io.StringIO(SETUP_HELP))
|
||||
load_dotenv(app_state["env"])
|
||||
debug = os.getenv("DEBUG", None)
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
level=logging.DEBUG if debug else logging.INFO,
|
||||
handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
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))
|
||||
print(f"Wrote defaults file {app_state['env']}.")
|
||||
bootstrap()
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
context: typer.Context,
|
||||
host: str = typer.Argument(
|
||||
default_host,
|
||||
help="bind address",
|
||||
),
|
||||
port: int = typer.Argument(
|
||||
default_port,
|
||||
help="bind port",
|
||||
),
|
||||
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.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__":
|
||||
app()
|
||||
@@ -0,0 +1,121 @@
|
||||
import enum
|
||||
|
||||
import nanoid
|
||||
from nanoid_dictionary import human_alphabet
|
||||
from pyramid_sqlalchemy import BaseObject
|
||||
from slugify import slugify
|
||||
from sqlalchemy import Column, String
|
||||
|
||||
|
||||
def genslug():
|
||||
return nanoid.generate(human_alphabet[2:], 5)
|
||||
|
||||
|
||||
class SlugMixin:
|
||||
slug = Column(String, index=True, unique=True, default=genslug)
|
||||
|
||||
@property
|
||||
def uri(self):
|
||||
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():
|
||||
if attr in values:
|
||||
yield attr, values[attr]
|
||||
for relname in self.__mapper__.relationships.keys():
|
||||
relvals = []
|
||||
reliter = self.__getattribute__(relname)
|
||||
if not reliter:
|
||||
yield relname, relvals
|
||||
continue
|
||||
for rel in reliter:
|
||||
try:
|
||||
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:
|
||||
try:
|
||||
serialized[key] = getattr(self.value, "__json__")(request)
|
||||
except AttributeError:
|
||||
serialized[key] = value
|
||||
return serialized
|
||||
|
||||
def __repr__(self):
|
||||
return 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 EnumField(enum.Enum):
|
||||
"""
|
||||
A serializable enum.
|
||||
"""
|
||||
|
||||
def __json__(self, request):
|
||||
return self.value
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
|
||||
StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS))
|
||||
|
||||
# class Table(*Bases):
|
||||
Bases = [BaseObject, IterableMixin, SlugMixin]
|
||||
@@ -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
|
||||
@@ -0,0 +1,87 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from functools import cached_property
|
||||
|
||||
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
|
||||
|
||||
|
||||
ttfrog.db.schema
|
||||
|
||||
|
||||
class SQLDatabaseManager:
|
||||
"""
|
||||
A context manager for working with sqllite database.
|
||||
"""
|
||||
|
||||
@cached_property
|
||||
def url(self):
|
||||
return os.environ.get("DATABASE_URL", f"sqlite:///{database()}")
|
||||
|
||||
@cached_property
|
||||
def engine(self):
|
||||
return create_engine(self.url)
|
||||
|
||||
@cached_property
|
||||
def session(self):
|
||||
return Session
|
||||
|
||||
@cached_property
|
||||
def metadata(self):
|
||||
return _metadata
|
||||
|
||||
@cached_property
|
||||
def tables(self):
|
||||
return dict((t.name, t) for t in self.metadata.sorted_tables)
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
with transaction.manager as tm:
|
||||
yield tm
|
||||
try:
|
||||
tm.commit()
|
||||
except Exception:
|
||||
tm.abort()
|
||||
raise
|
||||
|
||||
def add(self, *args, **kwargs):
|
||||
self.session.add(*args, **kwargs)
|
||||
self.session.flush()
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
return self.session.query(*args, **kwargs)
|
||||
|
||||
def slugify(self, rec: dict) -> str:
|
||||
"""
|
||||
Create a uniquish slug from a dictionary.
|
||||
"""
|
||||
sha1bytes = hashlib.sha1(str(rec["id"]).encode())
|
||||
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
|
||||
|
||||
def init(self):
|
||||
init_sqlalchemy(self.engine)
|
||||
self.metadata.create_all(self.engine)
|
||||
|
||||
def dump(self):
|
||||
results = {}
|
||||
for table_name, table in self.tables.items():
|
||||
results[table_name] = [row for row in self.query(table).all()]
|
||||
return results
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
try:
|
||||
return self.tables[name]
|
||||
except KeyError:
|
||||
raise AttributeError(f"{self} does not contain the attribute '{name}'.")
|
||||
|
||||
|
||||
db = SQLDatabaseManager()
|
||||
@@ -0,0 +1,4 @@
|
||||
from .character import *
|
||||
from .classes import *
|
||||
from .property import *
|
||||
from .transaction import *
|
||||
@@ -0,0 +1,151 @@
|
||||
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",
|
||||
]
|
||||
|
||||
|
||||
def class_map_creator(fields):
|
||||
if isinstance(fields, CharacterClassMap):
|
||||
return fields
|
||||
return CharacterClassMap(**fields)
|
||||
|
||||
|
||||
def attr_map_creator(fields):
|
||||
if isinstance(fields, CharacterClassAttributeMap):
|
||||
return fields
|
||||
return CharacterClassAttributeMap(**fields)
|
||||
|
||||
|
||||
class AncestryTraitMap(BaseObject):
|
||||
__tablename__ = "trait_map"
|
||||
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})
|
||||
|
||||
|
||||
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")
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
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)
|
||||
description = Column(Text)
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class CharacterClassMap(BaseObject, IterableMixin):
|
||||
__tablename__ = "class_map"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
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)
|
||||
|
||||
character_class = relationship("CharacterClass", lazy="immediate")
|
||||
character = relationship("Character", uselist=False, viewonly=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.character.name}, {self.character_class.name}, level {self.level}"
|
||||
|
||||
|
||||
class CharacterClassAttributeMap(BaseObject, IterableMixin):
|
||||
__tablename__ = "character_class_attribute_map"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
character_id = Column(Integer, ForeignKey("character.id"), nullable=False)
|
||||
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
|
||||
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")
|
||||
|
||||
character_class = relationship(
|
||||
"CharacterClass",
|
||||
secondary="class_map",
|
||||
primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id",
|
||||
secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id",
|
||||
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})
|
||||
proficiencies = Column(String)
|
||||
|
||||
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)
|
||||
|
||||
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default="1")
|
||||
ancestry = relationship("Ancestry", uselist=False)
|
||||
|
||||
@property
|
||||
def traits(self):
|
||||
return [mapping.trait for mapping in self.ancestry.traits]
|
||||
|
||||
@property
|
||||
def level(self):
|
||||
return sum(mapping.level for mapping in self.class_map)
|
||||
|
||||
@property
|
||||
def levels(self):
|
||||
return dict([(mapping.character_class.name, mapping.level) for mapping in self.class_map])
|
||||
|
||||
def add_class(self, newclass, level=1):
|
||||
if level == 0:
|
||||
return self.remove_class(newclass)
|
||||
level_in_class = [mapping for mapping in self.class_map if mapping.character_class_id == newclass.id]
|
||||
if level_in_class:
|
||||
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))
|
||||
|
||||
def remove_class(self, target):
|
||||
self.class_map = [m for m in self.class_map if m.id != target.id]
|
||||
@@ -0,0 +1,45 @@
|
||||
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",
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class ClassAttribute(BaseObject, IterableMixin):
|
||||
__tablename__ = "class_attribute"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.id}: {self.name}"
|
||||
|
||||
|
||||
class ClassAttributeOption(BaseObject, IterableMixin):
|
||||
__tablename__ = "class_attribute_option"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, nullable=False)
|
||||
attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
|
||||
# attribute = relationship("ClassAttribute", uselist=False)
|
||||
|
||||
|
||||
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)
|
||||
attributes = relationship("ClassAttributeMap")
|
||||
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, UniqueConstraint
|
||||
|
||||
from ttfrog.db.base import BaseObject, Bases, IterableMixin
|
||||
|
||||
__all__ = [
|
||||
"Skill",
|
||||
"Proficiency",
|
||||
"Modifier",
|
||||
]
|
||||
|
||||
|
||||
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 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 Modifier(BaseObject, IterableMixin):
|
||||
__tablename__ = "modifier"
|
||||
__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)
|
||||
value = Column(String, nullable=False)
|
||||
type = Column(String, nullable=False)
|
||||
target = Column(String, nullable=False)
|
||||
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import Column, Integer, String, Text
|
||||
|
||||
from ttfrog.db.base import BaseObject, IterableMixin
|
||||
|
||||
__all__ = ["TransactionLog"]
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
from ttfrog.db.schema import TransactionLog
|
||||
|
||||
|
||||
def record(previous, new):
|
||||
logging.debug(f"{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)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,26 @@
|
||||
import logging
|
||||
from wsgiref.simple_server import make_server
|
||||
|
||||
from pyramid.config import Configurator
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
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.")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
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")
|
||||
make_server(host, int(port), config.make_wsgi_app()).serve_forever()
|
||||
@@ -0,0 +1,5 @@
|
||||
from .base import BaseController
|
||||
from .character_sheet import CharacterSheet
|
||||
from .json_data import JsonData
|
||||
|
||||
__all__ = [BaseController, CharacterSheet, JsonData]
|
||||
@@ -0,0 +1,13 @@
|
||||
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"]
|
||||
|
||||
def get_session():
|
||||
return db.session
|
||||
@@ -0,0 +1,149 @@
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
from pyramid.interfaces import IRoutesMapper
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
|
||||
|
||||
def get_all_routes(request):
|
||||
routes = {
|
||||
"static": "/static",
|
||||
}
|
||||
uri_pattern = re.compile(r"^([^\{\*]+)")
|
||||
mapper = request.registry.queryUtility(IRoutesMapper)
|
||||
for route in mapper.get_routes():
|
||||
if route.name.startswith("__"):
|
||||
continue
|
||||
m = uri_pattern.search(route.pattern)
|
||||
if m:
|
||||
routes[route.name] = m.group(0)
|
||||
return routes
|
||||
|
||||
|
||||
class BaseController:
|
||||
model = None
|
||||
model_form = None
|
||||
|
||||
def __init__(self, request):
|
||||
self.request = request
|
||||
self.attrs = defaultdict(str)
|
||||
self._slug = None
|
||||
self._record = None
|
||||
self._form = None
|
||||
|
||||
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("/", "")
|
||||
return self._slug
|
||||
|
||||
@property
|
||||
def record(self):
|
||||
if not self._record and self.model:
|
||||
try:
|
||||
self._record = db.query(self.model).filter(self.model.slug == self.slug)[0]
|
||||
except IndexError:
|
||||
logging.warning(f"Could not load record with slug {self.slug}")
|
||||
self._record = self.model()
|
||||
return self._record
|
||||
|
||||
@property
|
||||
def form(self):
|
||||
if not self.model:
|
||||
return
|
||||
if not self.model_form:
|
||||
return
|
||||
if not self._form:
|
||||
if self.request.POST:
|
||||
self._form = self.model_form(self.request.POST, obj=self.record)
|
||||
else:
|
||||
self._form = self.model_form(obj=self.record)
|
||||
if not self.record.id:
|
||||
# apply the db schema defaults
|
||||
self._form.process()
|
||||
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()
|
||||
|
||||
def template_context(self, **kwargs) -> dict:
|
||||
return dict(
|
||||
config=self.config,
|
||||
request=self.request,
|
||||
form=self.form,
|
||||
record=self.record,
|
||||
routes=get_all_routes(self.request),
|
||||
resources=self.resources,
|
||||
**self.attrs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def populate(self):
|
||||
self.form.populate_obj(self.record)
|
||||
|
||||
def populate_association(self, key, formdata):
|
||||
populated = []
|
||||
for field in formdata:
|
||||
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
|
||||
populated.append(field)
|
||||
return populated
|
||||
|
||||
def validate(self):
|
||||
return self.form.validate()
|
||||
|
||||
def save(self):
|
||||
if not self.form.save.data:
|
||||
return
|
||||
if not self.validate():
|
||||
return
|
||||
logging.debug(f"{self.form.data = }")
|
||||
# previous = dict(self.record)
|
||||
logging.debug(f"{self.record = }")
|
||||
self.populate()
|
||||
# transaction_log.record(previous, self.record)
|
||||
with db.transaction():
|
||||
db.add(self.record)
|
||||
self.save_callback()
|
||||
logging.debug(f"Saved {self.record = }")
|
||||
location = self.request.current_route_path()
|
||||
if self.record.slug not in location:
|
||||
location = f"{location}/{self.record.uri}"
|
||||
logging.debug(f"Redirecting to {location}")
|
||||
return HTTPFound(location=location)
|
||||
|
||||
def delete(self):
|
||||
if not self.record.id:
|
||||
return
|
||||
with db.transaction():
|
||||
db.query(self.model).filter_by(id=self.record.id).delete()
|
||||
location = self.request.current_route_path()
|
||||
return HTTPFound(location=location)
|
||||
|
||||
def response(self):
|
||||
if not self.form:
|
||||
return
|
||||
elif self.form.save.data:
|
||||
return self.save()
|
||||
elif self.form.delete.data:
|
||||
return self.delete()
|
||||
@@ -0,0 +1,191 @@
|
||||
import logging
|
||||
|
||||
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 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)
|
||||
|
||||
|
||||
class ClassAttributeWidget:
|
||||
def __call__(self, field, **kwargs):
|
||||
kwargs.setdefault("id", field.id)
|
||||
html = [
|
||||
f"<span {html_params(**kwargs)}>{field.character_class_map.class_attribute.name}</span>",
|
||||
"<span>",
|
||||
]
|
||||
for subfield in field:
|
||||
html.append(subfield())
|
||||
html.append("</span>")
|
||||
return Markup("".join(html))
|
||||
|
||||
|
||||
class ClassAttributesFormField(FormField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.character_class_map = None
|
||||
|
||||
def process(self, *args, **kwargs):
|
||||
super().process(*args, **kwargs)
|
||||
self.character_class_map = db.query(CharacterClassAttributeMap).get(self.data["id"])
|
||||
self.label.text = self.character_class_map.character_class[0].name
|
||||
|
||||
|
||||
class ClassAttributesForm(ModelForm):
|
||||
id = HiddenField()
|
||||
class_attribute_id = HiddenField()
|
||||
|
||||
option_id = SelectField(widget=Select(), choices=[], validators=[Optional()], coerce=int)
|
||||
|
||||
def __init__(self, formdata=None, obj=None, prefix=None):
|
||||
if obj:
|
||||
obj = db.query(CharacterClassAttributeMap).get(obj)
|
||||
super().__init__(formdata=formdata, obj=obj, prefix=prefix)
|
||||
|
||||
if obj:
|
||||
options = db.query(ClassAttributeOption).filter_by(attribute_id=obj.class_attribute.id)
|
||||
self.option_id.choices = [(rec.id, rec.name) for rec in options.all()]
|
||||
|
||||
|
||||
class MulticlassForm(ModelForm):
|
||||
id = HiddenField()
|
||||
character_class_id = NullableDeferredSelectField(
|
||||
model=CharacterClass, validate_choice=True, widget=Select(), coerce=int
|
||||
)
|
||||
level = SelectField(choices=VALID_LEVELS, default=1, coerce=int, validate_choice=True, widget=Select())
|
||||
|
||||
def __init__(self, formdata=None, obj=None, prefix=None):
|
||||
"""
|
||||
Populate the form field with a CharacterClassMap object by converting the object ID
|
||||
to an instance. This will ensure that the rendered field is populated with the current
|
||||
value of the class_map.
|
||||
"""
|
||||
if obj:
|
||||
obj = db.query(CharacterClassMap).get(obj)
|
||||
super().__init__(formdata=formdata, obj=obj, prefix=prefix)
|
||||
|
||||
|
||||
class CharacterForm(ModelForm):
|
||||
class Meta:
|
||||
model = Character
|
||||
exclude = ["slug"]
|
||||
|
||||
save = SubmitField()
|
||||
delete = SubmitField()
|
||||
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
|
||||
)
|
||||
|
||||
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"},
|
||||
]
|
||||
|
||||
def validate_callback(self):
|
||||
"""
|
||||
Validate multiclass fields in form data.
|
||||
"""
|
||||
ret = super().validate()
|
||||
if not self.form.data["classes"]:
|
||||
return ret
|
||||
|
||||
err = ""
|
||||
total_level = 0
|
||||
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."
|
||||
break
|
||||
if total_level not in VALID_LEVELS:
|
||||
err = f"Total level for all multiclasses ({total_level}) is outside possible range."
|
||||
if err:
|
||||
logging.error(err)
|
||||
raise ValidationError(err)
|
||||
return ret and True
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
assigned = [int(m.class_attribute_id) for m in self.record.character_class_attribute_map]
|
||||
logging.debug(f"{assigned = }")
|
||||
|
||||
# step through the list of class mappings for this character
|
||||
for class_map in self.record.class_map:
|
||||
thisclass = classes_by_id[class_map.character_class_id]
|
||||
|
||||
# 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()
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
def save_callback(self):
|
||||
self.add_class_attributes()
|
||||
|
||||
def populate(self):
|
||||
"""
|
||||
Delete the association proxies' form data before calling form.populate_obj(),
|
||||
and instead use our own methods for populating the fieldlist.
|
||||
"""
|
||||
|
||||
# multiclass form
|
||||
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"]
|
||||
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)
|
||||
@@ -0,0 +1,21 @@
|
||||
from pyramid.httpexceptions import exception_response
|
||||
|
||||
from ttfrog.db import schema
|
||||
from ttfrog.db.manager import db
|
||||
|
||||
from .base import BaseController
|
||||
|
||||
|
||||
class JsonData(BaseController):
|
||||
model = None
|
||||
model_form = None
|
||||
|
||||
def configure_for_model(self):
|
||||
try:
|
||||
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()}
|
||||
@@ -0,0 +1,21 @@
|
||||
from wtforms.fields import SelectField, SelectMultipleField
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
|
||||
|
||||
class DeferredSelectMultipleField(SelectMultipleField):
|
||||
def __init__(self, *args, model=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.choices = [(rec.id, rec.name) for rec in db.query(model).all()]
|
||||
|
||||
|
||||
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()]
|
||||
|
||||
|
||||
class NullableDeferredSelectField(DeferredSelectField):
|
||||
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")
|
||||
@@ -0,0 +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
|
||||
|
||||
|
||||
def response_from(controller):
|
||||
return controller.response() or AttributeMap.from_dict({"c": controller.template_context()})
|
||||
|
||||
|
||||
@view_config(route_name="index")
|
||||
def index(request):
|
||||
ancestries = [a.name for a in db.session.query(Ancestry).all()]
|
||||
return Response(",".join(ancestries))
|
||||
|
||||
|
||||
@view_config(route_name="sheet", renderer="character_sheet.html")
|
||||
def sheet(request):
|
||||
return response_from(request.context)
|
||||
|
||||
|
||||
@view_config(route_name="data", renderer="json")
|
||||
def data(request):
|
||||
return response_from(request.context)
|
||||
Reference in New Issue
Block a user