Compare commits

..
42 Commits
Author SHA1 Message Date
evilchili a8bb6de008 adding hit dice and defenses 2024-05-14 20:15:42 -07:00
evilchili d2bed7c859 rename property module 2024-05-12 11:20:52 -07:00
evilchili 09549bf68c add support for modifier overrides with proficiency and expertise 2024-05-08 01:40:19 -07:00
evilchili 9a2d28ae75 add support for skills 2024-05-06 00:13:52 -07:00
evilchili b574dacfa1 fix schemas 2024-05-04 13:16:20 -07:00
evilchili 3292b11d89 modifiable columns subclass int/str 2024-04-29 01:09:58 -07:00
evilchili 3980be5f07 convert to modern MappedAsDataclass models 2024-04-28 14:30:47 -07:00
evilchili 1ff0e5ca7d fixing modifier bugs, fixing traits, adding speed attrs 2024-04-23 00:15:13 -07:00
evilchili 5db6e40eae refactor modifiers 2024-04-21 21:30:24 -07:00
evilchili 36f6f831d9 implemented modifiers on char stats 2024-04-21 02:17:47 -07:00
evilchili 5ec27e9344 default attributes on db object are now queries 2024-04-20 23:40:28 -07:00
evilchili 12c643c542 adding size/speed to ancestry 2024-04-20 23:33:36 -07:00
evilchili a520ea249e adding tests for ancestries 2024-04-20 23:27:47 -07:00
evilchili 46ef48669d typo 2024-04-20 20:39:13 -07:00
evilchili a9593e83a2 formatting 2024-04-20 20:35:24 -07:00
evilchili 412efe2aec Make objects iterable by default, add tests, refactoring 2024-04-20 20:35:07 -07:00
evilchili 44cd8fe9c9 wip 2024-04-14 11:37:34 -07:00
evilchili dbb9461b7a adding tests and helper UX to schema 2024-03-26 21:58:04 -07:00
evilchili 78115023bb restructuring for poetry-slam 2024-03-26 00:53:21 -07:00
evilchili b1d7639a62 Adding tests of character schema 2024-03-24 16:56:13 -07:00
evilchili dba8bb315a Fixing UX of class attributes 2024-03-24 16:55:51 -07:00
evilchili b92ff868a5 fix multiclass submissions 2024-03-23 13:46:56 -07:00
evilchili 304a4d9c79 adding support for managing class attributes 2024-02-26 01:12:45 -08:00
evilchili 75b9aec28e fix deletes 2024-02-23 11:02:36 -08:00
evilchili 9757d3bee0 fix multiclassing 2024-02-23 10:45:38 -08:00
evilchili ba0e66f9af modeling many-to-many relationships 2024-02-18 19:30:41 -08:00
evilchili e231828425 layout updates, added json views, fixed relationships in schema 2024-02-16 01:19:25 -08:00
evilchili 1baf73a338 fixed edit bug 2024-02-08 23:26:19 -08:00
evilchili 8bde2ab5f3 adding defaults, modifiers, traits and attributes 2024-02-08 23:04:28 -08:00
evilchili da6255a86a added transaction log, UX scaffolding 2024-02-08 01:14:35 -08:00
evilchili 2dcaa3fac6 Switching from broken QuerySelectFields 2024-02-04 16:12:03 -08:00
evilchili 99ef4d61f9 adding deletes 2024-02-04 15:22:54 -08:00
evilchili 669c9b46d6 fixing form handling for relationships 2024-02-04 11:40:30 -08:00
evilchili 32d9c42847 fixing slugs 2024-02-02 15:40:45 -08:00
evilchili 9cdf28502a sample macro 2024-02-01 00:28:35 -08:00
evilchili 9277494a05 adding create 2024-02-01 00:28:17 -08:00
evilchili 5de3f74a88 simplified post handling 2024-01-31 23:05:46 -08:00
evilchili 3444f83c91 rewrite using pyramid and wtforms 2024-01-31 22:39:54 -08:00
evilchili 5faf5c97c1 rewrite in pyramid 2024-01-30 01:25:02 -08:00
evilchili 8f17ddfb05 Adding slugs, refactoring 2024-01-28 22:14:50 -08:00
evilchili 64451ddf8b adding character sheets 2024-01-28 14:31:50 -08:00
evilchiliandGitHub 17da4a73ee Merge pull request #1 from evilchili/mainline
Initial import
2024-01-28 11:02:39 -08:00
48 changed files with 2906 additions and 282 deletions
+42 -11
View File
@@ -5,24 +5,30 @@ description = ""
authors = ["evilchili <evilchili@gmail.com>"] authors = ["evilchili <evilchili@gmail.com>"]
readme = "README.md" readme = "README.md"
packages = [ packages = [
{ include = 'ttfrog' }, {include = "*", from = "src"},
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
TurboGears2 = "^2.4.3"
sqlalchemy = "^2.0.25"
tgext-admin = "^0.7.4"
webhelpers2 = "^2.0"
typer = "^0.9.0"
python-dotenv = "^0.21.0" python-dotenv = "^0.21.0"
typer = "^0.9.0"
rich = "^13.7.0" rich = "^13.7.0"
jinja2 = "^3.1.3" sqlalchemy = "^2.0.25"
pyramid = "^2.0.2"
#"tg.devtools" = "^2.4.3" pyramid-tm = "^2.5"
#repoze-who = "^3.0.0" pyramid-jinja2 = "^2.10"
# tw2-forms = "^2.2.6" pyramid-sqlalchemy = "^1.6"
wtforms-sqlalchemy = "^0.4.1"
transaction = "^4.0"
unicode-slugify = "^0.1.5"
nanoid = "^2.0.0"
nanoid-dictionary = "^2.4.0"
wtforms-alchemy = "^0.18.0"
sqlalchemy-serializer = "^1.4.1"
[tool.poetry.group.dev.dependencies]
pytest = "^8.1.1"
pytest-cov = "^5.0.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
@@ -33,3 +39,28 @@ build-backend = "poetry.core.masonry.api"
ttfrog = "ttfrog.cli:app" ttfrog = "ttfrog.cli:app"
### SLAM
[tool.black]
line-length = 120
target-version = ['py310']
[tool.isort]
multi_line_output = 3
line_length = 120
include_trailing_comma = true
[tool.autoflake]
check = false # return error code if changes are needed
in-place = true # make changes to files instead of printing diffs
recursive = true # drill down directories recursively
remove-all-unused-imports = true # remove all unused imports (not just those from the standard library)
ignore-init-module-imports = true # exclude __init__.py when removing unused imports
remove-duplicate-keys = true # remove all duplicate keys in objects
remove-unused-variables = true # remove unused variables
[tool.pytest.ini_options]
log_cli_level = "DEBUG"
addopts = "--cov=src --cov-report=term-missing"
### ENDSLAM
+252
View File
@@ -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();
})();
+30
View File
@@ -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['class_list'] %}
{{ obj(class='multiclass') }}
{% endfor %}
<span class='label'>Add Class:</span> {{ c.form['newclass'](class='multiclass') }}
<div id='controls'>
{{ c.form.save }} &nbsp; {{ 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&nbsp;
{% 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.attribute_list %}
{{ field('attribute_list') }}
{% 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 }}
</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 %}
+8
View File
@@ -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 %}
+71
View File
@@ -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)
+47 -30
View File
@@ -2,8 +2,8 @@ import io
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional
from textwrap import dedent from textwrap import dedent
from typing import Optional
import typer import typer
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -12,9 +12,8 @@ from rich.logging import RichHandler
from ttfrog.path import assets from ttfrog.path import assets
default_data_path = Path("~/.dnd/ttfrog") default_data_path = Path("~/.dnd/ttfrog")
default_host = '127.0.0.1' default_host = "127.0.0.1"
default_port = 2323 default_port = 2323
SETUP_HELP = f""" SETUP_HELP = f"""
@@ -37,45 +36,32 @@ HOST={default_host}
PORT={default_port} PORT={default_port}
""" """
db_app = typer.Typer()
app = typer.Typer() app = typer.Typer()
app.add_typer(db_app, name="db", help="Manage the database.")
app_state = dict() app_state = dict()
@app.callback() @app.callback()
@db_app.callback()
def main( def main(
context: typer.Context, context: typer.Context,
root: Optional[Path] = typer.Option( root: Optional[Path] = typer.Option(
default_data_path, default_data_path,
help="Path to the TableTop Frog environment", help="Path to the TableTop Frog environment",
) ),
): ):
app_state['env'] = root.expanduser() / Path('defaults') app_state["env"] = root.expanduser() / Path("defaults")
load_dotenv(stream=io.StringIO(SETUP_HELP)) load_dotenv(stream=io.StringIO(SETUP_HELP))
load_dotenv(app_state['env']) load_dotenv(app_state["env"])
debug = os.getenv('DEBUG', None) debug = os.getenv("DEBUG", None)
logging.basicConfig( logging.basicConfig(
format='%(message)s', format="%(message)s",
level=logging.DEBUG if debug else logging.INFO, level=logging.DEBUG if debug else logging.INFO,
handlers=[ handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
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() @app.command()
def serve( def serve(
context: typer.Context, context: typer.Context,
@@ -87,21 +73,52 @@ def serve(
default_port, default_port,
help="bind port", help="bind port",
), ),
debug: bool = typer.Option( debug: bool = typer.Option(False, help="Enable debugging output"),
False,
help='Enable debugging output'
),
): ):
""" """
Start the TableTop Frog server. Start the TableTop Frog server.
""" """
# delay loading the app until we have configured our environment # delay loading the app until we have configured our environment
from ttfrog.db.bootstrap import bootstrap
from ttfrog.webserver import application from ttfrog.webserver import application
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)
if __name__ == '__main__': @db_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()
@db_app.command()
def list(context: typer.Context):
from ttfrog.db.manager import db
print("\n".join(sorted(db.tables.keys())))
@db_app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
def dump(context: typer.Context):
"""
Dump tables (or the entire database) as a JSON blob.
"""
from ttfrog.db.manager import db
setup(context)
print(db.dump(context.args))
if __name__ == "__main__":
app() app()
+87
View File
@@ -0,0 +1,87 @@
import enum
import nanoid
from nanoid_dictionary import human_alphabet
from slugify import slugify
from sqlalchemy import Column, String
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
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 BaseObject(MappedAsDataclass, DeclarativeBase):
"""
Allows for iterating over Model objects' column names and values
"""
__abstract__ = True
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):
"""
Provide a custom JSON encoder.
"""
raise NotImplementedError()
def __repr__(self):
return str(dict(self))
class EnumField(enum.Enum):
"""
A serializable enum.
"""
def __json__(self):
return self.value
STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
CREATURE_TYPES = [
"aberation",
"beast",
"celestial",
"construct",
"dragon",
"elemental",
"fey",
"fiend",
"Giant",
"humanoid",
"monstrosity",
"ooze",
"plant",
"undead",
]
SIZES = ["Tiny", "Small", "Medium", "Large", "Huge", "Gargantuan"]
CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS))
SizesEnum = EnumField("SizesEnum", ((k, k) for k in SIZES))
+36
View File
@@ -0,0 +1,36 @@
from ttfrog.db import schema
from ttfrog.db.manager import db
def bootstrap():
db.metadata.drop_all(bind=db.engine)
db.init()
with db.transaction():
# ancestries
human = schema.Ancestry("human")
tiefling = schema.Ancestry("tiefling")
tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="intelligence", relative_value=1))
tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="charisma", relative_value=2))
darkvision = schema.AncestryTrait(
"Darkvision",
description=(
"You can see in dim light within 60 feet of you as if it were bright light, and in darkness as if it "
"were dim light. You cant discern color in darkness, only shades of gray."
),
)
darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision)
# classes
fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON")
rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX")
# characters
sabetha = schema.Character("Sabetha", ancestry=tiefling, _intelligence=14)
sabetha.add_class(fighter, level=2)
sabetha.add_class(rogue, level=3)
bob = schema.Character("Bob", ancestry=human)
# persist all the records we've created
db.add_or_update([sabetha, bob])
+106
View File
@@ -0,0 +1,106 @@
import base64
import hashlib
import json
import os
from contextlib import contextmanager
from functools import cached_property
import transaction
from pyramid_sqlalchemy.meta import Session
from sqlalchemy import create_engine, event
import ttfrog.db.schema
from ttfrog.path import database
assert ttfrog.db.schema
class AlchemyEncoder(json.JSONEncoder):
def default(self, obj):
try:
return getattr(obj, "__json__")()
except (AttributeError, NotImplementedError): # pragma: no cover
return super().default(obj)
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 ttfrog.db.schema.BaseObject.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: # pragam: no cover
tm.abort()
raise
def add_or_update(self, record, *args, **kwargs):
if not isinstance(record, list):
record = [record]
for rec in record:
self.session.add(rec, *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):
self.session.configure(bind=self.engine)
self.metadata.bind = self.engine
self.metadata.create_all(self.engine)
def dump(self, names: list = []):
results = {}
for table_name, table in self.tables.items():
if not names or table_name in names:
results[table_name] = [dict(row._mapping) for row in self.query(table).all()]
return json.dumps(results, indent=2, cls=AlchemyEncoder)
def __getattr__(self, name: str):
return self.query(getattr(ttfrog.db.schema, name))
db = SQLDatabaseManager()
@event.listens_for(db.session, "after_flush")
def session_after_flush(session, flush_context):
"""
Listen to flush events looking for newly-created objects. For each one, if the
obj has a __after_insert__ method, call it.
"""
for obj in session.new:
callback = getattr(obj, "__after_insert__", None)
if callback:
callback(session)
+5
View File
@@ -0,0 +1,5 @@
from .character import *
from .classes import *
from .log import *
from .modifiers import *
from .skill import *
+509
View File
@@ -0,0 +1,509 @@
from collections import defaultdict
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject, SlugMixin
from ttfrog.db.schema.classes import CharacterClass, ClassAttribute
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.skill import Skill
__all__ = [
"Ancestry",
"AncestryTrait",
"AncestryTraitMap",
"CharacterClassMap",
"CharacterClassAttributeMap",
"Character",
"Modifier",
]
def class_map_creator(fields):
if isinstance(fields, CharacterClassMap):
return fields
return CharacterClassMap(**fields)
def skill_creator(fields):
if isinstance(fields, CharacterSkillMap):
return fields
return CharacterSkillMap(**fields)
def attr_map_creator(fields):
if isinstance(fields, CharacterClassAttributeMap):
return fields
return CharacterClassAttributeMap(**fields)
class HitDie(BaseObject):
__tablename__ = "hit_die"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"))
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
character_class = relationship("CharacterClass", lazy="immediate")
spent: Mapped[bool] = mapped_column(nullable=False, default=False)
@property
def name(self):
return self.character_class.hit_die_name
@property
def stat(self):
return self.character_class.hit_die_stat_name
class AncestryTraitMap(BaseObject):
__tablename__ = "trait_map"
__table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"))
ancestry_trait_id: Mapped[int] = mapped_column(ForeignKey("ancestry_trait.id"), init=False)
trait: Mapped["AncestryTrait"] = relationship(uselist=False, lazy="immediate")
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20})
class Ancestry(BaseObject, ModifierMixin):
"""
A character ancestry ("race"), which has zero or more AncestryTraits and Modifiers.
"""
__tablename__ = "ancestry"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
creature_type: Mapped[str] = mapped_column(nullable=False, default="humanoid")
size: Mapped[str] = mapped_column(nullable=False, default="medium")
speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99})
_fly_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
_climb_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
_swim_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
_traits = relationship(
"AncestryTraitMap", init=False, uselist=True, cascade="all,delete,delete-orphan", lazy="immediate"
)
@property
def traits(self):
return [mapping.trait for mapping in self._traits]
@property
def climb_speed(self):
return self._climb_speed or int(self.speed / 2)
@property
def swim_speed(self):
return self._swim_speed or int(self.speed / 2)
def add_trait(self, trait, level=1):
if not self._traits or trait not in self._traits:
mapping = AncestryTraitMap(ancestry_id=self.id, trait=trait, level=level)
if not self._traits:
self._traits = [mapping]
else:
self._traits.append(mapping)
return True
return False
def __repr__(self):
return self.name
class AncestryTrait(BaseObject, ModifierMixin):
"""
A trait granted to a character via its Ancestry.
"""
__tablename__ = "ancestry_trait"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[Text] = mapped_column(Text, default="")
def __repr__(self):
return self.name
class CharacterSkillMap(BaseObject):
__tablename__ = "character_skill_map"
__table_args__ = (UniqueConstraint("skill_id", "character_id", "character_class_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
skill_id: Mapped[int] = mapped_column(ForeignKey("skill.id"))
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=True, default=None)
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=True, default=None)
proficient: Mapped[bool] = mapped_column(default=True)
expert: Mapped[bool] = mapped_column(default=False)
skill = relationship("Skill", lazy="immediate")
class CharacterClassMap(BaseObject):
__tablename__ = "class_map"
__table_args__ = (UniqueConstraint("character_id", "character_class_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character: Mapped["Character"] = relationship(uselist=False, viewonly=True)
character_class: Mapped["CharacterClass"] = relationship(lazy="immediate")
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), init=False, nullable=False)
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), init=False, nullable=False)
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
def __repr__(self):
return f"{self.character.name}, {self.character_class.name}, level {self.level}"
class CharacterClassAttributeMap(BaseObject):
__tablename__ = "character_class_attribute_map"
__table_args__ = (UniqueConstraint("character_id", "class_attribute_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=False)
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=False)
option_id: Mapped[int] = mapped_column(ForeignKey("class_attribute_option.id"), nullable=False)
class_attribute: Mapped["ClassAttribute"] = relationship(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,
uselist=False,
)
class Character(BaseObject, SlugMixin, ModifierMixin):
__tablename__ = "character"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, default="New Character")
hit_points: Mapped[int] = mapped_column(default=10, nullable=False, info={"min": 0, "max": 999})
temp_hit_points: Mapped[int] = mapped_column(default=0, nullable=False, info={"min": 0, "max": 999})
_max_hit_points: Mapped[int] = mapped_column(
default=10, nullable=False, info={"min": 0, "max": 999, "modifiable": True}
)
_armor_class: Mapped[int] = mapped_column(
default=10, nullable=False, info={"min": 1, "max": 99, "modifiable": True}
)
_strength: Mapped[int] = mapped_column(
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
)
_dexterity: Mapped[int] = mapped_column(
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
)
_constitution: Mapped[int] = mapped_column(
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
)
_intelligence: Mapped[int] = mapped_column(
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
)
_wisdom: Mapped[int] = mapped_column(
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
)
_charisma: Mapped[int] = mapped_column(
nullable=False, default=10, info={"min": 0, "max": 30, "modifiable_class": Stat}
)
_vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
class_list = association_proxy("class_map", "id", creator=class_map_creator)
_skills = relationship("CharacterSkillMap", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
skills = association_proxy("_skills", "skill", creator=skill_creator)
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan")
attribute_list = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator)
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
_hit_dice = relationship("HitDie", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
@property
def hit_dice(self):
pool = defaultdict(list)
for die in self._hit_dice:
pool[die.character_class.name].append(die)
return pool
@property
def hit_dice_available(self):
return [die for die in self._hit_dice if die.spent is False]
@property
def proficiency_bonus(self):
return 1 + int(0.5 + self.level / 4)
@property
def expertise_bonus(self):
return 2 * self.proficiency_bonus
@property
def proficiencies(self):
unified = {}
unified.update(**self._proficiencies)
@property
def modifiers(self):
unified = {}
unified.update(**self.ancestry.modifiers)
for trait in self.traits:
unified.update(**trait.modifiers)
unified.update(**super().modifiers)
return unified
@property
def check_modifiers(self):
return [self.check_modifier(skill) for skill in self.skills]
@property
def classes(self):
return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map])
@property
def traits(self):
return self.ancestry.traits
@property
def speed(self):
return self._apply_modifiers("speed", self.ancestry.speed)
@property
def climb_speed(self):
return self._apply_modifiers("climb_speed", self.ancestry._climb_speed)
@property
def swim_speed(self):
return self._apply_modifiers("swim_speed", self.ancestry._swim_speed)
@property
def fly_speed(self):
return self._apply_modifiers("fly_speed", self.ancestry._fly_speed)
@property
def size(self):
return self._apply_modifiers("size", self.ancestry.size)
@property
def vision_in_darkness(self):
return self.apply_modifiers("vision_in_darkness", self.vision if self.vision is not None else 0)
@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])
@property
def class_attributes(self):
return dict([(mapping.class_attribute.name, mapping.option) for mapping in self.character_class_attribute_map])
def level_in_class(self, charclass):
mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id]
if not mapping:
return None
return mapping[0]
def immune(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'immune'
def resistant(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'resistant'
def vulnerable(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'vulnerable'
def absorbs(self, damage_type: str, magical: bool = False):
return self.defense(damage_type, magical) == 'absorbs'
def defense(self, damage_type: str, magical: bool = False):
attr_name = damage_type
if magical:
attr_name = f"magical_{attr_name}"
return self._apply_modifiers(f"defenses.{attr_name}", None)
def check_modifier(self, skill: Skill, save: bool = False):
# if the skill is not assigned, but we have modifiers, apply them to zero.
if skill not in self.skills:
target = f"{skill.name.lower()}_{'save' if save else 'check'}"
if self.has_modifier(target):
modified = self._apply_modifiers(target, 0)
return modified
# if the skill is a stat, start with the bonus value
attr = skill.name.lower()
stat = getattr(self, attr, None)
initial = getattr(stat, "bonus", None)
# if the skill isn't a stat, try the parent.
if initial is None and skill.parent:
stat = getattr(self, skill.parent.name.lower(), None)
initial = getattr(stat, "bonus", initial)
# if the skill is a proficiency, apply the bonus to the initial value
if skill in self.skills:
mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][-1]
if mapping.expert and not save:
initial += 2 * self.proficiency_bonus
elif mapping.proficient:
initial += self.proficiency_bonus
# return the initial value plus any modifiers.
return self._apply_modifiers(f"{attr}_{'save' if save else 'check'}", initial)
def add_class(self, newclass, level=1):
if level == 0:
return self.remove_class(newclass)
# add the class mapping and/or set the character's level in the class
mapping = self.level_in_class(newclass)
if not mapping:
self.class_list.append(CharacterClassMap(character=self, character_class=newclass, level=level))
else:
mapping.level = level
# add class attributes with default values
for lvl in range(1, level + 1):
for attr in newclass.attributes_at_level(lvl):
self.add_class_attribute(newclass, attr, attr.options[0])
# add default class skills
for skill in newclass.skills[: newclass.starting_skills]:
self.add_skill(skill, proficient=True, character_class=newclass)
# add hit dice
existing = len(self.hit_dice[newclass.name])
for lvl in range(level - existing):
self._hit_dice.append(HitDie(character_id=self.id, character_class_id=newclass.id))
def remove_class(self, target):
for mapping in self.character_class_attribute_map:
if mapping.character_class == target:
self.remove_class_attribute(mapping.class_attribute)
for skill in target.skills:
self.remove_skill(skill, proficient=True, expert=False, character_class=target)
self._hit_dice = [die for die in self._hit_dice if die.character_class != target]
self.class_map = [m for m in self.class_map if m.character_class != target]
def remove_class_attribute(self, attribute):
self.character_class_attribute_map = [
m for m in self.character_class_attribute_map if m.class_attribute.id != attribute.id
]
def has_class_attribute(self, attribute):
return attribute in [m.class_attribute for m in self.character_class_attribute_map]
def add_class_attribute(self, character_class, attribute, option):
if self.has_class_attribute(attribute):
return False
mapping = self.level_in_class(character_class)
if not mapping:
return False
if attribute not in mapping.character_class.attributes_at_level(mapping.level):
return False
self.attribute_list.append(
CharacterClassAttributeMap(
character_id=self.id,
class_attribute_id=attribute.id,
option_id=option.id,
class_attribute=attribute,
)
)
return True
def add_skill(self, skill, proficient=False, expert=False, character_class=None):
if not self.id:
raise Exception("Cannot add a skill before the character has been persisted.")
skillmap = None
exists = False
if skill in self.skills:
for mapping in self._skills:
if mapping.skill_id != skill.id:
continue
if character_class is None and mapping.character_class_id:
continue
if (character_class is None and mapping.character_class_id is None) or (
mapping.character_class_id == character_class.id
):
skillmap = mapping
exists = True
break
if not skillmap:
skillmap = CharacterSkillMap(skill_id=skill.id, character_id=self.id)
skillmap.proficient = proficient
skillmap.expert = expert
if character_class:
skillmap.character_class_id = character_class.id
if not exists:
self._skills.append(skillmap)
return True
return False
def remove_skill(self, skill, proficient, expert, character_class):
to_delete = [
mapping
for mapping in self._skills
if (
mapping.skill_id == skill.id
and mapping.proficient == proficient
and mapping.expert == expert
and (
(mapping.character_class_id is None and character_class is None)
or (character_class and mapping.character_class_id == character_class.id)
)
)
]
if not to_delete:
return False
self._skills = [m for m in self._skills if m not in to_delete]
return True
def apply_healing(self, value: int):
self.hit_points = min(self.hit_points + value, self._max_hit_points)
def apply_damage(self, value: int, damage_type: str, magical=False):
total = value
if self.absorbs(damage_type, magical):
return self.apply_healing(total)
if self.immune(damage_type, magical):
return
if self.resistant(damage_type, magical):
total = int(value / 2)
elif self.vulnerable(damage_type, magical):
total = value * 2
if total <= self.temp_hit_points:
self.temp_hit_points -= total
return
self.hit_points = max(0, self.hit_points - (total - self.temp_hit_points))
self.temp_hit_points = 0
return
def spend_hit_die(self, die):
die.spent = True
def reset_hit_die(self, die):
die.spent = False
def __after_insert__(self, session):
"""
Called by the session after_flush event listener to add default joins in other tables.
"""
for skill in session.query(Skill).filter(
Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
):
self.add_skill(skill, proficient=False, expert=False)
+121
View File
@@ -0,0 +1,121 @@
import itertools
from collections import defaultdict
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject
from ttfrog.db.schema.skill import Skill
__all__ = [
"ClassAttributeMap",
"ClassAttribute",
"ClassAttributeOption",
"CharacterClass",
"Skill",
"ClassSkillMap",
]
def skill_creator(fields):
if isinstance(fields, ClassSkillMap):
return fields
return ClassSkillMap(**fields)
class ClassSkillMap(BaseObject):
__tablename__ = "class_skill_map"
__table_args__ = (UniqueConstraint("skill_id", "character_class_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
skill_id: Mapped[int] = mapped_column(ForeignKey("skill.id"))
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
proficient: Mapped[bool] = mapped_column(default=True)
expert: Mapped[bool] = mapped_column(default=False)
skill = relationship("Skill", lazy="immediate")
class ClassAttributeMap(BaseObject):
__tablename__ = "class_attribute_map"
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), primary_key=True)
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), primary_key=True)
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
attribute = relationship("ClassAttribute", uselist=False, viewonly=True, lazy="immediate")
class ClassAttribute(BaseObject):
__tablename__ = "class_attribute"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False)
options = relationship("ClassAttributeOption", cascade="all,delete,delete-orphan", lazy="immediate")
def add_option(self, **kwargs):
option = ClassAttributeOption(attribute_id=self.id, **kwargs)
if not self.options or option not in self.options:
option.attribute_id = self.id
if not self.options:
self.options = [option]
else:
self.options.append(option)
return True
return False
def __repr__(self):
return f"{self.id}: {self.name}"
class ClassAttributeOption(BaseObject):
__tablename__ = "class_attribute_option"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False)
attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=True)
class CharacterClass(BaseObject):
__tablename__ = "character_class"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(index=True, unique=True)
hit_die_name: Mapped[str] = mapped_column(default="1d6")
hit_die_stat_name: Mapped[str] = mapped_column(default="")
starting_skills: int = mapped_column(nullable=False, default=0)
attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate")
_skills = relationship("ClassSkillMap", cascade="all,delete,delete-orphan", lazy="immediate")
skills = association_proxy("_skills", "skill", creator=skill_creator)
def add_skill(self, skill, expert=False):
if not self.skills or skill not in self.skills:
if not self.id:
raise Exception("Cannot add a skill before the class has been persisted.")
mapping = ClassSkillMap(character_class_id=self.id, skill_id=skill.id, proficient=True, expert=expert)
self.skills.append(mapping)
return True
return False
def add_attribute(self, attribute, level=1):
if not self.attributes or attribute not in self.attributes:
mapping = ClassAttributeMap(character_class_id=self.id, class_attribute_id=attribute.id, level=level)
if not self.attributes:
self.attributes = [mapping]
else:
self.attributes.append(mapping)
return True
return False
@property
def attributes_by_level(self):
by_level = defaultdict(list)
for mapping in self.attributes:
by_level[mapping.level].append(mapping.attribute)
return by_level
def attribute(self, name: str):
for mapping in self.attributes:
if mapping.attribute.name.lower() == name.lower():
return mapping.attribute
return None
def attributes_at_level(self, level: int):
return list(itertools.chain(*[attrs for lvl, attrs in self.attributes_by_level.items() if lvl <= level]))
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text
from ttfrog.db.base import BaseObject
__all__ = ["TransactionLog"]
class TransactionLog(BaseObject):
__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)
+277
View File
@@ -0,0 +1,277 @@
from collections import defaultdict
from typing import Any, Union
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject
class Modifiable:
def __new__(cls, base, modified=None):
cls.base = base
return super().__new__(cls, modified)
class ModifiableStr(Modifiable, str):
"""
A string that also has a '.base' property.
"""
class ModifiableInt(Modifiable, int):
"""
An integer that also has a '.base' property
"""
class Stat(ModifiableInt):
"""
Same as a Score except it also has a bonus for STR, DEX, CON, etc.
"""
@property
def bonus(self):
return int((self - 10) / 2)
class ModifierMap(BaseObject):
"""
Creates a many-to-many between Modifier and any model inheriting from the ModifierMixin.
"""
__tablename__ = "modifier_map"
__table_args__ = (UniqueConstraint("primary_table_name", "primary_table_id", "modifier_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
modifier_id: Mapped[int] = mapped_column(ForeignKey("modifier.id"), init=False)
modifier: Mapped["Modifier"] = relationship(uselist=False, lazy="immediate")
primary_table_name: Mapped[str] = mapped_column(nullable=False)
primary_table_id: Mapped[int] = mapped_column(nullable=False)
class Modifier(BaseObject):
"""
Modifiers modify the base value of an existing attribute on another table.
Modifiers are applied by the Character class, but may be associated with any model via the
ModifierMixIn model; refer to the Ancestry class for an example.
"""
__tablename__ = "modifier"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False)
target: Mapped[str] = mapped_column(nullable=False)
stacks: Mapped[bool] = mapped_column(nullable=False, default=False)
absolute_value: Mapped[int] = mapped_column(nullable=True, default=None)
multiply_value: Mapped[float] = mapped_column(nullable=True, default=None)
multiply_attribute: Mapped[str] = mapped_column(nullable=True, default=None)
relative_value: Mapped[int] = mapped_column(nullable=True, default=None)
relative_attribute: Mapped[str] = mapped_column(nullable=True, default=None)
new_value: Mapped[str] = mapped_column(nullable=True, default=None)
description: Mapped[str] = mapped_column(default="")
class ModifierMixin:
"""
Add modifiers to an existing class.
Attributes:
modifier_map - get/set a list of Modifier records associated with the parent
modifiers - read-only dict of lists of modifiers keyed on Modifier.target
Methods:
add_modifier - Add a Modifier association to the modifier_map
remove_modifier - Remove a modifier association from the modifier_map
Example:
>>> class Item(BaseObject, ModifierMixin):
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False)
>>> dwarven_belt = Item(name="Dwarven Belt")
>>> dwarven_belt.add_modifier(Modifier(name="STR+1", target="strength", relative_value=1))
>>> dwarven_belt.modifiers
{'strength': [Modifier(id=1, target='strength', name='STR+1', relative_value=1 ... ]}
"""
@declared_attr
def modifier_map(cls):
"""
Create the join between the current model and the ModifierMap table.
"""
return relationship(
"ModifierMap",
primaryjoin=(
"and_("
f"foreign(ModifierMap.primary_table_name)=='{cls.__tablename__}', "
f"foreign(ModifierMap.primary_table_id)=={cls.__name__}.id"
")"
),
cascade="all,delete,delete-orphan",
overlaps="modifier_map,modifier_map",
single_parent=True,
uselist=True,
lazy="immediate",
)
@property
def modifiers(self):
"""
Return all modifiers for the current instance as a dict keyed on target attribute name.
"""
all_modifiers = defaultdict(list)
for mapping in self.modifier_map:
all_modifiers[mapping.modifier.target].append(mapping.modifier)
return all_modifiers
def has_modifier(self, name: str):
return True if self.modifiers.get(name, None) else False
def add_modifier(self, modifier: Modifier) -> bool:
"""
Associate a modifier to the current instance if it isn't already.
Returns True if the modifier was added; False if was already present.
"""
if modifier.absolute_value is not None and modifier.relative_value is not None and modifier.multiple_value:
raise AttributeError(f"You must provide only one of absolute, relative, and multiple values {modifier}.")
if [mod for mod in self.modifier_map if mod.modifier == modifier]:
return False
self.modifier_map.append(
ModifierMap(
primary_table_name=self.__tablename__,
primary_table_id=self.id,
modifier=modifier,
)
)
return True
def remove_modifier(self, modifier: Modifier) -> bool:
"""
Remove a modifier from the map.
Returns True if it was removed and False if it wasn't present.
"""
if modifier not in self.modifiers[modifier.target]:
return False
self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier]
return True
def _modifiable_column(self, attr_name: str) -> Union[mapped_column, None]:
"""
Given an atttribute name, look for a column attribute with the same
name but with an underscore prefix. If that column exists, and it
has one or more of the expected "modifiable" keys in its info, the
column is modifiable.
Returns the matching column if it was found, or None.
"""
col = getattr(self.__table__.columns, f"_{attr_name}", None)
if col is None:
return None
for key in col.info.keys():
if key.startswith("modifiable"):
return col
return None
def _get_modifiable_base(self, attr_name: str) -> object:
"""
Resolve a dottted string "foo.bar.baz" as its corresponding nested attribute.
This is useful for cases where a column definition includes a modifiable_base
that is some other attribute. For example:
foo[int] = mapped_column(default=0, info={"modifiable_base": "ancestry.bar")
This will create an initial value for self.foo equal to self.ancesetry.bar.
"""
def get_attr(obj, parts):
if parts:
name, *parts = parts
return get_attr(getattr(obj, name), parts)
return obj
return get_attr(self, attr_name.split("."))
def _apply_one_modifier(self, modifier, initial, modified):
if modifier.new_value is not None:
return modifier.new_value
elif modifier.absolute_value is not None:
return modifier.absolute_value
base_value = modified if modifier.stacks else initial
if modifier.multiply_attribute is not None:
return int(base_value * getattr(self, modifier.multiply_attribute) + 0.5)
if modifier.multiply_value is not None:
return int(base_value * modifier.multiply_value + 0.5)
if modifier.relative_attribute is not None:
return base_value + getattr(self, modifier.relative_attribute)
if modifier.relative_value is not None:
return base_value + modifier.relative_value
raise Exception(f"Cannot apply modifier: {modifier = }")
def _apply_modifiers(self, target: str, initial: Any, modifiable_class: type = None) -> Modifiable:
"""
Apply all the modifiers for a given target and return the modified value.
This is mostly called from __getattr__() below to handle cases where a
column is named self._foo but the modified value is accessible as
self.foo. It can also be invoked directly, as, say from a property:
@property
def speed(self):
return self._apply_modifiers("speed", self.ancestry.speed)
Args:
target - The name of the attribute to modify
initial - The initial value for the target
modifiable_class - The object type to return; inferred from the
target attribute's type if not specified.
"""
if not modifiable_class:
modifiable_class = globals()["ModifiableInt"] if isinstance(initial, int) else globals()["ModifiableStr"]
modifiers = self.modifiers.get(target, [])
nonstacking = [m for m in modifiers if not m.stacks]
if nonstacking:
return modifiable_class(base=initial, modified=self._apply_one_modifier(nonstacking[-1], initial, initial))
modified = initial
for modifier in modifiers:
if modifier.stacks:
modified = self._apply_one_modifier(modifier, initial, modified)
return modifiable_class(base=initial, modified=modified)
def __setattr__(self, attr_name, value):
"""
Prevent callers from setting the value of a Modifiable directly.
"""
col = self._modifiable_column(attr_name)
if col is not None:
raise AttributeError(f"You cannot modify .{attr_name}. Did you mean ._{attr_name}?")
return super().__setattr__(attr_name, value)
def __getattr__(self, attr_name):
"""
If the instance has an attribute equal to attr_name but prefixed with an
underscore, check to see if that attribute is a column, and modifiable.
If it is, return a Modifiable instance corresponding to that column's value.
"""
col = self._modifiable_column(attr_name)
if col is not None:
return self._apply_modifiers(
attr_name,
self._get_modifiable_base(col.info.get("modifiable_base", col.name)),
modifiable_class=col.info.get("modifiable_class", None),
)
raise AttributeError(f"No such attribute on {self.__class__.__name__} object: {attr_name}.")
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject
__all__ = [
"Skill",
]
class Skill(BaseObject):
__tablename__ = "skill"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
base_id: Mapped[int] = mapped_column(ForeignKey("skill.id"), nullable=True, default=None)
parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False)
+37
View File
@@ -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)
+29
View File
@@ -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"
+26
View File
@@ -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
+149
View File
@@ -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,170 @@
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"])
if self.character_class_map:
self.label.text = self.character_class_map.character_class.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:
logging.debug(f"Loading existing attribute {self = } {formdata = } {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.
"""
logging.debug(f"Loading existing class {self = } {formdata = } {obj = }")
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())
class_list = FieldList(FormField(MulticlassForm, label=None, widget=ListWidget()), min_entries=0)
newclass = FormField(MulticlassForm, widget=ListWidget())
attribute_list = 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["class_list"]:
return ret
err = ""
total_level = 0
for field in self.form.data["class_list"]:
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):
# step through the list of class mappings for this character
for class_name, class_def in self.record.classes.items():
logging.error(f"{class_name = }, {class_def = }")
for level in range(1, self.record.levels[class_name] + 1):
for attr in class_def.attributes_by_level.get(level, None):
self.record.add_class_attribute(attr, attr.options[0])
def save_callback(self):
# self.add_class_attributes()
pass
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["class_list"]
classes_formdata.append(self.form.data["newclass"])
del self.form.class_list
del self.form.newclass
# class attributes
attrs_formdata = self.form.data["attribute_list"]
del self.form.attribute_list
super().populate()
self.record.class_list = self.populate_association("character_class_id", classes_formdata)
self.record.attribute_list = 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()}
+21
View File
@@ -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
+4
View File
@@ -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")
+26
View File
@@ -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)
+107
View File
@@ -0,0 +1,107 @@
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from ttfrog.db import schema
from ttfrog.db.manager import db as _db
FIXTURE_PATH = Path(__file__).parent / "fixtures"
def load_fixture(db, fixture_name):
with db.transaction():
data = json.loads((FIXTURE_PATH / f"{fixture_name}.json").read_text())
for schema_name in data:
for record in data[schema_name]:
print(f"Loading {schema_name} {record = }")
obj = getattr(schema, schema_name)(**record)
db.session.add(obj)
@pytest.fixture(autouse=True)
def db(monkeypatch):
monkeypatch.setattr("ttfrog.db.manager.database", MagicMock(return_value=""))
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv("DEBUG", "1")
_db.init()
yield _db
_db.metadata.drop_all(bind=_db.engine)
@pytest.fixture
def bootstrap(db):
with db.transaction():
# ancestries
human = schema.Ancestry("human")
tiefling = schema.Ancestry("tiefling")
tiefling.add_modifier(
schema.Modifier("Ability Score Increase", target="intelligence", stacks=True, relative_value=1)
)
tiefling.add_modifier(
schema.Modifier("Ability Score Increase", target="charisma", stacks=True, relative_value=2)
)
# ancestry traits
darkvision = schema.AncestryTrait("Darkvision")
darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision)
# resistant to both magical and non-magical sources of fire
infernal_origin = schema.AncestryTrait("Infernal Origin")
infernal_origin.add_modifier(schema.Modifier("Infernal Origin", target="defenses.fire", new_value="resistant"))
infernal_origin.add_modifier(
schema.Modifier("Infernal Origin", target="defenses.magical_fire", new_value="resistant")
)
tiefling.add_trait(infernal_origin)
db.add_or_update(tiefling)
dragonborn = schema.Ancestry("dragonborn")
dragonborn.add_trait(darkvision)
db.add_or_update([human, dragonborn, tiefling])
# skills
skills = {
name: schema.Skill(name=name)
for name in ("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma")
}
db.add_or_update(list(skills.values()))
acrobatics = schema.Skill(name="Acrobatics", base_id=skills["dexterity"].id)
athletics = schema.Skill(name="Athletics", base_id=skills["strength"].id)
db.add_or_update([acrobatics, athletics])
# classes
fighting_style = schema.ClassAttribute("Fighting Style")
fighting_style.add_option(name="Archery")
fighting_style.add_option(name="Defense")
db.add_or_update(fighting_style)
fighter = schema.CharacterClass(
"fighter", hit_die_name="1d10", hit_die_stat_name="_constitution", starting_skills=2
)
db.add_or_update(fighter)
# add skills
fighter.add_skill(acrobatics)
fighter.add_skill(athletics)
fighter.add_attribute(fighting_style, level=2)
db.add_or_update(fighter)
assert acrobatics in fighter.skills
assert athletics in fighter.skills
rogue = schema.CharacterClass("rogue", hit_die_name="1d8", hit_die_stat_name="_dexterity")
db.add_or_update([rogue, fighter])
# characters
foo = schema.Character("Foo", ancestry=tiefling, _intelligence=14)
db.add_or_update(foo)
foo.add_class(fighter, level=2)
foo.add_class(rogue, level=3)
bar = schema.Character("Bar", ancestry=human)
# persist all the records we've created
db.add_or_update([foo, bar])
+19
View File
@@ -0,0 +1,19 @@
{
"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},
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1},
{"ancestry_id": 2, "ancestry_trait_id": 3, "level": 1},
{"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"Ancestry": [
{"id": 1, "name": "+1 to All Ability Scores"},
{"id": 2, "name": "Breath Weapon"},
{"id": 3, "name": "Darkvision"}
]
}
+45
View File
@@ -0,0 +1,45 @@
{
"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"]
}
],
"ClassAttribute": [
{
"id": 1,
"name": "Fighting Style"
}
],
"ClassAttributeMap": [
{
"class_attribute_id": 1,
"character_class_id": 1,
"level": 2
}
],
"ClassAttributeOption": [
{
"attribute_id": 1,
"name": "Archery"
},
{
"attribute_id": 1,
"name": "Battlemaster"
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "multiclass_pc",
"classes": [
"fighter": 5,
"rogue": 3
]
}
+16
View File
@@ -0,0 +1,16 @@
import pytest
from ttfrog.db import schema
@pytest.mark.skip
def test_many_records(db):
with db.transaction():
for i in range(1, 1000):
obj = schema.Ancestry(name=f"{i}-ancestry")
db.add_or_update(obj)
assert obj.id == i
for i in range(1, 1000):
obj = schema.Character(name=f"{i}-char")
db.add_or_update(obj)
+323
View File
@@ -0,0 +1,323 @@
import json
from ttfrog.db import schema
def test_manage_character(db, bootstrap):
with db.transaction():
darkvision = db.AncestryTrait.filter_by(name="Darkvision").one()
human = db.Ancestry.filter_by(name="human").one()
# create a human character (the default)
char = schema.Character(name="Test Character", ancestry=human)
db.add_or_update(char)
assert char.id == 3
assert char.name == "Test Character"
assert char.ancestry.name == "human"
assert char.armor_class == 10
assert char.hit_points == 10
assert char.strength == 10
assert char.dexterity == 10
assert char.constitution == 10
assert char.intelligence == 10
assert char.wisdom == 10
assert char.charisma == 10
assert darkvision not in char.traits
# verify basic skills were added at creation time
for skill in db.Skill.filter(
schema.Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
):
assert char.check_modifier(skill) == 0
# switch ancestry to tiefling
tiefling = db.Ancestry.filter_by(name="tiefling").one()
char.ancestry = tiefling
db.add_or_update(char)
char = db.session.get(schema.Character, char.id)
assert char.ancestry_id == tiefling.id
assert char.ancestry.name == "tiefling"
assert darkvision in char.traits
# tiefling ancestry adds INT and CHA modifiers
assert char.intelligence == 11
assert char.intelligence.base == 10
assert char.charisma == 12
assert char.charisma.base == 10
# switch ancestry to dragonborn and assert darkvision persists
char.ancestry = db.Ancestry.filter_by(name="dragonborn").one()
db.add_or_update(char)
assert darkvision in char.traits
# verify tiefling modifiers were removed
assert char.intelligence == 10
assert char.charisma == 10
# switch ancestry to human and assert darkvision is removed
char.ancestry = human
db.add_or_update(char)
assert darkvision not in char.traits
fighter = db.CharacterClass.filter_by(name="fighter").one()
rogue = db.CharacterClass.filter_by(name="rogue").one()
# assign a class and level
char.add_class(fighter, level=1)
db.add_or_update(char)
assert char.levels == {"fighter": 1}
assert char.level == 1
assert char.class_attributes == {}
# 'fighting style' is available, but not at this level
fighting_style = fighter.attribute("Fighting Style")
assert char.has_class_attribute(fighting_style) is False
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
db.add_or_update(char)
assert char.class_attributes == {}
# level up
char.add_class(fighter, level=7)
db.add_or_update(char)
assert char.levels == {"fighter": 7}
assert char.level == 7
# Assert the fighting style is added automatically and idempotent...ly?
assert char.has_class_attribute(fighting_style)
assert char.class_attributes[fighting_style.name] == fighting_style.options[0]
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
assert char.has_class_attribute(fighting_style)
db.add_or_update(char)
athletics = db.Skill.filter_by(name="athletics").one()
acrobatics = db.Skill.filter_by(name="acrobatics").one()
assert athletics in char.skills
assert acrobatics in char.skills
assert char.check_modifier(athletics) == char.proficiency_bonus + char.strength.bonus == 3
assert char.check_modifier(acrobatics) == char.proficiency_bonus + char.dexterity.bonus == 3
# multiclass
char.add_class(rogue, level=1)
db.add_or_update(char)
assert char.level == 8
assert char.levels == {"fighter": 7, "rogue": 1}
assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 8
# remove a class
char.remove_class(rogue)
db.add_or_update(char)
assert char.levels == {"fighter": 7}
assert char.level == 7
assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 7
# verify hit dice are added and removed correctly
assert len(char.hit_dice["fighter"]) == char.level_in_class(fighter).level == 7
assert char.hit_dice["fighter"][0].name == fighter.hit_die_name == "1d10"
assert char.hit_dice["fighter"][0].stat == fighter.hit_die_stat_name == "_constitution"
# remove remaining class by setting level to zero
char.add_class(fighter, level=0)
db.add_or_update(char)
assert char.levels == {}
assert char.class_attributes == {}
# verify the proficiencies added by the classes have been removed
assert athletics not in char.skills
assert acrobatics not in char.skills
assert char.check_modifier(athletics) == 0
assert char.check_modifier(acrobatics) == 0
# ensure we're not persisting any orphan records in the map tables
dump = json.loads(db.dump())
assert not [m for m in dump["character_class_attribute_map"] if m["character_id"] == char.id]
assert not [m for m in dump["class_map"] if m["character_id"] == char.id]
def test_ancestries(db, bootstrap):
with db.transaction():
# create the Pygmy Orc ancestry
porc = schema.Ancestry(
name="Pygmy Orc",
size="Small",
speed=25,
)
assert porc.name == "Pygmy Orc"
assert porc.creature_type == "humanoid"
assert porc.size == "Small"
assert porc.speed == 25
# create the Relentless Endurance trait and add it to the Orc
endurance = schema.AncestryTrait(name="Relentless Endurance")
db.add_or_update(endurance)
porc.add_trait(endurance, level=1)
db.add_or_update(porc)
assert endurance in porc.traits
# add a +3 STR modifier
str_bonus = schema.Modifier(
name="STR+3 (Pygmy Orc)",
target="strength",
stacks=True,
relative_value=3,
description="Your Strength score is increased by 3.",
)
assert porc.add_modifier(str_bonus) is True
assert porc.add_modifier(str_bonus) is False # test idempotency
assert str_bonus in porc.modifiers["strength"]
# now create an orc character and assert it gets traits and modifiers
grognak = schema.Character(name="Grognak the Mighty", ancestry=porc)
db.add_or_update(grognak)
assert endurance in grognak.traits
# verify the strength bonus is applied
assert grognak.strength.base == 10
assert grognak.strength == 13
assert grognak.strength.bonus == 1
assert str_bonus in grognak.modifiers["strength"]
# make sure bonuses are applied to checks and saves
strength = db.Skill.filter_by(name="strength").one()
assert grognak.check_modifier(strength) == 1
assert grognak.check_modifier(strength, save=True) == 1
def test_modifiers(db, bootstrap):
with db.transaction():
human = db.Ancestry.filter_by(name="human").one()
tiefling = db.Ancestry.filter_by(name="tiefling").one()
# no modifiers; speed is ancestry speed
carl = schema.Character(name="Carl", ancestry=tiefling)
marx = schema.Character(name="Marx", ancestry=human)
db.add_or_update([carl, marx])
assert carl.speed == carl.ancestry.speed == 30
cold = schema.Modifier(target="speed", stacks=True, relative_value=-10, name="Cold")
hasted = schema.Modifier(target="speed", multiply_value=2.0, name="Hasted")
slowed = schema.Modifier(target="speed", multiply_value=0.5, name="Slowed")
restrained = schema.Modifier(target="speed", absolute_value=0, name="Restrained")
reduced = schema.Modifier(target="size", new_value="Tiny", name="Reduced")
# reduce speed by 10
assert carl.add_modifier(cold)
assert carl.speed == 20
# make sure modifiers only apply to carl. Carl is having a bad day.
assert marx.speed == 30
# speed is doubled
assert carl.remove_modifier(cold)
assert carl.speed == 30
assert carl.add_modifier(hasted)
assert carl.speed == 60
# speed is halved, overriding hasted because it was applied after
assert carl.add_modifier(slowed)
assert carl.speed == 15
# speed is 0
assert carl.add_modifier(restrained)
assert carl.speed == 0
# no longer restrained, but still slowed
assert carl.remove_modifier(restrained)
assert carl.speed == 15
# back to normal
assert carl.remove_modifier(slowed)
assert carl.remove_modifier(hasted)
assert carl.speed == carl.ancestry.speed
# modifiers can modify string values too
assert carl.add_modifier(reduced)
assert carl.size == "Tiny"
# modifiers can be applied to skills, even if the character doesn't have a skill associated.
athletics = db.Skill.filter_by(name="athletics").one()
assert athletics not in carl.skills
assert carl.check_modifier(athletics) == 0
temp_proficiency = schema.Modifier(
"Expertise in Athletics",
target="athletics_check",
stacks=True,
relative_attribute="expertise_bonus",
)
assert carl.add_modifier(temp_proficiency)
assert carl.check_modifier(athletics) == carl.expertise_bonus + carl.strength.bonus == 2
assert carl.remove_modifier(temp_proficiency)
# fighters get proficiency in athletics by default
fighter = db.CharacterClass.filter_by(name="fighter").one()
carl.add_class(fighter)
db.add_or_update(carl)
assert carl.check_modifier(athletics) == 1
# add the skill directly, which will grant proficiency but will not stack with proficiency from the class
carl.add_skill(athletics, proficient=True)
db.add_or_update(carl)
assert len([s for s in carl.skills if s == athletics]) == 2
assert carl.check_modifier(athletics) == 1
# manually override proficiency with expertise
carl.add_skill(athletics, expert=True)
assert carl.check_modifier(athletics) == 2
assert len([s for s in carl.skills if s == athletics]) == 2
# remove expertise
carl.add_skill(athletics, proficient=True, expert=False)
assert carl.check_modifier(athletics) == 1
# remove the extra skill entirely, but the fighter proficiency remains
carl.remove_skill(athletics, proficient=True, expert=False, character_class=None)
assert len([s for s in carl.skills if s == athletics]) == 1
assert carl.check_modifier(athletics) == 1
def test_defenses(db, bootstrap):
with db.transaction():
tiefling = db.Ancestry.filter_by(name="tiefling").one()
carl = schema.Character(name="Carl", ancestry=tiefling)
assert carl.resistant("fire", magical=False)
assert carl.resistant("fire", magical=True)
carl.apply_damage(5, "fire", magical=True)
assert carl.hit_points == 8 # half damage
immunity = [
schema.Modifier("Fire Immunity", target="defenses.fire", new_value="immune"),
schema.Modifier("Fire Immunity", target="defenses.magical_fire", new_value="immune")
]
for i in immunity:
carl.add_modifier(i)
assert carl.immune("fire")
carl.apply_damage(5, "fire", magical=True)
carl.apply_damage(5, "fire", magical=False)
assert carl.hit_points == 8 # no damage
vulnerability = [
schema.Modifier("Fire Vulnerability", target="defenses.fire", new_value="vulnerable"),
schema.Modifier("Fire Vulnerability", target="defenses.magical_fire", new_value="vulnerable")
]
for i in vulnerability:
carl.add_modifier(i)
assert carl.vulnerable("fire")
assert not carl.immune("fire")
carl.apply_damage(2, "fire", magical=True)
assert carl.hit_points == 4 # double damage
absorbs = [
schema.Modifier("Absorbs Non-Magical Fire", target="defenses.fire", new_value="absorbs"),
]
carl.add_modifier(absorbs[0])
carl.apply_damage(20, "fire", magical=False)
assert carl.hit_points == carl._max_hit_points == 10
for i in immunity + vulnerability + absorbs:
carl.remove_modifier(i)
carl.apply_damage(5, "fire", magical=True)
assert carl.resistant("fire")
assert not carl.immune("fire")
assert not carl.vulnerable("fire")
assert not carl.absorbs("fire")
assert carl.hit_points == 8 # half damage
-4
View File
@@ -1,4 +0,0 @@
from .manager import db, session
__ALL__ = [db, session]
-47
View File
@@ -1,47 +0,0 @@
import base64
import hashlib
import logging
from ttfrog.db import db, session
# move this to json or whatever
data = {
'ancestry': [
{'name': 'human'},
{'name': 'dragonborn'},
],
}
def slug_from_rec(rec):
"""
Create a uniquish slug from a dictionary.
"""
sha1bytes = hashlib.sha1(str(rec).encode())
return '-'.join([
base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10],
rec.get('name', '') # will need to normalize this for URLs
])
def bootstrap():
"""
Initialize the database with source data. Idempotent; will skip anything that already exists.
"""
db.init_model()
for table_name, table in db.tables.items():
if table_name not in data:
logging.debug("No bootstrap data for table {table_name}; skipping.")
continue
for rec in data[table_name]:
if 'slug' in table.columns:
rec['slug'] = slug_from_rec(rec)
stmt = table.insert().values(**rec).prefix_with("OR IGNORE")
result = session.execute(stmt)
session.commit()
last_id = result.inserted_primary_key[0]
if last_id == 0:
logging.info(f"Skipped existing {table_name} {rec}")
else:
logging.info(f"Created {table_name} {result.inserted_primary_key[0]}: {rec}")
-54
View File
@@ -1,54 +0,0 @@
from functools import cached_property
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from ttfrog.path import database
from ttfrog.db.schema import metadata
class SQLDatabaseManager:
"""
A context manager for working with sqllite database.
"""
@cached_property
def url(self):
return f"sqlite:///{database()}"
@cached_property
def engine(self):
return create_engine(self.url, future=True)
@cached_property
def DBSession(self):
maker = sessionmaker(bind=self.engine, future=True, autoflush=True)
return scoped_session(maker)
@cached_property
def tables(self):
return dict((t.name, t) for t in metadata.sorted_tables)
def query(self, *args, **kwargs):
return self.DBSession.query(*args, **kwargs)
def init_model(self, engine=None):
metadata.create_all(bind=engine or self.engine)
return self.DBSession
def __getattr__(self, name: str):
try:
return self.tables[name]
except KeyError:
raise AttributeError(f"{self} does not contain the attribute '{name}'.")
def __enter__(self):
self.init_model(self.engine)
return self
def __exit__(self, exc_type, exc_value, traceback):
if self.DBSession:
self.DBSession.close()
db = SQLDatabaseManager()
session = db.DBSession
-30
View File
@@ -1,30 +0,0 @@
from sqlalchemy import MetaData
from sqlalchemy import Table
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import UnicodeText
from sqlalchemy import ForeignKey
# from sqlalchemy import PrimaryKeyConstraint
# from sqlalchemy import DateTime
metadata = MetaData()
Ancestry = Table(
"ancestry",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("slug", String, index=True, unique=True),
Column("name", String, index=True, unique=True),
Column("description", UnicodeText),
)
Character = Table(
"character",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("slug", String, index=True, unique=True),
Column("name", String),
Column("ancestry_id", Integer, ForeignKey("ancestry.id")),
)
-31
View File
@@ -1,31 +0,0 @@
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'
-59
View File
@@ -1,59 +0,0 @@
import logging
from tg import MinimalApplicationConfigurator
from tg.configurator.components.statics import StaticsConfigurationComponent
from tg.configurator.components.sqlalchemy import SQLAlchemyConfigurationComponent
from tg.util.bunch import Bunch
from wsgiref.simple_server import make_server
import webhelpers2
from ttfrog.webserver import controllers
from ttfrog.db import db
import ttfrog.path
def app_globals():
return Bunch
def application():
"""
Create a TurboGears2 application
"""
config = MinimalApplicationConfigurator()
config.register(StaticsConfigurationComponent)
config.register(SQLAlchemyConfigurationComponent)
config.update_blueprint({
# rendering
'root_controller': controllers.RootController(),
'default_renderer': 'jinja',
'renderers': ['jinja'],
'tg.jinja_filters': {},
'auto_reload_templates': True,
# helpers
'app_globals': app_globals,
'helpers': webhelpers2,
'tw2.enabled': True,
# assets
'serve_static': True,
'paths': {
'static_files': ttfrog.path.static_files(),
'templates': [ttfrog.path.templates()],
},
# db
'use_sqlalchemy': True,
'sqlalchemy.url': db.url,
'model': db,
})
return config.make_wsgi_app()
def start(host: str, port: int, debug: bool = False) -> None:
logging.debug(f"Configuring webserver with {host=}, {port=}, {debug=}")
make_server(host, int(port), application()).serve_forever()
-16
View File
@@ -1,16 +0,0 @@
from tg import expose
from tg import TGController
from tg import tmpl_context
from ttfrog.db import db
from ttfrog.db.schema import Character
class RootController(TGController):
def _before(self, *args, **kwargs):
tmpl_context.project_name = 'TableTop Frog'
@expose('index.html')
def index(self):
ancestries = [row._mapping for row in db.query(db.ancestry).all()]
return dict(page='index', content=str(ancestries))