Compare commits
51
Commits
mainline
...
708d6fe9e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
708d6fe9e9 | ||
|
|
26bb645d22 | ||
|
|
3f45dbe9b9 | ||
|
|
b68dda2b77 | ||
|
|
e2ff1eb027 | ||
|
|
da1b4223ea | ||
|
|
4dd72d47d0 | ||
|
|
551140b5bc | ||
|
|
68251ff4e9 | ||
|
|
a8bb6de008 | ||
|
|
d2bed7c859 | ||
|
|
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 |
+43
-12
@@ -5,24 +5,30 @@ description = ""
|
||||
authors = ["evilchili <evilchili@gmail.com>"]
|
||||
readme = "README.md"
|
||||
packages = [
|
||||
{ include = 'ttfrog' },
|
||||
{include = "*", from = "src"},
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
TurboGears2 = "^2.4.3"
|
||||
sqlalchemy = "^2.0.25"
|
||||
tgext-admin = "^0.7.4"
|
||||
webhelpers2 = "^2.0"
|
||||
typer = "^0.9.0"
|
||||
python = "^3.11"
|
||||
python-dotenv = "^0.21.0"
|
||||
typer = "^0.9.0"
|
||||
rich = "^13.7.0"
|
||||
jinja2 = "^3.1.3"
|
||||
|
||||
#"tg.devtools" = "^2.4.3"
|
||||
#repoze-who = "^3.0.0"
|
||||
# tw2-forms = "^2.2.6"
|
||||
sqlalchemy = "^2.0.25"
|
||||
pyramid = "^2.0.2"
|
||||
pyramid-tm = "^2.5"
|
||||
pyramid-jinja2 = "^2.10"
|
||||
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]
|
||||
requires = ["poetry-core"]
|
||||
@@ -33,3 +39,28 @@ build-backend = "poetry.core.masonry.api"
|
||||
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 os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from dotenv import load_dotenv
|
||||
@@ -12,9 +12,8 @@ from rich.logging import RichHandler
|
||||
|
||||
from ttfrog.path import assets
|
||||
|
||||
|
||||
default_data_path = Path("~/.dnd/ttfrog")
|
||||
default_host = '127.0.0.1'
|
||||
default_host = "127.0.0.1"
|
||||
default_port = 2323
|
||||
|
||||
SETUP_HELP = f"""
|
||||
@@ -37,45 +36,32 @@ HOST={default_host}
|
||||
PORT={default_port}
|
||||
"""
|
||||
|
||||
db_app = typer.Typer()
|
||||
app = typer.Typer()
|
||||
app.add_typer(db_app, name="db", help="Manage the database.")
|
||||
app_state = dict()
|
||||
|
||||
|
||||
@app.callback()
|
||||
@db_app.callback()
|
||||
def main(
|
||||
context: typer.Context,
|
||||
root: Optional[Path] = typer.Option(
|
||||
default_data_path,
|
||||
help="Path to the TableTop Frog environment",
|
||||
)
|
||||
),
|
||||
):
|
||||
app_state['env'] = root.expanduser() / Path('defaults')
|
||||
app_state["env"] = root.expanduser() / Path("defaults")
|
||||
load_dotenv(stream=io.StringIO(SETUP_HELP))
|
||||
load_dotenv(app_state['env'])
|
||||
debug = os.getenv('DEBUG', None)
|
||||
load_dotenv(app_state["env"])
|
||||
debug = os.getenv("DEBUG", None)
|
||||
logging.basicConfig(
|
||||
format='%(message)s',
|
||||
format="%(message)s",
|
||||
level=logging.DEBUG if debug else logging.INFO,
|
||||
handlers=[
|
||||
RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])
|
||||
]
|
||||
handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def setup(context: typer.Context):
|
||||
"""
|
||||
(Re)Initialize TableTop Frog. Idempotent; will preserve any existing configuration.
|
||||
"""
|
||||
from ttfrog.db.bootstrap import bootstrap
|
||||
if not os.path.exists(app_state['env']):
|
||||
app_state['env'].parent.mkdir(parents=True, exist_ok=True)
|
||||
app_state['env'].write_text(dedent(SETUP_HELP))
|
||||
print(f"Wrote defaults file {app_state['env']}.")
|
||||
bootstrap()
|
||||
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
context: typer.Context,
|
||||
@@ -87,21 +73,52 @@ def serve(
|
||||
default_port,
|
||||
help="bind port",
|
||||
),
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
help='Enable debugging output'
|
||||
),
|
||||
debug: bool = typer.Option(False, help="Enable debugging output"),
|
||||
):
|
||||
"""
|
||||
Start the TableTop Frog server.
|
||||
"""
|
||||
|
||||
# delay loading the app until we have configured our environment
|
||||
from ttfrog.db.bootstrap import loader
|
||||
from ttfrog.webserver import application
|
||||
|
||||
print("Starting TableTop Frog server...")
|
||||
loader.load()
|
||||
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 loader
|
||||
|
||||
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']}.")
|
||||
loader.load()
|
||||
|
||||
|
||||
@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()
|
||||
@@ -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,208 @@
|
||||
{
|
||||
"ancestry": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "human",
|
||||
"creature_type": "humanoid",
|
||||
"size": "medium",
|
||||
"speed": 30,
|
||||
"_fly_speed": null,
|
||||
"_climb_speed": null,
|
||||
"_swim_speed": null
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "tiefling",
|
||||
"creature_type": "humanoid",
|
||||
"size": "medium",
|
||||
"speed": 30,
|
||||
"_fly_speed": null,
|
||||
"_climb_speed": null,
|
||||
"_swim_speed": null
|
||||
}
|
||||
],
|
||||
"ancestry_trait": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "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\u2019t discern color in darkness, only shades of gray."
|
||||
}
|
||||
],
|
||||
"character_class": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "fighter",
|
||||
"hit_die_name": "1d10",
|
||||
"hit_die_stat_name": "constitution",
|
||||
"starting_skills": 0
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "rogue",
|
||||
"hit_die_name": "1d8",
|
||||
"hit_die_stat_name": "dexterity",
|
||||
"starting_skills": 0
|
||||
}
|
||||
],
|
||||
"class_feature": [],
|
||||
"modifier": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Darkvision",
|
||||
"target": "vision_in_darkness",
|
||||
"stacks": false,
|
||||
"absolute_value": 120,
|
||||
"multiply_value": null,
|
||||
"multiply_attribute": null,
|
||||
"relative_value": null,
|
||||
"relative_attribute": null,
|
||||
"new_value": null,
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Ability Score Increase",
|
||||
"target": "intelligence",
|
||||
"stacks": false,
|
||||
"absolute_value": null,
|
||||
"multiply_value": null,
|
||||
"multiply_attribute": null,
|
||||
"relative_value": 1,
|
||||
"relative_attribute": null,
|
||||
"new_value": null,
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Ability Score Increase",
|
||||
"target": "charisma",
|
||||
"stacks": false,
|
||||
"absolute_value": null,
|
||||
"multiply_value": null,
|
||||
"multiply_attribute": null,
|
||||
"relative_value": 2,
|
||||
"relative_attribute": null,
|
||||
"new_value": null,
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"skill": [],
|
||||
"transaction_log": [],
|
||||
"character": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Sabetha",
|
||||
"hit_points": 10,
|
||||
"temp_hit_points": 0,
|
||||
"_max_hit_points": 10,
|
||||
"_armor_class": 10,
|
||||
"_strength": 10,
|
||||
"_dexterity": 10,
|
||||
"_constitution": 10,
|
||||
"_intelligence": 14,
|
||||
"_wisdom": 10,
|
||||
"_charisma": 10,
|
||||
"_vision": null,
|
||||
"exhaustion": 0,
|
||||
"ancestry_id": 2,
|
||||
"slug": "nMnWu"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Bob",
|
||||
"hit_points": 10,
|
||||
"temp_hit_points": 0,
|
||||
"_max_hit_points": 10,
|
||||
"_armor_class": 10,
|
||||
"_strength": 10,
|
||||
"_dexterity": 10,
|
||||
"_constitution": 10,
|
||||
"_intelligence": 10,
|
||||
"_wisdom": 10,
|
||||
"_charisma": 10,
|
||||
"_vision": null,
|
||||
"exhaustion": 0,
|
||||
"ancestry_id": 1,
|
||||
"slug": "PjPdM"
|
||||
}
|
||||
],
|
||||
"class_feature_map": [],
|
||||
"class_feature_option": [],
|
||||
"class_skill_map": [],
|
||||
"modifier_map": [
|
||||
{
|
||||
"id": 1,
|
||||
"modifier_id": 1,
|
||||
"primary_table_name": "ancestry_trait",
|
||||
"primary_table_id": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"modifier_id": 2,
|
||||
"primary_table_name": "ancestry",
|
||||
"primary_table_id": 2
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"modifier_id": 3,
|
||||
"primary_table_name": "ancestry",
|
||||
"primary_table_id": 2
|
||||
}
|
||||
],
|
||||
"trait_map": [
|
||||
{
|
||||
"id": 1,
|
||||
"ancestry_id": 2,
|
||||
"ancestry_trait_id": 1,
|
||||
"level": 1
|
||||
}
|
||||
],
|
||||
"character_class_feature_map": [],
|
||||
"character_skill_map": [],
|
||||
"class_map": [
|
||||
{
|
||||
"id": 1,
|
||||
"character_id": 1,
|
||||
"character_class_id": 1,
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"character_id": 1,
|
||||
"character_class_id": 2,
|
||||
"level": 3
|
||||
}
|
||||
],
|
||||
"hit_die": [
|
||||
{
|
||||
"id": 1,
|
||||
"character_id": 1,
|
||||
"character_class_id": 1,
|
||||
"spent": false
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"character_id": 1,
|
||||
"character_class_id": 1,
|
||||
"spent": false
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"character_id": 1,
|
||||
"character_class_id": 2,
|
||||
"spent": false
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"character_id": 1,
|
||||
"character_class_id": 2,
|
||||
"spent": false
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"character_id": 1,
|
||||
"character_class_id": 2,
|
||||
"spent": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ttfrog.db import schema
|
||||
from ttfrog.db.manager import db
|
||||
|
||||
DATA_PATH = Path(__file__).parent
|
||||
|
||||
|
||||
def load(data: str = ""):
|
||||
db.metadata.drop_all(bind=db.engine)
|
||||
db.init()
|
||||
with db.transaction():
|
||||
if not data:
|
||||
data = (DATA_PATH / "bootstrap.json").read_text()
|
||||
db.load(json.loads(data))
|
||||
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
||||
human = db.Ancestry.filter_by(name="human").one()
|
||||
fighter = db.CharacterClass.filter_by(name="fighter").one()
|
||||
rogue = db.CharacterClass.filter_by(name="rogue").one()
|
||||
|
||||
sabetha = schema.Character("Sabetha", ancestry=tiefling, _intelligence=14)
|
||||
sabetha.add_class(fighter)
|
||||
sabetha.add_class(rogue)
|
||||
sabetha.level_up(fighter, 2)
|
||||
sabetha.level_up(rogue, 3)
|
||||
|
||||
bob = schema.Character("Bob", ancestry=human)
|
||||
|
||||
# persist all the records we've created
|
||||
db.add_or_update([sabetha, bob])
|
||||
@@ -0,0 +1,115 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from functools import cached_property
|
||||
from sqlite3 import IntegrityError
|
||||
|
||||
import transaction
|
||||
from pyramid_sqlalchemy.meta import Session
|
||||
from sqlalchemy import create_engine, event, insert
|
||||
|
||||
from ttfrog.db import schema
|
||||
from ttfrog.path import database
|
||||
|
||||
|
||||
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 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 load(self, data: dict):
|
||||
for table_name, rows in data.items():
|
||||
table = self.tables.get(table_name, None)
|
||||
if table is None:
|
||||
raise IntegrityError(f"Table {table_name} not found in database.")
|
||||
if not rows:
|
||||
continue
|
||||
query = insert(table), rows
|
||||
self.session.execute(*query)
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return self.query(getattr(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,6 @@
|
||||
from .character import *
|
||||
from .classes import *
|
||||
from .constants import *
|
||||
from .log import *
|
||||
from .modifiers import *
|
||||
from .skill import *
|
||||
@@ -0,0 +1,670 @@
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject, SlugMixin
|
||||
from ttfrog.db.schema.classes import CharacterClass, ClassFeature
|
||||
from ttfrog.db.schema.constants import DamageType, Defenses
|
||||
from ttfrog.db.schema.inventory import Inventory, InventoryMap, InventoryType
|
||||
from ttfrog.db.schema.item import ItemType
|
||||
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
|
||||
from ttfrog.db.schema.skill import Skill
|
||||
|
||||
__all__ = [
|
||||
"Ancestry",
|
||||
"AncestryTrait",
|
||||
"AncestryTraitMap",
|
||||
"CharacterClassMap",
|
||||
"CharacterClassFeatureMap",
|
||||
"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 inventory_creator(fields):
|
||||
if isinstance(fields, InventoryMap):
|
||||
return fields
|
||||
return InventoryMap(**fields)
|
||||
|
||||
|
||||
def condition_creator(fields):
|
||||
if isinstance(fields, CharacterConditionMap):
|
||||
return fields
|
||||
return CharacterConditionMap(**fields)
|
||||
|
||||
|
||||
def attr_map_creator(fields):
|
||||
if isinstance(fields, CharacterClassFeatureMap):
|
||||
return fields
|
||||
return CharacterClassFeatureMap(**fields)
|
||||
|
||||
|
||||
class SpellSlot(BaseObject):
|
||||
__tablename__ = "spell_slot"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"))
|
||||
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
|
||||
character_class = relationship("CharacterClass", lazy="immediate")
|
||||
|
||||
spell_level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 9})
|
||||
expended: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
|
||||
class HitDie(BaseObject):
|
||||
__tablename__ = "hit_die"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"))
|
||||
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
|
||||
character_class = relationship("CharacterClass", lazy="immediate")
|
||||
|
||||
spent: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.character_class.hit_die_name
|
||||
|
||||
@property
|
||||
def stat(self):
|
||||
return self.character_class.hit_die_stat_name
|
||||
|
||||
|
||||
class AncestryTraitMap(BaseObject):
|
||||
__tablename__ = "trait_map"
|
||||
__table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"),)
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"))
|
||||
ancestry_trait_id: Mapped[int] = mapped_column(ForeignKey("ancestry_trait.id"), init=False)
|
||||
trait: Mapped["AncestryTrait"] = relationship(uselist=False, lazy="immediate")
|
||||
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20})
|
||||
|
||||
|
||||
class Ancestry(BaseObject, ModifierMixin):
|
||||
"""
|
||||
A character ancestry ("race"), which has zero or more AncestryTraits and Modifiers.
|
||||
"""
|
||||
|
||||
__tablename__ = "ancestry"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
|
||||
|
||||
creature_type: Mapped[str] = mapped_column(nullable=False, default="humanoid")
|
||||
size: Mapped[str] = mapped_column(nullable=False, default="medium")
|
||||
speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99})
|
||||
|
||||
fly_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
||||
climb_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
||||
swim_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
||||
|
||||
_traits = relationship(
|
||||
"AncestryTraitMap", init=False, uselist=True, cascade="all,delete,delete-orphan", lazy="immediate"
|
||||
)
|
||||
|
||||
@property
|
||||
def traits(self):
|
||||
return [mapping.trait for mapping in self._traits]
|
||||
|
||||
def add_trait(self, trait, level=1):
|
||||
if 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
|
||||
|
||||
|
||||
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="")
|
||||
|
||||
|
||||
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_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=False)
|
||||
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=False)
|
||||
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
|
||||
|
||||
character_class: Mapped["CharacterClass"] = relationship(lazy="immediate", init=False, viewonly=True)
|
||||
|
||||
|
||||
class CharacterClassFeatureMap(BaseObject):
|
||||
__tablename__ = "character_class_feature_map"
|
||||
__table_args__ = (UniqueConstraint("character_id", "class_feature_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_feature_id: Mapped[int] = mapped_column(ForeignKey("class_feature.id"), nullable=False)
|
||||
option_id: Mapped[int] = mapped_column(ForeignKey("class_feature_option.id"), nullable=False)
|
||||
|
||||
class_feature: Mapped["ClassFeature"] = relationship(lazy="immediate")
|
||||
option = relationship("ClassFeatureOption", lazy="immediate")
|
||||
|
||||
character_class = relationship(
|
||||
"CharacterClass",
|
||||
secondary="class_map",
|
||||
primaryjoin="CharacterClassFeatureMap.character_id == CharacterClassMap.character_id",
|
||||
secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id",
|
||||
viewonly=True,
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
|
||||
class CharacterConditionMap(BaseObject):
|
||||
__tablename__ = "character_condition_map"
|
||||
__table_args__ = (UniqueConstraint("condition_id", "character_id"),)
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id"))
|
||||
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=True, default=None)
|
||||
condition = relationship("Condition", lazy="immediate")
|
||||
|
||||
|
||||
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}
|
||||
)
|
||||
|
||||
_actions_per_turn: Mapped[int] = mapped_column(
|
||||
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
|
||||
)
|
||||
_bonus_actions_per_turn: Mapped[int] = mapped_column(
|
||||
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
|
||||
)
|
||||
_reactions_per_turn: Mapped[int] = mapped_column(
|
||||
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
|
||||
)
|
||||
_attacks_per_action: Mapped[int] = mapped_column(
|
||||
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
|
||||
)
|
||||
|
||||
vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
|
||||
exhaustion: Mapped[int] = mapped_column(nullable=False, default=0, info={"min": 0, "max": 5})
|
||||
|
||||
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)
|
||||
|
||||
_conditions = relationship(
|
||||
"CharacterConditionMap", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate"
|
||||
)
|
||||
conditions = association_proxy("_conditions", "condition", creator=condition_creator)
|
||||
|
||||
character_class_feature_map = relationship("CharacterClassFeatureMap", cascade="all,delete,delete-orphan")
|
||||
features = association_proxy("character_class_feature_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)
|
||||
|
||||
_inventories = relationship("Inventory", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
inventories = association_proxy("_inventories", "id", creator=inventory_creator)
|
||||
|
||||
_hit_dice = relationship("HitDie", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
_spell_slots = relationship("SpellSlot", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
|
||||
@property
|
||||
def spells(self):
|
||||
return [inv for inv in self._inventories if inv.inventory_type == InventoryType.SPELL][0]
|
||||
|
||||
@property
|
||||
def prepared_spells(self):
|
||||
hashmap = dict([(mapping.item.name, mapping) for mapping in self.spells if mapping.prepared])
|
||||
return list(hashmap.values())
|
||||
|
||||
@property
|
||||
def spell_slots(self):
|
||||
return list(itertools.chain(*[slot for lvl, slot in self.spell_slots_by_level.items()]))
|
||||
|
||||
@property
|
||||
def spell_slots_by_level(self):
|
||||
pool = defaultdict(list)
|
||||
for slot in self._spell_slots:
|
||||
pool[slot.spell_level].append(slot)
|
||||
return pool
|
||||
|
||||
@property
|
||||
def spell_slots_available(self):
|
||||
available = defaultdict(list)
|
||||
for slot in self._spell_slots:
|
||||
if not slot.expended:
|
||||
available[slot.spell_level].append(slot)
|
||||
return available
|
||||
|
||||
@property
|
||||
def hit_dice(self):
|
||||
pool = defaultdict(list)
|
||||
for die in self._hit_dice:
|
||||
pool[die.character_class.name].append(die)
|
||||
return pool
|
||||
|
||||
@property
|
||||
def hit_dice_available(self):
|
||||
return [die for die in self._hit_dice if die.spent is False]
|
||||
|
||||
@property
|
||||
def proficiency_bonus(self):
|
||||
return 1 + int(0.5 + self.level / 4)
|
||||
|
||||
@property
|
||||
def expertise_bonus(self):
|
||||
return 2 * self.proficiency_bonus
|
||||
|
||||
@property
|
||||
def modifiers(self):
|
||||
unified = {}
|
||||
unified.update(**self.ancestry.modifiers)
|
||||
for trait in self.traits:
|
||||
unified.update(**trait.modifiers)
|
||||
for condition in self.conditions:
|
||||
unified.update(**condition.modifiers)
|
||||
unified.update(**super().modifiers)
|
||||
return unified
|
||||
|
||||
@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 initiative(self):
|
||||
return self._apply_modifiers("initiative", self.dexterity.bonus)
|
||||
|
||||
@property
|
||||
def speed(self):
|
||||
return self._apply_modifiers("speed", self._apply_modifiers("walking_speed", self.ancestry.speed))
|
||||
|
||||
@property
|
||||
def climb_speed(self):
|
||||
return self._apply_modifiers("climb_speed", self.ancestry.climb_speed or int(self.speed / 2))
|
||||
|
||||
@property
|
||||
def swim_speed(self):
|
||||
return self._apply_modifiers("swim_speed", self.ancestry.swim_speed or int(self.speed / 2))
|
||||
|
||||
@property
|
||||
def fly_speed(self):
|
||||
modified = self._apply_modifiers("fly_speed", self.ancestry.fly_speed or 0)
|
||||
if self.ancestry.fly_speed is None and not modified:
|
||||
return None
|
||||
return self._apply_modifiers("speed", modified)
|
||||
|
||||
@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 spellcaster_level(self):
|
||||
return max(slot.spell_level for slot in self.spell_slots)
|
||||
|
||||
@property
|
||||
def class_features(self):
|
||||
return dict([(mapping.class_feature.name, mapping.option) for mapping in self.character_class_feature_map])
|
||||
|
||||
@property
|
||||
def equipment(self):
|
||||
return [inv for inv in self._inventories if inv.inventory_type == InventoryType.EQUIPMENT][0]
|
||||
|
||||
def equip(self, mapping):
|
||||
if mapping.equipped:
|
||||
return False
|
||||
mapping.equipped = True
|
||||
return True
|
||||
|
||||
def unequip(self, mapping):
|
||||
if not mapping.equipped:
|
||||
return False
|
||||
mapping.equipped = False
|
||||
return True
|
||||
|
||||
def prepare(self, mapping):
|
||||
if mapping.item.item_type != ItemType.SPELL:
|
||||
return False
|
||||
if mapping.item.level > 0 and not self.spell_slots_by_level[mapping.item.level]:
|
||||
return False
|
||||
return self.equip(mapping)
|
||||
|
||||
def unprepare(self, mapping):
|
||||
if mapping.item.item_type != ItemType.SPELL:
|
||||
return False
|
||||
return self.unequip(mapping)
|
||||
|
||||
def cast(self, mapping: InventoryMap, level=0):
|
||||
if not mapping.prepared:
|
||||
return False
|
||||
if not level:
|
||||
level = mapping.item.level
|
||||
|
||||
# cantrips
|
||||
if level == 0:
|
||||
return True
|
||||
|
||||
# expend the spell slot
|
||||
avail = self.spell_slots_available[level]
|
||||
if not avail:
|
||||
return False
|
||||
avail[0].expended = True
|
||||
return True
|
||||
|
||||
def level_in_class(self, charclass):
|
||||
mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id]
|
||||
if not mapping:
|
||||
return None
|
||||
return mapping[0]
|
||||
|
||||
def immune(self, damage_type: DamageType):
|
||||
return self.defense(damage_type) == Defenses.immune
|
||||
|
||||
def resistant(self, damage_type: DamageType):
|
||||
return self.defense(damage_type) == Defenses.resistant.value
|
||||
|
||||
def vulnerable(self, damage_type: DamageType):
|
||||
return self.defense(damage_type) == Defenses.vulnerable
|
||||
|
||||
def absorbs(self, damage_type: DamageType):
|
||||
return self.defense(damage_type) == Defenses.absorbs
|
||||
|
||||
def defense(self, damage_type: DamageType):
|
||||
return self._apply_modifiers(damage_type, None)
|
||||
|
||||
def check_modifier(self, skill: Skill, save: bool = False):
|
||||
# if the skill is not assigned, but we have modifiers, apply them to zero.
|
||||
if skill not in self.skills:
|
||||
target = f"{skill.name.lower()}_{'save' if save else 'check'}"
|
||||
if self.has_modifier(target):
|
||||
modified = self._apply_modifiers(target, 0)
|
||||
return modified
|
||||
|
||||
# if the skill is a stat, start with the bonus value
|
||||
attr = skill.name.lower()
|
||||
stat = getattr(self, attr, None)
|
||||
initial = getattr(stat, "bonus", None)
|
||||
|
||||
# if the skill isn't a stat, try the parent.
|
||||
if initial is None and skill.parent:
|
||||
stat = getattr(self, skill.parent.name.lower(), None)
|
||||
initial = getattr(stat, "bonus", initial)
|
||||
|
||||
# if the skill is a proficiency, apply the bonus to the initial value
|
||||
if skill in self.skills:
|
||||
mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][-1]
|
||||
if mapping.expert and not save:
|
||||
initial += 2 * self.proficiency_bonus
|
||||
elif mapping.proficient:
|
||||
initial += self.proficiency_bonus
|
||||
|
||||
# return the initial value plus any modifiers.
|
||||
return self._apply_modifiers(f"{attr}_{'save' if save else 'check'}", initial)
|
||||
|
||||
def level_up(self, charclass, num_levels=1):
|
||||
for _ in range(num_levels):
|
||||
self._level_up_once(charclass)
|
||||
return self.level_in_class(charclass)
|
||||
|
||||
def _level_up_once(self, charclass):
|
||||
current = self.level_in_class(charclass)
|
||||
if not current:
|
||||
return False
|
||||
current.level += 1
|
||||
|
||||
# add new features
|
||||
for feature in charclass.features_at_level(current.level):
|
||||
self.add_class_feature(charclass, feature, feature.options[0])
|
||||
|
||||
# add new spell slots
|
||||
for slot in charclass.spell_slots_by_level[current.level]:
|
||||
self._spell_slots.append(
|
||||
SpellSlot(character_id=self.id, character_class_id=charclass.id, spell_level=slot.spell_level)
|
||||
)
|
||||
|
||||
# add a new hit die
|
||||
self._hit_dice.append(HitDie(character_id=self.id, character_class_id=charclass.id))
|
||||
|
||||
return current
|
||||
|
||||
def add_class(self, newclass):
|
||||
if self.level_in_class(newclass):
|
||||
return False
|
||||
self.class_list.append(CharacterClassMap(character_id=self.id, character_class_id=newclass.id, level=0))
|
||||
self._level_up_once(newclass)
|
||||
for skill in newclass.skills[: newclass.starting_skills]:
|
||||
self.add_skill(skill, proficient=True, character_class=newclass)
|
||||
return True
|
||||
|
||||
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_feature_map:
|
||||
if mapping.character_class == target:
|
||||
self.remove_class_feature(mapping.class_feature)
|
||||
for skill in target.skills:
|
||||
self.remove_skill(skill, proficient=True, expert=False, character_class=target)
|
||||
self._hit_dice = [die for die in self._hit_dice if die.character_class != target]
|
||||
self._spell_slots = [slot for slot in self._spell_slots if slot.character_class != target]
|
||||
|
||||
def remove_class_feature(self, feature):
|
||||
self.character_class_feature_map = [
|
||||
m for m in self.character_class_feature_map if m.class_feature.id != feature.id
|
||||
]
|
||||
|
||||
def has_class_feature(self, feature):
|
||||
return feature in [m.class_feature for m in self.character_class_feature_map]
|
||||
|
||||
def add_class_feature(self, character_class, feature, option):
|
||||
if self.has_class_feature(feature):
|
||||
return False
|
||||
mapping = self.level_in_class(character_class)
|
||||
if not mapping:
|
||||
return False
|
||||
if feature not in character_class.features_at_level(mapping.level):
|
||||
return False
|
||||
self.features.append(
|
||||
CharacterClassFeatureMap(
|
||||
character_id=self.id,
|
||||
class_feature_id=feature.id,
|
||||
option_id=option.id,
|
||||
class_feature=feature,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
def add_modifier(self, modifier):
|
||||
if not super().add_modifier(modifier):
|
||||
return False
|
||||
|
||||
if modifier.new_value != Defenses.immune:
|
||||
return True
|
||||
|
||||
modified_condition = None
|
||||
for cond in self.conditions:
|
||||
if modifier.target == cond.name:
|
||||
modified_condition = cond
|
||||
break
|
||||
if not modified_condition:
|
||||
return True
|
||||
|
||||
return self.remove_condition(modified_condition)
|
||||
|
||||
def has_condition(self, condition):
|
||||
return condition in self.conditions
|
||||
|
||||
def add_condition(self, condition):
|
||||
if self.immune(condition.name):
|
||||
return False
|
||||
if self.has_condition(condition):
|
||||
return False
|
||||
self._conditions.append(CharacterConditionMap(condition_id=condition.id, character_id=self.id))
|
||||
return True
|
||||
|
||||
def remove_condition(self, condition):
|
||||
if not self.has_condition(condition):
|
||||
return False
|
||||
mappings = [mapping for mapping in self._conditions if mapping.condition_id != condition.id]
|
||||
self._conditions = mappings
|
||||
return True
|
||||
|
||||
def add_skill(self, skill, proficient=False, expert=False, character_class=None):
|
||||
skillmap = None
|
||||
exists = False
|
||||
if skill in self.skills:
|
||||
for mapping in self._skills:
|
||||
if mapping.skill_id != skill.id:
|
||||
continue
|
||||
if character_class is None and mapping.character_class_id:
|
||||
continue
|
||||
if (character_class is None and mapping.character_class_id is None) or (
|
||||
mapping.character_class_id == character_class.id
|
||||
):
|
||||
skillmap = mapping
|
||||
exists = True
|
||||
break
|
||||
|
||||
if not skillmap:
|
||||
skillmap = CharacterSkillMap(skill_id=skill.id, character_id=self.id)
|
||||
|
||||
skillmap.proficient = proficient
|
||||
skillmap.expert = expert
|
||||
if character_class:
|
||||
skillmap.character_class_id = character_class.id
|
||||
|
||||
if not exists:
|
||||
self._skills.append(skillmap)
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_skill(self, skill, proficient, expert, character_class):
|
||||
to_delete = [
|
||||
mapping
|
||||
for mapping in self._skills
|
||||
if (
|
||||
mapping.skill_id == skill.id
|
||||
and mapping.proficient == proficient
|
||||
and mapping.expert == expert
|
||||
and (
|
||||
(mapping.character_class_id is None and character_class is None)
|
||||
or (character_class and mapping.character_class_id == character_class.id)
|
||||
)
|
||||
)
|
||||
]
|
||||
if not to_delete:
|
||||
return False
|
||||
self._skills = [m for m in self._skills if m not in to_delete]
|
||||
return True
|
||||
|
||||
def apply_healing(self, value: int):
|
||||
self.hit_points = min(self.hit_points + value, self._max_hit_points)
|
||||
|
||||
def apply_damage(self, value: int, damage_type: DamageType):
|
||||
total = value
|
||||
if self.absorbs(damage_type):
|
||||
return self.apply_healing(total)
|
||||
if self.immune(damage_type):
|
||||
return
|
||||
if self.resistant(damage_type):
|
||||
total = int(value / 2)
|
||||
elif self.vulnerable(damage_type):
|
||||
total = value * 2
|
||||
|
||||
if total <= self.temp_hit_points:
|
||||
self.temp_hit_points -= total
|
||||
return
|
||||
|
||||
self.hit_points = max(0, self.hit_points - (total - self.temp_hit_points))
|
||||
self.temp_hit_points = 0
|
||||
|
||||
def spend_hit_die(self, die):
|
||||
die.spent = True
|
||||
|
||||
def expend_sell_splot(self, slot):
|
||||
slot.expended = 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)
|
||||
|
||||
self._inventories.append(Inventory(inventory_type=InventoryType.EQUIPMENT, character_id=self.id))
|
||||
self._inventories.append(Inventory(inventory_type=InventoryType.SPELL, character_id=self.id))
|
||||
session.add(self)
|
||||
@@ -0,0 +1,141 @@
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject
|
||||
from ttfrog.db.schema.skill import Skill
|
||||
|
||||
__all__ = [
|
||||
"ClassFeatureMap",
|
||||
"ClassFeature",
|
||||
"ClassFeatureOption",
|
||||
"ClassSpellSlotMap",
|
||||
"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 ClassFeatureMap(BaseObject):
|
||||
__tablename__ = "class_feature_map"
|
||||
class_feature_id: Mapped[int] = mapped_column(ForeignKey("class_feature.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)
|
||||
feature = relationship("ClassFeature", uselist=False, viewonly=True, lazy="immediate")
|
||||
|
||||
|
||||
class ClassSpellSlotMap(BaseObject):
|
||||
__tablename__ = "class_spell_slot_map"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
|
||||
class_level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
|
||||
spell_level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 9}, default=1)
|
||||
|
||||
|
||||
class ClassFeature(BaseObject):
|
||||
__tablename__ = "class_feature"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
options = relationship("ClassFeatureOption", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
|
||||
def add_option(self, **kwargs):
|
||||
option = ClassFeatureOption(feature_id=self.id, **kwargs)
|
||||
if not self.options or option not in self.options:
|
||||
option.feature_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 ClassFeatureOption(BaseObject):
|
||||
__tablename__ = "class_feature_option"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
feature_id: Mapped[int] = mapped_column(ForeignKey("class_feature.id"), nullable=True)
|
||||
|
||||
|
||||
class CharacterClass(BaseObject):
|
||||
__tablename__ = "character_class"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(index=True, unique=True)
|
||||
hit_die_name: Mapped[str] = mapped_column(default="1d6")
|
||||
hit_die_stat_name: Mapped[str] = mapped_column(default="")
|
||||
starting_skills: int = mapped_column(nullable=False, default=0)
|
||||
|
||||
features = relationship("ClassFeatureMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
spell_slots = relationship("ClassSpellSlotMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
|
||||
_skills = relationship("ClassSkillMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
skills = association_proxy("_skills", "skill", creator=skill_creator)
|
||||
|
||||
def add_skill(self, skill, expert=False):
|
||||
if not self.skills or skill not in self.skills:
|
||||
if not self.id:
|
||||
raise Exception("Cannot add a skill before the class has been persisted.")
|
||||
mapping = ClassSkillMap(character_class_id=self.id, skill_id=skill.id, proficient=True, expert=expert)
|
||||
self.skills.append(mapping)
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_feature(self, feature, level=1):
|
||||
if not self.features or feature not in self.features:
|
||||
mapping = ClassFeatureMap(character_class_id=self.id, class_feature_id=feature.id, level=level)
|
||||
if not self.features:
|
||||
self.features = [mapping]
|
||||
else:
|
||||
self.features.append(mapping)
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def spell_slots_by_level(self):
|
||||
by_level = defaultdict(list)
|
||||
for mapping in self.spell_slots:
|
||||
by_level[mapping.class_level].append(mapping)
|
||||
return by_level
|
||||
|
||||
def spell_slots_at_level(self, level: int):
|
||||
return list(itertools.chain(*[mapping for lvl, mapping in self.spell_slots_by_level.items() if lvl <= level]))
|
||||
|
||||
@property
|
||||
def features_by_level(self):
|
||||
by_level = defaultdict(list)
|
||||
for mapping in self.features:
|
||||
by_level[mapping.level].append(mapping.feature)
|
||||
return by_level
|
||||
|
||||
def feature(self, name: str):
|
||||
for mapping in self.features:
|
||||
if mapping.feature.name.lower() == name.lower():
|
||||
return mapping.feature
|
||||
return None
|
||||
|
||||
def features_at_level(self, level: int):
|
||||
return list(itertools.chain(*[attrs for lvl, attrs in self.features_by_level.items() if lvl <= level]))
|
||||
@@ -0,0 +1,52 @@
|
||||
from enum import StrEnum, auto
|
||||
|
||||
|
||||
class Conditions(StrEnum):
|
||||
blinded = auto()
|
||||
charmed = auto()
|
||||
deafened = auto()
|
||||
frightened = auto()
|
||||
grappled = auto()
|
||||
incapacitated = auto()
|
||||
invisible = auto()
|
||||
paralyzed = auto()
|
||||
petrified = auto()
|
||||
poisoned = auto()
|
||||
prone = auto()
|
||||
restrained = auto()
|
||||
stunned = auto()
|
||||
unconscious = auto()
|
||||
dead = auto()
|
||||
|
||||
|
||||
class DamageType(StrEnum):
|
||||
piercing = auto()
|
||||
slashing = auto()
|
||||
bludgeoning = auto()
|
||||
fire = auto()
|
||||
cold = auto()
|
||||
lightning = auto()
|
||||
thunder = auto()
|
||||
acid = auto()
|
||||
poison = auto()
|
||||
radiant = auto()
|
||||
necrotic = auto()
|
||||
psychic = auto()
|
||||
force = auto()
|
||||
magical = auto()
|
||||
magical_piercing = auto()
|
||||
magical_slashing = auto()
|
||||
magical_bludgeoning = auto()
|
||||
silvered_piercing = auto()
|
||||
silvered_slashing = auto()
|
||||
silvered_bludgeoning = auto()
|
||||
adamantium_piercing = auto()
|
||||
adamantium_slashing = auto()
|
||||
adamantium_bludgeoning = auto()
|
||||
|
||||
|
||||
class Defenses(StrEnum):
|
||||
vulnerable = auto()
|
||||
resistant = auto()
|
||||
immune = auto()
|
||||
absorbs = auto()
|
||||
@@ -0,0 +1,108 @@
|
||||
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, EnumField
|
||||
from ttfrog.db.schema.item import Item, ItemType
|
||||
|
||||
|
||||
class InventoryType(EnumField):
|
||||
EQUIPMENT = "EQUIPMENT"
|
||||
SPELL = "SPELL"
|
||||
|
||||
|
||||
inventory_type_map = {
|
||||
InventoryType.EQUIPMENT: [
|
||||
ItemType.ITEM,
|
||||
ItemType.SCROLL,
|
||||
],
|
||||
InventoryType.SPELL: [ItemType.SPELL],
|
||||
}
|
||||
|
||||
|
||||
def inventory_map_creator(fields):
|
||||
if isinstance(fields, InventoryMap):
|
||||
return fields
|
||||
return InventoryMap(**fields)
|
||||
|
||||
|
||||
class Inventory(BaseObject):
|
||||
__tablename__ = "inventory"
|
||||
__table_args__ = (UniqueConstraint("character_id", "inventory_type"),)
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"))
|
||||
inventory_type: Mapped[InventoryType] = mapped_column(nullable=False)
|
||||
|
||||
_inventory_map = relationship("InventoryMap", lazy="immediate", uselist=True, cascade="all,delete,delete-orphan")
|
||||
inventory_map = association_proxy("_inventory_map", "id", creator=inventory_map_creator)
|
||||
|
||||
def get(self, item):
|
||||
return [mapping for mapping in self._inventory_map if mapping.item == item]
|
||||
|
||||
def add(self, item):
|
||||
if item.item_type not in inventory_type_map[self.inventory_type]:
|
||||
return False
|
||||
mapping = InventoryMap(inventory_id=self.id, item_id=item.id)
|
||||
if item.consumable:
|
||||
mapping.count = item.count
|
||||
self.inventory_map.append(mapping)
|
||||
return mapping
|
||||
|
||||
def remove(self, mapping):
|
||||
if mapping.id not in self.inventory_map:
|
||||
return False
|
||||
self.inventory_map.remove(mapping.id)
|
||||
return True
|
||||
|
||||
def equip(self, mapping):
|
||||
if mapping.equipped:
|
||||
return False
|
||||
mapping.equipped = True
|
||||
return True
|
||||
|
||||
def unequip(self, mapping):
|
||||
if not mapping.equipped:
|
||||
return False
|
||||
mapping.equipped = False
|
||||
return True
|
||||
|
||||
def __contains__(self, obj):
|
||||
for mapping in self._inventory_map:
|
||||
if mapping.item == obj:
|
||||
return True
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
yield from self._inventory_map
|
||||
|
||||
|
||||
class InventoryMap(BaseObject):
|
||||
__tablename__ = "inventory_map"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
inventory_id: Mapped[int] = mapped_column(ForeignKey("inventory.id"))
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("item.id"))
|
||||
item: Mapped["Item"] = relationship(uselist=False, lazy="immediate", viewonly=True, init=False)
|
||||
inventory: Mapped["Inventory"] = relationship(uselist=False, viewonly=True, init=False)
|
||||
|
||||
equipped: Mapped[bool] = mapped_column(default=False)
|
||||
count: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||
|
||||
always_prepared: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
@property
|
||||
def prepared(self):
|
||||
if self.item.item_type == ItemType.SPELL:
|
||||
return self.equipped or self.always_prepared
|
||||
|
||||
def use(self, count=1):
|
||||
if count < 0:
|
||||
return False
|
||||
if not self.item.consumable:
|
||||
return False
|
||||
if self.count < count:
|
||||
return False
|
||||
self.count -= count
|
||||
if self.count == 0:
|
||||
self.inventory.remove(self)
|
||||
return 0
|
||||
return self.count
|
||||
@@ -0,0 +1,35 @@
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ttfrog.db.base import BaseObject, EnumField
|
||||
from ttfrog.db.schema.modifiers import ConditionMixin
|
||||
|
||||
__all__ = [
|
||||
"Item",
|
||||
"Spell",
|
||||
]
|
||||
|
||||
|
||||
class ItemType(EnumField):
|
||||
ITEM = "ITEM"
|
||||
SPELL = "SPELL"
|
||||
SCROLL = "SCROLL"
|
||||
|
||||
|
||||
class Item(BaseObject, ConditionMixin):
|
||||
__tablename__ = "item"
|
||||
__mapper_args__ = {"polymorphic_identity": ItemType.ITEM, "polymorphic_on": "item_type"}
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
|
||||
consumable: Mapped[bool] = mapped_column(default=False)
|
||||
count: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||
item_type: Mapped[ItemType] = mapped_column(default=ItemType.ITEM, nullable=False)
|
||||
|
||||
|
||||
class Spell(Item):
|
||||
__tablename__ = "spell"
|
||||
__mapper_args__ = {"polymorphic_identity": ItemType.SPELL}
|
||||
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
|
||||
level: Mapped[int] = mapped_column(nullable=False, info={"min": 0, "max": 9}, default=0)
|
||||
concentration: Mapped[bool] = mapped_column(default=False)
|
||||
item_type: Mapped[ItemType] = mapped_column(default=ItemType.SPELL, init=False)
|
||||
@@ -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,343 @@
|
||||
from collections import defaultdict
|
||||
from typing import Any, Union
|
||||
|
||||
from sqlalchemy import ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.ext.declarative import declared_attr
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject
|
||||
|
||||
# from sqlalchemy.ext.associationproxy import association_proxy
|
||||
|
||||
|
||||
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="")
|
||||
condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id"), init=False, nullable=True, default=None)
|
||||
|
||||
|
||||
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}."
|
||||
) # pragma: no cover
|
||||
|
||||
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.get(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 # pragma: no cover
|
||||
|
||||
def _get_modifiable_base(self, attr_name: str) -> object:
|
||||
"""
|
||||
Resolve a dottted string "foo.bar.baz" as its corresponding nested attribute.
|
||||
|
||||
This is useful for cases where a column definition includes a modifiable_base
|
||||
that is some other attribute. For example:
|
||||
|
||||
foo[int] = mapped_column(default=0, info={"modifiable_base": "ancestry.bar")
|
||||
|
||||
This will create an initial value for self.foo equal to self.ancesetry.bar.
|
||||
"""
|
||||
|
||||
def get_attr(obj, parts):
|
||||
if parts:
|
||||
name, *parts = parts
|
||||
return get_attr(getattr(obj, name), parts)
|
||||
return obj
|
||||
|
||||
return get_attr(self, attr_name.split("."))
|
||||
|
||||
def _apply_one_modifier(self, modifier, initial, modified):
|
||||
if modifier.new_value is not None:
|
||||
return modifier.new_value
|
||||
elif modifier.absolute_value is not None:
|
||||
return modifier.absolute_value
|
||||
|
||||
base_value = modified if modifier.stacks else initial
|
||||
|
||||
if modifier.multiply_attribute is not None:
|
||||
return int(base_value * getattr(self, modifier.multiply_attribute) + 0.5)
|
||||
|
||||
if modifier.multiply_value is not None:
|
||||
return int(base_value * modifier.multiply_value + 0.5)
|
||||
|
||||
if modifier.relative_attribute is not None:
|
||||
return base_value + getattr(self, modifier.relative_attribute)
|
||||
|
||||
if modifier.relative_value is not None:
|
||||
return base_value + modifier.relative_value
|
||||
|
||||
raise Exception(f"Cannot apply modifier: {modifier = }") # pragma: no cover
|
||||
|
||||
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}?") # pragma: no cover
|
||||
return super().__setattr__(attr_name, value)
|
||||
|
||||
def __getattr__(self, attr_name):
|
||||
"""
|
||||
If the instance has an attribute equal to attr_name but prefixed with an
|
||||
underscore, check to see if that attribute is a column, and modifiable.
|
||||
If it is, return a Modifiable instance corresponding to that column's value.
|
||||
"""
|
||||
col = self._modifiable_column(attr_name)
|
||||
if col is not None:
|
||||
return self._apply_modifiers(
|
||||
attr_name,
|
||||
self._get_modifiable_base(col.info.get("modifiable_base", col.name)),
|
||||
modifiable_class=col.info.get("modifiable_class", None),
|
||||
)
|
||||
raise AttributeError(f"No such attribute on {self.__class__.__name__} object: {attr_name}.")
|
||||
|
||||
|
||||
class ConditionMixin:
|
||||
@property
|
||||
def modifiers(self):
|
||||
"""
|
||||
Return all modifiers for the current instance as a dict keyed on target attribute name.
|
||||
"""
|
||||
all_modifiers = defaultdict(list)
|
||||
for modifier in self._modifiers:
|
||||
all_modifiers[modifier.target].append(modifier)
|
||||
for condition in self.conditions:
|
||||
print(condition.modifiers)
|
||||
all_modifiers.update(**condition.modifiers)
|
||||
return all_modifiers
|
||||
|
||||
def add_modifier(self, modifier):
|
||||
if modifier in self._modifiers:
|
||||
return False
|
||||
self._modifiers.append(modifier)
|
||||
return True
|
||||
|
||||
def remove_modifier(self, modifier):
|
||||
if modifier not in self._modifiers:
|
||||
return False
|
||||
self._modifiers = [m for m in self._modifiers if m is not modifier]
|
||||
return True
|
||||
|
||||
def add_condition(self, condition):
|
||||
if condition in self.conditions:
|
||||
return False
|
||||
if self._parent_condition_id and self._parent_condition_id == condition.id:
|
||||
return False
|
||||
self.conditions.append(condition)
|
||||
return True
|
||||
|
||||
def remove_condition(self, condition):
|
||||
if condition not in self.conditions:
|
||||
return False
|
||||
self.conditions = [c for c in self.conditions if c != condition]
|
||||
return True
|
||||
|
||||
|
||||
class Condition(BaseObject, ConditionMixin):
|
||||
__tablename__ = "condition"
|
||||
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[str] = mapped_column(default="")
|
||||
|
||||
_modifiers = relationship("Modifier", uselist=True, cascade="all,delete,delete-orphan")
|
||||
_parent_condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id"), nullable=True, default=None)
|
||||
conditions = relationship("Condition", lazy="immediate", uselist=True)
|
||||
|
||||
def __str___(self):
|
||||
return self.name
|
||||
|
||||
def __repr__(self):
|
||||
mods = ""
|
||||
if self._modifiers:
|
||||
mods = "\n" + "\n".join([f" - {mod}" for mod in self._modifiers])
|
||||
return f"{self.name}{mods}"
|
||||
@@ -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)
|
||||
@@ -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,140 @@
|
||||
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
|
||||
from ttfrog.db.schema.constants import DamageType, Defenses
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def load_fixture(db, fixture_name):
|
||||
with db.transaction():
|
||||
data = json.loads((FIXTURE_PATH / f"{fixture_name}.json").read_text())
|
||||
for schema_name in data:
|
||||
for record in data[schema_name]:
|
||||
print(f"Loading {schema_name} {record = }")
|
||||
obj = getattr(schema, schema_name)(**record)
|
||||
db.session.add(obj)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def db(monkeypatch):
|
||||
monkeypatch.setattr("ttfrog.db.manager.database", MagicMock(return_value=""))
|
||||
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
|
||||
monkeypatch.setenv("DEBUG", "1")
|
||||
_db.init()
|
||||
yield _db
|
||||
_db.metadata.drop_all(bind=_db.engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bootstrap(db):
|
||||
with db.transaction():
|
||||
# ancestries
|
||||
human = schema.Ancestry("human")
|
||||
|
||||
tiefling = schema.Ancestry("tiefling")
|
||||
tiefling.add_modifier(
|
||||
schema.Modifier("Ability Score Increase", target="intelligence", stacks=True, relative_value=1)
|
||||
)
|
||||
tiefling.add_modifier(
|
||||
schema.Modifier("Ability Score Increase", target="charisma", stacks=True, relative_value=2)
|
||||
)
|
||||
|
||||
# ancestry traits
|
||||
darkvision = schema.AncestryTrait("Darkvision")
|
||||
darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120))
|
||||
tiefling.add_trait(darkvision)
|
||||
|
||||
# resistant to fire
|
||||
infernal_origin = schema.AncestryTrait("Infernal Origin")
|
||||
infernal_origin.add_modifier(
|
||||
schema.Modifier("Infernal Origin", target=DamageType.fire, new_value=Defenses.resistant)
|
||||
)
|
||||
tiefling.add_trait(infernal_origin)
|
||||
|
||||
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.ClassFeature("Fighting Style")
|
||||
fighting_style.add_option(name="Archery")
|
||||
fighting_style.add_option(name="Defense")
|
||||
db.add_or_update(fighting_style)
|
||||
|
||||
fighter = schema.CharacterClass(
|
||||
"fighter", hit_die_name="1d10", hit_die_stat_name="_constitution", starting_skills=2
|
||||
)
|
||||
db.add_or_update(fighter)
|
||||
|
||||
# add skills
|
||||
fighter.add_skill(acrobatics)
|
||||
fighter.add_skill(athletics)
|
||||
fighter.add_feature(fighting_style, level=2)
|
||||
db.add_or_update(fighter)
|
||||
assert acrobatics in fighter.skills
|
||||
assert athletics in fighter.skills
|
||||
|
||||
wizard = schema.CharacterClass(
|
||||
"wizard",
|
||||
hit_die_name="1d6",
|
||||
hit_die_stat_name="_intelligence",
|
||||
)
|
||||
db.add_or_update(wizard)
|
||||
wizard.spell_slots = [
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=1, spell_level=1),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=1, spell_level=1),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=2, spell_level=1),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=3, spell_level=1),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=3, spell_level=2),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=3, spell_level=2),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=4, spell_level=2),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=5, spell_level=3),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=5, spell_level=3),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=6, spell_level=3),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=7, spell_level=4),
|
||||
]
|
||||
|
||||
rogue = schema.CharacterClass("rogue", hit_die_name="1d8", hit_die_stat_name="_dexterity")
|
||||
db.add_or_update([rogue, fighter, wizard])
|
||||
|
||||
# create a character: Carl the Wizard
|
||||
carl = schema.Character("Carl", ancestry=tiefling, _intelligence=14)
|
||||
carl.add_class(wizard)
|
||||
db.add_or_update(carl)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def carl(db, bootstrap):
|
||||
return db.Character.filter_by(name="Carl").one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wizard(db, bootstrap):
|
||||
return db.CharacterClass.filter_by(name="wizard").one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tiefling(db, bootstrap):
|
||||
return db.Ancestry.filter_by(name="tiefling").one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def human(db, bootstrap):
|
||||
return db.Ancestry.filter_by(name="human").one()
|
||||
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,28 @@
|
||||
import json
|
||||
|
||||
from ttfrog.db.bootstrap import loader
|
||||
|
||||
|
||||
def test_dump_load(db, bootstrap):
|
||||
# dump the bootstrapped data
|
||||
data = json.loads(db.dump())
|
||||
|
||||
# clear the database and reinitialize
|
||||
db.metadata.drop_all(bind=db.engine)
|
||||
db.init()
|
||||
|
||||
# load the dump
|
||||
db.load(data)
|
||||
|
||||
# dump again and compare to the initial data
|
||||
assert data == json.loads(db.dump())
|
||||
|
||||
|
||||
def test_loader(db, bootstrap):
|
||||
loader.load(db.dump())
|
||||
assert len(db.Ancestry.all()) > 0
|
||||
|
||||
|
||||
def test_default(db):
|
||||
loader.load()
|
||||
assert len(db.Ancestry.all()) > 0
|
||||
@@ -0,0 +1,147 @@
|
||||
from ttfrog.db.schema.item import Item, ItemType, Spell
|
||||
|
||||
|
||||
def test_equipment_inventory(db, carl):
|
||||
with db.transaction():
|
||||
# trigger the creation of inventory mappings
|
||||
db.add_or_update(carl)
|
||||
|
||||
# create some items
|
||||
ten_foot_pole = Item(name="10ft. Pole", item_type=ItemType.ITEM, consumable=False)
|
||||
fireball = Spell(name="Fireball", level=3, concentration=False)
|
||||
db.add_or_update([ten_foot_pole, fireball])
|
||||
|
||||
# add the pole to carl's equipment, and the spell to his spell list.
|
||||
assert carl.equipment.add(ten_foot_pole)
|
||||
assert carl.spells.add(fireball)
|
||||
|
||||
# can't mix and match inventory item types
|
||||
assert not carl.equipment.add(fireball)
|
||||
assert not carl.spells.add(ten_foot_pole)
|
||||
|
||||
# add two more 10 foot poles. You can never have too many.
|
||||
carl.equipment.add(ten_foot_pole)
|
||||
carl.equipment.add(ten_foot_pole)
|
||||
db.add_or_update(carl)
|
||||
|
||||
all_carls_poles = carl.equipment.get(ten_foot_pole)
|
||||
assert len(all_carls_poles) == 3
|
||||
|
||||
# check the "contains" logic
|
||||
assert ten_foot_pole in carl.equipment
|
||||
assert ten_foot_pole not in carl.spells
|
||||
assert fireball in carl.spells
|
||||
assert fireball not in carl.equipment
|
||||
|
||||
# equip one pole
|
||||
assert carl.equip(all_carls_poles[0])
|
||||
|
||||
# can't equip it twice
|
||||
assert not carl.equip(all_carls_poles[0])
|
||||
|
||||
# unequip it
|
||||
assert carl.unequip(all_carls_poles[0])
|
||||
|
||||
# can't unequip the unequipped ones
|
||||
assert not carl.unequip(all_carls_poles[1])
|
||||
assert not carl.unequip(all_carls_poles[2])
|
||||
|
||||
# drop one pole
|
||||
assert carl.equipment.remove(all_carls_poles[0])
|
||||
assert ten_foot_pole in carl.equipment
|
||||
|
||||
# drop the remaining poles
|
||||
assert carl.equipment.remove(all_carls_poles[1])
|
||||
assert carl.equipment.remove(all_carls_poles[2])
|
||||
assert ten_foot_pole not in carl.equipment
|
||||
|
||||
# can't drop what you don't have
|
||||
assert not carl.equipment.remove(all_carls_poles[0])
|
||||
|
||||
|
||||
def test_inventory_bundles(db, carl):
|
||||
with db.transaction():
|
||||
arrows = Item(name="Arrows", item_type=ItemType.ITEM, consumable=True, count=20)
|
||||
db.add_or_update([carl, arrows])
|
||||
quiver = carl.equipment.add(arrows)
|
||||
db.add_or_update(carl)
|
||||
|
||||
# full quiver
|
||||
assert arrows in carl.equipment
|
||||
assert quiver.count == 20
|
||||
|
||||
# use one
|
||||
assert quiver.use(1) == 19
|
||||
assert quiver.count == 19
|
||||
|
||||
# cannot use more than you have
|
||||
assert not quiver.use(20)
|
||||
|
||||
# cannot use a negative amount
|
||||
assert not quiver.use(-1)
|
||||
|
||||
# consume all remaining arrows
|
||||
assert quiver.use(19) == 0
|
||||
assert arrows not in carl.equipment
|
||||
|
||||
|
||||
def test_spell_slots(db, carl, wizard):
|
||||
with db.transaction():
|
||||
prestidigitation = Spell(name="Prestidigitation", level=0, concentration=False)
|
||||
fireball = Spell(name="Fireball", level=3, concentration=False)
|
||||
db.add_or_update([carl, prestidigitation, fireball])
|
||||
|
||||
carl.spells.add(prestidigitation)
|
||||
carl.spells.add(fireball)
|
||||
db.add_or_update(carl)
|
||||
|
||||
# verify carl has the spell slots granted by wizard at 1st level
|
||||
print(carl.levels)
|
||||
print(carl.spell_slots)
|
||||
assert len(carl.spell_slots) == 2
|
||||
assert carl.spell_slots[0].spell_level == 1
|
||||
assert carl.spell_slots[1].spell_level == 1
|
||||
|
||||
# carl knows the spells but hasn't prepared them
|
||||
assert prestidigitation in carl.spells
|
||||
assert fireball in carl.spells
|
||||
assert prestidigitation not in carl.prepared_spells
|
||||
assert fireball not in carl.prepared_spells
|
||||
|
||||
# prepare the cantrip
|
||||
carls_prestidigitation = carl.spells.get(prestidigitation)[0]
|
||||
assert carl.prepare(carls_prestidigitation)
|
||||
assert carl.cast(carls_prestidigitation)
|
||||
|
||||
# prepare() and cast() require a spell from the spell inventory
|
||||
carls_fireball = carl.spells.get(fireball)[0]
|
||||
|
||||
# can't prepare a 3rd level spell if you don't have 3rd level slots
|
||||
assert carl.spellcaster_level == 1
|
||||
assert not carl.prepare(carls_fireball)
|
||||
|
||||
# make carl a 5th level wizard so he gets a 3rd level spell slot
|
||||
carl.level_up(wizard, num_levels=4)
|
||||
assert carl.level == 5
|
||||
assert carl.spellcaster_level == 3
|
||||
|
||||
# cast fireball until he's out of 3rd level slots
|
||||
assert carl.prepare(carls_fireball)
|
||||
assert carl.cast(carls_fireball)
|
||||
assert carl.cast(carls_fireball)
|
||||
assert not carl.cast(carls_fireball)
|
||||
|
||||
# level up to 7th level, gaining 1 4th level slot and 1 more 3rd level slot
|
||||
carl.add_class(wizard)
|
||||
carl.level_up(wizard, num_levels=2)
|
||||
assert carl.spellcaster_level == 4
|
||||
assert len(carl.spell_slots_available[4]) == 1
|
||||
assert len(carl.spell_slots_available[3]) == 1
|
||||
|
||||
# cast at 4th level
|
||||
assert carl.cast(carls_fireball, 4)
|
||||
assert not carl.cast(carls_fireball, 4)
|
||||
|
||||
# use the last 3rd level slot
|
||||
assert carl.cast(carls_fireball)
|
||||
assert not carl.cast(carls_fireball)
|
||||
@@ -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,478 @@
|
||||
import json
|
||||
|
||||
from ttfrog.db import schema
|
||||
from ttfrog.db.schema.constants import Conditions, DamageType, Defenses
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
assert char.vision_in_darkness == 0
|
||||
|
||||
# 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
|
||||
assert char.vision_in_darkness == 120
|
||||
|
||||
# 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
|
||||
assert char.add_class(fighter)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {"fighter": 1}
|
||||
assert char.level == 1
|
||||
assert char.class_features == {}
|
||||
|
||||
# 'fighting style' is available, but not at this level
|
||||
fighting_style = fighter.feature("Fighting Style")
|
||||
assert char.has_class_feature(fighting_style) is False
|
||||
assert char.add_class_feature(fighter, fighting_style, fighting_style.options[0]) is False
|
||||
db.add_or_update(char)
|
||||
assert char.class_features == {}
|
||||
|
||||
# level up
|
||||
char.level_up(fighter, num_levels=6)
|
||||
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_feature(fighting_style)
|
||||
assert char.class_features[fighting_style.name] == fighting_style.options[0]
|
||||
assert char.add_class_feature(fighter, fighting_style, fighting_style.options[0]) is False
|
||||
assert char.has_class_feature(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
|
||||
|
||||
# assert dexterity bonus apply to initiative
|
||||
char._dexterity = 17
|
||||
assert char.dexterity.bonus == 3
|
||||
assert char.initiative == char.dexterity.bonus == 3
|
||||
char.add_modifier(schema.Modifier("+1 initiative", target="initiative", relative_value=1))
|
||||
assert char.initiative == 4
|
||||
char._dexterity = 10
|
||||
|
||||
# multiclass
|
||||
char.add_class(rogue)
|
||||
db.add_or_update(char)
|
||||
assert char.level == 8
|
||||
assert char.levels == {"fighter": 7, "rogue": 1}
|
||||
assert list(char.classes.keys()) == ["fighter", "rogue"]
|
||||
assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 8
|
||||
|
||||
# use a hit die
|
||||
char.spend_hit_die(char.hit_dice["rogue"][0])
|
||||
assert len([die for die in char.hit_dice_available if die.character_class.name == "rogue"]) == 0
|
||||
|
||||
# remove a class
|
||||
char.remove_class(rogue)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {"fighter": 7}
|
||||
assert char.level == 7
|
||||
assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 7
|
||||
|
||||
# verify hit dice are added and removed correctly
|
||||
assert len(char.hit_dice["fighter"]) == char.level_in_class(fighter).level == 7
|
||||
assert char.hit_dice["fighter"][0].name == fighter.hit_die_name == "1d10"
|
||||
assert char.hit_dice["fighter"][0].stat == fighter.hit_die_stat_name == "_constitution"
|
||||
|
||||
char.remove_class(fighter)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {}
|
||||
assert char.class_features == {}
|
||||
|
||||
# verify the proficiencies etc. 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
|
||||
assert char.hit_dice == {}
|
||||
|
||||
# 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_feature_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 it again and assert nothing changes
|
||||
porc.add_trait(endurance, level=1)
|
||||
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, carl, tiefling, human, wizard):
|
||||
with db.transaction():
|
||||
# no modifiers; speed is ancestry speed
|
||||
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")
|
||||
fly = schema.Modifier(target="fly_speed", absolute_value=30, name="Fly Spell")
|
||||
|
||||
# test sets of modifiers from conditions are applied when a condition is active
|
||||
incapacitated = schema.Condition(name="incapacitated")
|
||||
incapacitated.add_modifier(schema.Modifier(target="actions_per_turn", absolute_value=0, name="Incapacitated"))
|
||||
incapacitated.add_modifier(
|
||||
schema.Modifier(target="bonus_actions_per_turn", absolute_value=0, name="Incapacitated")
|
||||
)
|
||||
incapacitated.add_modifier(schema.Modifier(target="reactions_per_turn", absolute_value=0, name="Incapacitated"))
|
||||
db.add_or_update(incapacitated)
|
||||
assert carl.actions_per_turn == 1
|
||||
assert carl.bonus_actions_per_turn == 1
|
||||
assert carl.reactions_per_turn == 1
|
||||
assert carl.add_condition(incapacitated)
|
||||
db.add_or_update(carl)
|
||||
assert carl.has_condition(incapacitated)
|
||||
assert carl.actions_per_turn == 0
|
||||
assert carl.bonus_actions_per_turn == 0
|
||||
assert carl.reactions_per_turn == 0
|
||||
assert carl.remove_condition(incapacitated)
|
||||
db.add_or_update(carl)
|
||||
assert carl.actions_per_turn == 1
|
||||
assert carl.bonus_actions_per_turn == 1
|
||||
assert carl.reactions_per_turn == 1
|
||||
|
||||
# reduce speed by 10
|
||||
assert carl.add_modifier(cold)
|
||||
assert carl.speed == 20
|
||||
assert carl.climb_speed == 10
|
||||
assert carl.swim_speed == 10
|
||||
assert carl.fly_speed is None
|
||||
|
||||
# cast fly
|
||||
carl.add_modifier(fly)
|
||||
assert carl.fly_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.fly_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"
|
||||
|
||||
# cannot remove a modifier that isn't applied
|
||||
assert not carl.remove_modifier(cold)
|
||||
|
||||
# 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)
|
||||
assert carl.check_modifier(athletics) == 0
|
||||
|
||||
# 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) == carl.proficiency_bonus
|
||||
|
||||
# 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) == 2
|
||||
|
||||
# manually override proficiency with expertise
|
||||
carl.add_skill(athletics, expert=True)
|
||||
assert carl.check_modifier(athletics) == 2 * carl.proficiency_bonus
|
||||
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) == carl.proficiency_bonus
|
||||
|
||||
# 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) == carl.proficiency_bonus
|
||||
|
||||
# ensure you can't remove a skill already removed
|
||||
assert not carl.remove_skill(athletics, proficient=True, expert=False, character_class=None)
|
||||
|
||||
|
||||
def test_defenses(db, bootstrap, tiefling, carl):
|
||||
with db.transaction():
|
||||
db.add_or_update(carl)
|
||||
assert carl.resistant(DamageType.fire)
|
||||
carl.apply_damage(5, DamageType.fire)
|
||||
assert carl.hit_points == 8 # half damage
|
||||
|
||||
# add temp HP
|
||||
carl.temp_hit_points = 3
|
||||
assert carl.hit_points == 8
|
||||
carl.apply_damage(1, DamageType.bludgeoning)
|
||||
assert carl.hit_points == 8
|
||||
assert carl.temp_hit_points == 2
|
||||
carl.apply_damage(3, DamageType.bludgeoning)
|
||||
assert carl.temp_hit_points == 0
|
||||
assert carl.hit_points == 7
|
||||
|
||||
immunity = [
|
||||
schema.Modifier("Fire Immunity", target=DamageType.fire, new_value=Defenses.immune),
|
||||
]
|
||||
for i in immunity:
|
||||
carl.add_modifier(i)
|
||||
assert carl.immune(DamageType.fire)
|
||||
carl.apply_damage(5, DamageType.fire)
|
||||
assert carl.hit_points == 7 # no damage
|
||||
|
||||
vulnerability = [
|
||||
schema.Modifier("Fire Vulnerability", target=DamageType.fire, new_value=Defenses.vulnerable),
|
||||
]
|
||||
for i in vulnerability:
|
||||
carl.add_modifier(i)
|
||||
assert carl.vulnerable(DamageType.fire)
|
||||
assert not carl.immune(DamageType.fire)
|
||||
carl.apply_damage(2, DamageType.fire)
|
||||
assert carl.hit_points == 3 # double damage
|
||||
|
||||
absorbs = [schema.Modifier("Absorbs Non-Magical Fire", target=DamageType.fire, new_value=Defenses.absorbs)]
|
||||
carl.add_modifier(absorbs[0])
|
||||
carl.apply_damage(20, DamageType.fire)
|
||||
assert carl.hit_points == carl._max_hit_points == 10
|
||||
|
||||
for i in immunity + vulnerability + absorbs:
|
||||
carl.remove_modifier(i)
|
||||
carl.apply_damage(5, DamageType.fire)
|
||||
assert carl.resistant(DamageType.fire)
|
||||
assert not carl.immune(DamageType.fire)
|
||||
assert not carl.vulnerable(DamageType.fire)
|
||||
assert not carl.absorbs(DamageType.fire)
|
||||
assert carl.hit_points == 8 # half damage
|
||||
|
||||
|
||||
def test_condition_immunity(db, bootstrap, carl, tiefling):
|
||||
"""
|
||||
Test immunities prevent conditions from being applied
|
||||
"""
|
||||
with db.transaction():
|
||||
db.add_or_update(carl)
|
||||
poisoned = schema.Condition(name=DamageType.poison)
|
||||
poison_immunity = schema.Modifier("Poison Immunity", target=DamageType.poison, new_value=Defenses.immune)
|
||||
db.add_or_update([carl, poisoned, poison_immunity])
|
||||
|
||||
# poison carl
|
||||
assert carl.add_condition(poisoned)
|
||||
db.add_or_update(carl)
|
||||
assert carl.has_condition(poisoned)
|
||||
|
||||
# grant carl immunity, which must remove the poisoned condition
|
||||
assert carl.add_modifier(poison_immunity)
|
||||
db.add_or_update(carl)
|
||||
assert not carl.has_condition(poisoned)
|
||||
|
||||
# ensure that carl cannot be poisoned while immune to poison
|
||||
assert not carl.add_condition(poisoned)
|
||||
|
||||
# remove the immunity and ensure the previous poison doesn't come back
|
||||
assert carl.remove_modifier(poison_immunity)
|
||||
db.add_or_update(carl)
|
||||
assert not carl.immune(str(poisoned))
|
||||
assert not carl.has_condition(poisoned)
|
||||
|
||||
# carl can be poisoned again
|
||||
assert carl.add_condition(poisoned)
|
||||
db.add_or_update(carl)
|
||||
assert carl.has_condition(poisoned)
|
||||
|
||||
|
||||
def test_partial_immunities(db, bootstrap, carl, tiefling):
|
||||
"""
|
||||
Test that individual modifiers applied by a condition can be negated even if not immune to the condition.
|
||||
"""
|
||||
with db.transaction():
|
||||
db.add_or_update(carl)
|
||||
|
||||
# Create some modifiers and conditions for this test
|
||||
fly = schema.Modifier(target="fly_speed", absolute_value=30, name="Fly Spell")
|
||||
cannot_move = schema.Modifier(name="Cannot Move (Petrified", target="speed", absolute_value=0)
|
||||
poisoned = schema.Condition(name=DamageType.poison)
|
||||
poison_immunity = schema.Modifier("Poison Immunity", target=DamageType.poison, new_value=Defenses.immune)
|
||||
|
||||
# incapacitated applies several modifiers
|
||||
incapacitated = schema.Condition(name=Conditions.incapacitated)
|
||||
incapacitated.add_modifier(schema.Modifier(target="actions_per_turn", absolute_value=0, name="Incapacitated"))
|
||||
incapacitated.add_modifier(
|
||||
schema.Modifier(target="bonus_actions_per_turn", absolute_value=0, name="Incapacitated")
|
||||
)
|
||||
incapacitated.add_modifier(schema.Modifier(target="reactions_per_turn", absolute_value=0, name="Incapacitated"))
|
||||
|
||||
# petrified applies several modifiers but also incapacitates you!
|
||||
petrified = schema.Condition(name=Conditions.petrified)
|
||||
assert petrified.add_modifier(poison_immunity)
|
||||
assert petrified.add_modifier(cannot_move)
|
||||
assert petrified.add_condition(incapacitated)
|
||||
db.add_or_update([fly, cannot_move, poisoned, poison_immunity, incapacitated, petrified])
|
||||
|
||||
# carl casts fly!
|
||||
assert carl.fly_speed is None
|
||||
assert carl.add_modifier(fly)
|
||||
assert carl.fly_speed == carl.speed == carl.ancestry.speed
|
||||
db.add_or_update(carl)
|
||||
|
||||
# poison carl
|
||||
assert not carl.immune(DamageType.poison)
|
||||
assert carl.add_condition(poisoned)
|
||||
db.add_or_update(carl)
|
||||
|
||||
# petrify carl
|
||||
assert not carl.immune(Conditions.petrified)
|
||||
assert carl.add_condition(petrified)
|
||||
db.add_or_update(carl)
|
||||
|
||||
# while petrified, carl is immune to poison
|
||||
assert carl.immune(DamageType.poison)
|
||||
|
||||
# but carl still can't move
|
||||
assert carl.actions_per_turn == 0
|
||||
assert carl.speed == 0
|
||||
assert carl.fly_speed == 0
|
||||
|
||||
# greater restoration ftw!
|
||||
assert carl.remove_condition(petrified)
|
||||
db.add_or_update(carl)
|
||||
|
||||
# ...but he's still poisoned. Sorry carl :(
|
||||
assert carl.has_condition(poisoned)
|
||||
assert not carl.immune(DamageType.poison)
|
||||
@@ -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