Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09549bf68c | ||
|
|
9a2d28ae75 | ||
|
|
b574dacfa1 | ||
|
|
3292b11d89 | ||
|
|
3980be5f07 | ||
|
|
1ff0e5ca7d | ||
|
|
5db6e40eae | ||
|
|
36f6f831d9 | ||
|
|
5ec27e9344 | ||
|
|
12c643c542 | ||
|
|
a520ea249e | ||
|
|
46ef48669d | ||
|
|
a9593e83a2 | ||
|
|
412efe2aec | ||
|
|
44cd8fe9c9 | ||
|
|
dbb9461b7a | ||
|
|
78115023bb | ||
|
|
b1d7639a62 | ||
|
|
dba8bb315a | ||
|
|
b92ff868a5 | ||
|
|
304a4d9c79 | ||
|
|
75b9aec28e | ||
|
|
9757d3bee0 | ||
|
|
ba0e66f9af | ||
|
|
e231828425 | ||
|
|
1baf73a338 | ||
|
|
8bde2ab5f3 | ||
|
|
da6255a86a | ||
|
|
2dcaa3fac6 | ||
|
|
99ef4d61f9 | ||
|
|
669c9b46d6 | ||
|
|
32d9c42847 | ||
|
|
9cdf28502a | ||
|
|
9277494a05 | ||
|
|
5de3f74a88 | ||
|
|
3444f83c91 | ||
|
|
5faf5c97c1 | ||
|
|
8f17ddfb05 | ||
|
|
64451ddf8b | ||
|
|
17da4a73ee |
+42
-11
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
position : relative;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.disabled:after {
|
||||||
|
position :absolute;
|
||||||
|
left : 0;
|
||||||
|
top : 0;
|
||||||
|
width : 100%;
|
||||||
|
height : 100%;
|
||||||
|
content :' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#content {
|
||||||
|
margin: 1rem auto;
|
||||||
|
max-width: 1280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #000055;
|
||||||
|
}
|
||||||
|
a:visited, a:active {
|
||||||
|
color: #000055;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ul.nav {
|
||||||
|
background: #e7e7e7;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
list-style-type: none;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.nav li {
|
||||||
|
display: inline;
|
||||||
|
text-align: center;
|
||||||
|
background: #FFF;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sheet_container {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#character_sheet {
|
||||||
|
margin-bottom:3rem;
|
||||||
|
display: grid;
|
||||||
|
grid-gap: 1rem;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sheet_container .banner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px 1fr;
|
||||||
|
grid-gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sheet_container .banner #portrait {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: #e7e7e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls {
|
||||||
|
float: right;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp_hp input {
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sheet_container h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sheet_container .sidebar {
|
||||||
|
grid-column-start: 2;
|
||||||
|
grid-row-start: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sheet_container .sidebar .card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sheet_container .sidebar ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
#sheet_container .sidebar ul > li {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hp {
|
||||||
|
grid-row-start: 1;
|
||||||
|
grid-column: 7;
|
||||||
|
grid-column-end: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#saves {
|
||||||
|
grid-row-start: 2;
|
||||||
|
grid-column: 3;
|
||||||
|
grid-column-start: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#proficiency {
|
||||||
|
grid-row-start: 2;
|
||||||
|
grid-column: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#initiative {
|
||||||
|
grid-row-start: 2;
|
||||||
|
grid-column: 6;
|
||||||
|
}
|
||||||
|
#ac {
|
||||||
|
grid-row-start: 2;
|
||||||
|
grid-column: 7;
|
||||||
|
}
|
||||||
|
#speed {
|
||||||
|
grid-row-start: 2;
|
||||||
|
grid-column: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#skills {
|
||||||
|
grid-row-start: 2;
|
||||||
|
grid-row-end: 50;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-column-start: span 2;
|
||||||
|
text-align:left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#actions {
|
||||||
|
grid-row-start: 3;
|
||||||
|
grid-column: 4;
|
||||||
|
grid-column-start: span 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(50px, 150px) 1fr;
|
||||||
|
grid-gap: 0rem;
|
||||||
|
}
|
||||||
|
table th {
|
||||||
|
grid-column-start: span 4;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td {
|
||||||
|
padding-right: 1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#sheet_container input,
|
||||||
|
#sheet_container select,
|
||||||
|
#sheet_container textarea {
|
||||||
|
font-weight: bold;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sheet_container input#name {
|
||||||
|
font-size: 2.0rem;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(6rem, 1fr));
|
||||||
|
grid-gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 2px solid #e7e7e7;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: .5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card input {
|
||||||
|
font-size: 1.25em;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ul.multiclass {
|
||||||
|
display: inline;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.multiclass li {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.multiclass label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ul#class_attributes {
|
||||||
|
list-style-type: none;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
ul#class_attributes li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min-content 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul#class_attributes span,
|
||||||
|
ul#class_attributes label {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul#class_attributes label {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul#class_attributes span select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
function getTraitModifiersForStat(stat) {
|
||||||
|
var mods = {};
|
||||||
|
for (const prop in TRAITS) {
|
||||||
|
var props = [];
|
||||||
|
for (const desc in TRAITS[prop]) {
|
||||||
|
trait = TRAITS[prop][desc]
|
||||||
|
if (trait.type == "stat" && trait.target == stat) {
|
||||||
|
props.push(trait);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (props) {
|
||||||
|
mods[prop] = props;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mods;
|
||||||
|
}
|
||||||
|
|
||||||
|
function proficiency() {
|
||||||
|
return parseInt(document.getElementById('proficiency_bonus').innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bonus(stat) {
|
||||||
|
return parseInt(document.getElementById(stat + '_bonus').innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatBonus(stat) {
|
||||||
|
var score = document.getElementById(stat).value;
|
||||||
|
var bonus = Math.floor((score - 10) / 2);
|
||||||
|
document.getElementById(stat + '_bonus').innerHTML = bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStatModifiers(stat) {
|
||||||
|
var score = parseInt(document.getElementById(stat).value);
|
||||||
|
var modsForStat = getTraitModifiersForStat(stat);
|
||||||
|
for (desc in modsForStat) {
|
||||||
|
for (idx in modsForStat[desc]) {
|
||||||
|
var value = modsForStat[desc][idx].value;
|
||||||
|
console.log(`Ancestry Trait "${desc}" grants ${value} to ${stat}`);
|
||||||
|
score += parseInt(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById(stat).value = score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProficiencyBonus() {
|
||||||
|
var score = document.getElementById('level').value;
|
||||||
|
var bonus = Math.ceil(1 + (0.25 * score));
|
||||||
|
document.getElementById('proficiency_bonus').innerHTML = bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSpellSaveDC() {
|
||||||
|
var score = 8 + proficiency() + bonus('wis');
|
||||||
|
document.getElementById('spell_save_dc').innerHTML = score;
|
||||||
|
}
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const stats = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||||
|
stats.forEach(applyStatModifiers);
|
||||||
|
stats.forEach(setStatBonus);
|
||||||
|
setProficiencyBonus();
|
||||||
|
// setSpellSaveDC();
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% from "list.html" import build_list %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{{ c.config.project_name }}{% block title %}{% endblock %}</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="og:provider_name" content="{{ c.config.project_name }}">
|
||||||
|
{% for resource in c.resources %}
|
||||||
|
<link rel='preload' href="{{c.routes.static}}/{{resource['uri']}}" as="{{resource['type']}}"/>
|
||||||
|
{% if resource['type'] == 'style' %}
|
||||||
|
<link rel='stylesheet' href="{{c.routes.static}}/{{resource['uri']}}" />
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% block headers %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{ build_list(c) }}
|
||||||
|
<div id='content'>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% block debug %}{% endblock %}
|
||||||
|
{% block script %}{% endblock %}
|
||||||
|
{% for resource in c.resources %}
|
||||||
|
{% if resource['type'] == 'script' %}
|
||||||
|
<script type="text/javascript" src="{{c.routes.static}}/{{resource['uri']}}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set DISABLED = False if c.record.id else True %}
|
||||||
|
|
||||||
|
{% macro field(name, disabled=False) %}
|
||||||
|
{% set default_value = c.record[name] if c.record.id else c.form[name].default %}
|
||||||
|
{{ c.form[name](disabled=disabled, **{'data-initial_value': default_value}) }}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div id='sheet_container'>
|
||||||
|
<form name="character_sheet" method="post" novalidate class="form">
|
||||||
|
|
||||||
|
<div class='banner'>
|
||||||
|
<div><img id='portrait' /></div>
|
||||||
|
<div>
|
||||||
|
{{ field('name') }}
|
||||||
|
{{ field('ancestry_id') }}
|
||||||
|
{% for obj in c.form['class_list'] %}
|
||||||
|
{{ obj(class='multiclass') }}
|
||||||
|
{% endfor %}
|
||||||
|
<span class='label'>Add Class:</span> {{ c.form['newclass'](class='multiclass') }}
|
||||||
|
|
||||||
|
|
||||||
|
<div id='controls'>
|
||||||
|
{{ c.form.save }} {{ c.form.delete }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id='character_sheet' {% if not c.record.id %}class='disabled'{% endif %} >
|
||||||
|
<div class='stats'>
|
||||||
|
{% for stat in ['str', 'dex', 'con', 'int', 'wis', 'cha'] %}
|
||||||
|
<div class='card'>
|
||||||
|
<div class='label'>{{ c.form[stat].label }}</div>
|
||||||
|
{{ field(stat, DISABLED) }}
|
||||||
|
<div id='{{stat}}_bonus'></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div id='hp' class='card'>
|
||||||
|
<div class='label'>HP</div>
|
||||||
|
{{ field('hit_points', DISABLED) }} / {{ field('max_hit_points', DISABLED) }}
|
||||||
|
<div id='temp_hp'>
|
||||||
|
<span class='label'>TEMP</span> {{ field('temp_hit_points', DISABLED) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id='skills'>
|
||||||
|
<div class='label'>Skills</div>
|
||||||
|
<table>
|
||||||
|
{% for skill in c.record.skills %}
|
||||||
|
<tr><td>{{ skill }}</td><td>3</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id='saves' class='card'>
|
||||||
|
<div class='label'>Saving Throws</div>
|
||||||
|
{% for save in c.record.saving_throws %}
|
||||||
|
{{ save }} 3
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id='proficiency' class='card'>
|
||||||
|
<div class='label'>PROF</div>
|
||||||
|
<div id='proficiency_bonus'></div>
|
||||||
|
<div class='label'>BONUS</div>
|
||||||
|
</div>
|
||||||
|
<div id="ac" class='card'>
|
||||||
|
<div class='label'>Armor</div>
|
||||||
|
{{ field('armor_class', DISABLED) }}
|
||||||
|
<div class='label'>Class</div>
|
||||||
|
</div>
|
||||||
|
<div id='initiative' class='card'>
|
||||||
|
<div class='label'>Initiative</div>
|
||||||
|
<span id='initiative_bonus'>3 </span>
|
||||||
|
<div class='label'>Bonus</div>
|
||||||
|
</div>
|
||||||
|
<div id='speed' class='card'>
|
||||||
|
<div class='label'>Speed</div>
|
||||||
|
{{ field('speed', DISABLED) }}
|
||||||
|
</div>
|
||||||
|
<div id="actions" class='card'>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td class='label' colspan='2'>Actions</td>
|
||||||
|
<td class='label'>To Hit</td>
|
||||||
|
<td class='label'>Range</td>
|
||||||
|
<td class='label'>Targets</td>
|
||||||
|
<td class='label'>Damage</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Attack</th>
|
||||||
|
<td>Dagger</td>
|
||||||
|
<td>+7</td>
|
||||||
|
<td>5</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td>1d4+3 slashing</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Attack</th>
|
||||||
|
<td>Sabetha's Fans</td>
|
||||||
|
<td>+7</td>
|
||||||
|
<td>5</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td>2d6 slashing</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Spell</th>
|
||||||
|
<td>Eldritch Blast</td>
|
||||||
|
<td>+5</td>
|
||||||
|
<td>120</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td>1d10 force</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class='label' colspan='2'>Bonus Actions</td>
|
||||||
|
<td class='label'>To Hit</td>
|
||||||
|
<td class='label'>Range</td>
|
||||||
|
<td class='label'>Targets</td>
|
||||||
|
<td class='label'>Damage</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<span class='note'>
|
||||||
|
Attack (1 per Action), Cast a Spell, Dash, Disengage, Dodge, Grapple,<br>Help, Hide, Improvise, Ready, Search, Shove, or Use an Object
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- SIDEBAR -->
|
||||||
|
|
||||||
|
<div class='sidebar'>
|
||||||
|
<div class='card'>
|
||||||
|
<div class='label'>Inspiration</div>
|
||||||
|
<ul>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class='card'>
|
||||||
|
<div class='label'>Conditions</div>
|
||||||
|
<ul>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class='card'>
|
||||||
|
<div class='label'>Attributes</div>
|
||||||
|
{% if c.record.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 %}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% macro build_list(c) %}
|
||||||
|
<ul class='nav'>
|
||||||
|
<li><a href="{{ c.routes.sheet }}">Create a Character</a></li>
|
||||||
|
{% for rec in c.all_records %}
|
||||||
|
<li><a href="{{ c.routes.sheet }}/{{ rec.uri }}">{{ rec.name }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AttributeMap(Mapping):
|
||||||
|
"""
|
||||||
|
AttributeMap is a data class that is also a mapping, converting a dict
|
||||||
|
into an object with attributes. Example:
|
||||||
|
|
||||||
|
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
|
||||||
|
>>> amap.foo
|
||||||
|
True
|
||||||
|
>>> amap.bar
|
||||||
|
False
|
||||||
|
|
||||||
|
Instantiating an AttributeMap using the from_dict() class method will
|
||||||
|
recursively transform dictionary members sinto AttributeMaps:
|
||||||
|
|
||||||
|
>>> nested_dict = {'foo': {'bar': {'baz': True}, 'boz': False}}
|
||||||
|
>>> amap = AttributeMap.from_dict(nested_dict)
|
||||||
|
>>> amap.foo.bar.baz
|
||||||
|
True
|
||||||
|
>>> amap.foo.boz
|
||||||
|
False
|
||||||
|
|
||||||
|
The dictionary can be accessed directly via 'attributes':
|
||||||
|
|
||||||
|
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
|
||||||
|
>>> list(amap.attributes.keys()):
|
||||||
|
>>>['foo', 'bar']
|
||||||
|
|
||||||
|
Because AttributeMap is a mapping, you can use it anywhere you would use
|
||||||
|
a regular mapping, like a dict:
|
||||||
|
|
||||||
|
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
|
||||||
|
>>> 'foo' in amap
|
||||||
|
True
|
||||||
|
>>> "{foo}, {bar}".format(**amap)
|
||||||
|
True, False
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
attributes: field(default_factory=dict)
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
if attr in self.attributes:
|
||||||
|
return self.attributes[attr]
|
||||||
|
return self.__getattribute__(attr)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.attributes)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self.attributes[key]
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self.attributes)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, kwargs: dict):
|
||||||
|
"""
|
||||||
|
Create a new AttributeMap object using keyword arguments. Dicts are
|
||||||
|
recursively converted to AttributeMap objects; everything else is
|
||||||
|
passed as-is.
|
||||||
|
"""
|
||||||
|
attrs = {}
|
||||||
|
for k, v in sorted(kwargs.items()):
|
||||||
|
attrs[k] = AttributeMap.from_dict(v) if type(v) is dict else v
|
||||||
|
return cls(attributes=attrs)
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -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 can’t 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])
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .character import *
|
||||||
|
from .classes import *
|
||||||
|
from .log import *
|
||||||
|
from .modifiers import *
|
||||||
|
from .property import *
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
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.property 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 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)
|
||||||
|
|
||||||
|
@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 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]
|
||||||
|
print(f"Found mapping: {mapping}")
|
||||||
|
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)
|
||||||
|
|
||||||
|
def remove_class(self, target):
|
||||||
|
self.class_map = [m for m in self.class_map if m.character_class != target]
|
||||||
|
for mapping in self.character_class_attribute_map:
|
||||||
|
if mapping.character_class.id == target.id:
|
||||||
|
self.remove_class_attribute(mapping.class_attribute)
|
||||||
|
for skill in target.skills:
|
||||||
|
self.remove_skill(skill, proficient=True, expert=False, 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 __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)
|
||||||
@@ -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.property 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_dice: Mapped[str] = mapped_column(default="1d6")
|
||||||
|
hit_dice_stat: 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(f"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]))
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
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):
|
||||||
|
print(f"Trying to apply {modifier}")
|
||||||
|
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: {attr_name}.")
|
||||||
@@ -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, lazy="immediate")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ttfrog.db.manager import db
|
||||||
|
from ttfrog.db.schema import TransactionLog
|
||||||
|
|
||||||
|
|
||||||
|
def record(previous, new):
|
||||||
|
logging.debug(f"{previous = }, {new = }")
|
||||||
|
diff = list((set(previous.items()) ^ set(dict(new).items())))
|
||||||
|
if not diff:
|
||||||
|
return
|
||||||
|
|
||||||
|
rec = TransactionLog(
|
||||||
|
source_table_name=new.__tablename__,
|
||||||
|
primary_key=new.id,
|
||||||
|
diff=json.dumps(diff),
|
||||||
|
)
|
||||||
|
with db.transaction():
|
||||||
|
db.add(rec)
|
||||||
|
logging.debug(f"Saved restore point: {dict(rec)}")
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def restore(rec, log_id=None):
|
||||||
|
if log_id:
|
||||||
|
log = db.query(TransactionLog).filter_by(id=log_id).one()
|
||||||
|
else:
|
||||||
|
log = db.query(TransactionLog).filter_by(source_table_name=rec.__tablename__, primary_key=rec.id).one()
|
||||||
|
logging.debug(f"Located restore point {log = }")
|
||||||
|
diff = json.loads(log.diff)
|
||||||
|
updates = dict(diff[::2])
|
||||||
|
if not updates:
|
||||||
|
return
|
||||||
|
logging.debug(f"{updates = }")
|
||||||
|
with db.transaction():
|
||||||
|
db.query(db.tables[log.source_table_name]).update(updates)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_setup_hint = "You may be able to solve this error by running 'ttfrog setup' or specifying the --root parameter."
|
||||||
|
|
||||||
|
|
||||||
|
def database():
|
||||||
|
path = Path(os.environ["DATA_PATH"]).expanduser()
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
|
raise RuntimeError(f"DATA_PATH {path} doesn't exist or isn't a directory.\n\n{_setup_hint}")
|
||||||
|
return path / Path("tabletop-frog.db")
|
||||||
|
|
||||||
|
|
||||||
|
def assets():
|
||||||
|
return Path(__file__).parent / "assets"
|
||||||
|
|
||||||
|
|
||||||
|
def templates():
|
||||||
|
try:
|
||||||
|
return Path(os.environ["TEMPLATES_PATH"])
|
||||||
|
except KeyError:
|
||||||
|
return assets() / "templates"
|
||||||
|
|
||||||
|
|
||||||
|
def static_files():
|
||||||
|
try:
|
||||||
|
return Path(os.environ["STATIC_FILES_PATH"])
|
||||||
|
except KeyError:
|
||||||
|
return assets() / "public"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import logging
|
||||||
|
from wsgiref.simple_server import make_server
|
||||||
|
|
||||||
|
from pyramid.config import Configurator
|
||||||
|
|
||||||
|
from ttfrog.db.manager import db
|
||||||
|
from ttfrog.webserver.routes import routes
|
||||||
|
|
||||||
|
|
||||||
|
def configuration():
|
||||||
|
config = Configurator(settings={"sqlalchemy.url": db.url, "jinja2.directories": "ttfrog.assets:templates/"})
|
||||||
|
config.include("pyramid_tm")
|
||||||
|
config.include("pyramid_sqlalchemy")
|
||||||
|
config.include("pyramid_jinja2")
|
||||||
|
config.add_static_view(name="/static", path="ttfrog.assets:static/")
|
||||||
|
config.add_jinja2_renderer(".html", settings_prefix="jinja2.")
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def start(host: str, port: int, debug: bool = False) -> None:
|
||||||
|
logging.debug(f"Configuring webserver with {host=}, {port=}, {debug=}")
|
||||||
|
config = configuration()
|
||||||
|
config.include(routes)
|
||||||
|
config.scan("ttfrog.webserver.views")
|
||||||
|
make_server(host, int(port), config.make_wsgi_app()).serve_forever()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .base import BaseController
|
||||||
|
from .character_sheet import CharacterSheet
|
||||||
|
from .json_data import JsonData
|
||||||
|
|
||||||
|
__all__ = [BaseController, CharacterSheet, JsonData]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from wtforms_alchemy import ModelForm
|
||||||
|
|
||||||
|
from ttfrog.db.manager import db
|
||||||
|
from ttfrog.db.schema import Ancestry
|
||||||
|
|
||||||
|
|
||||||
|
class AncestryForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Ancestry
|
||||||
|
exclude = ["slug"]
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
return db.session
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from pyramid.httpexceptions import HTTPFound
|
||||||
|
from pyramid.interfaces import IRoutesMapper
|
||||||
|
|
||||||
|
from ttfrog.db.manager import db
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_routes(request):
|
||||||
|
routes = {
|
||||||
|
"static": "/static",
|
||||||
|
}
|
||||||
|
uri_pattern = re.compile(r"^([^\{\*]+)")
|
||||||
|
mapper = request.registry.queryUtility(IRoutesMapper)
|
||||||
|
for route in mapper.get_routes():
|
||||||
|
if route.name.startswith("__"):
|
||||||
|
continue
|
||||||
|
m = uri_pattern.search(route.pattern)
|
||||||
|
if m:
|
||||||
|
routes[route.name] = m.group(0)
|
||||||
|
return routes
|
||||||
|
|
||||||
|
|
||||||
|
class BaseController:
|
||||||
|
model = None
|
||||||
|
model_form = None
|
||||||
|
|
||||||
|
def __init__(self, request):
|
||||||
|
self.request = request
|
||||||
|
self.attrs = defaultdict(str)
|
||||||
|
self._slug = None
|
||||||
|
self._record = None
|
||||||
|
self._form = None
|
||||||
|
|
||||||
|
self.config = {"static_url": "/static", "project_name": "TTFROG"}
|
||||||
|
self.configure_for_model()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slug(self):
|
||||||
|
if not self._slug:
|
||||||
|
parts = self.request.matchdict.get("uri", "").split("-")
|
||||||
|
self._slug = parts[0].replace("/", "")
|
||||||
|
return self._slug
|
||||||
|
|
||||||
|
@property
|
||||||
|
def record(self):
|
||||||
|
if not self._record and self.model:
|
||||||
|
try:
|
||||||
|
self._record = db.query(self.model).filter(self.model.slug == self.slug)[0]
|
||||||
|
except IndexError:
|
||||||
|
logging.warning(f"Could not load record with slug {self.slug}")
|
||||||
|
self._record = self.model()
|
||||||
|
return self._record
|
||||||
|
|
||||||
|
@property
|
||||||
|
def form(self):
|
||||||
|
if not self.model:
|
||||||
|
return
|
||||||
|
if not self.model_form:
|
||||||
|
return
|
||||||
|
if not self._form:
|
||||||
|
if self.request.POST:
|
||||||
|
self._form = self.model_form(self.request.POST, obj=self.record)
|
||||||
|
else:
|
||||||
|
self._form = self.model_form(obj=self.record)
|
||||||
|
if not self.record.id:
|
||||||
|
# apply the db schema defaults
|
||||||
|
self._form.process()
|
||||||
|
return self._form
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resources(self):
|
||||||
|
return [
|
||||||
|
{"type": "style", "uri": "css/styles.css"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def configure_for_model(self):
|
||||||
|
if "all_records" not in self.attrs:
|
||||||
|
self.attrs["all_records"] = db.query(self.model).all()
|
||||||
|
|
||||||
|
def template_context(self, **kwargs) -> dict:
|
||||||
|
return dict(
|
||||||
|
config=self.config,
|
||||||
|
request=self.request,
|
||||||
|
form=self.form,
|
||||||
|
record=self.record,
|
||||||
|
routes=get_all_routes(self.request),
|
||||||
|
resources=self.resources,
|
||||||
|
**self.attrs,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def populate(self):
|
||||||
|
self.form.populate_obj(self.record)
|
||||||
|
|
||||||
|
def populate_association(self, key, formdata):
|
||||||
|
populated = []
|
||||||
|
for field in formdata:
|
||||||
|
map_id = field.pop("id")
|
||||||
|
map_id = int(map_id) if map_id else 0
|
||||||
|
if not field[key]:
|
||||||
|
continue
|
||||||
|
elif not map_id:
|
||||||
|
populated.append(field)
|
||||||
|
else:
|
||||||
|
field["id"] = map_id
|
||||||
|
populated.append(field)
|
||||||
|
return populated
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
return self.form.validate()
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
if not self.form.save.data:
|
||||||
|
return
|
||||||
|
if not self.validate():
|
||||||
|
return
|
||||||
|
logging.debug(f"{self.form.data = }")
|
||||||
|
# previous = dict(self.record)
|
||||||
|
logging.debug(f"{self.record = }")
|
||||||
|
self.populate()
|
||||||
|
# transaction_log.record(previous, self.record)
|
||||||
|
with db.transaction():
|
||||||
|
db.add(self.record)
|
||||||
|
self.save_callback()
|
||||||
|
logging.debug(f"Saved {self.record = }")
|
||||||
|
location = self.request.current_route_path()
|
||||||
|
if self.record.slug not in location:
|
||||||
|
location = f"{location}/{self.record.uri}"
|
||||||
|
logging.debug(f"Redirecting to {location}")
|
||||||
|
return HTTPFound(location=location)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
if not self.record.id:
|
||||||
|
return
|
||||||
|
with db.transaction():
|
||||||
|
db.query(self.model).filter_by(id=self.record.id).delete()
|
||||||
|
location = self.request.current_route_path()
|
||||||
|
return HTTPFound(location=location)
|
||||||
|
|
||||||
|
def response(self):
|
||||||
|
if not self.form:
|
||||||
|
return
|
||||||
|
elif self.form.save.data:
|
||||||
|
return self.save()
|
||||||
|
elif self.form.delete.data:
|
||||||
|
return self.delete()
|
||||||
@@ -0,0 +1,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()}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from wtforms.fields import SelectField, SelectMultipleField
|
||||||
|
|
||||||
|
from ttfrog.db.manager import db
|
||||||
|
|
||||||
|
|
||||||
|
class DeferredSelectMultipleField(SelectMultipleField):
|
||||||
|
def __init__(self, *args, model=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.choices = [(rec.id, rec.name) for rec in db.query(model).all()]
|
||||||
|
|
||||||
|
|
||||||
|
class DeferredSelectField(SelectField):
|
||||||
|
def __init__(self, *args, model=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.choices = [(rec.id, getattr(rec, "name", str(rec))) for rec in db.query(model).all()]
|
||||||
|
|
||||||
|
|
||||||
|
class NullableDeferredSelectField(DeferredSelectField):
|
||||||
|
def __init__(self, *args, model=None, label="---", **kwargs):
|
||||||
|
super().__init__(*args, model=model, **kwargs)
|
||||||
|
self.choices = [(0, label)] + self.choices
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
def routes(config):
|
||||||
|
config.add_route("index", "/")
|
||||||
|
config.add_route("sheet", "/c{uri:.*}", factory="ttfrog.webserver.controllers.CharacterSheet")
|
||||||
|
config.add_route("data", "/_/{table_name}{uri:.*}", factory="ttfrog.webserver.controllers.JsonData")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from pyramid.response import Response
|
||||||
|
from pyramid.view import view_config
|
||||||
|
|
||||||
|
from ttfrog.attribute_map import AttributeMap
|
||||||
|
from ttfrog.db.manager import db
|
||||||
|
from ttfrog.db.schema import Ancestry
|
||||||
|
|
||||||
|
|
||||||
|
def response_from(controller):
|
||||||
|
return controller.response() or AttributeMap.from_dict({"c": controller.template_context()})
|
||||||
|
|
||||||
|
|
||||||
|
@view_config(route_name="index")
|
||||||
|
def index(request):
|
||||||
|
ancestries = [a.name for a in db.session.query(Ancestry).all()]
|
||||||
|
return Response(",".join(ancestries))
|
||||||
|
|
||||||
|
|
||||||
|
@view_config(route_name="sheet", renderer="character_sheet.html")
|
||||||
|
def sheet(request):
|
||||||
|
return response_from(request.context)
|
||||||
|
|
||||||
|
|
||||||
|
@view_config(route_name="data", renderer="json")
|
||||||
|
def data(request):
|
||||||
|
return response_from(request.context)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
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_dice="1d10", hit_dice_stat="CON", 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_dice="1d8", hit_dice_stat="DEX")
|
||||||
|
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])
|
||||||
Vendored
+19
@@ -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}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"Ancestry": [
|
||||||
|
{"id": 1, "name": "+1 to All Ability Scores"},
|
||||||
|
{"id": 2, "name": "Breath Weapon"},
|
||||||
|
{"id": 3, "name": "Darkvision"}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+45
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "multiclass_pc",
|
||||||
|
"classes": [
|
||||||
|
"fighter": 5,
|
||||||
|
"rogue": 3
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
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}
|
||||||
|
|
||||||
|
# remove a class
|
||||||
|
char.remove_class(rogue)
|
||||||
|
db.add_or_update(char)
|
||||||
|
assert char.levels == {"fighter": 7}
|
||||||
|
assert char.level == 7
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
from .manager import db, session
|
|
||||||
|
|
||||||
|
|
||||||
__ALL__ = [db, session]
|
|
||||||
@@ -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}")
|
|
||||||
@@ -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
|
|
||||||
@@ -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")),
|
|
||||||
)
|
|
||||||
@@ -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'
|
|
||||||
@@ -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()
|
|
||||||
@@ -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))
|
|
||||||
Reference in New Issue
Block a user