Compare commits
68 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
f85c5f6bd3 | ||
|
3269320569 | ||
|
d5b81dafb4 | ||
|
9bece1550d | ||
|
1b9ff9b393 | ||
|
926d2fdaf6 | ||
|
17a951b1b2 | ||
|
d9b3c4500e | ||
|
b09b07d172 | ||
|
01a4360dca | ||
|
709b0f5ad0 | ||
|
68a8f4920b | ||
|
0eda35b90d | ||
|
5c80565264 | ||
|
5d9fde949d | ||
|
9f75630c74 | ||
|
b7732f1581 | ||
|
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 |
|
@ -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
|
||||
|
|
252
src/ttfrog/assets/static/css/styles.css
Normal file
252
src/ttfrog/assets/static/css/styles.css
Normal file
|
@ -0,0 +1,252 @@
|
|||
body {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
position : relative;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.disabled:after {
|
||||
position :absolute;
|
||||
left : 0;
|
||||
top : 0;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
content :' ';
|
||||
}
|
||||
|
||||
|
||||
#content {
|
||||
margin: 1rem auto;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #000055;
|
||||
}
|
||||
a:visited, a:active {
|
||||
color: #000055;
|
||||
}
|
||||
|
||||
|
||||
ul.nav {
|
||||
background: #e7e7e7;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
list-style-type: none;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
ul.nav li {
|
||||
display: inline;
|
||||
text-align: center;
|
||||
background: #FFF;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
#sheet_container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#character_sheet {
|
||||
margin-bottom:3rem;
|
||||
display: grid;
|
||||
grid-gap: 1rem;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
#sheet_container .banner {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
#sheet_container .banner #portrait {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #e7e7e7;
|
||||
}
|
||||
|
||||
#controls {
|
||||
float: right;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.temp_hp input {
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
#sheet_container h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#sheet_container .sidebar {
|
||||
grid-column-start: 2;
|
||||
grid-row-start: 1;
|
||||
}
|
||||
|
||||
#sheet_container .sidebar .card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#sheet_container .sidebar ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#sheet_container .sidebar ul > li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#hp {
|
||||
grid-row-start: 1;
|
||||
grid-column: 7;
|
||||
grid-column-end: 9;
|
||||
}
|
||||
|
||||
#saves {
|
||||
grid-row-start: 2;
|
||||
grid-column: 3;
|
||||
grid-column-start: span 2;
|
||||
}
|
||||
|
||||
#proficiency {
|
||||
grid-row-start: 2;
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
#initiative {
|
||||
grid-row-start: 2;
|
||||
grid-column: 6;
|
||||
}
|
||||
#ac {
|
||||
grid-row-start: 2;
|
||||
grid-column: 7;
|
||||
}
|
||||
#speed {
|
||||
grid-row-start: 2;
|
||||
grid-column: 8;
|
||||
}
|
||||
|
||||
#skills {
|
||||
grid-row-start: 2;
|
||||
grid-row-end: 50;
|
||||
grid-column: 1;
|
||||
grid-column-start: span 2;
|
||||
text-align:left;
|
||||
}
|
||||
|
||||
#actions {
|
||||
grid-row-start: 3;
|
||||
grid-column: 4;
|
||||
grid-column-start: span 6;
|
||||
}
|
||||
|
||||
|
||||
table {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, 150px) 1fr;
|
||||
grid-gap: 0rem;
|
||||
}
|
||||
table th {
|
||||
grid-column-start: span 4;
|
||||
white-space: nowrap;
|
||||
padding-right: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table td {
|
||||
padding-right: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 0.75em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#sheet_container input,
|
||||
#sheet_container select,
|
||||
#sheet_container textarea {
|
||||
font-weight: bold;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#sheet_container input#name {
|
||||
font-size: 2.0rem;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(6rem, 1fr));
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 2px solid #e7e7e7;
|
||||
border-radius: 4px;
|
||||
padding: .5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.card input {
|
||||
font-size: 1.25em;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
ul.multiclass {
|
||||
display: inline;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.multiclass li {
|
||||
display: inline;
|
||||
}
|
||||
.multiclass label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
ul#class_attributes {
|
||||
list-style-type: none;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ul#class_attributes li {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr 1fr;
|
||||
}
|
||||
|
||||
ul#class_attributes span,
|
||||
ul#class_attributes label {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
ul#class_attributes label {
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ul#class_attributes span select {
|
||||
width: 100%;
|
||||
}
|
63
src/ttfrog/assets/static/js/character_sheet.js
Normal file
63
src/ttfrog/assets/static/js/character_sheet.js
Normal file
|
@ -0,0 +1,63 @@
|
|||
function getTraitModifiersForStat(stat) {
|
||||
var mods = {};
|
||||
for (const prop in TRAITS) {
|
||||
var props = [];
|
||||
for (const desc in TRAITS[prop]) {
|
||||
trait = TRAITS[prop][desc]
|
||||
if (trait.type == "stat" && trait.target == stat) {
|
||||
props.push(trait);
|
||||
}
|
||||
}
|
||||
if (props) {
|
||||
mods[prop] = props;
|
||||
}
|
||||
}
|
||||
return mods;
|
||||
}
|
||||
|
||||
function proficiency() {
|
||||
return parseInt(document.getElementById('proficiency_bonus').innerHTML);
|
||||
}
|
||||
|
||||
function bonus(stat) {
|
||||
return parseInt(document.getElementById(stat + '_bonus').innerHTML);
|
||||
}
|
||||
|
||||
function setStatBonus(stat) {
|
||||
var score = document.getElementById(stat).value;
|
||||
var bonus = Math.floor((score - 10) / 2);
|
||||
document.getElementById(stat + '_bonus').innerHTML = bonus;
|
||||
}
|
||||
|
||||
function applyStatModifiers(stat) {
|
||||
var score = parseInt(document.getElementById(stat).value);
|
||||
var modsForStat = getTraitModifiersForStat(stat);
|
||||
for (desc in modsForStat) {
|
||||
for (idx in modsForStat[desc]) {
|
||||
var value = modsForStat[desc][idx].value;
|
||||
console.log(`Ancestry Trait "${desc}" grants ${value} to ${stat}`);
|
||||
score += parseInt(value);
|
||||
}
|
||||
}
|
||||
document.getElementById(stat).value = score;
|
||||
}
|
||||
|
||||
function setProficiencyBonus() {
|
||||
var score = document.getElementById('level').value;
|
||||
var bonus = Math.ceil(1 + (0.25 * score));
|
||||
document.getElementById('proficiency_bonus').innerHTML = bonus;
|
||||
}
|
||||
|
||||
function setSpellSaveDC() {
|
||||
var score = 8 + proficiency() + bonus('wis');
|
||||
document.getElementById('spell_save_dc').innerHTML = score;
|
||||
}
|
||||
|
||||
(function () {
|
||||
const stats = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
stats.forEach(applyStatModifiers);
|
||||
stats.forEach(setStatBonus);
|
||||
setProficiencyBonus();
|
||||
// setSpellSaveDC();
|
||||
|
||||
})();
|
30
src/ttfrog/assets/templates/base.html
Normal file
30
src/ttfrog/assets/templates/base.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
{% from "list.html" import build_list %}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ c.config.project_name }}{% block title %}{% endblock %}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="og:provider_name" content="{{ c.config.project_name }}">
|
||||
{% for resource in c.resources %}
|
||||
<link rel='preload' href="{{c.routes.static}}/{{resource['uri']}}" as="{{resource['type']}}"/>
|
||||
{% if resource['type'] == 'style' %}
|
||||
<link rel='stylesheet' href="{{c.routes.static}}/{{resource['uri']}}" />
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% block headers %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{{ build_list(c) }}
|
||||
<div id='content'>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% block debug %}{% endblock %}
|
||||
{% block script %}{% endblock %}
|
||||
{% for resource in c.resources %}
|
||||
{% if resource['type'] == 'script' %}
|
||||
<script type="text/javascript" src="{{c.routes.static}}/{{resource['uri']}}"></script>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
197
src/ttfrog/assets/templates/character_sheet.html
Normal file
197
src/ttfrog/assets/templates/character_sheet.html
Normal file
|
@ -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 %}
|
8
src/ttfrog/assets/templates/list.html
Normal file
8
src/ttfrog/assets/templates/list.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% macro build_list(c) %}
|
||||
<ul class='nav'>
|
||||
<li><a href="{{ c.routes.sheet }}">Create a Character</a></li>
|
||||
{% for rec in c.all_records %}
|
||||
<li><a href="{{ c.routes.sheet }}/{{ rec.uri }}">{{ rec.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endmacro %}
|
71
src/ttfrog/attribute_map.py
Normal file
71
src/ttfrog/attribute_map.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeMap(Mapping):
|
||||
"""
|
||||
AttributeMap is a data class that is also a mapping, converting a dict
|
||||
into an object with attributes. Example:
|
||||
|
||||
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
|
||||
>>> amap.foo
|
||||
True
|
||||
>>> amap.bar
|
||||
False
|
||||
|
||||
Instantiating an AttributeMap using the from_dict() class method will
|
||||
recursively transform dictionary members sinto AttributeMaps:
|
||||
|
||||
>>> nested_dict = {'foo': {'bar': {'baz': True}, 'boz': False}}
|
||||
>>> amap = AttributeMap.from_dict(nested_dict)
|
||||
>>> amap.foo.bar.baz
|
||||
True
|
||||
>>> amap.foo.boz
|
||||
False
|
||||
|
||||
The dictionary can be accessed directly via 'attributes':
|
||||
|
||||
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
|
||||
>>> list(amap.attributes.keys()):
|
||||
>>>['foo', 'bar']
|
||||
|
||||
Because AttributeMap is a mapping, you can use it anywhere you would use
|
||||
a regular mapping, like a dict:
|
||||
|
||||
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
|
||||
>>> 'foo' in amap
|
||||
True
|
||||
>>> "{foo}, {bar}".format(**amap)
|
||||
True, False
|
||||
|
||||
|
||||
"""
|
||||
|
||||
attributes: field(default_factory=dict)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
if attr in self.attributes:
|
||||
return self.attributes[attr]
|
||||
return self.__getattribute__(attr)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.attributes)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.attributes[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.attributes)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, kwargs: dict):
|
||||
"""
|
||||
Create a new AttributeMap object using keyword arguments. Dicts are
|
||||
recursively converted to AttributeMap objects; everything else is
|
||||
passed as-is.
|
||||
"""
|
||||
attrs = {}
|
||||
for k, v in sorted(kwargs.items()):
|
||||
attrs[k] = AttributeMap.from_dict(v) if type(v) is dict else v
|
||||
return cls(attributes=attrs)
|
|
@ -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,59 @@ 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))
|
||||
|
||||
|
||||
@db_app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
||||
def importer(context: typer.Context):
|
||||
from ttfrog.five_e_tools import importer
|
||||
|
||||
importer.import_from_5e_tools()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
99
src/ttfrog/db/base.py
Normal file
99
src/ttfrog/db/base.py
Normal file
|
@ -0,0 +1,99 @@
|
|||
import enum
|
||||
|
||||
import nanoid
|
||||
from nanoid_dictionary import human_alphabet
|
||||
from slugify import slugify
|
||||
from sqlalchemy import Column, String, inspect
|
||||
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))
|
||||
|
||||
def copy(self):
|
||||
self_as_dict = dict(self.__dict__)
|
||||
self_as_dict.pop("_sa_instance_state")
|
||||
mapper = inspect(self).mapper
|
||||
for primary_key in mapper.primary_key:
|
||||
self_as_dict.pop(primary_key.name)
|
||||
for key in mapper.relationships.keys():
|
||||
if key in self_as_dict:
|
||||
self_as_dict.pop(key)
|
||||
return self.__class__(**self_as_dict)
|
||||
|
||||
|
||||
class EnumField(enum.Enum):
|
||||
"""
|
||||
A serializable enum.
|
||||
"""
|
||||
|
||||
def __json__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
STATS = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]
|
||||
|
||||
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))
|
208
src/ttfrog/db/bootstrap/bootstrap.json
Normal file
208
src/ttfrog/db/bootstrap/bootstrap.json
Normal file
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
31
src/ttfrog/db/bootstrap/loader.py
Normal file
31
src/ttfrog/db/bootstrap/loader.py
Normal file
|
@ -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])
|
116
src/ttfrog/db/manager.py
Normal file
116
src/ttfrog/db/manager.py
Normal file
|
@ -0,0 +1,116 @@
|
|||
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.metadata.bind = self.engine
|
||||
self.session.remove()
|
||||
self.session.configure(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)
|
8
src/ttfrog/db/schema/__init__.py
Normal file
8
src/ttfrog/db/schema/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from .character import *
|
||||
from .classes import *
|
||||
from .constants import *
|
||||
from .inventory import *
|
||||
from .log import *
|
||||
from .modifiers import *
|
||||
from .prototypes import *
|
||||
from .skill import *
|
752
src/ttfrog/db/schema/character.py
Normal file
752
src/ttfrog/db/schema/character.py
Normal file
|
@ -0,0 +1,752 @@
|
|||
import itertools
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy.ext.declarative import declared_attr
|
||||
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, InventoryType
|
||||
from ttfrog.db.schema.inventory import InventoryMixin
|
||||
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 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(nullable=True, info={"min": 0, "max": 99}, default=None)
|
||||
climb_speed: Mapped[int] = mapped_column(nullable=True, info={"min": 0, "max": 99}, default=None)
|
||||
swim_speed: Mapped[int] = mapped_column(nullable=True, info={"min": 0, "max": 99}, default=None)
|
||||
|
||||
_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")
|
||||
|
||||
|
||||
@dataclass
|
||||
class InventoryMap(InventoryMixin):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), unique=True)
|
||||
|
||||
@declared_attr
|
||||
def character(cls) -> Mapped["Character"]:
|
||||
return relationship("Character", default=None)
|
||||
|
||||
@property
|
||||
def contents(self):
|
||||
return self.inventory.contents
|
||||
|
||||
|
||||
class CharacterItemInventory(BaseObject, InventoryMap):
|
||||
__tablename__ = "character_item_inventory"
|
||||
__item_class__ = "Item"
|
||||
inventory_type: InventoryType = InventoryType.EQUIPMENT
|
||||
|
||||
|
||||
class CharacterSpellInventory(BaseObject, InventoryMap):
|
||||
__tablename__ = "character_spell_inventory"
|
||||
__item_class__ = "Spell"
|
||||
inventory_type: InventoryType = InventoryType.SPELL
|
||||
|
||||
@property
|
||||
def all_contents(self):
|
||||
yield from self.inventory.contents
|
||||
for item in self.character.equipment.all_contents:
|
||||
if item.prototype.inventory_type == InventoryType.SPELL:
|
||||
yield from item.inventory.contents
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
yield from [spell.prototype for spell in self.all_contents]
|
||||
|
||||
@property
|
||||
def known(self):
|
||||
yield from [spell.prototype for spell in self.inventory.contents]
|
||||
|
||||
@property
|
||||
def prepared(self):
|
||||
yield from [spell.prototype for spell in self.all_contents if spell.prepared]
|
||||
|
||||
def get_all(self, prototype):
|
||||
return [mapping for mapping in self.all_contents if mapping.prototype == prototype]
|
||||
|
||||
def get(self, prototype):
|
||||
return self.get_all(prototype)[0]
|
||||
|
||||
def learn(self, prototype):
|
||||
return self.inventory.add(prototype)
|
||||
|
||||
def forget(self, spell):
|
||||
return self.inventory.remove(spell)
|
||||
|
||||
def prepare(self, prototype):
|
||||
spell = self.get(prototype)
|
||||
if spell.prototype.level > 0 and not self.character.spell_slots_by_level[spell.prototype.level]:
|
||||
return False
|
||||
spell._prepared = True
|
||||
return True
|
||||
|
||||
def unprepare(self, prototype):
|
||||
spell = self.get(prototype)
|
||||
if spell.prepared:
|
||||
spell._prepared = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def cast(self, prototype, level=0):
|
||||
spell = self.get(prototype)
|
||||
if not spell.prepared:
|
||||
return False
|
||||
if not level:
|
||||
level = spell.prototype.level
|
||||
|
||||
# cantrips
|
||||
if level == 0:
|
||||
return True
|
||||
|
||||
# expend the spell slot
|
||||
avail = self.character.spell_slots_available[level]
|
||||
if not avail:
|
||||
return False
|
||||
avail[0].expended = True
|
||||
return True
|
||||
|
||||
|
||||
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)
|
||||
|
||||
_equipment = relationship(
|
||||
"CharacterItemInventory",
|
||||
uselist=False,
|
||||
cascade="all,delete,delete-orphan",
|
||||
lazy="immediate",
|
||||
back_populates="character",
|
||||
)
|
||||
spells = relationship(
|
||||
"CharacterSpellInventory",
|
||||
uselist=False,
|
||||
cascade="all,delete,delete-orphan",
|
||||
lazy="immediate",
|
||||
back_populates="character",
|
||||
)
|
||||
|
||||
_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 equipment(self):
|
||||
return self._equipment.inventory
|
||||
|
||||
@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 = defaultdict(list)
|
||||
|
||||
def merge_modifiers(object_list):
|
||||
for obj in object_list:
|
||||
for target, mods in obj.modifiers.items():
|
||||
unified[target] += mods
|
||||
|
||||
merge_modifiers([self.ancestry])
|
||||
merge_modifiers(self.traits)
|
||||
merge_modifiers(self.conditions)
|
||||
|
||||
for item in self.equipped_items:
|
||||
for target, mods in item.modifiers.items():
|
||||
for mod in mods:
|
||||
if mod.requires_attunement and not item.attuned:
|
||||
continue
|
||||
unified[target].append(mod)
|
||||
|
||||
merge_modifiers([super()])
|
||||
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 equipped_items(self):
|
||||
return [item for item in self.equipment.contents if item.equipped]
|
||||
|
||||
@property
|
||||
def attuned_items(self):
|
||||
return [item for item in self.equipment.contents if item.attuned]
|
||||
|
||||
def attune(self, mapping):
|
||||
if mapping.attuned:
|
||||
return False
|
||||
if not mapping.item.requires_attunement:
|
||||
return False
|
||||
if len(self.attuned_items) >= 3:
|
||||
return False
|
||||
mapping.attuned = True
|
||||
return True
|
||||
|
||||
def unattune(self, mapping):
|
||||
if not mapping.attuned:
|
||||
return False
|
||||
mapping.attuned = False
|
||||
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._equipment = CharacterItemInventory(character_id=self.id)
|
||||
self.spells = CharacterSpellInventory(character_id=self.id)
|
||||
session.add(self)
|
141
src/ttfrog/db/schema/classes.py
Normal file
141
src/ttfrog/db/schema/classes.py
Normal file
|
@ -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]))
|
61
src/ttfrog/db/schema/constants.py
Normal file
61
src/ttfrog/db/schema/constants.py
Normal file
|
@ -0,0 +1,61 @@
|
|||
from enum import StrEnum, auto
|
||||
|
||||
from ttfrog.db.base import EnumField
|
||||
|
||||
|
||||
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()
|
||||
ranged_weapon_attacks = auto()
|
||||
melee_weapon_attacks = auto()
|
||||
|
||||
|
||||
class Defenses(StrEnum):
|
||||
vulnerable = auto()
|
||||
resistant = auto()
|
||||
immune = auto()
|
||||
absorbs = auto()
|
||||
|
||||
|
||||
class InventoryType(EnumField):
|
||||
EQUIPMENT = "EQUIPMENT"
|
||||
SPELL = "SPELL"
|
285
src/ttfrog/db/schema/inventory.py
Normal file
285
src/ttfrog/db/schema/inventory.py
Normal file
|
@ -0,0 +1,285 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy.ext.declarative import declared_attr
|
||||
from sqlalchemy.orm import Mapped
|
||||
from sqlalchemy.orm import base as sa_base
|
||||
from sqlalchemy.orm import mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject
|
||||
from ttfrog.db.schema import prototypes
|
||||
from ttfrog.db.schema.constants import InventoryType
|
||||
from ttfrog.db.schema.modifiers import ModifierMixin
|
||||
|
||||
inventory_type_map = {
|
||||
InventoryType.EQUIPMENT: [
|
||||
prototypes.ItemType.WEAPON,
|
||||
prototypes.ItemType.ARMOR,
|
||||
prototypes.ItemType.SHIELD,
|
||||
prototypes.ItemType.ITEM,
|
||||
prototypes.ItemType.SCROLL,
|
||||
prototypes.ItemType.CONTAINER,
|
||||
],
|
||||
InventoryType.SPELL: [prototypes.ItemType.SPELL],
|
||||
}
|
||||
|
||||
|
||||
def inventory_map_creator(fields):
|
||||
# if isinstance(fields, Item):
|
||||
# return fields
|
||||
# return Item(**fields)
|
||||
return Item(**fields)
|
||||
|
||||
|
||||
class Inventory(BaseObject):
|
||||
"""
|
||||
Creates a many-to-many between Items or Spells and any model inheriting from the InventoryMixin.
|
||||
"""
|
||||
|
||||
__tablename__ = "inventory"
|
||||
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
inventory_type: Mapped[InventoryType] = mapped_column(nullable=False)
|
||||
|
||||
primary_table_name: Mapped[str] = mapped_column(nullable=False)
|
||||
primary_table_id: Mapped[int] = mapped_column(nullable=False)
|
||||
|
||||
_item_contents: Mapped[List["Item"]] = relationship(
|
||||
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
|
||||
)
|
||||
|
||||
_spell_contents: Mapped[List["Spell"]] = relationship(
|
||||
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
|
||||
)
|
||||
|
||||
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=True, default=None)
|
||||
character = relationship("Character", uselist=False, default=None)
|
||||
|
||||
@property
|
||||
def contents(self):
|
||||
if self.inventory_type == InventoryType.SPELL:
|
||||
return self._spell_contents
|
||||
return self._item_contents
|
||||
|
||||
@property
|
||||
def all_contents(self):
|
||||
def nested(obj):
|
||||
if hasattr(obj, "contents"):
|
||||
for mapping in obj.contents:
|
||||
yield mapping
|
||||
yield from nested(mapping)
|
||||
elif hasattr(obj, "inventory"):
|
||||
yield from nested(obj.inventory)
|
||||
|
||||
yield from nested(self)
|
||||
|
||||
def get(self, prototype):
|
||||
return self.get_all(prototype)[0]
|
||||
|
||||
def get_all(self, prototype):
|
||||
return [mapping for mapping in self.all_contents if mapping.prototype == prototype]
|
||||
|
||||
def add(self, prototype):
|
||||
if prototype.item_type not in inventory_type_map[self.inventory_type]:
|
||||
return False
|
||||
|
||||
mapping = globals()[prototype.__inventory_item_class__](prototype_id=prototype.id)
|
||||
mapping.prototype = prototype
|
||||
|
||||
if prototype.consumable:
|
||||
mapping.count = prototype.count
|
||||
if prototype.charges:
|
||||
mapping.charges = [Charge(item_id=mapping.id) for i in range(prototype.charges)]
|
||||
|
||||
self.contents.append(mapping)
|
||||
return mapping
|
||||
|
||||
def remove(self, mapping):
|
||||
if mapping in self.contents:
|
||||
self.contents.remove(mapping)
|
||||
return mapping
|
||||
return False
|
||||
|
||||
def __contains__(self, obj):
|
||||
if isinstance(obj, prototypes.BaseItem):
|
||||
return obj in [mapping.prototype for mapping in self.all_contents]
|
||||
elif isinstance(obj, Item):
|
||||
return obj in self.all_contents
|
||||
|
||||
def __iter__(self):
|
||||
yield from self.all_contents
|
||||
|
||||
|
||||
@dataclass
|
||||
class InventoryItemMixin:
|
||||
@declared_attr
|
||||
def container(cls) -> Mapped["Inventory"]:
|
||||
return relationship(uselist=False, viewonly=True, init=False)
|
||||
|
||||
@declared_attr
|
||||
def _inventory_id(cls) -> Mapped[int]:
|
||||
return mapped_column(ForeignKey("inventory.id"), init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InventoryMixin:
|
||||
"""
|
||||
Add to a class to make it an inventory.
|
||||
"""
|
||||
|
||||
@declared_attr
|
||||
def inventory(cls):
|
||||
"""
|
||||
Create the join between the current model and the ModifierMap table.
|
||||
"""
|
||||
return relationship(
|
||||
"Inventory",
|
||||
primaryjoin=(
|
||||
"and_("
|
||||
f"foreign(Inventory.primary_table_name)=='{cls.__tablename__}', "
|
||||
f"foreign(Inventory.primary_table_id)=={cls.__name__}.id"
|
||||
")"
|
||||
),
|
||||
cascade="all,delete,delete-orphan",
|
||||
overlaps="inventory,inventory",
|
||||
single_parent=True,
|
||||
uselist=False,
|
||||
lazy="immediate",
|
||||
)
|
||||
|
||||
def __after_insert__(self, session):
|
||||
if self.inventory_type:
|
||||
self.inventory = Inventory(
|
||||
inventory_type=self.inventory_type,
|
||||
primary_table_name=self.__tablename__,
|
||||
primary_table_id=self.id,
|
||||
character_id=getattr(self, "character_id", None),
|
||||
)
|
||||
session.add(self)
|
||||
|
||||
def __contains__(self, obj):
|
||||
return obj in self.inventory
|
||||
|
||||
|
||||
class Spell(BaseObject, InventoryItemMixin):
|
||||
__tablename__ = "spell"
|
||||
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
prototype_id: Mapped[int] = mapped_column(ForeignKey("spell_prototype.id"))
|
||||
always_prepared: Mapped[bool] = mapped_column(default=False)
|
||||
_prepared: Mapped[bool] = mapped_column(init=False, default=False)
|
||||
|
||||
prototype: Mapped["prototypes.BaseSpell"] = relationship(uselist=False, lazy="immediate", init=False)
|
||||
|
||||
@property
|
||||
def prepared(self):
|
||||
return self._prepared or self.always_prepared
|
||||
|
||||
|
||||
class Charge(BaseObject):
|
||||
__tablename__ = "charge"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("item.id"))
|
||||
expended: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
|
||||
class Item(BaseObject, InventoryMixin, InventoryItemMixin, ModifierMixin):
|
||||
__tablename__ = "item"
|
||||
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
|
||||
prototype_id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"))
|
||||
equipped: Mapped[bool] = mapped_column(default=False)
|
||||
attuned: Mapped[bool] = mapped_column(default=False)
|
||||
count: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||
charges: Mapped[List["Charge"]] = relationship(
|
||||
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
|
||||
)
|
||||
|
||||
_inventory_type: Mapped[InventoryType] = mapped_column(nullable=True, default=None)
|
||||
|
||||
@property
|
||||
def inventory_type(self):
|
||||
if self._inventory_type:
|
||||
return self._inventory_type
|
||||
elif self.prototype:
|
||||
return self.prototype.inventory_type
|
||||
|
||||
prototype: Mapped["prototypes.BaseItem"] = relationship(uselist=False, lazy="immediate", init=False)
|
||||
|
||||
@property
|
||||
def modifiers(self):
|
||||
return self.prototype.modifiers
|
||||
|
||||
@property
|
||||
def charges_available(self):
|
||||
return [charge for charge in self.charges if not charge.expended]
|
||||
|
||||
def equip(self):
|
||||
if self.equipped:
|
||||
return False
|
||||
self.equipped = True
|
||||
return True
|
||||
|
||||
def unequip(self):
|
||||
if not self.equipped:
|
||||
return False
|
||||
self.equipped = False
|
||||
return True
|
||||
|
||||
def use(self, item_property: prototypes.ItemProperty, charges=None):
|
||||
if item_property.charge_cost is None:
|
||||
return True
|
||||
avail = self.charges_available
|
||||
if charges is None:
|
||||
charges = item_property.charge_cost
|
||||
if len(avail) < charges:
|
||||
return False
|
||||
for charge in avail[:charges]:
|
||||
charge.expended = True
|
||||
return True
|
||||
|
||||
def consume(self, count=1):
|
||||
if count < 0:
|
||||
return False
|
||||
if not self.prototype.consumable:
|
||||
return False
|
||||
if self.count < count:
|
||||
return False
|
||||
self.count -= count
|
||||
if self.count == 0:
|
||||
self.container.remove(self)
|
||||
return 0
|
||||
return self.count
|
||||
|
||||
def attune(self):
|
||||
if self.attuned:
|
||||
return False
|
||||
if not self.requires_attunement:
|
||||
return False
|
||||
if len(self.container.character.attuned_items) >= 3:
|
||||
return False
|
||||
self.attuned = True
|
||||
return True
|
||||
|
||||
def unattune(self):
|
||||
if not self.attuned:
|
||||
return False
|
||||
self.attuned = False
|
||||
return True
|
||||
|
||||
def move_to(self, target):
|
||||
target_inventory = getattr(target, "inventory", target)
|
||||
if self.container == target_inventory:
|
||||
return False
|
||||
self.container.contents.remove(self)
|
||||
self.container = target_inventory
|
||||
self.container.id = target_inventory.id
|
||||
target_inventory.contents.append(self)
|
||||
return True
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if name == sa_base.DEFAULT_STATE_ATTR:
|
||||
raise AttributeError()
|
||||
return getattr(self.prototype, name)
|
13
src/ttfrog/db/schema/log.py
Normal file
13
src/ttfrog/db/schema/log.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from sqlalchemy import Column, Integer, String, Text
|
||||
|
||||
from ttfrog.db.base import BaseObject
|
||||
|
||||
__all__ = ["TransactionLog"]
|
||||
|
||||
|
||||
class TransactionLog(BaseObject):
|
||||
__tablename__ = "transaction_log"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source_table_name = Column(String, index=True, nullable=False)
|
||||
primary_key = Column(Integer, index=True)
|
||||
diff = Column(Text)
|
348
src/ttfrog/db/schema/modifiers.py
Normal file
348
src/ttfrog/db/schema/modifiers.py
Normal file
|
@ -0,0 +1,348 @@
|
|||
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)
|
||||
requires_attunement: 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.
|
||||
"""
|
||||
if attr_name.startswith("_"):
|
||||
return None
|
||||
col = getattr(self.__table__.columns, f"_{attr_name}", None)
|
||||
if col is None:
|
||||
return None
|
||||
for key in getattr(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"{self.__class__.__name__} object either does not have the attribute '{attr_name}', or an error occurred when accessing it."
|
||||
)
|
||||
|
||||
|
||||
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}"
|
184
src/ttfrog/db/schema/prototypes.py
Normal file
184
src/ttfrog/db/schema/prototypes.py
Normal file
|
@ -0,0 +1,184 @@
|
|||
from typing import List
|
||||
|
||||
from sqlalchemy import ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject, EnumField, StatsEnum
|
||||
from ttfrog.db.schema.classes import CharacterClass
|
||||
from ttfrog.db.schema.constants import DamageType, InventoryType
|
||||
from ttfrog.db.schema.modifiers import ModifierMixin
|
||||
|
||||
__all__ = [
|
||||
"ItemType",
|
||||
"ItemProperty",
|
||||
"Rarity",
|
||||
"RechargeTime",
|
||||
"Cost",
|
||||
"BaseItem",
|
||||
"BaseSpell",
|
||||
"Armor",
|
||||
"Shield",
|
||||
"Weapon",
|
||||
]
|
||||
|
||||
|
||||
ITEM_TYPES = [
|
||||
"ITEM",
|
||||
"SPELL",
|
||||
"SCROLL",
|
||||
"WEAPON",
|
||||
"ARMOR",
|
||||
"SHIELD",
|
||||
"CONTAINER",
|
||||
"SPELLBOOK",
|
||||
]
|
||||
|
||||
RECHARGE_TIMES = [
|
||||
"short rest",
|
||||
"long rest",
|
||||
"dawn",
|
||||
]
|
||||
|
||||
|
||||
COST_TYPES = ["Action", "Bonus Action", "Reaction"]
|
||||
|
||||
RARITY = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"]
|
||||
|
||||
ItemType = EnumField("ItemType", ((k, k) for k in ITEM_TYPES))
|
||||
Rarity = EnumField("Rarity", ((k, k) for k in RARITY))
|
||||
RechargeTime = EnumField("RechargeTime", ((k.replace(" ", "_").upper(), k) for k in RECHARGE_TIMES))
|
||||
Cost = EnumField("Cost", ((k, k) for k in COST_TYPES))
|
||||
|
||||
|
||||
def item_property_creator(fields):
|
||||
if isinstance(fields, list):
|
||||
for f in fields:
|
||||
yield f
|
||||
elif isinstance(fields, ItemProperty):
|
||||
return fields
|
||||
return ItemProperty(**fields)
|
||||
|
||||
|
||||
class BaseItem(BaseObject, ModifierMixin):
|
||||
__tablename__ = "item_prototype"
|
||||
__table_args__ = (UniqueConstraint("item_type", "name", "source"),)
|
||||
__inventory_item_class__ = "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)
|
||||
description: Mapped[str] = mapped_column(String, nullable=True, default=None)
|
||||
|
||||
item_type: Mapped[ItemType] = mapped_column(default=ItemType.ITEM, nullable=False)
|
||||
rarity: Mapped[Rarity] = mapped_column(default=Rarity.Common, nullable=False)
|
||||
requires_attunement: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
attunement_restrictions: Mapped[str] = mapped_column(nullable=False, default="")
|
||||
|
||||
consumable: Mapped[bool] = mapped_column(default=False)
|
||||
count: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||
|
||||
charges: Mapped[int] = mapped_column(nullable=True, info={"min": 0}, default=None)
|
||||
recharge_time: Mapped[RechargeTime] = mapped_column(default=RechargeTime.LONG_REST)
|
||||
recharge_amount: Mapped[str] = mapped_column(String(collation="NOCASE"), default="1")
|
||||
|
||||
_class_restrictions: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=True, default=None)
|
||||
class_restrictions: Mapped["CharacterClass"] = relationship(init=False)
|
||||
|
||||
properties: Mapped[List["ItemProperty"]] = relationship(
|
||||
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
|
||||
)
|
||||
|
||||
# if this item is a container, set the inventory type
|
||||
inventory_type: Mapped[InventoryType] = mapped_column(nullable=True, default=None)
|
||||
|
||||
# the source publication name
|
||||
source: Mapped[str] = mapped_column(default="", nullable=False)
|
||||
|
||||
@property
|
||||
def has_charges(self):
|
||||
return self.charges is not None
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}(id={self.id}, name={self.name})"
|
||||
|
||||
|
||||
class BaseSpell(BaseItem):
|
||||
__tablename__ = "spell_prototype"
|
||||
__inventory_item_class__ = "Spell"
|
||||
__mapper_args__ = {"polymorphic_identity": ItemType.SPELL}
|
||||
id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False)
|
||||
item_type: Mapped[ItemType] = ItemType.SPELL
|
||||
|
||||
school: Mapped[str] = mapped_column(default="", nullable=False)
|
||||
target_range: Mapped[int] = mapped_column(nullable=False, info={"min": 0}, default=0)
|
||||
target_shape: Mapped[str] = mapped_column(nullable=True, default=None)
|
||||
target_size: Mapped[str] = mapped_column(nullable=True, default=None)
|
||||
time: Mapped[str] = mapped_column(default="", nullable=False)
|
||||
duration: Mapped[str] = mapped_column(default="", nullable=False)
|
||||
level: Mapped[int] = mapped_column(nullable=False, info={"min": 0, "max": 9}, default=0)
|
||||
components: Mapped[str] = mapped_column(default="", nullable=False)
|
||||
concentration: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
# XXX some spells do multiple types (e.g. Meteor Swarm). Do we model that here or just in desc?
|
||||
damage_die: Mapped[str] = mapped_column(nullable=True, default=None)
|
||||
damage_type: Mapped[DamageType] = mapped_column(nullable=True, default=None)
|
||||
saving_throw: Mapped[StatsEnum] = mapped_column(nullable=True, default=None)
|
||||
|
||||
|
||||
class Weapon(BaseItem):
|
||||
__tablename__ = "weapon"
|
||||
__mapper_args__ = {"polymorphic_identity": ItemType.WEAPON}
|
||||
id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False)
|
||||
item_type: Mapped[ItemType] = ItemType.WEAPON
|
||||
|
||||
damage_die: Mapped[str] = mapped_column(nullable=False, default="1d6")
|
||||
damage_die_two_handed: Mapped[str] = mapped_column(nullable=True, default=None)
|
||||
damage_type: Mapped[DamageType] = mapped_column(nullable=False, default=DamageType.slashing)
|
||||
attack_range: Mapped[int] = mapped_column(nullable=False, info={"min": 0}, default=0)
|
||||
attack_range_long: Mapped[int] = mapped_column(nullable=True, info={"min": 0}, default=None)
|
||||
reach: Mapped[int] = mapped_column(nullable=False, info={"min": 0}, default=0)
|
||||
targets: Mapped[int] = mapped_column(nullable=False, info={"min": 1}, default=1)
|
||||
martial: Mapped[bool] = mapped_column(default=False)
|
||||
melee: Mapped[bool] = mapped_column(default=False)
|
||||
ammunition: Mapped[bool] = mapped_column(default=False)
|
||||
finesse: Mapped[bool] = mapped_column(default=False)
|
||||
heavy: Mapped[bool] = mapped_column(default=False)
|
||||
light: Mapped[bool] = mapped_column(default=False)
|
||||
loading: Mapped[bool] = mapped_column(default=False)
|
||||
thrown: Mapped[bool] = mapped_column(default=False)
|
||||
two_handed: Mapped[bool] = mapped_column(default=False)
|
||||
versatile: Mapped[bool] = mapped_column(default=False)
|
||||
silvered: Mapped[bool] = mapped_column(default=False)
|
||||
adamantine: Mapped[bool] = mapped_column(default=False)
|
||||
magical: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
@property
|
||||
def ranged(self):
|
||||
return self.attack_range > 0
|
||||
|
||||
|
||||
class Shield(BaseItem):
|
||||
__tablename__ = "shield"
|
||||
__mapper_args__ = {"polymorphic_identity": ItemType.SHIELD}
|
||||
id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False)
|
||||
item_type: Mapped[ItemType] = ItemType.SHIELD
|
||||
|
||||
|
||||
class Armor(BaseItem):
|
||||
__tablename__ = "armor"
|
||||
__mapper_args__ = {"polymorphic_identity": ItemType.ARMOR}
|
||||
id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False)
|
||||
item_type: Mapped[ItemType] = ItemType.ARMOR
|
||||
armor_class: Mapped[int] = mapped_column(nullable=False, info={"min": 0}, default=10)
|
||||
|
||||
|
||||
class ItemProperty(BaseObject):
|
||||
__tablename__ = "item_property"
|
||||
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(String, nullable=True, default=None)
|
||||
charge_cost: Mapped[int] = mapped_column(nullable=True, info={"min": 1}, default=None)
|
||||
item_prototype_id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), default=0)
|
||||
|
||||
# action/reaction/bonus
|
||||
# modifiers?
|
17
src/ttfrog/db/schema/skill.py
Normal file
17
src/ttfrog/db/schema/skill.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject
|
||||
|
||||
__all__ = [
|
||||
"Skill",
|
||||
]
|
||||
|
||||
|
||||
class Skill(BaseObject):
|
||||
__tablename__ = "skill"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
|
||||
base_id: Mapped[int] = mapped_column(ForeignKey("skill.id"), nullable=True, default=None)
|
||||
|
||||
parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False)
|
37
src/ttfrog/db/transaction_log.py
Normal file
37
src/ttfrog/db/transaction_log.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
from ttfrog.db.schema import TransactionLog
|
||||
|
||||
|
||||
def record(previous, new):
|
||||
logging.debug(f"{previous = }, {new = }")
|
||||
diff = list((set(previous.items()) ^ set(dict(new).items())))
|
||||
if not diff:
|
||||
return
|
||||
|
||||
rec = TransactionLog(
|
||||
source_table_name=new.__tablename__,
|
||||
primary_key=new.id,
|
||||
diff=json.dumps(diff),
|
||||
)
|
||||
with db.transaction():
|
||||
db.add(rec)
|
||||
logging.debug(f"Saved restore point: {dict(rec)}")
|
||||
return rec
|
||||
|
||||
|
||||
def restore(rec, log_id=None):
|
||||
if log_id:
|
||||
log = db.query(TransactionLog).filter_by(id=log_id).one()
|
||||
else:
|
||||
log = db.query(TransactionLog).filter_by(source_table_name=rec.__tablename__, primary_key=rec.id).one()
|
||||
logging.debug(f"Located restore point {log = }")
|
||||
diff = json.loads(log.diff)
|
||||
updates = dict(diff[::2])
|
||||
if not updates:
|
||||
return
|
||||
logging.debug(f"{updates = }")
|
||||
with db.transaction():
|
||||
db.query(db.tables[log.source_table_name]).update(updates)
|
0
src/ttfrog/five_e_tools/__init__.py
Normal file
0
src/ttfrog/five_e_tools/__init__.py
Normal file
32
src/ttfrog/five_e_tools/importer.py
Normal file
32
src/ttfrog/five_e_tools/importer.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from . import parsers
|
||||
|
||||
SOURCE_DATA_PATH = Path(__file__).parent / "sources"
|
||||
|
||||
|
||||
SEARCH_PATTERNS = [
|
||||
"spells/spells-*.json",
|
||||
"items*.json",
|
||||
]
|
||||
|
||||
|
||||
def get_sources(pattern: str):
|
||||
yield from SOURCE_DATA_PATH.rglob(pattern)
|
||||
|
||||
|
||||
def walk_sources():
|
||||
for search_path in SEARCH_PATTERNS:
|
||||
for source in get_sources(search_path):
|
||||
data = json.loads(source.read_text())
|
||||
yield from [i for i in data.items() if not i[0].startswith("_")]
|
||||
|
||||
|
||||
def get_all():
|
||||
for source_type, entries in walk_sources():
|
||||
parser = parsers.get_parser(source_type)
|
||||
if not parser:
|
||||
continue
|
||||
for entry in entries:
|
||||
yield parser(entry).parse()
|
12
src/ttfrog/five_e_tools/parsers/__init__.py
Normal file
12
src/ttfrog/five_e_tools/parsers/__init__.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from .item import BaseitemParser, ItemParser
|
||||
from .spell import SpellParser
|
||||
|
||||
__ALL__ = [SpellParser, ItemParser, BaseitemParser]
|
||||
|
||||
|
||||
def get_parser(key: str):
|
||||
try:
|
||||
return globals()[key.title() + "Parser"]
|
||||
except KeyError:
|
||||
# raise NotImplementedError(f"Could not find a parser for {key} data.")
|
||||
return None
|
50
src/ttfrog/five_e_tools/parsers/base.py
Normal file
50
src/ttfrog/five_e_tools/parsers/base.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
@dataclass
|
||||
class Parser:
|
||||
data: dict
|
||||
|
||||
@property
|
||||
def object_type(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@cached_property
|
||||
def description(self):
|
||||
def add_one_entry(entry):
|
||||
desc = ""
|
||||
if type(entry) == str:
|
||||
desc += entry
|
||||
elif type(entry) == dict:
|
||||
if entry["type"] == "entries":
|
||||
if "name" in entry:
|
||||
desc += f"\n{entry['name']}\n"
|
||||
for subentry in entry["entries"]:
|
||||
desc += add_one_entry(subentry)
|
||||
else:
|
||||
desc += f"\n[UNSUPPORTED ENTRY TYPE: {entry['type']}"
|
||||
return desc
|
||||
|
||||
return "\n".join([add_one_entry(entry) for entry in self.data.get("entries", [])])
|
||||
|
||||
@property
|
||||
def requires_attunement(self):
|
||||
return True if self.data.get("reqAttune", None) else False
|
||||
|
||||
def parse(self):
|
||||
params = {}
|
||||
mapped = inspect(self.object_type)
|
||||
for col in mapped.columns.keys():
|
||||
val = getattr(self, col, None)
|
||||
if val:
|
||||
params[col] = val
|
||||
return self.object_type(**params)
|
||||
|
||||
def __getattr__(self, attr_name):
|
||||
try:
|
||||
return self.data[attr_name]
|
||||
except KeyError:
|
||||
raise AttributeError()
|
131
src/ttfrog/five_e_tools/parsers/item.py
Normal file
131
src/ttfrog/five_e_tools/parsers/item.py
Normal file
|
@ -0,0 +1,131 @@
|
|||
from ttfrog.db.schema.constants import DamageType
|
||||
from ttfrog.db.schema.prototypes import Armor, BaseItem, Rarity, Shield, Weapon
|
||||
|
||||
from .base import Parser
|
||||
|
||||
DAMAGE_TYPES = {
|
||||
"P": DamageType.piercing,
|
||||
"S": DamageType.slashing,
|
||||
"B": DamageType.bludgeoning,
|
||||
"R": DamageType.radiant,
|
||||
"Y": DamageType.psychic,
|
||||
"N": DamageType.necrotic,
|
||||
"C": DamageType.cold,
|
||||
"F": DamageType.fire,
|
||||
"T": DamageType.thunder,
|
||||
"": None,
|
||||
}
|
||||
|
||||
|
||||
class ItemParser(Parser):
|
||||
@property
|
||||
def object_type(self):
|
||||
objtype = BaseItem
|
||||
if "type" not in self.data:
|
||||
return objtype
|
||||
|
||||
if self.data["type"][:2] in ["LA", "MA", "HA"]:
|
||||
objtype = Armor
|
||||
elif self.data["type"][0] in ["R", "M"]:
|
||||
objtype = Weapon
|
||||
elif self.data["type"][0] == "S":
|
||||
objtype = Shield
|
||||
else:
|
||||
objtype = BaseItem
|
||||
return objtype
|
||||
|
||||
@property
|
||||
def _properties(self):
|
||||
return self.data.get("property", [])
|
||||
|
||||
@property
|
||||
def damage_die(self):
|
||||
return self.data.get("dmg1", None)
|
||||
|
||||
@property
|
||||
def damage_die_two_handed(self):
|
||||
return self.data.get("dmg2", None)
|
||||
|
||||
@property
|
||||
def damage_type(self):
|
||||
return DAMAGE_TYPES[self.data.get("dmgType", "")]
|
||||
|
||||
@property
|
||||
def attack_range(self):
|
||||
rangeval = self.data.get("range", None)
|
||||
if not rangeval:
|
||||
return None
|
||||
return rangeval.split("/")[0]
|
||||
|
||||
@property
|
||||
def attack_range_long(self):
|
||||
rangeval = self.data.get("range", None)
|
||||
if not rangeval:
|
||||
return None
|
||||
try:
|
||||
return rangeval.split("/")[1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def martial(self):
|
||||
return self.data.get("weaponCategory", "") == "martial"
|
||||
|
||||
@property
|
||||
def melee(self):
|
||||
return self.data.get("type", "") == "M"
|
||||
|
||||
@property
|
||||
def ammunition(self):
|
||||
return self.data.get("type", "") == "A"
|
||||
|
||||
@property
|
||||
def finesse(self):
|
||||
return "F" in self._properties
|
||||
|
||||
@property
|
||||
def heavy(self):
|
||||
return "H" in self._properties
|
||||
|
||||
@property
|
||||
def light(self):
|
||||
return "L" in self._properties
|
||||
|
||||
@property
|
||||
def loading(self):
|
||||
return "RLD" in self._properties
|
||||
|
||||
@property
|
||||
def loading(self):
|
||||
return "RLD" in self._properties
|
||||
|
||||
@property
|
||||
def thrown(self):
|
||||
return "T" in self._properties
|
||||
|
||||
@property
|
||||
def two_handed(self):
|
||||
return "2H" in self._properties
|
||||
|
||||
@property
|
||||
def versatile(self):
|
||||
return "V" in self._properties
|
||||
|
||||
@property
|
||||
def magical(self):
|
||||
return True if self.data.get("bonusWeapon", False) or self.requires_attunement else False
|
||||
|
||||
@property
|
||||
def rarity(self):
|
||||
return (
|
||||
Rarity.Common
|
||||
if self.data.get("rarity", "none") == "none"
|
||||
else getattr(Rarity, self.data["rarity"].title(), None)
|
||||
)
|
||||
|
||||
@property
|
||||
def armor_class(self):
|
||||
return self.data.get("ac", None)
|
||||
|
||||
|
||||
BaseitemParser = ItemParser
|
100
src/ttfrog/five_e_tools/parsers/spell.py
Normal file
100
src/ttfrog/five_e_tools/parsers/spell.py
Normal file
|
@ -0,0 +1,100 @@
|
|||
import re
|
||||
|
||||
from ttfrog.db.base import StatsEnum
|
||||
from ttfrog.db.schema.prototypes import BaseSpell
|
||||
|
||||
from .base import Parser
|
||||
|
||||
DAMAGE_DIE_PATTERN = re.compile(r"{@damage ([\dd]+)} (\w+) damage")
|
||||
|
||||
|
||||
ONE_MILE_IN_FEET = 5280
|
||||
|
||||
SCHOOLS = {
|
||||
"A": "Abjuration",
|
||||
"C": "Conjuration",
|
||||
"D": "Divination",
|
||||
"E": "Enchantment",
|
||||
"V": "Evocation",
|
||||
"I": "Illusion",
|
||||
"N": "Necromancy",
|
||||
"T": "Transmutation",
|
||||
}
|
||||
|
||||
|
||||
class SpellParser(Parser):
|
||||
object_type = BaseSpell
|
||||
|
||||
@property
|
||||
def target_range(self):
|
||||
if self.data["range"]["type"] == "special":
|
||||
return None
|
||||
elif self.data["range"]["distance"]["type"] in ["sight", "unlimited"]:
|
||||
return None
|
||||
elif self.data["range"]["distance"]["type"] in ["self", "touch"]:
|
||||
return 0
|
||||
elif self.data["range"]["distance"]["type"] == "feet":
|
||||
return self.data["range"]["distance"]["amount"]
|
||||
elif self.data["range"]["distance"]["type"] == "miles":
|
||||
return self.data["range"]["distance"]["amount"] * ONE_MILE_IN_FEET
|
||||
else:
|
||||
raise Exception(f"Don't know how to handle {self.data}")
|
||||
|
||||
@property
|
||||
def target_shape(self):
|
||||
return self.data["range"]["type"]
|
||||
|
||||
@property
|
||||
def target_size(self):
|
||||
return self.data["range"].get("amount", None)
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
dur = self.data["duration"][0]
|
||||
if dur["type"] in ["instant", "permanent", "special"]:
|
||||
return dur["type"]
|
||||
elif dur["type"] == "timed":
|
||||
return f"{dur['duration']['amount']} {dur['duration']['type']}s"
|
||||
else:
|
||||
raise Exception(f"Don't know how to handle {dur}")
|
||||
|
||||
@property
|
||||
def components(self):
|
||||
components = []
|
||||
if self.data["components"].get("v", False):
|
||||
components.append("verbal")
|
||||
|
||||
if self.data["components"].get("s", False):
|
||||
components.append("somatic")
|
||||
|
||||
mat = self.data["components"].get("m", False)
|
||||
if mat:
|
||||
desc = mat if type(mat) == str else mat["text"]
|
||||
components.append(f"materials: {desc}")
|
||||
return ", ".join(components)
|
||||
|
||||
@property
|
||||
def school(self):
|
||||
return SCHOOLS[self.data["school"]]
|
||||
|
||||
@property
|
||||
def saving_throw(self):
|
||||
return getattr(StatsEnum, self.data["savingThrow"][0]) if "savingThrow" in self.data else None
|
||||
|
||||
@property
|
||||
def concentration(self):
|
||||
return self.data.get("duration", [])[0].get("concentration", False)
|
||||
|
||||
@property
|
||||
def time(self):
|
||||
return f"{self.data['time'][0]['number']} {self.data['time'][0]['unit']}"
|
||||
|
||||
@property
|
||||
def damage_die(self):
|
||||
m = DAMAGE_DIE_PATTERN.findall(self.description)
|
||||
return m[0][0] if m else None
|
||||
|
||||
@property
|
||||
def damage_type(self):
|
||||
m = DAMAGE_DIE_PATTERN.findall(self.description)
|
||||
return m[0][1] if m else None
|
4
src/ttfrog/five_e_tools/sources/README.md
Normal file
4
src/ttfrog/five_e_tools/sources/README.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
Everything here is forked from
|
||||
|
||||
https://github.com/5etools-mirror-3/5etools-src
|
||||
|
30555
src/ttfrog/five_e_tools/sources/backgrounds.json
Normal file
30555
src/ttfrog/five_e_tools/sources/backgrounds.json
Normal file
File diff suppressed because it is too large
Load Diff
6249
src/ttfrog/five_e_tools/sources/books.json
Normal file
6249
src/ttfrog/five_e_tools/sources/books.json
Normal file
File diff suppressed because it is too large
Load Diff
2220
src/ttfrog/five_e_tools/sources/class/class-artificer.json
Normal file
2220
src/ttfrog/five_e_tools/sources/class/class-artificer.json
Normal file
File diff suppressed because it is too large
Load Diff
3802
src/ttfrog/five_e_tools/sources/class/class-barbarian.json
Normal file
3802
src/ttfrog/five_e_tools/sources/class/class-barbarian.json
Normal file
File diff suppressed because it is too large
Load Diff
4205
src/ttfrog/five_e_tools/sources/class/class-bard.json
Normal file
4205
src/ttfrog/five_e_tools/sources/class/class-bard.json
Normal file
File diff suppressed because it is too large
Load Diff
9107
src/ttfrog/five_e_tools/sources/class/class-cleric.json
Normal file
9107
src/ttfrog/five_e_tools/sources/class/class-cleric.json
Normal file
File diff suppressed because it is too large
Load Diff
5094
src/ttfrog/five_e_tools/sources/class/class-druid.json
Normal file
5094
src/ttfrog/five_e_tools/sources/class/class-druid.json
Normal file
File diff suppressed because it is too large
Load Diff
5144
src/ttfrog/five_e_tools/sources/class/class-fighter.json
Normal file
5144
src/ttfrog/five_e_tools/sources/class/class-fighter.json
Normal file
File diff suppressed because it is too large
Load Diff
4516
src/ttfrog/five_e_tools/sources/class/class-monk.json
Normal file
4516
src/ttfrog/five_e_tools/sources/class/class-monk.json
Normal file
File diff suppressed because it is too large
Load Diff
1378
src/ttfrog/five_e_tools/sources/class/class-mystic.json
Normal file
1378
src/ttfrog/five_e_tools/sources/class/class-mystic.json
Normal file
File diff suppressed because it is too large
Load Diff
5385
src/ttfrog/five_e_tools/sources/class/class-paladin.json
Normal file
5385
src/ttfrog/five_e_tools/sources/class/class-paladin.json
Normal file
File diff suppressed because it is too large
Load Diff
4378
src/ttfrog/five_e_tools/sources/class/class-ranger.json
Normal file
4378
src/ttfrog/five_e_tools/sources/class/class-ranger.json
Normal file
File diff suppressed because it is too large
Load Diff
4412
src/ttfrog/five_e_tools/sources/class/class-rogue.json
Normal file
4412
src/ttfrog/five_e_tools/sources/class/class-rogue.json
Normal file
File diff suppressed because it is too large
Load Diff
1134
src/ttfrog/five_e_tools/sources/class/class-sidekick.json
Normal file
1134
src/ttfrog/five_e_tools/sources/class/class-sidekick.json
Normal file
File diff suppressed because it is too large
Load Diff
5746
src/ttfrog/five_e_tools/sources/class/class-sorcerer.json
Normal file
5746
src/ttfrog/five_e_tools/sources/class/class-sorcerer.json
Normal file
File diff suppressed because it is too large
Load Diff
4653
src/ttfrog/five_e_tools/sources/class/class-warlock.json
Normal file
4653
src/ttfrog/five_e_tools/sources/class/class-warlock.json
Normal file
File diff suppressed because it is too large
Load Diff
4611
src/ttfrog/five_e_tools/sources/class/class-wizard.json
Normal file
4611
src/ttfrog/five_e_tools/sources/class/class-wizard.json
Normal file
File diff suppressed because it is too large
Load Diff
17
src/ttfrog/five_e_tools/sources/class/index.json
Normal file
17
src/ttfrog/five_e_tools/sources/class/index.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"artificer": "class-artificer.json",
|
||||
"barbarian": "class-barbarian.json",
|
||||
"bard": "class-bard.json",
|
||||
"cleric": "class-cleric.json",
|
||||
"druid": "class-druid.json",
|
||||
"fighter": "class-fighter.json",
|
||||
"monk": "class-monk.json",
|
||||
"mystic": "class-mystic.json",
|
||||
"paladin": "class-paladin.json",
|
||||
"ranger": "class-ranger.json",
|
||||
"rogue": "class-rogue.json",
|
||||
"sidekick": "class-sidekick.json",
|
||||
"sorcerer": "class-sorcerer.json",
|
||||
"warlock": "class-warlock.json",
|
||||
"wizard": "class-wizard.json"
|
||||
}
|
1381
src/ttfrog/five_e_tools/sources/conditionsdiseases.json
Normal file
1381
src/ttfrog/five_e_tools/sources/conditionsdiseases.json
Normal file
File diff suppressed because it is too large
Load Diff
8521
src/ttfrog/five_e_tools/sources/feats.json
Normal file
8521
src/ttfrog/five_e_tools/sources/feats.json
Normal file
File diff suppressed because it is too large
Load Diff
5013
src/ttfrog/five_e_tools/sources/items-base.json
Normal file
5013
src/ttfrog/five_e_tools/sources/items-base.json
Normal file
File diff suppressed because it is too large
Load Diff
59139
src/ttfrog/five_e_tools/sources/items.json
Normal file
59139
src/ttfrog/five_e_tools/sources/items.json
Normal file
File diff suppressed because it is too large
Load Diff
2165
src/ttfrog/five_e_tools/sources/languages.json
Normal file
2165
src/ttfrog/five_e_tools/sources/languages.json
Normal file
File diff suppressed because it is too large
Load Diff
4695
src/ttfrog/five_e_tools/sources/loot.json
Normal file
4695
src/ttfrog/five_e_tools/sources/loot.json
Normal file
File diff suppressed because it is too large
Load Diff
4513
src/ttfrog/five_e_tools/sources/magicvariants.json
Normal file
4513
src/ttfrog/five_e_tools/sources/magicvariants.json
Normal file
File diff suppressed because it is too large
Load Diff
20978
src/ttfrog/five_e_tools/sources/races.json
Normal file
20978
src/ttfrog/five_e_tools/sources/races.json
Normal file
File diff suppressed because it is too large
Load Diff
865
src/ttfrog/five_e_tools/sources/renderdemo.json
Normal file
865
src/ttfrog/five_e_tools/sources/renderdemo.json
Normal file
|
@ -0,0 +1,865 @@
|
|||
{
|
||||
"data": [
|
||||
{
|
||||
"type": "section",
|
||||
"name": "Renderer Demo",
|
||||
"entries": [
|
||||
"(This is a section heading. This is mainly used in Adventures, for the header of entire chapters. The root entry does {@b not} need have the 'section' type, 'entries' is generally used instead. The 'section' produces a {@bold level -1} header; the 'basement' level, if you will.)",
|
||||
{
|
||||
"type": "quote",
|
||||
"entries": [
|
||||
"Look, don't quote me on this, but"
|
||||
],
|
||||
"by": "Anon",
|
||||
"from": "Archive of Lost Chats"
|
||||
},
|
||||
"This is a demo of the JSON format, and how it (usually) gets rendered. You can edit this text, and it will reset on refresh.",
|
||||
"{@b If a JSON property is described as 'optional,' the property may be excluded, unless otherwise noted.}",
|
||||
"The goal of this system is to emulate the look and feel of the books, while maintaining a convenient and consistent data structure that can be re-used.",
|
||||
"The general format is to nest multiple levels of 'entries'-typed objects, with other specific types (described in detail below) used as needed.",
|
||||
"String entries can contain tags, and these work when nested in most other structures. These will eventually all work with the hover-to-view feature. They are case-insensitive, unless otherwise noted. These are:",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"Style tags; {@bold some text to be bolded} (alternative {@b shorthand}), {@italic some text to be italicised} (alternative {@i shorthand}), {@underline some text to be underlined} (alternative {@u shorthand}), {@underlineDouble some text to be underlined} (alternative {@u2 shorthand}), {@strike some text to strike-through}, (alternative {@s shorthand}), , {@strikeDouble some text to strike-through}, (alternative {@s2 shorthand}), {@color color|e40707}/{@color color variable|--rgb-name} tags, {@highlight highlight} tags, {@sup superscript} tags, {@sub subscript} tags, {@kbd keyboard} tags, {@code print("hello world")} tags, misc {@style Style|small-caps;small;capitalize;dnd-font} tags, {@font alternate font|Comic Sans MS} tags",
|
||||
"Additionally, {@note note tags}, used for adding errata or Twitter \"designer footnotes,\" and {@tip tooltip tags|a note}.",
|
||||
"Dice roller tags; {@dice 1d2-2+2d3+5} for regular dice rolls ({@dice 1d6;2d6} for multiple options; {@dice 1d6 + #$prompt_number:min=1,title=Enter a Number!,default=123$#} for input prompts), with extended {@dice 1d20+2|display text} and {@dice 1d20+2|display text|rolled by name}, and a special 'hit' version which assumes a d20 is to be rolled {@hit +7} (and rolls advantage on {@kbd SHIFT}+click, disadvantage on {@kbd ALT}+click). There's also {@damage 1d12+3} which will roll critical hits on {@kbd SHIFT}+click and half damage (rounding down) on {@kbd ALT}+click, and {@d20 -4} which will also roll advantage/disadvantage, although @hit tags are preferred where appropriate. Spells can have scaling-dice tags, (damage of 2d6 or 3d6 at level 1, add an extra {@scaledamage 2d6;3d6|2-9|1d6} for each level beyond 2nd; or, roll 2d6 when using 1 psi point, add an {@scaledice 2d6|1,3,5,7,9|1d6|psi|extra amount} for each additional psi point spent), for when a spell effect scales at higher levels. {@ability str 20}, {@savingThrow str 5}, and {@skillCheck animal_handling 5} are used as internal shorthand, but may be useful elsewhere.",
|
||||
"Auto dice tags; as above, but a result is automatically rolled upon rendering: {@autodice 2d10+2}.",
|
||||
"Chance tags; similar to dice roller tags, but output success/failure. Similar syntax as dice tags: {@chance 50}; {@chance 50|display text}; {@chance 50|display text|rolled by name}; {@chance 50|display text|rolled by name|on success text}; {@chance 50|display text|rolled by name|on success text|on failure text}.",
|
||||
"Summon/companion-specific tags: {@hitYourSpellAttack} ({@hitYourSpellAttack with optional display text}), {@dcYourSpellSave} ({@dcYourSpellSave with optional display text}).",
|
||||
"Recharge tags; output success/failure for ability recharge. {@recharge} in an ability title for '(Recharge 6)', {@recharge 4} for '(Recharge 4\u20136)'.",
|
||||
"Coinflip tags; {@coinflip} or {@coinflip display text|rolee name|success text|failure text}",
|
||||
"Skill and sense tags; {@skill Athletics}/{@skill Perception} (case sensitive) and {@sense Truesight}/{@sense darkvision} (case insensitive) which provide tooltips on hover.",
|
||||
"Footnote tags; allows a footnote to be embedded {@footnote directly in text|This is primarily for homebrew purposes, as the official texts (so far) avoid using footnotes}, with {@footnote optional reference information|This is the footnote. References are free text.|Footnote 1, page 20}.",
|
||||
"The homebrew tag; can show {@homebrew changes|modifications}, {@homebrew additions} or {@homebrew |removals}.",
|
||||
"Content tags:",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"Spells: {@spell acid splash} assumes PHB by default, {@spell tiny servant|xge} can have sources added with a pipe, {@spell tiny servant|xge|and optional link text added with another pipe}.",
|
||||
"Items: {@item alchemy jug} assumes DMG by default, {@item longsword|phb} can have sources added with a pipe, {@item longsword|phb|and optional link text added with another pipe}.",
|
||||
"Creatures: {@creature goblin} assumes MM by default, {@creature cow|vgm} can have sources added with a pipe, {@creature cow|vgm|and optional link text added with another pipe}.",
|
||||
"Creature legendary groups: {@legroup unicorn} assumes MM by default, {@legroup balhannoth|MPMM} can have sources added with a pipe, {@legroup balhannoth|MPMM|and optional link text added with another pipe}.",
|
||||
"Backgrounds: {@background Charlatan} assumes PHB by default, {@background Anthropologist|toa} can have sources added with a pipe, {@background Anthropologist|ToA|and optional link text added with another pipe}.",
|
||||
"Races: {@race Human} assumes PHB by default, {@race Aarakocra|eepc} can have sources added with a pipe, {@race Aarakocra|eepc|and optional link text added with another pipe}.",
|
||||
"Invocations and Other Optional Features: {@optfeature Agonizing Blast} assumes PHB by default, {@optfeature Aspect of the Moon|xge} can have sources added with a pipe, {@optfeature Aspect of the Moon|xge|and optional link text added with another pipe}.",
|
||||
"Classes: {@class fighter} assumes PHB by default, {@class artificer|tce} can have sources added with a pipe, {@class fighter|phb|optional link text added with another pipe}, {@class fighter|phb|subclasses added|Eldritch Knight} with another pipe, {@class fighter|phb|and class feature added|Eldritch Knight|phb|2-0} with another pipe (first number is level index (0-19), second number is feature index (0-n)).",
|
||||
"Subclasses: {@subclass Berserker|Barbarian}, {@subclass Berserker|Barbarian}, {@subclass Ancestral Guardian|Barbarian||XGE}, {@subclass Artillerist|Artificer|TCE|TCE}. Class and subclass source is assumed to be PHB.",
|
||||
"Class Features: {@classFeature Rage|Barbarian||1}, {@classFeature Infuse Item|Artificer|TCE|2}, {@classFeature Primal Knowledge|Barbarian||3|TCE}, {@classFeature Rage|Barbarian||1||optional display text}. Class source is assumed to be PHB. Class feature source is assumed to be the same as class source.",
|
||||
"Subclass Features: {@subclassFeature Path of the Berserker|Barbarian||Berserker||3}, {@subclassFeature Alchemist|Artificer|TCE|Alchemist|TCE|3}, {@subclassFeature Path of the Battlerager|Barbarian||Battlerager|SCAG|3}, {@subclassFeature Blessed Strikes|Cleric||Life||8|TCE}, {@subclassFeature Path of the Berserker|Barbarian||Berserker||3||optional display text}. Class source is assumed to be PHB. Subclass source is assumed to be PHB. Subclass feature source is assumed to be the same as subclass source.",
|
||||
"Conditions: {@condition stunned} assumes PHB by default, {@condition stunned|PHB} can have sources added with a pipe (not that it's ever useful), {@condition stunned|PHB|and optional link text added with another pipe}.",
|
||||
"Diseases: {@disease cackle fever} assumes DMG by default, {@disease cackle fever} can have sources added with a pipe, {@disease cackle fever|DMG|and optional link text added with another pipe}.",
|
||||
"Other Rewards: {@reward Blessing of Health} assumes DMG by default, {@reward Blessing of Health} can have sources added with a pipe, {@reward Blessing of Health|DMG|and optional link text added with another pipe}.",
|
||||
"Feats: {@feat Alert} assumes PHB by default, {@feat Elven Accuracy|xge} can have sources added with a pipe, {@feat Elven Accuracy|xge|and optional link text added with another pipe}.",
|
||||
"Psionics: {@psionic Mastery of Force} assumes UATheMysticClass by default, {@psionic Mastery of Force|UATheMysticClass} can have sources added with a pipe, {@psionic Mastery of Force|UATheMysticClass|and optional link text added with another pipe}.",
|
||||
"Objects: {@object Ballista} assumes DMG by default, {@object Ballista} can have sources added with a pipe, {@object Ballista|DMG|and optional link text added with another pipe}.",
|
||||
"Boons: {@boon Demonic Boon of Demogorgon} assumes MTF by default, {@boon Demonic Boon of Demogorgon} can have sources added with a pipe, {@boon Demonic Boon of Demogorgon|MTF|and optional link text added with another pipe}.",
|
||||
"Cults: {@cult Cult of Asmodeus} assumes MTF by default, {@cult Cult of Asmodeus} can have sources added with a pipe, {@cult Cult of Asmodeus|MTF|and optional link text added with another pipe}.",
|
||||
"Traps: {@trap falling net} assumes DMG by default, {@trap falling portcullis|xge} can have sources added with a pipe, {@trap falling portcullis|xge|and optional link text added with another pipe}.",
|
||||
"Hazards: {@hazard brown mold} assumes DMG by default, {@hazard russet mold|vgm} can have sources added with a pipe, {@hazard russet mold|vgm|and optional link text added with another pipe}.",
|
||||
"Deities: {@deity Gond} assumes PHB Forgotten Realms pantheon by default, {@deity Gruumsh|nonhuman} can have pantheons added with a pipe, {@deity Ioun|dawn war|dmg} can have sources added with another pipe, {@deity Ioun|dawn war|dmg|and optional link text added with another pipe}.",
|
||||
"Variant rules: {@variantrule Diagonals} assumes DMG by default, {@variantrule Multiclassing|phb} can have sources added with a pipe, {@variantrule Multiclassing|phb|and optional link text added with another pipe}.",
|
||||
"Vehicles: {@vehicle Galley} assumes GoS by default, {@vehicle Galley|GoS} can have sources added with a pipe, {@vehicle Galley|GoS|and optional link text added with another pipe}.",
|
||||
"Vehicle upgrades: {@vehupgrade Guardian Figurehead} assumes GoS by default, {@vehicle Gilded Death Armor|BGDIA} can have sources added with a pipe, {@vehicle Guardian Figurehead|GoS|and optional link text added with another pipe}.",
|
||||
"Tables: {@table 25 gp Art Objects} assumes DMG by default, {@table Adventuring Gear|phb} can have sources added with a pipe, {@table Adventuring Gear|phb|and optional link text added with another pipe}.",
|
||||
"Actions: {@action Attack} assumes PHB by default, {@action Tumble|DMG} can have sources added with a pipe, {@action Tumble|DMG|and optional link text added with another pipe}.",
|
||||
"Languages: {@language common} assumes PHB by default, {@language Dambrathan|SCAG} can have sources added with a pipe, {@language Dambrathan|SCAG|and optional link text added with another pipe}.",
|
||||
"Other Character Creation Options: {@charoption Anvilwrought} assumes MOT by default, {@charoption Hollow One|EGW} can have sources added with a pipe, {@charoption Hollow One|EGW|and optional link text added with another pipe}.",
|
||||
"Recipes: {@recipe Elven Bread} assumes Heroes' Feast by default, {@recipe Barovian Garlic Bread|HFFotM} can have sources added with a pipe, {@recipe Barovian Garlic Bread|HFFotM|and optional link text added with another pipe}.",
|
||||
"Decks: {@deck Deck of Many Things} assumes DMG by default, {@deck Deck of Many Things|DMG} can have sources added with a pipe, {@deck Deck of Many Things|DMG|and optional link text added with another pipe}.",
|
||||
"Cards: {@card Vizier|Deck of Many Things} assumes DMG by default, {@card Vizier|Deck of Many Things|DMG} can have sources added with a pipe, {@card Vizier|Deck of Many Things|DMG|and optional link text added with another pipe}."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "inset",
|
||||
"name": "Filter Tag",
|
||||
"entries": [
|
||||
"Another tag that appears occasionally in the data is the @filter tag. This tag can be tricky to use, and generally relies on knowledge of the internals of each page. As such, use with caution, and if you're not sure about something, don't be afraid to drop by our {@link Discord|https://discord.gg/5etools} and ask questions. Note that you can {@kbd CTRL}+click the \"Get link to filters\" (magnet) button on a list page to copy the @filter tag for your current filter selection.",
|
||||
"The syntax for the @filter tag is as follows:",
|
||||
"(open curly brace)@filter display text|page_without_file_extension|filter_name_1=filter_1_value_1;filter_1_value_2;...filter_1_value_n|...|filter_name_m=filter_m_value_1;filter_m_value_2;...(close curly brace)",
|
||||
"The purpose of this tag is to open the given page, pre-filtered with the specified filtering options. There's one major caveat that makes this tricky to use for aspiring JSONtranauts\u2014the filter values use the {@i internal} version, as you'd find in the data. So, for example, ..|school=D|.. would filter spells to Divination school spells, as 'D' is the Divination identifier in the data. Trying to do e.g. ..|school=Divination|.. will not work.",
|
||||
"The 'filter_name_X' here matches the literal text name of the filter, as displayed in the dropdown.",
|
||||
"Some examples of the tag in action:",
|
||||
"{@filter Races that have a bonus to Intelligence|races|Ability Scores (Including Subspecies)=Intelligence +any}",
|
||||
"{@filter Bard cantrips and first-level spells|spells|level=0;1|class=bard}",
|
||||
"{@filter Beast with challenge rating 1 or lower|bestiary|challenge rating=[&0;&1]|type=beast}",
|
||||
"{@filter Creatures with a Strength score of 18 or more|bestiary|strength=[18;]}",
|
||||
"{@filter Divination and Evocation spells|spells|school=D;V}",
|
||||
"{@filter All uncommon magic items|items|source=|type=|rarity=uncommon|miscellaneous=Magic}",
|
||||
"{@filter Simple melee weapons|items|source=phb|category=basic|type=simple weapon;melee weapon=sand}",
|
||||
"Meta-options can be set; inline for individual filters, and using 'fbmt' for filtering meta-options {@filter filter meta example|spells|level=1;2=sor~sand|fbmt=sor|fbmh=source}",
|
||||
"Search can be set; {@filter view dinosaurs|bestiary|search=dinosaur}",
|
||||
"An exact starting entity; {@filter filter for beasts and show cat|bestiary|type=beast|hash=cat_mm} where 'hash' is the part after a '#' and before any commas which appears in the URL bar when viewing the entity.",
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "Note",
|
||||
"entries": [
|
||||
"Setting a filter to \"no value\", i.e. ..|school=|.. is equivalent to 'clear the filter,' resetting it to all-white.",
|
||||
"The pages that support this functionality are those with the 'magnet' button; the link this button exports will give clues as to what one could do with the filter tag."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "inset",
|
||||
"name": "Area Tags and IDs",
|
||||
"entries": [
|
||||
"Adventure and book content supports \"area\" tags, which have the form `@area [area name]|[entry id]` or `@area [area name]|[entry id]|[modifiers]` where [modifiers] is a combination of \"x\" for e[x]act text, and \"u\" for [u]ppercase. The \"exact\" modifier removes the leading \"area \" which is prepended to the area name, and the \"uppercase\" modifier causes the prepended text to \"Area \".",
|
||||
"A variety of entries blocks can have \"id\" fields (and may therefore be linked to with \"area\" tags), notable \"entries\"-type, \"section\"-type, and \"inset\"-type. These IDs are conventionally 3-digit hexadecimal numbers (allowing for a total of 4096 IDs per adventure/book), but any string which is unique within the document will suffice."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "inset",
|
||||
"name": "Book/Reference Tags",
|
||||
"entries": [
|
||||
"The following tags can be used to link specific sections of adventures/books. Note that chapters and headers are indexed from zero.",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"Adventure tags: link to an adventure {@adventure display text|CoS}; a chapter in an adventure {@adventure display text|CoS|2}; a heading in a chapter in an adventure {@adventure display text|CoS|2|Treasure}; or the Nth heading of a given name in a chapter in an adventure {@adventure display text|CoS|2|Treasure|1}",
|
||||
"Book tags: link to a book {@book display text|PHB}; a chapter in a book {@book display text|PHB|2}; a heading in a chapter in a book {@book display text|PHB|2|Age}; or the Nth heading of a given name in a chapter in a book {@book display text|PHB|2|Age|0} (although this is generally unnecessary as, unlike adventures, books don't repeat their headings very often.",
|
||||
"Quick Reference tags; {@code <name>|<source>|<chapterIndex>|<headerIndex>|<displayText>}: {@quickref Multiclassing}, {@quickref Multiclassing|PHB}, {@quickref Adventuring Gear|PHB|1}, {@quickref Adventuring Gear|PHB|1|0}, {@quickref Adventuring Gear|PHB|1|0|Display Text}."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "inset",
|
||||
"name": "Other Tags",
|
||||
"entries": [
|
||||
"Links:",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"Internal links: {@5etools This Is Your Life|lifegen.html}",
|
||||
"Internal \"img\" links (i.e., anything in {@link https://github.com/5etools-mirror-3/5etools-img}): {@5etoolsImg Players Handbook Cover|covers/PHB.webp}; {@5etoolsImg Human Paladin sheet|pdf/DoSI/Human-Paladin.pdf}",
|
||||
"External links: {@link https://discord.gg/5etools} or {@link Discord|https://discord.gg/5etools}"
|
||||
]
|
||||
},
|
||||
"Page specific/other tags:",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"Bestiary 'attack' tags: {@atk m}, {@atk r}, {@atk m,r}, {@atk mw}, {@atk rw}, {@atk ms}, {@atk rs}; (homebrew only) {@atk a}, {@atk aw}. Commas separate or'd attack types; the general form is: '@atk <m|r|a>[<w|s>][,<m|r|a>[<w|s>][,...]]'"
|
||||
]
|
||||
},
|
||||
"Homebrew loading tags: {@loader Tome of Beasts|creature/Kobold Press; Tome of Beasts.json}. The URL after the pipe is assumed to be from the root of the homebrew repository, unless it starts with a \"...://\" prefix (i.e., any link should work, but there's a shorthand for homebrew repository links).",
|
||||
"Prerelease loading tags; as above: {@loader UA: When Armies Clash|collection/Unearthed Arcana - When Armies Clash.json|prerelease}."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"These tags may be {@b {@i {@spell fireball|phb|nested}}}, although there are often better ways to get the combination of bold and italic that you're probably going to use this for; consider using one of the properly typed objects below (such as level 2 headers).",
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "A Subclass Name, For Example",
|
||||
"entries": [
|
||||
"The above is a 'level 0' header.",
|
||||
"The 'name' property is optional",
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "A Subclass Feature Name",
|
||||
"entries": [
|
||||
"The above is a 'level 1' header.",
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "A Subclass Feature Inline Header",
|
||||
"entries": [
|
||||
"This bold-italic inline header is a 'level 2' header, please use this as opposed to nesting tags. You can make a chain of empty entry objects with no names to get to this header level, if required.",
|
||||
"Note that the period in the inline name is added automagically."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "entries",
|
||||
"entries": [
|
||||
{
|
||||
"type": "entries",
|
||||
"entries": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "This is an example of a level 2 header with no real parents",
|
||||
"entries": [
|
||||
"This technique can be useful, sometimes."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"entries": [
|
||||
"Entries can be inlined, useful for e.g. links beyond the tags listed above, ",
|
||||
{
|
||||
"type": "link",
|
||||
"href": {
|
||||
"type": "internal",
|
||||
"path": "index.html"
|
||||
},
|
||||
"text": "such as a link to the homepage"
|
||||
},
|
||||
" but you can see why the tags are preferable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "inlineBlock",
|
||||
"entries": [
|
||||
"Similar to the above, but keeps the paragraph/etc tags around the inlined children, ",
|
||||
{
|
||||
"type": "link",
|
||||
"href": {
|
||||
"type": "internal",
|
||||
"path": "index.html"
|
||||
},
|
||||
"text": "such as a link to the homepage"
|
||||
},
|
||||
" so can easily be mixed with lines of text."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "options",
|
||||
"entries": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "Option B",
|
||||
"entries": [
|
||||
"Sometimes useful, e.g. for Fighting Styles on a class. (Note that this option is rendered second, when it's listed first in the data.)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "Option A",
|
||||
"entries": [
|
||||
"The 'options' type is very similar to a list of entries, but it alphabetically sorts the list of options by name, before displaying them."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"caption": "Optional Caption",
|
||||
"colLabels": [
|
||||
"Col 1",
|
||||
"Column the Second",
|
||||
"Third Col"
|
||||
],
|
||||
"colStyles": [
|
||||
"col-6 text-center",
|
||||
"col-4-5 text-right",
|
||||
"col-1-5"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
"This is a table row",
|
||||
"The number of entries should match the number of columns, naturally*",
|
||||
""
|
||||
],
|
||||
[
|
||||
"The 'colStyles' list is literally used as CSS classes",
|
||||
"Column widths can be specified in 12ths, as `col-[1 to 12]` (e.g. col-12 is 100% width, and col-6 is 50% width)",
|
||||
"**"
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"Nested entries (generally) work, too.",
|
||||
"Although layout may start to deteriorate with more exotic nesting."
|
||||
]
|
||||
},
|
||||
"{@spell bless||Spells, on the other hand, make sense.}",
|
||||
""
|
||||
],
|
||||
{
|
||||
"type": "row",
|
||||
"style": "row-indent-first",
|
||||
"row": [
|
||||
"Rows may also be defined",
|
||||
"As objects with additional properties",
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"footnotes": [
|
||||
"* Optional footer entries",
|
||||
"** As an extension to Bootstrap's 1-to-12 system, 1/10th subdivisions can be used, e.g. col-1-5 for 1.5/12. The total column widths should still sum to exactly 12; enjoy doing the math."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"caption": "Optional Caption",
|
||||
"colLabelGroups": [
|
||||
{
|
||||
"colLabels": [
|
||||
"Single"
|
||||
]
|
||||
},
|
||||
{
|
||||
"colLabels": [
|
||||
"Stacked Top",
|
||||
"Stacked Bottom"
|
||||
]
|
||||
},
|
||||
{}
|
||||
],
|
||||
"colStyles": [
|
||||
"col-4",
|
||||
"col-4",
|
||||
"col-4"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
"This table has multiple header rows stacked one atop the other",
|
||||
"Lorem ipsum",
|
||||
"This column has no labels"
|
||||
]
|
||||
]
|
||||
},
|
||||
"Rollable tables can be made like so (note the 'pad' to format single digit numbers e.g. '01' for tables with >10 rows):",
|
||||
{
|
||||
"type": "table",
|
||||
"caption": "Rollable Table Caption",
|
||||
"colLabels": [
|
||||
"{@dice d6}",
|
||||
"Item"
|
||||
],
|
||||
"colStyles": [
|
||||
"col-1 text-center",
|
||||
"col-11"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
{
|
||||
"type": "cell",
|
||||
"roll": {
|
||||
"exact": 1,
|
||||
"pad": true
|
||||
}
|
||||
},
|
||||
"First Item"
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "cell",
|
||||
"roll": {
|
||||
"exact": 2
|
||||
}
|
||||
},
|
||||
"Item the Second"
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "cell",
|
||||
"roll": {
|
||||
"min": 3,
|
||||
"max": 4
|
||||
}
|
||||
},
|
||||
"Three of Four Items"
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "cell",
|
||||
"roll": {
|
||||
"exact": 5
|
||||
}
|
||||
},
|
||||
"Item V: The Item Items Back"
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "cell",
|
||||
"roll": {
|
||||
"exact": 6
|
||||
}
|
||||
},
|
||||
"Item for Lucky Winners"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"A basic list of items",
|
||||
"As usual, nested entries work",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"Such as a nested list"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"columns": 3,
|
||||
"items": [
|
||||
"A list of items with wrapping columns",
|
||||
"Which will wrap",
|
||||
"Up to a maximum number of times as specified by the 'columns' field",
|
||||
"And will attempt to balance content between each column"
|
||||
]
|
||||
},
|
||||
"There's also:",
|
||||
{
|
||||
"type": "list",
|
||||
"style": "list-no-bullets",
|
||||
"items": [
|
||||
"A list of items without bullets",
|
||||
"Used very rarely"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"style": "list-hang",
|
||||
"name": "Hanging List",
|
||||
"items": [
|
||||
"A hanging items list",
|
||||
"Which has a 'name'/title, and will hang below that title"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"style": "list-hang",
|
||||
"name": "Hanging List",
|
||||
"items": [
|
||||
{
|
||||
"type": "item",
|
||||
"name": "An extension of the hanging list",
|
||||
"entry": "Which has bold inline titles -- note that this only allows {@i one} entry per item (it's 'entry' and not 'entries')"
|
||||
},
|
||||
"Can be mixed with regular strings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"style": "list-hang-notitle",
|
||||
"items": [
|
||||
{
|
||||
"type": "item",
|
||||
"name": "A similar story here",
|
||||
"entry": "The 'item' is an entry as above, but there's no 'name' for the entire list, so this has no left inset. Some long text to show what happens when the lines wrap around, it should continue but be indented after the first line. Some example of this in the source books can be found in Metalic Dragon breath weapons in the Monster Manual."
|
||||
},
|
||||
{
|
||||
"type": "itemSub",
|
||||
"name": "A sub-item used in some places",
|
||||
"entry": "For example, XGE's complex traps"
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Multiple entry version",
|
||||
"entries": [
|
||||
"This was, again, required specifically for XGE's complex traps, which can have multiple lines of text, often long enough to wrap into multiple rows in the same column. For example:",
|
||||
"Either portal can be neutralized with three successful DC 20 Intelligence ({@skill Arcana}) checks, but the process of analyzing a portal to disrupt it takes time. Faint runes in the ceiling and floor at both ends of the hallway are involved in the functioning of the portals. A creature must first use an action to examine a set of runes, then use a subsequent action to attempt to vandalize the runes. Each successful check reduces the sphere's damage by 11 ({@dice 2d10}), as the disrupted sphere loses speed moving through the failing portal.",
|
||||
"Alternatively, a set of runes can be disabled with three successful castings of {@spell dispel magic} (DC 19) targeting any of the runes in the set.",
|
||||
"If the southern portal is destroyed, the sphere slams into the south wall and comes to a halt. It blocks the door to the tomb, but the characters can escape."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"style": "list-decimal",
|
||||
"items": [
|
||||
"A decimal-numbered list. Alternatively, 'list-lower-roman' can be used for lowercase Roman numerals.",
|
||||
"So far only seen in homebrew."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"style": "list-upper-roman",
|
||||
"start": 5,
|
||||
"items": [
|
||||
"First list item",
|
||||
"Second list item"
|
||||
]
|
||||
},
|
||||
"The 'bonus' type just sticks a + in front of things",
|
||||
{
|
||||
"type": "bonus",
|
||||
"value": 3
|
||||
},
|
||||
"We don't use that one much. It's useful because it allows us to store the data as a number, and render it with a plus sign as required, but it's mostly just forgotten about.",
|
||||
"Similar story with 'bonusSpeed' type (these 'bonus' flavors are mainly used in the class tables)",
|
||||
{
|
||||
"type": "bonusSpeed",
|
||||
"value": 100
|
||||
},
|
||||
"The 'dice' type is rarely used, since the shorthand tag exists, but it looks like this:",
|
||||
{
|
||||
"type": "dice",
|
||||
"toRoll": [
|
||||
{
|
||||
"number": 1,
|
||||
"faces": 4
|
||||
},
|
||||
{
|
||||
"number": 2,
|
||||
"faces": 7,
|
||||
"modifier": 0
|
||||
},
|
||||
{
|
||||
"number": 3,
|
||||
"faces": 10,
|
||||
"modifier": 0,
|
||||
"hideModifier": true
|
||||
}
|
||||
],
|
||||
"rollable": true
|
||||
},
|
||||
"The 'abilityDc' type is more helpful:",
|
||||
{
|
||||
"type": "abilityDc",
|
||||
"name": "Buggy Code",
|
||||
"attributes": [
|
||||
"wis",
|
||||
"int"
|
||||
]
|
||||
},
|
||||
"As is the 'abilityAttackMod' type:",
|
||||
{
|
||||
"type": "abilityAttackMod",
|
||||
"name": "Killing Things",
|
||||
"attributes": [
|
||||
"str"
|
||||
]
|
||||
},
|
||||
"There's also a generic version that allows more text customisation; 'abilityGeneric':",
|
||||
{
|
||||
"type": "abilityGeneric",
|
||||
"name": "Initiative",
|
||||
"text": "10 - your power level + somebody else's",
|
||||
"attributes": [
|
||||
"dex",
|
||||
"str"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "abilityGeneric",
|
||||
"text": "leave out the 'name' and 'attributes' properties as required"
|
||||
},
|
||||
"External links using 'link':",
|
||||
{
|
||||
"type": "link",
|
||||
"href": {
|
||||
"type": "external",
|
||||
"url": "https://raw.githubusercontent.com/TheGiddyLimit/5etools-utils/master/schema/site/entry.json"
|
||||
},
|
||||
"text": "The full 'entry' schema."
|
||||
},
|
||||
"Internal links are also possible, but generally covered by @tags. Check out the schema linked above for the full usage; it's massive and horrible so I'm not going to go into detail here.",
|
||||
{
|
||||
"type": "inset",
|
||||
"name": "Inset Title (Optional)",
|
||||
"entries": [
|
||||
"This is a text inset/insert/sidebar/etc."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "insetReadaloud",
|
||||
"name": "Same As Above",
|
||||
"entries": [
|
||||
"But a different color."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "variant",
|
||||
"name": "Better Monster",
|
||||
"entries": [
|
||||
"Some variant monster text",
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "Inline Header",
|
||||
"entries": [
|
||||
"Text text text",
|
||||
{
|
||||
"type": "variantSub",
|
||||
"name": "These can have child items",
|
||||
"entries": [
|
||||
"And the header style for them is unique, so this exists to cope with that."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Images (similar to the structure of the 'link' type):",
|
||||
{
|
||||
"type": "image",
|
||||
"href": {
|
||||
"type": "internal",
|
||||
"path": "blank.webp"
|
||||
},
|
||||
"title": "Optional Title",
|
||||
"credit": "Optional credit"
|
||||
},
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "Data Embeds",
|
||||
"entries": [
|
||||
"Creatures; format based on Bestiary data, with added type (note that a proper Bestiary entry and a link to the entry is the preferred method of adding creatures, this functionality exists primarily for homebrew data):",
|
||||
{
|
||||
"type": "statblockInline",
|
||||
"style": "inset",
|
||||
"dataType": "monster",
|
||||
"data": {
|
||||
"name": "Unicorn",
|
||||
"size": [
|
||||
"L"
|
||||
],
|
||||
"type": "celestial",
|
||||
"source": "MM",
|
||||
"alignment": [
|
||||
"L",
|
||||
"G"
|
||||
],
|
||||
"ac": [
|
||||
12
|
||||
],
|
||||
"hp": {
|
||||
"average": 67,
|
||||
"formula": "9d10 + 18"
|
||||
},
|
||||
"speed": {
|
||||
"walk": 50
|
||||
},
|
||||
"str": 18,
|
||||
"dex": 14,
|
||||
"con": 15,
|
||||
"int": 11,
|
||||
"wis": 17,
|
||||
"cha": 16,
|
||||
"immune": [
|
||||
"poison"
|
||||
],
|
||||
"conditionImmune": [
|
||||
"charmed",
|
||||
"paralyzed",
|
||||
"poisoned"
|
||||
],
|
||||
"senses": [
|
||||
"darkvision 60 ft."
|
||||
],
|
||||
"passive": 13,
|
||||
"languages": [
|
||||
"Celestial",
|
||||
"Elvish",
|
||||
"Sylvan",
|
||||
"telepathy 60 ft."
|
||||
],
|
||||
"cr": "5",
|
||||
"trait": [
|
||||
{
|
||||
"name": "Charge",
|
||||
"entries": [
|
||||
"If the unicorn moves at least 20 ft. straight toward a target and then hits it with a horn attack on the same turn, the target takes an extra 9 ({@dice 2d8}) piercing damage. If the target is a creature, it must succeed on a {@dc 15} Strength saving throw or be knocked prone."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Magic Resistance",
|
||||
"entries": [
|
||||
"The unicorn has advantage on saving throws against spells and other magical effects."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Magic Weapons",
|
||||
"entries": [
|
||||
"The unicorn's weapon attacks are magical."
|
||||
]
|
||||
}
|
||||
],
|
||||
"action": [
|
||||
{
|
||||
"name": "Multiattack",
|
||||
"entries": [
|
||||
"The unicorn makes two attacks: one with its hooves and one with its horn."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Hooves",
|
||||
"entries": [
|
||||
"{@atk mw} {@hit 7} to hit, reach 5 ft., one target. Hit: 11 ({@damage 2d6 + 4}) bludgeoning damage."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Horn",
|
||||
"entries": [
|
||||
"{@atk mw} {@hit 7} to hit, reach 5 ft., one target. Hit: 8 ({@damage 1d8 + 4}) piercing damage."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Healing Touch (3/Day)",
|
||||
"entries": [
|
||||
"The unicorn touches another creature with its horn. The target magically regains 11 ({@dice 2d8 + 2}) hit points. In addition, the touch removes all diseases and neutralizes all poisons afflicting the target."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Teleport (1/Day)",
|
||||
"entries": [
|
||||
"The unicorn magically teleports itself and up to three willing creatures it can see within 5 feet of it, along with any equipment they are wearing or carrying, to a location the unicorn is familiar with, up to 1 mile away."
|
||||
]
|
||||
}
|
||||
],
|
||||
"legendaryGroup": {
|
||||
"name": "Unicorn",
|
||||
"source": "MM"
|
||||
},
|
||||
"legendary": [
|
||||
{
|
||||
"name": "Hooves",
|
||||
"entries": [
|
||||
"The unicorn makes one attack with its hooves."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Shimmering Shield (Costs 2 Actions)",
|
||||
"entries": [
|
||||
"The unicorn creates a shimmering, magical field around itself or another creature it can see within 60 feet of it. The target gains a +2 bonus to AC until the end of the unicorn's next turn."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Heal Self (Costs 3 Actions)",
|
||||
"entries": [
|
||||
"The unicorn magically regains 11 ({@dice 2d8 + 2}) hit points."
|
||||
]
|
||||
}
|
||||
],
|
||||
"page": 294,
|
||||
"spellcasting": [
|
||||
{
|
||||
"name": "Innate Spellcasting",
|
||||
"type": "spellcasting",
|
||||
"headerEntries": [
|
||||
"The unicorn's innate spellcasting ability is Charisma (spell save {@dc 14}). The unicorn can innately cast the following spells, requiring no components:"
|
||||
],
|
||||
"will": [
|
||||
"{@spell detect evil and good}",
|
||||
"{@spell druidcraft}",
|
||||
"{@spell pass without trace}"
|
||||
],
|
||||
"daily": {
|
||||
"1e": [
|
||||
"{@spell calm emotions}",
|
||||
"{@spell dispel evil and good}",
|
||||
"{@spell entangle}"
|
||||
]
|
||||
},
|
||||
"ability": "cha"
|
||||
}
|
||||
],
|
||||
"environment": [
|
||||
"forest"
|
||||
],
|
||||
"soundClip": {
|
||||
"type": "internal",
|
||||
"path": "bestiary/unicorn.mp3"
|
||||
},
|
||||
"traitTags": [
|
||||
"Charge",
|
||||
"Magic Resistance",
|
||||
"Magic Weapons"
|
||||
],
|
||||
"actionTags": [
|
||||
"Multiattack"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Spells; as above:",
|
||||
{
|
||||
"type": "statblockInline",
|
||||
"collapsed": true,
|
||||
"dataType": "spell",
|
||||
"data": {
|
||||
"name": "Fireball",
|
||||
"level": 3,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 150
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a tiny ball of bat guano and sulfur"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"source": "PHB",
|
||||
"entries": [
|
||||
"A bright streak flashes from your pointing finger to a point you choose within range and then blossoms with a low roar into an explosion of flame. Each creature in a 20-foot-radius sphere centered on that point must make a Dexterity saving throw. A target takes {@dice 8d6} fire damage on a failed save, or half as much damage on a successful one.",
|
||||
"The fire spreads around corners. It ignites flammable objects in the area that aren't being worn or carried."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 4th level or higher, the damage increases by {@scaledice 8d6|3-9|1d6} for each slot level above 3rd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"page": 241
|
||||
}
|
||||
},
|
||||
"Using \"dependencies\" and \"_copy\" to copy an existing piece of content:",
|
||||
{
|
||||
"type": "statblockInline",
|
||||
"dataType": "monster",
|
||||
"dependencies": [
|
||||
"MM"
|
||||
],
|
||||
"data": {
|
||||
"name": "Ancient Red Dragon (Weakened)",
|
||||
"source": "MM",
|
||||
"_copy": {
|
||||
"name": "Ancient Red Dragon",
|
||||
"source": "MM"
|
||||
},
|
||||
"str": 3,
|
||||
"con": 3
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "Reference Embeds",
|
||||
"entries": [
|
||||
{
|
||||
"type": "statblock",
|
||||
"tag": "creature",
|
||||
"name": "Goblin",
|
||||
"style": "inset"
|
||||
},
|
||||
{
|
||||
"type": "statblock",
|
||||
"prop": "subclass",
|
||||
"source": "XPHB",
|
||||
"name": "Wild Magic",
|
||||
"displayName": "Wild Magic Sorcerer",
|
||||
"className": "Sorcerer",
|
||||
"classSource": "XPHB",
|
||||
"collapsed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
130
src/ttfrog/five_e_tools/sources/senses.json
Normal file
130
src/ttfrog/five_e_tools/sources/senses.json
Normal file
|
@ -0,0 +1,130 @@
|
|||
{
|
||||
"sense": [
|
||||
{
|
||||
"name": "Blindsight",
|
||||
"source": "PHB",
|
||||
"page": 183,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Blindsight|XPHB"
|
||||
],
|
||||
"entries": [
|
||||
"A creature with blindsight can perceive its surroundings without relying on sight, within a specific radius. Creatures without eyes, such as oozes, and creatures with echolocation or heightened senses, such as bats and true dragons, have this sense."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Blindsight",
|
||||
"source": "XPHB",
|
||||
"page": 361,
|
||||
"entries": [
|
||||
"If you have Blindsight, you can see within a specific range without relying on physical sight. Within that range, you can see anything that isn't behind {@variantrule Cover|XPHB|Total Cover} even if you have the {@condition Blinded|XPHB} condition or are in {@variantrule Darkness|XPHB}. Moreover, in that range, you can see something that has the {@condition Invisible|XPHB} condition."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Darkvision",
|
||||
"source": "PHB",
|
||||
"page": 183,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Darkvision|XPHB"
|
||||
],
|
||||
"entries": [
|
||||
"Many creatures in fantasy gaming worlds, especially those that dwell underground, have darkvision. Within a specified range, a creature with darkvision can see in dim light as if it were bright light and in darkness as if it were dim light, so areas of darkness are only lightly obscured as far as that creature is concerned. However, the creature can't discern color in that darkness, only shades of gray."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Darkvision",
|
||||
"source": "XPHB",
|
||||
"page": 365,
|
||||
"entries": [
|
||||
"If you have Darkvision, you can see in {@variantrule Dim Light|XPHB} within a specified range as if it were {@variantrule Bright Light|XPHB} and in {@variantrule Darkness|XPHB} within that range as if it were {@variantrule Dim Light|XPHB}. You discern colors in that {@variantrule Darkness|XPHB} only as shades of gray."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Tremorsense",
|
||||
"source": "MM",
|
||||
"page": 9,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Tremorsense|XPHB"
|
||||
],
|
||||
"entries": [
|
||||
"A creature with tremorsense can detect and pinpoint the origin of vibrations within a specific radius, provided that the creature and the source of the vibrations are in contact with the same ground or substance. Tremorsense can't be used to detect flying or incorporeal creatures. Many burrowing creatures, such as ankhegs and umber hulks, have this special sense."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Tremorsense",
|
||||
"source": "XPHB",
|
||||
"page": 377,
|
||||
"entries": [
|
||||
"A creature with Tremorsense can pinpoint the location of creatures and moving objects within a specific range, provided that the creature with Tremorsense and anything it is detecting are both in contact with the same surface (such as the ground, a wall, or a ceiling) or the same liquid.",
|
||||
"Tremorsense can't detect creatures or objects in the air, and it doesn't count as a form of sight."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Truesight",
|
||||
"source": "PHB",
|
||||
"page": 183,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Truesight|XPHB"
|
||||
],
|
||||
"entries": [
|
||||
"A creature with truesight can, out to a specific range, see in normal and magical darkness, see invisible creatures and objects, automatically detect visual illusions and succeed on saving throws against them, and perceives the original form of a shapechanger or a creature that is transformed by magic. Furthermore, the creature can see into the Ethereal Plane."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Truesight",
|
||||
"source": "XPHB",
|
||||
"page": 377,
|
||||
"entries": [
|
||||
"If you have Truesight, your vision is enhanced within a specified range. Within that range, your vision pierces through the following:",
|
||||
{
|
||||
"type": "list",
|
||||
"style": "list-hang-notitle",
|
||||
"items": [
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Darkness",
|
||||
"entries": [
|
||||
"You can see in normal and magical {@variantrule Darkness|XPHB}."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Invisibility",
|
||||
"entries": [
|
||||
"You see creatures and objects that have the {@condition Invisible|XPHB} condition."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Visual Illusions",
|
||||
"entries": [
|
||||
"Visual illusions appear transparent to you, and you automatically succeed on {@variantrule Saving Throw|XPHB|saving throws} against them."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Transformations",
|
||||
"entries": [
|
||||
"You discern the true form of any creature or object you see that has been transformed by magic."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Ethereal Plane",
|
||||
"entries": [
|
||||
"You see into the Ethereal Plane."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
427
src/ttfrog/five_e_tools/sources/skills.json
Normal file
427
src/ttfrog/five_e_tools/sources/skills.json
Normal file
|
@ -0,0 +1,427 @@
|
|||
{
|
||||
"skill": [
|
||||
{
|
||||
"name": "Acrobatics",
|
||||
"source": "PHB",
|
||||
"page": 176,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Acrobatics|XPHB"
|
||||
],
|
||||
"ability": "dex",
|
||||
"entries": [
|
||||
"Your Dexterity (Acrobatics) check covers your attempt to stay on your feet in a tricky situation, such as when you're trying to run across a sheet of ice, balance on a tightrope, or stay upright on a rocking ship's deck. The DM might also call for a Dexterity (Acrobatics) check to see if you can perform acrobatic stunts, including dives, rolls, somersaults, and flips."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Acrobatics",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "dex",
|
||||
"entries": [
|
||||
"Stay on your feet in a tricky situation, or perform an acrobatic stunt."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Animal Handling",
|
||||
"source": "PHB",
|
||||
"page": 178,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Animal Handling|XPHB"
|
||||
],
|
||||
"ability": "wis",
|
||||
"entries": [
|
||||
"When there is any question whether you can calm down a domesticated animal, keep a mount from getting spooked, or intuit an animal's intentions, the DM might call for a Wisdom (Animal Handling) check. You also make a Wisdom (Animal Handling) check to control your mount when you attempt a risky maneuver."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Animal Handling",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "wis",
|
||||
"entries": [
|
||||
"Calm or train an animal, or get an animal to behave in a certain way."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Arcana",
|
||||
"source": "PHB",
|
||||
"page": 177,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Arcana|XPHB"
|
||||
],
|
||||
"ability": "int",
|
||||
"entries": [
|
||||
"Your Intelligence (Arcana) check measures your ability to recall lore about spells, magic items, eldritch symbols, magical traditions, the planes of existence, and the inhabitants of those planes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Arcana",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "int",
|
||||
"entries": [
|
||||
"Recall lore about spells, magic items, and the planes of existence."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Athletics",
|
||||
"source": "PHB",
|
||||
"page": 175,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Athletics|XPHB"
|
||||
],
|
||||
"ability": "str",
|
||||
"entries": [
|
||||
"Your Strength (Athletics) check covers difficult situations you encounter while climbing, jumping, or swimming. Examples include the following activities:",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"You attempt to climb a sheer or slippery cliff, avoid hazards while scaling a wall, or cling to a surface while something is trying to knock you off.",
|
||||
"You try to jump an unusually long distance or pull off a stunt mid jump.",
|
||||
"You struggle to swim or stay afloat in treacherous currents, storm-tossed waves, or areas of thick seaweed. Or another creature tries to push or pull you underwater or otherwise interfere with your swimming."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Athletics",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "str",
|
||||
"entries": [
|
||||
"Jump farther than normal, stay afloat in rough water, or break something."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Deception",
|
||||
"source": "PHB",
|
||||
"page": 178,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Deception|XPHB"
|
||||
],
|
||||
"ability": "cha",
|
||||
"entries": [
|
||||
"Your Charisma (Deception) check determines whether you can convincingly hide the truth, either verbally or through your actions. This deception can encompass everything from misleading others through ambiguity to telling outright lies. Typical situations include trying to fast-talk a guard, con a merchant, earn money through gambling, pass yourself off in a disguise, dull someone's suspicions with false assurances, or maintain a straight face while telling a blatant lie."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Deception",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "cha",
|
||||
"entries": [
|
||||
"Tell a convincing lie, or wear a disguise convincingly."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "History",
|
||||
"source": "PHB",
|
||||
"page": 177,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"History|XPHB"
|
||||
],
|
||||
"ability": "int",
|
||||
"entries": [
|
||||
"Your Intelligence (History) check measures your ability to recall lore about historical events, legendary people, ancient kingdoms, past disputes, recent wars, and lost civilizations."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "History",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "int",
|
||||
"entries": [
|
||||
"Recall lore about historical events, people, nations, and cultures."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Insight",
|
||||
"source": "PHB",
|
||||
"page": 178,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Insight|XPHB"
|
||||
],
|
||||
"ability": "wis",
|
||||
"entries": [
|
||||
"Your Wisdom (Insight) check decides whether you can determine the true intentions of a creature, such as when searching out a lie or predicting someone's next move. Doing so involves gleaning clues from body language, speech habits, and changes in mannerisms."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Insight",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "wis",
|
||||
"entries": [
|
||||
"Discern a person's mood and intentions."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Intimidation",
|
||||
"source": "PHB",
|
||||
"page": 179,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Intimidation|XPHB"
|
||||
],
|
||||
"ability": "cha",
|
||||
"entries": [
|
||||
"When you attempt to influence someone through overt threats, hostile actions, and physical violence, the DM might ask you to make a Charisma (Intimidation) check. Examples include trying to pry information out of a prisoner, convincing street thugs to back down from a confrontation, or using the edge of a broken bottle to convince a sneering vizier to reconsider a decision."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Intimidation",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "cha",
|
||||
"entries": [
|
||||
"Awe or threaten someone into doing what you want."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Investigation",
|
||||
"source": "PHB",
|
||||
"page": 178,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Investigation|XPHB"
|
||||
],
|
||||
"ability": "int",
|
||||
"entries": [
|
||||
"When you look around for clues and make deductions based on those clues, you make an Intelligence (Investigation) check. You might deduce the location of a hidden object, discern from the appearance of a wound what kind of weapon dealt it, or determine the weakest point in a tunnel that could cause it to collapse. Poring through ancient scrolls in search of a hidden fragment of knowledge might also call for an Intelligence (Investigation) check."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Investigation",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "int",
|
||||
"entries": [
|
||||
"Find obscure information in books, or deduce how something works."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Medicine",
|
||||
"source": "PHB",
|
||||
"page": 178,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Medicine|XPHB"
|
||||
],
|
||||
"ability": "wis",
|
||||
"entries": [
|
||||
"A Wisdom (Medicine) check lets you try to stabilize a dying companion or diagnose an illness."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Medicine",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "wis",
|
||||
"entries": [
|
||||
"Diagnose an illness, or determine what killed the recently slain."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Nature",
|
||||
"source": "PHB",
|
||||
"page": 178,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Nature|XPHB"
|
||||
],
|
||||
"ability": "int",
|
||||
"entries": [
|
||||
"Your Intelligence (Nature) check measures your ability to recall lore about terrain, plants and animals, the weather, and natural cycles."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Nature",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "int",
|
||||
"entries": [
|
||||
"Recall lore about terrain, plants, animals, and weather."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Perception",
|
||||
"source": "PHB",
|
||||
"page": 178,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Perception|XPHB"
|
||||
],
|
||||
"ability": "wis",
|
||||
"entries": [
|
||||
"Your Wisdom (Perception) check lets you spot, hear, or otherwise detect the presence of something. It measures your general awareness of your surroundings and the keenness of your senses.",
|
||||
"For example, you might try to hear a conversation through a closed door, eavesdrop under an open window, or hear monsters moving stealthily in the forest. Or you might try to spot things that are obscured or easy to miss, whether they are orcs lying in ambush on a road, thugs hiding in the shadows of an alley, or candlelight under a closed secret door."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Perception",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "wis",
|
||||
"entries": [
|
||||
"Using a combination of senses, notice something that's easy to miss."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Performance",
|
||||
"source": "PHB",
|
||||
"page": 179,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Performance|XPHB"
|
||||
],
|
||||
"ability": "cha",
|
||||
"entries": [
|
||||
"Your Charisma (Performance) check determines how well you can delight an audience with music, dance, acting, storytelling, or some other form of entertainment."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Performance",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "cha",
|
||||
"entries": [
|
||||
"Act, tell a story, perform music, or dance."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Persuasion",
|
||||
"source": "PHB",
|
||||
"page": 179,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Persuasion|XPHB"
|
||||
],
|
||||
"ability": "cha",
|
||||
"entries": [
|
||||
"When you attempt to influence someone or a group of people with tact, social graces, or good nature, the DM might ask you to make a Charisma (Persuasion) check. Typically, you use persuasion when acting in good faith, to foster friendships, make cordial requests, or exhibit proper etiquette. Examples of persuading others include convincing a chamberlain to let your party see the king, negotiating peace between warring tribes, or inspiring a crowd of townsfolk."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Persuasion",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "cha",
|
||||
"entries": [
|
||||
"Honestly and graciously convince someone of something."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Religion",
|
||||
"source": "PHB",
|
||||
"page": 178,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Religion|XPHB"
|
||||
],
|
||||
"ability": "int",
|
||||
"entries": [
|
||||
"Your Intelligence (Religion) check measures your ability to recall lore about deities, rites and prayers, religious hierarchies, holy symbols, and the practices of secret cults."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Religion",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "int",
|
||||
"entries": [
|
||||
"Recall lore about gods, religious rituals, and holy symbols."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Sleight of Hand",
|
||||
"source": "PHB",
|
||||
"page": 177,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Sleight of Hand|XPHB"
|
||||
],
|
||||
"ability": "dex",
|
||||
"entries": [
|
||||
"Whenever you attempt an act of legerdemain or manual trickery, such as planting something on someone else or concealing an object on your person, make a Dexterity (Sleight of Hand) check. The DM might also call for a Dexterity (Sleight of Hand) check to determine whether you can lift a coin purse off another person or slip something out of another person's pocket."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Sleight of Hand",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "dex",
|
||||
"entries": [
|
||||
"Pick a pocket, conceal a handheld object, or perform legerdemain."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Stealth",
|
||||
"source": "PHB",
|
||||
"page": 177,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Stealth|XPHB"
|
||||
],
|
||||
"ability": "dex",
|
||||
"entries": [
|
||||
"Make a Dexterity (Stealth) check when you attempt to conceal yourself from enemies, slink past guards, slip away without being noticed, or sneak up on someone without being seen or heard."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Stealth",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "dex",
|
||||
"entries": [
|
||||
"Escape notice by moving quietly and hiding behind things."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Survival",
|
||||
"source": "PHB",
|
||||
"page": 178,
|
||||
"srd": true,
|
||||
"basicRules": true,
|
||||
"reprintedAs": [
|
||||
"Survival|XPHB"
|
||||
],
|
||||
"ability": "wis",
|
||||
"entries": [
|
||||
"The DM might ask you to make a Wisdom (Survival) check to follow tracks, hunt wild game, guide your group through frozen wastelands, identify signs that owlbears live nearby, predict the weather, or avoid quicksand and other natural hazards."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Survival",
|
||||
"source": "XPHB",
|
||||
"page": 14,
|
||||
"ability": "wis",
|
||||
"entries": [
|
||||
"Follow tracks, forage, find a trail, or avoid natural hazards."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
21
src/ttfrog/five_e_tools/sources/spells/index.json
Normal file
21
src/ttfrog/five_e_tools/sources/spells/index.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"AAG": "spells-aag.json",
|
||||
"AI": "spells-ai.json",
|
||||
"AitFR-AVT": "spells-aitfr-avt.json",
|
||||
"BMT": "spells-bmt.json",
|
||||
"DoDk": "spells-dodk.json",
|
||||
"EGW": "spells-egw.json",
|
||||
"FTD": "spells-ftd.json",
|
||||
"GGR": "spells-ggr.json",
|
||||
"GHLoE": "spells-ghloe.json",
|
||||
"HWCS": "spells-hwcs.json",
|
||||
"IDRotF": "spells-idrotf.json",
|
||||
"LLK": "spells-llk.json",
|
||||
"PHB": "spells-phb.json",
|
||||
"SatO": "spells-sato.json",
|
||||
"SCC": "spells-scc.json",
|
||||
"TCE": "spells-tce.json",
|
||||
"TDCSR": "spells-tdcsr.json",
|
||||
"XGE": "spells-xge.json",
|
||||
"XPHB": "spells-xphb.json"
|
||||
}
|
16338
src/ttfrog/five_e_tools/sources/spells/sources.json
Normal file
16338
src/ttfrog/five_e_tools/sources/spells/sources.json
Normal file
File diff suppressed because it is too large
Load Diff
88
src/ttfrog/five_e_tools/sources/spells/spells-aag.json
Normal file
88
src/ttfrog/five_e_tools/sources/spells/spells-aag.json
Normal file
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Air Bubble",
|
||||
"source": "AAG",
|
||||
"page": 22,
|
||||
"level": 2,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 24
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You create a spectral globe around the head of a willing creature you can see within range. The globe is filled with fresh air that lasts until the spell ends. If the creature has more than one head, the globe of air appears around only one of its heads (which is all the creature needs to avoid suffocation, assuming that all its heads share the same respiratory system)."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 3rd level or higher, you can create two additional globes of fresh air for each slot level above 2nd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Create Spelljamming Helm",
|
||||
"source": "AAG",
|
||||
"page": 22,
|
||||
"level": 5,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a crystal rod worth at least 5,000 gp, which the spell consumes",
|
||||
"cost": 500000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"Holding the rod used in the casting of the spell, you touch a Large or smaller chair that is unoccupied. The rod disappears, and the chair is transformed into a {@item spelljamming helm|AAG}."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
379
src/ttfrog/five_e_tools/sources/spells/spells-ai.json
Normal file
379
src/ttfrog/five_e_tools/sources/spells/spells-ai.json
Normal file
|
@ -0,0 +1,379 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Distort Value",
|
||||
"source": "AI",
|
||||
"page": 75,
|
||||
"level": 1,
|
||||
"school": "I",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "minute"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"Do you need to squeeze a few more gold pieces out of a merchant as you try to sell that weird octopus statue you liberated from the chaos temple? Do you need to downplay the worth of some magical assets when the tax collector stops by? Distort value has you covered.",
|
||||
"You cast this spell on an object no more than 1 foot on a side, doubling the object's perceived value by adding illusory flourishes or polish to it, or reducing its perceived value by half with the help of illusory scratches, dents, and other unsightly features. Anyone examining the object can ascertain its true value with a successful Intelligence ({@skill Investigation}) check against your spell save DC."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 2nd level or higher, the maximum size of the object increases by 1 foot for each slot level above 1st."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Fast Friends",
|
||||
"source": "AI",
|
||||
"page": 75,
|
||||
"level": 3,
|
||||
"school": "E",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 30
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you need to make sure something gets done, you can't rely on vague promises, sworn oaths, or binding contracts of employment. When you cast this spell, choose one humanoid within range that can see and hear you, and that can understand you. The creature must succeed on a Wisdom saving throw or become {@condition charmed} by you for the duration. While the creature is {@condition charmed} in this way, it undertakes to perform any services or activities you ask of it in a friendly manner, to the best of its ability.",
|
||||
"You can set the creature new tasks when a previous task is completed, or if you decide to end its current task. If the service or activity might cause harm to the creature, or if it conflicts with the creature's normal activities and desires, the creature can make another Wisdom saving throw to try to end the effect. This save is made with advantage if you or your companions are fighting the creature. If the activity would result in certain death for the creature, the spell ends.",
|
||||
"When the spell ends, the creature knows it was {@condition charmed} by you."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 4th level or higher, you can target one additional creature for each slot level above 3rd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"conditionInflict": [
|
||||
"charmed"
|
||||
],
|
||||
"savingThrow": [
|
||||
"wisdom"
|
||||
],
|
||||
"affectsCreatureType": [
|
||||
"humanoid"
|
||||
],
|
||||
"miscTags": [
|
||||
"SCT",
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"MT",
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Gift of Gab",
|
||||
"source": "AI",
|
||||
"page": 76,
|
||||
"level": 2,
|
||||
"school": "E",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "reaction",
|
||||
"condition": "which you take when you speak to another creature"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"r": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
{
|
||||
"type": "quote",
|
||||
"entries": [
|
||||
"When I met Jim Darkmagic, I wondered how he got anything done in that outfit. I have since learned that most of his talents involve standing and talking. His outfit is perfect for that."
|
||||
],
|
||||
"by": "Môrgæn"
|
||||
},
|
||||
"Jim Darkmagic is said to have invented this spell, originally calling it {@i I said what?!} Have you ever been talking to the local monarch and accidentally mentioned how their son looks like your favorite hog from when you were growing up on the family farm? We've all been there! But rather than being beheaded for an honest slip of the tongue, you can pretend it never happened\u2014by ensuring that no one knows it happened.",
|
||||
"When you cast this spell, you skillfully reshape the memories of listeners in your immediate area, so that each creature of your choice within 5 feet of you forgets everything you said within the last 6 seconds. Those creatures then remember that you actually said the words you speak as the verbal component of the spell."
|
||||
],
|
||||
"areaTags": [
|
||||
"MT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Incite Greed",
|
||||
"source": "AI",
|
||||
"page": 76,
|
||||
"level": 3,
|
||||
"school": "E",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 30
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a gem worth at least 50 gp",
|
||||
"cost": 5000
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you cast this spell, you present the gem used as the material component and choose any number of creatures within range that can see you. Each target must succeed on a Wisdom saving throw or be {@condition charmed} by you until the spell ends, or until you or your companions do anything harmful to it. While {@condition charmed} in this way, a creature can do nothing but use its movement to approach you in a safe manner. While an affected creature is within 5 feet of you, it cannot move, but simply stares greedily at the gem you present.",
|
||||
"At the end of each of its turns, an affected target can make a Wisdom saving throw. If it succeeds, this effect ends for that target."
|
||||
],
|
||||
"conditionInflict": [
|
||||
"charmed"
|
||||
],
|
||||
"savingThrow": [
|
||||
"wisdom"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"MT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Jim's Glowing Coin",
|
||||
"source": "AI",
|
||||
"page": 76,
|
||||
"level": 2,
|
||||
"school": "E",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"s": true,
|
||||
"m": "a coin",
|
||||
"r": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"Of the many tactics employed by master magician and renowned adventurer Jim Darkmagic, the old glowing coin trick is a time-honored classic. When you cast the spell, you hurl the coin that is the spell's material component to any spot within range. The coin lights up as if under the effect of a {@spell light} spell. Each creature of your choice that you can see within 30 feet of the coin must succeed on a Wisdom saving throw or be distracted for the duration. While distracted, a creature has disadvantage on Wisdom ({@skill Perception}) checks and initiative rolls."
|
||||
],
|
||||
"savingThrow": [
|
||||
"wisdom"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"MT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Jim's Magic Missile",
|
||||
"source": "AI",
|
||||
"page": 76,
|
||||
"level": 1,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 120
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"r": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
{
|
||||
"type": "quote",
|
||||
"entries": [
|
||||
"Jim's magic missile is an ancient and powerful spell, as well as being the name of my band in Wizard Academy."
|
||||
],
|
||||
"by": "Jim Darkmagic"
|
||||
},
|
||||
"Any apprentice wizard can cast a boring old magic missile. Sure, it always strikes its target. Yawn. Do away with the drudgery of your grandfather's magic with this improved version of the spell, as used by Jim Darkmagic!",
|
||||
"You create three twisting, whistling, hypoallergenic, gluten-free darts of magical force. Each dart targets a creature of your choice that you can see within range. Make a ranged spell attack for each missile. On a hit, a missile deals {@damage 2d4} force damage to its target.",
|
||||
"If the attack roll scores a critical hit, the target of that missile takes {@damage 5d4} force damage instead of you rolling damage twice for a critical hit. If the attack roll for any missile is a 1, all missiles miss their targets and blow up in your face, dealing 1 force damage per missile to you."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 2nd level or higher, the spell creates one more dart, and the royalty component increases by 1 gp, for each slot level above 1st."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"force"
|
||||
],
|
||||
"spellAttack": [
|
||||
"R"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"MT",
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Motivational Speech",
|
||||
"source": "AI",
|
||||
"page": 77,
|
||||
"level": 3,
|
||||
"school": "E",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "minute"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
{
|
||||
"type": "quote",
|
||||
"entries": [
|
||||
"I once heard a motivational speech by Jim and it was the worst ninety seconds of my life. What does Omin see in him, anyway?"
|
||||
],
|
||||
"by": "Walnut Dankgrass"
|
||||
},
|
||||
"You address allies, staff, or innocent bystanders to exhort and inspire them to greatness, whether they have anything to get excited about or not. Choose up to five creatures within range that can hear you. For the duration, each affected creature gains 5 temporary hit points and has advantage on Wisdom saving throws. If an affected creature is hit by an attack, it has advantage on the next attack roll it makes. Once an affected creature loses the temporary hit points granted by this spell, the spell ends for that creature."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 4th level or higher, the temporary hit points increase by 5 for each slot level above 3rd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"miscTags": [
|
||||
"ADV",
|
||||
"SGT",
|
||||
"THP"
|
||||
],
|
||||
"areaTags": [
|
||||
"MT"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
83
src/ttfrog/five_e_tools/sources/spells/spells-aitfr-avt.json
Normal file
83
src/ttfrog/five_e_tools/sources/spells/spells-aitfr-avt.json
Normal file
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Linked Glyphs",
|
||||
"source": "AitFR-AVT",
|
||||
"page": 9,
|
||||
"otherSources": [
|
||||
{
|
||||
"source": "AitFR-FCD",
|
||||
"page": 11
|
||||
}
|
||||
],
|
||||
"level": 3,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "hour"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "incense and powdered diamond worth at least 300 gp, which the spell consumes",
|
||||
"cost": 30000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "permanent",
|
||||
"ends": [
|
||||
"dispel",
|
||||
"trigger"
|
||||
]
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you cast this spell, you inscribe a detection glyph that later activates a magical effect at the site of a separate, linked glyph. You inscribe the detection glyph either on a surface (such as a table or a section of floor or wall) or within an object that can be closed (such as a book, a scroll, or a treasure chest) to conceal the glyph. The glyph can cover an area no larger than 10 feet in diameter. If the surface or object is moved more than 10 feet from where you cast this spell, the detection glyph is broken, and the spell ends without being triggered.",
|
||||
"Both glyphs are nearly invisible, and finding either requires a successful Intelligence ({@skill Investigation}) check against your spell save DC.",
|
||||
"You decide what triggers the detection glyph when you cast this spell. For glyphs inscribed on a surface, the most typical triggers include touching or standing on the glyph, removing another object covering the glyph, approaching within a certain distance of the glyph, or manipulating the object on which the glyph is inscribed. For glyphs inscribed within an object, the most common triggers include opening that object, approaching within a certain distance of the object, or seeing or reading the glyph. Once a glyph is triggered, this spell ends.",
|
||||
"You can further refine the trigger so the spell activates only under certain circumstances or according to physical characteristics (such as height or weight), creature kind (for example, the ward could be set to affect aberrations or drow), or alignment. You can also set conditions for creatures that don't trigger the glyph, such as those who say a certain password.",
|
||||
"When you inscribe the detection glyph, choose an alarm glyph or spell glyph to link to it.",
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "Alarm Glyph",
|
||||
"entries": [
|
||||
"You create and magically link two glyphs: a detection glyph and an effect glyph. Each of these two glyphs must stay within 100 miles of the other or the spell effect ends. When the detection glyph is triggered, the effect glyph reacts like an alarm spell, creating a mental ping in your mind if you are within 1 mile of the effect glyph. This ping awakens you if you are sleeping."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "Spell Glyph",
|
||||
"entries": [
|
||||
"You create and magically link two glyphs: a detection glyph and an effect glyph. These two glyphs must be within 100 feet of each other. You can store a prepared spell of 4th level or lower in the effect glyph by casting it as part of creating the glyphs. The spell must target a single creature or an area. The spell being stored has no immediate effect when cast in this way. When the detection glyph is triggered, the spell stored in the effect glyph is cast. If the spell has a target, it targets the creature that triggered the glyph. If the spell affects an area, the area is centered on the effect glyph. If the spell summons hostile creatures or creates harmful objects or traps, they appear as close as possible to the effect glyph. If the spell requires {@status concentration}, it lasts until the end of its full duration."
|
||||
]
|
||||
}
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 5th level or higher to create a spell glyph, you can store any spell of up to the same level as the slot you use."
|
||||
]
|
||||
}
|
||||
],
|
||||
"conditionInflict": [
|
||||
"invisible"
|
||||
],
|
||||
"miscTags": [
|
||||
"PRM"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
161
src/ttfrog/five_e_tools/sources/spells/spells-bmt.json
Normal file
161
src/ttfrog/five_e_tools/sources/spells/spells-bmt.json
Normal file
|
@ -0,0 +1,161 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Antagonize",
|
||||
"source": "BMT",
|
||||
"page": 50,
|
||||
"level": 3,
|
||||
"school": "E",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 30
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a playing card depicting a rogue"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You whisper magical words that antagonize one creature of your choice within range. The target must make a Wisdom saving throw. On a failed save, the target takes {@damage 4d4} psychic damage and must immediately use its reaction to make a melee attack against another creature of your choice that you can see. If the target can't make this attack (for example, because there is no one within its reach or because its reaction is unavailable), the target instead has disadvantage on the next attack roll it makes before the start of your next turn. On a successful save, the target takes half as much damage only."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 4th level or higher, the damage increases by {@dice 1d4} for each slot level above 3rd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"psychic"
|
||||
],
|
||||
"savingThrow": [
|
||||
"wisdom"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Spirit of Death",
|
||||
"source": "BMT",
|
||||
"page": 50,
|
||||
"level": 4,
|
||||
"school": "N",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a gilded playing card worth at least 400 gp and depicting an avatar of death",
|
||||
"cost": 40000
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You call forth a spirit that embodies death. The spirit manifests in an unoccupied space you can see within range and uses the {@creature reaper spirit|BMT} stat block. The spirit disappears when it is reduced to 0 hit points or when the spell ends.",
|
||||
"The spirit is an ally to you and your companions. In combat, the spirit shares your initiative count and takes its turn immediately after yours. It obeys your verbal commands (no action required by you). If you don't issue the spirit any commands, it takes the {@action Dodge} action and uses its movement to avoid danger."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 5th level or higher, use the higher level wherever the spell's level appears in the reaper spirit stat block."
|
||||
]
|
||||
}
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT",
|
||||
"SMN"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Spray of Cards",
|
||||
"source": "BMT",
|
||||
"page": 50,
|
||||
"level": 2,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "cone",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 15
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a deck of cards"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You spray a 15-foot cone of spectral cards. Each creature in that area must make a Dexterity saving throw. On a failed save, a creature takes {@damage 2d10} force damage and has the {@condition blinded} condition until the end of its next turn. On a successful save, a creature takes half as much damage only."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 3rd level or higher, the damage increases by {@dice 1d10} for each slot level above 2nd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"force"
|
||||
],
|
||||
"conditionInflict": [
|
||||
"blinded"
|
||||
],
|
||||
"savingThrow": [
|
||||
"dexterity"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
738
src/ttfrog/five_e_tools/sources/spells/spells-dodk.json
Normal file
738
src/ttfrog/five_e_tools/sources/spells/spells-dodk.json
Normal file
|
@ -0,0 +1,738 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Conjure the Deep Haze",
|
||||
"source": "DoDk",
|
||||
"page": 226,
|
||||
"level": 5,
|
||||
"school": "C",
|
||||
"subschools": [
|
||||
"contaminated"
|
||||
],
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 120
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a delerium fragment worth 100 gp, which the spell consumes",
|
||||
"cost": 10000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 10
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you cast this spell, you gain one {@adventure level of contamination|DoDk|12}.",
|
||||
"You create a 20-foot-radius sphere of the Deep Haze on a point you choose within range. The fog spreads around corners. It lasts for the duration or until strong wind disperses the fog, ending the spell. Its area is heavily obscured.",
|
||||
"When a creature enters the spell's area for the first time on a turn or starts its turn there, that creature must succeed on a Constitution saving throw. On a failed saving throw, it suffers ({@dice 8d6}) necrotic damage and gains one {@adventure level of contamination|DoDk|12}. On a successful save, the creature takes half as much damage and does not gain any {@adventure contamination levels|DoDk|12}. Creatures are affected even if they hold their breath or don't need to breathe."
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"OBS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Contaminated Power",
|
||||
"source": "DoDk",
|
||||
"page": 226,
|
||||
"level": 8,
|
||||
"school": "T",
|
||||
"subschools": [
|
||||
"contaminated"
|
||||
],
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "bonus"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a delerium shard worth 500 gp, which the spell consumes",
|
||||
"cost": 50000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you cast this spell, you gain one {@adventure level of contamination|DoDk|12}. You draw unrestrained arcane magic from the delerium shard you hold in your hand. Until the spell ends, you gain the following benefits:",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"When you cast a spell that deals acid, fire, cold, lightning, or poison damage, you can change the damage type to force, necrotic, psychic, or radiant damage.",
|
||||
"Once per turn when you deal damage with a spell, you can deal an extra {@damage 1d12} necrotic damage for each {@adventure contamination level|DoDk|12} you have gained to one target of that spell.",
|
||||
"When you cast a spell that forces a creature to make a saving throw to resist its effects, you can choose to gain one {@adventure level of contamination|DoDk|12}. If you do, one target of the spell has disadvantage on its first saving throw made against that spell."
|
||||
]
|
||||
},
|
||||
"This spell can't be dispelled by {@spell dispel magic}."
|
||||
],
|
||||
"damageInflict": [
|
||||
"necrotic"
|
||||
],
|
||||
"hasFluff": true
|
||||
},
|
||||
{
|
||||
"name": "Contamination Immunity",
|
||||
"source": "DoDk",
|
||||
"page": 226,
|
||||
"level": 7,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "minute"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "An eldritch lily mixed into 250 gp worth of specially-prepared purified fluids, which the spell consumes",
|
||||
"cost": 25000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 24
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"Until the spell ends, one willing creature you touch is immune to necrotic damage and cannot gain {@adventure contamination levels|DoDk|12}. The affected creature may rest normally within the Haze.",
|
||||
"This spell ends immediately if the creature casts a contaminated spell."
|
||||
],
|
||||
"hasFluff": true
|
||||
},
|
||||
{
|
||||
"name": "Delerium Blast",
|
||||
"source": "DoDk",
|
||||
"page": 227,
|
||||
"level": 4,
|
||||
"school": "V",
|
||||
"subschools": [
|
||||
"contaminated"
|
||||
],
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 150
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a delerium fragment worth 100 gp, which the spell consumes",
|
||||
"cost": 10000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you cast this spell, you gain one {@adventure level of contamination|DoDk|12}.",
|
||||
"You ignite arcane energies stored inside a delerium fragment held in your hand, and hurl it towards a space you can see within range. It explodes in a 20-foot-radius sphere of erratic psychic energy that overwhelms the minds and senses of those within. Each creature in the sphere must succeed on an Intelligence saving throw. On a failed save, a creature takes ({@dice 10d6}) psychic damage and becomes {@condition incapacitated} until the end of their next turn. On a successful save, a target takes half as much damage and suffers none of the spell's other effects."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 5th level or higher, the damage increases by {@dice 2d6} for each slot level above 4th."
|
||||
]
|
||||
}
|
||||
],
|
||||
"conditionInflict": [
|
||||
"incapacitated"
|
||||
],
|
||||
"savingThrow": [
|
||||
"intelligence"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delerium Orb",
|
||||
"source": "DoDk",
|
||||
"page": 227,
|
||||
"level": 1,
|
||||
"school": "V",
|
||||
"subschools": [
|
||||
"contaminated"
|
||||
],
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 120
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a delerium fragment worth 100 gp, which the spell consumes",
|
||||
"cost": 10000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you cast this spell, you gain one {@adventure level of contamination|DoDk|12}. You draw chaotic magical power from the delerium fragment held in your hand, and blast a creature you can see within range with paradoxical power. Choose one ability score, then choose cold, fire, lightning, necrotic, psychic, or radiant damage. The target must succeed on a saving throw using the chosen ability score; it takes {@damage 6d6} damage of the chosen type on a failed saving throw, or half as much on a successful one."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 2nd level or higher, the damage increases by {@dice 2d6} for each slot level above 1st."
|
||||
]
|
||||
}
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Forced Evolution",
|
||||
"source": "DoDk",
|
||||
"page": 227,
|
||||
"level": 4,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 8,
|
||||
"unit": "hour"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "An alchemical cocktail made from rare components and delerium dust worth 250 gold, which the target consumes as part of the spell",
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"By means of this spell, you transform a mutation gained from {@adventure contamination|DoDk|12}. One of the target's existing mutations is removed and is replaced by a different one. This mutation may be chosen by the Game Master or determined randomly (see Appendix C of Dungeons of Drakkenheim)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Neutralizing Field",
|
||||
"source": "DoDk",
|
||||
"page": 228,
|
||||
"level": 5,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "sphere",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 10
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "A delerium crystal or holy relic worth 1,000 gp",
|
||||
"cost": 100000
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You negate contaminated magical energies in a 10-foot radius sphere. Until the spell ends, the sphere moves with you, centred on you. Creatures in the sphere (including you) can't gain {@adventure contamination levels|DoDk|12} and have immunity to necrotic damage. Contaminated spells can't be cast by creatures in the area."
|
||||
],
|
||||
"hasFluff": true
|
||||
},
|
||||
{
|
||||
"name": "Octarine Spray",
|
||||
"source": "DoDk",
|
||||
"page": 228,
|
||||
"level": 7,
|
||||
"school": "V",
|
||||
"subschools": [
|
||||
"contaminated"
|
||||
],
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "cone",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a delerium fragment worth 100 gp, which the spell consumes",
|
||||
"cost": 10000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you cast this spell, you gain one {@adventure level of contamination|DoDk|12}. Octarine rays of light flash from your hand. Each ray is a different colour and has a different power and purpose, see the table below. Each creature in a 60-foot cone must succeed on a Constitution saving throw. For each target, roll a {@dice d8} to determine which colour ray affects it.",
|
||||
{
|
||||
"type": "table",
|
||||
"colStyles": [
|
||||
"col-2 text-center",
|
||||
"col-10"
|
||||
],
|
||||
"colLabels": [
|
||||
"d8",
|
||||
"Octarine Spray Effect"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
"1",
|
||||
"The target takes {@damage 20d6} psychic damage on a failed save, or half as much damage on a success."
|
||||
],
|
||||
[
|
||||
"2",
|
||||
"The target takes {@damage 20d6} necrotic damage on a failed save, or half as much damage on a success."
|
||||
],
|
||||
[
|
||||
"3",
|
||||
"The target takes {@damage 20d6} force damage on a failed save, or half as much damage on a success."
|
||||
],
|
||||
[
|
||||
"4",
|
||||
"The target takes {@damage 20d6} radiant damage on a failed save, or half as much damage on a success."
|
||||
],
|
||||
[
|
||||
"5",
|
||||
"The target takes {@damage 20d6} thunder damage on a failed save, or half as much damage on a success."
|
||||
],
|
||||
[
|
||||
"6",
|
||||
"At the start of each of its turns, the affected target uses all its movement to move directly towards the closest creature it can see. Then, the affected target uses its action to make a melee attack against a randomly determined creature within its reach. If there is no creature within its reach, the affected target does nothing this turn. At the end of each of its turns, the affected target can make a Wisdom saving throw. If it succeeds, this effect ends for that target."
|
||||
],
|
||||
[
|
||||
"7",
|
||||
"At the start of each of its turns, the affected target gains one {@adventure level of contamination|DoDk|12}. At the end of each of its turns, an affected target can make a Constitution saving throw. If it succeeds, this effect ends for that target, but any {@adventure contamination levels|DoDk|12} gained remain."
|
||||
],
|
||||
[
|
||||
"8",
|
||||
"{@b Special.} The target is struck by an additional ray. Roll again twice. There's no limit to how many additional rays can strike a single creature in this manner."
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"force",
|
||||
"necrotic",
|
||||
"psychic",
|
||||
"radiant",
|
||||
"thunder"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution",
|
||||
"wisdom"
|
||||
],
|
||||
"miscTags": [
|
||||
"RO"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Purge Contamination",
|
||||
"source": "DoDk",
|
||||
"page": 228,
|
||||
"level": 3,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "hour"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "100 gp of alchemical fluids or holy water, which the spell consumes",
|
||||
"cost": 10000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You apply alchemical fluids or holy water to a contaminated humanoid creature while reciting an exacting magical chant that expels eldritch contaminants from the target's body. When you finish casting the spell, all {@adventure contamination levels|DoDk|12} and mutations are removed from the creature. It then gains one level of {@condition exhaustion} for each {@adventure contamination level|DoDk|12} removed with this spell."
|
||||
],
|
||||
"conditionInflict": [
|
||||
"exhaustion"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ray of Contamination",
|
||||
"source": "DoDk",
|
||||
"page": 229,
|
||||
"level": 6,
|
||||
"school": "N",
|
||||
"subschools": [
|
||||
"contaminated"
|
||||
],
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a delerium fragment worth 100 gp, which the spell consumes",
|
||||
"cost": 10000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you cast this spell, you gain one {@adventure level of contamination|DoDk|12}. An octarine ray springs from your pointing finger to a creature that you can see within range. The target must succeed on a Constitution saving throw. On a failed save, a creature takes {@damage 16d6} necrotic damage and gains {@dice 1d4} {@adventure levels of contamination|DoDk|12}. On a successful save, a target takes half as much damage and does not gain any {@adventure contamination levels|DoDk|12}."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 7th level or higher, you can target one additional creature for each slot level above 6th. The creatures must be within 30 feet of each other when you target them."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"necrotic"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"SCT",
|
||||
"SGT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ride the Rifts",
|
||||
"source": "DoDk",
|
||||
"page": 228,
|
||||
"level": 3,
|
||||
"school": "C",
|
||||
"subschools": [
|
||||
"contaminated"
|
||||
],
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "line",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a delerium fragment worth 100 gp, which the spell consumes",
|
||||
"cost": 10000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you cast this spell, you gain one {@adventure level of contamination|DoDk|12}. A stroke of eldritch lightning forming a line 60 feet long and 5 feet wide blasts out from you in a direction you choose. Each creature in the line must succeed on a Dexterity saving throw. A creature takes {@damage 10d6} lightning damage on a failed save, or half as much damage on a successful one. You then teleport to an unoccupied space you can see within the line's area."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 4th level or higher, the damage increases by {@dice 2d6} for each slot level above 3rd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"lightning"
|
||||
],
|
||||
"savingThrow": [
|
||||
"dexterity"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Sacrament of the Falling Fire",
|
||||
"source": "DoDk",
|
||||
"page": 229,
|
||||
"level": 9,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "hour"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 30
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a delerium fragment worth 100 gp for each participating creature, which the spell consumes",
|
||||
"cost": 10000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"This spell may only be cast in an area covered by the Deep Haze.",
|
||||
"You perform a holy ceremony involving up to 12 faithful and willing humanoid creatures that ends when each participating creature drives a delerium shard into their chest. Those creatures become sanctified (see the Followers of the Falling Fire dossier in chapter 3 of Dungeons of Drakkenheim).",
|
||||
"When you finish casting this spell, a hostile {@creature shadow} appears and attacks each participant, as a manifestation of that character's inner darkness. The GM may determine that a character who carries great anger, fear, or hate in their heart causes a {@creature wraith} to manifest instead."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Siphon Contamination",
|
||||
"source": "DoDk",
|
||||
"page": 229,
|
||||
"level": 6,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "hour"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "500 gp worth of specially-prepared alchemical fluids and a delerium geode worth 5,000 gp, all of which are consumed by the spell",
|
||||
"cost": 50000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"This demanding spell transfers {@adventure contamination|DoDk|12} from one creature to another. When you cast this spell, you must first touch either a willing humanoid creature with one or more {@adventure levels of contamination|DoDk|12}, or a former humanoid creature whose current form is the result of a monstrous transformation. You then touch a different willing humanoid creature.",
|
||||
"If both creatures touched were willing humanoid creatures, you remove all {@adventure contamination levels|DoDk|12} and mutations from the first creature, then the second creature you touch gains a number of {@adventure contamination levels|DoDk|12} equal to the number of levels removed from the first creature.",
|
||||
"If the first creature you touch is a former humanoid who suffered a monstrous {@adventure contamination|DoDk|12}, and the second creature is a willing humanoid creature with a character level or challenge rating equal to or higher than the challenge rating of the transformed creature, you restore the fully contaminated creature to its original form, then the humanoid creature immediately undergoes a monstrous transformation as if it had gained six {@adventure contamination levels|DoDk|12}.",
|
||||
"The second creature touched cannot prevent or negate these {@adventure contamination levels|DoDk|12} or an ensuing transformation in any way, or else the spell fails with no effect."
|
||||
],
|
||||
"hasFluff": true
|
||||
},
|
||||
{
|
||||
"name": "Warp Bolt",
|
||||
"source": "DoDk",
|
||||
"page": 229,
|
||||
"level": 2,
|
||||
"school": "V",
|
||||
"subschools": [
|
||||
"contaminated"
|
||||
],
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a delerium fragment worth 100 gp, which the spell consumes",
|
||||
"cost": 10000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"When you cast this spell, you gain one {@adventure level of contamination|DoDk|12}. An arcing bolt of octarine lightning strikes a creature within range. If the target has one or more {@adventure contamination levels|DoDk|12}, it is hit automatically. Otherwise, make a ranged spell attack against that creature. On a hit, the target takes {@damage 6d6} necrotic damage and gains one {@adventure level of contamination|DoDk|12}.",
|
||||
"You can create a new bolt of lightning as your action on any turn until the spell ends."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 3rd level or higher, you can target 1 additional creature for each spell level above 2nd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"necrotic"
|
||||
],
|
||||
"spellAttack": [
|
||||
"R"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
845
src/ttfrog/five_e_tools/sources/spells/spells-egw.json
Normal file
845
src/ttfrog/five_e_tools/sources/spells/spells-egw.json
Normal file
|
@ -0,0 +1,845 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Dark Star",
|
||||
"source": "EGW",
|
||||
"page": 186,
|
||||
"level": 8,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 150
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a shard of onyx and a drop of the caster's blood, both of which the spell consumes",
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"This spell creates a sphere centered on a point you choose within range. The sphere can have a radius of up to 40 feet. The area within this sphere is filled with magical darkness and crushing gravitational force.",
|
||||
"For the duration, the spell's area is {@quickref difficult terrain||3}. A creature with darkvision can't see through the magical darkness, and nonmagical light can't illuminate it. No sound can be created within or pass through the area. Any creature or object entirely inside the sphere is immune to thunder damage, and creatures are {@condition deafened} while entirely inside it. Casting a spell that includes a verbal component is impossible there.",
|
||||
"Any creature that enters the spell's area for the first time on a turn or starts its turn there must make a Constitution saving throw. The creature takes {@damage 8d10} force damage on a failed save, or half as much damage on a successful one. A creature reduced to 0 hit points by this damage is disintegrated. A disintegrated creature and everything it is wearing and carrying, except magic items, are reduced to a pile of fine gray dust."
|
||||
],
|
||||
"damageInflict": [
|
||||
"force"
|
||||
],
|
||||
"conditionInflict": [
|
||||
"deafened"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"DFT",
|
||||
"OBJ"
|
||||
],
|
||||
"areaTags": [
|
||||
"S"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Fortune's Favor",
|
||||
"source": "EGW",
|
||||
"page": 186,
|
||||
"level": 2,
|
||||
"school": "D",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "minute"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a white pearl worth at least 100 gp, which the spell consumes",
|
||||
"cost": 10000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You impart latent luck to yourself or one willing creature you can see within range. When the chosen creature makes an attack roll, an ability check, or a saving throw before the spell ends, it can dismiss this spell on itself to roll an additional {@dice d20} and choose which of the {@dice d20}s to use. Alternatively, when an attack roll is made against the chosen creature, it can dismiss this spell on itself to roll a {@dice d20} and choose which of the {@dice d20}s to use, the one it rolled or the one the attacker rolled.",
|
||||
"If the original {@dice d20} roll has advantage or disadvantage, the creature rolls the additional {@dice d20} after advantage or disadvantage has been applied to the original roll."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 3rd level or higher, you can target one additional creature for each slot level above 2nd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"miscTags": [
|
||||
"SCT",
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Gift of Alacrity",
|
||||
"source": "EGW",
|
||||
"page": 186,
|
||||
"level": 1,
|
||||
"school": "D",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "minute"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You touch a willing creature. For the duration, the target can add {@dice 1d8} to its initiative rolls."
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Gravity Fissure",
|
||||
"source": "EGW",
|
||||
"page": 187,
|
||||
"level": 6,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "line",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 100
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a fistful of iron filings"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You manifest a ravine of gravitational energy in a line originating from you that is 100 feet long and 5 feet wide. Each creature in that line must make a Constitution saving throw, taking {@damage 8d8} force damage on a failed save, or half as much damage on a successful one.",
|
||||
"Each creature within 10 feet of the line but not in it must succeed on a Constitution saving throw or take {@damage 8d8} force damage and be pulled toward the line until the creature is in its area."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 7th level or higher, the damage increases by {@scaledamage 8d8|6-9|1d8} for each slot level above 6th."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"force"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"areaTags": [
|
||||
"L"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Gravity Sinkhole",
|
||||
"source": "EGW",
|
||||
"page": 187,
|
||||
"level": 4,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 120
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a black marble"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"A 20-foot-radius sphere of crushing force forms at a point you can see within range and tugs at the creatures there. Each creature in the sphere must make a Constitution saving throw. On a failed save, the creature takes {@damage 5d10} force damage and is pulled in a straight line toward the center of the sphere, ending in an unoccupied space as close to the center as possible (even if that space is in the air). On a successful save, the creature takes half as much damage and isn't pulled."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 5th level or higher, the damage increases by {@scaledamage 5d10|4-9|1d10} for each slot level above 4th."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"force"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"S"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Immovable Object",
|
||||
"source": "EGW",
|
||||
"page": 187,
|
||||
"level": 2,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "gold dust worth at least 25 gp, which the spell consumes",
|
||||
"cost": 2500,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You touch an object that weighs no more than 10 pounds and cause it to become magically fixed in place. You and the creatures you designate when you cast this spell can move the object normally. You can also set a password that, when spoken within 5 feet of the object, suppresses this spell for 1 minute.",
|
||||
"If the object is fixed in the air, it can hold up to 4,000 pounds of weight. More weight causes the object to fall. Otherwise, a creature can use an action to make a Strength check against your spell save DC. On a success, the creature can move the object up to 10 feet."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"If you cast this spell using a spell slot of 4th or 5th level, the DC to move the object increases by 5, it can carry up to 8,000 pounds of weight, and the duration increases to 24 hours. If you cast this spell using a spell slot of 6th level or higher, the DC to move the object increases by 10, it can carry up to 20,000 pounds of weight, and the effect is permanent until dispelled."
|
||||
]
|
||||
}
|
||||
],
|
||||
"abilityCheck": [
|
||||
"strength"
|
||||
],
|
||||
"miscTags": [
|
||||
"OBJ",
|
||||
"PRM"
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Magnify Gravity",
|
||||
"source": "EGW",
|
||||
"page": 188,
|
||||
"level": 1,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "round",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"The gravity in a 10-foot-radius sphere centered on a point you can see within range increases for a moment. Each creature in the sphere on the turn when you cast the spell must make a Constitution saving throw. On a failed save, a creature takes {@damage 2d8} force damage, and its speed is halved until the end of its next turn. On a successful save, a creature takes half as much damage and suffers no reduction to its speed.",
|
||||
"Until the start of your next turn, any object that isn't being worn or carried in the sphere requires a successful Strength check against your spell save DC to pick up or move."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 2nd level or higher, the damage increases by {@scaledamage 2d8|1-9|1d8} for each slot level above 1st."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"force"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"OBJ",
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"S"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Pulse Wave",
|
||||
"source": "EGW",
|
||||
"page": 188,
|
||||
"level": 3,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "cone",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 30
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You create intense pressure, unleash it in a 30-foot cone, and decide whether the pressure pulls or pushes creatures and objects. Each creature in that cone must make a Constitution saving throw. A creature takes {@damage 6d6} force damage on a failed save, or half as much damage on a successful one. And every creature that fails the save is either pulled 15 feet toward you or pushed 15 feet away from you, depending on the choice you made for the spell.",
|
||||
"In addition, unsecured objects that are completely within the cone are likewise pulled or pushed 15 feet."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 4th level or higher, the damage increases by {@scaledamage 6d6|3-9|1d6} and the distance pulled or pushed increases by 5 feet for each slot level above 3rd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"force"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"FMV",
|
||||
"OBJ"
|
||||
],
|
||||
"areaTags": [
|
||||
"N"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ravenous Void",
|
||||
"source": "EGW",
|
||||
"page": 188,
|
||||
"level": 9,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 1000
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a small, nine-pointed star made of iron"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You create a 20-foot-radius sphere of destructive gravitational force centered on a point you can see within range. For the spell's duration, the sphere and any space within 100 feet of it are {@quickref difficult terrain||3}, and nonmagical objects fully inside the sphere are destroyed if they aren't being worn or carried.",
|
||||
"When the sphere appears and at the start of each of your turns until the spell ends, unsecured objects within 100 feet of the sphere are pulled toward the sphere's center, ending in an unoccupied space as close to the center as possible.",
|
||||
"A creature that starts its turn within 100 feet of the sphere must succeed on a Strength saving throw or be pulled straight toward the sphere's center, ending in an unoccupied space as close to the center as possible. A creature that enters the sphere for the first time on a turn or starts its turn there takes {@damage 5d10} force damage and is {@condition restrained} until it is no longer in the sphere. If the sphere is in the air, the {@condition restrained} creature hovers inside the sphere. A creature can use its action to make a Strength check against your spell save DC, ending this {@condition restrained} condition on itself or another creature in the sphere that it can reach. A creature reduced to 0 hit points by this spell is annihilated, along with any nonmagical items it is wearing or carrying."
|
||||
],
|
||||
"damageInflict": [
|
||||
"force"
|
||||
],
|
||||
"conditionInflict": [
|
||||
"restrained"
|
||||
],
|
||||
"savingThrow": [
|
||||
"strength"
|
||||
],
|
||||
"abilityCheck": [
|
||||
"strength"
|
||||
],
|
||||
"miscTags": [
|
||||
"OBJ",
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"S"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Reality Break",
|
||||
"source": "EGW",
|
||||
"page": 189,
|
||||
"level": 8,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a crystal prism"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You shatter the barriers between realities and timelines, thrusting a creature into turmoil and madness. The target must succeed on a Wisdom saving throw, or it can't take reactions until the spell ends. The affected target must also roll a {@dice d10} at the start of each of its turns; the number rolled determines what happens to the target, as shown on the Reality Break Effects table.",
|
||||
"At the end of each of its turns, the affected target can repeat the Wisdom saving throw, ending the spell on itself on a success.",
|
||||
{
|
||||
"type": "table",
|
||||
"caption": "Reality Break Effects",
|
||||
"colLabels": [
|
||||
"d10",
|
||||
"Effect"
|
||||
],
|
||||
"colStyles": [
|
||||
"col-2 text-center",
|
||||
"col-10"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
"1-2",
|
||||
"{@b Vision of the Far Realm.} The target takes {@damage 6d12} psychic damage, and it is {@condition stunned} until the end of the turn."
|
||||
],
|
||||
[
|
||||
"3-5",
|
||||
"{@b Rending Rift.} The target must make a Dexterity saving throw, taking {@damage 8d12} force damage on a failed save, or half as much damage on a successful one."
|
||||
],
|
||||
[
|
||||
"6-8",
|
||||
"{@b Wormhole.} The target is teleported, along with everything it is wearing and carrying, up to 30 feet to an unoccupied space of your choice that you can see. The target also takes {@damage 10d12} force damage and is knocked {@condition prone}."
|
||||
],
|
||||
[
|
||||
"9-10",
|
||||
"{@b Chill of the Dark Void.} The target takes {@damage 10d12} cold damage, and it is {@condition blinded} until the end of the turn."
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"psychic",
|
||||
"force",
|
||||
"cold"
|
||||
],
|
||||
"conditionInflict": [
|
||||
"stunned",
|
||||
"prone",
|
||||
"blinded"
|
||||
],
|
||||
"savingThrow": [
|
||||
"wisdom",
|
||||
"dexterity"
|
||||
],
|
||||
"miscTags": [
|
||||
"RO",
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Sapping Sting",
|
||||
"source": "EGW",
|
||||
"page": 189,
|
||||
"level": 0,
|
||||
"school": "N",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 30
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You sap the vitality of one creature you can see in range. The target must succeed on a Constitution saving throw or take {@damage 1d4} necrotic damage and fall {@condition prone}.",
|
||||
"This spell's damage increases by {@damage 1d4} when you reach 5th level ({@damage 2d4}), 11th level ({@damage 3d4}), and 17th level ({@damage 4d4})."
|
||||
],
|
||||
"scalingLevelDice": {
|
||||
"label": "necrotic damage",
|
||||
"scaling": {
|
||||
"1": "1d4",
|
||||
"5": "2d4",
|
||||
"11": "3d4",
|
||||
"17": "4d4"
|
||||
}
|
||||
},
|
||||
"damageInflict": [
|
||||
"necrotic"
|
||||
],
|
||||
"conditionInflict": [
|
||||
"prone"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"SCL",
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Temporal Shunt",
|
||||
"source": "EGW",
|
||||
"page": 189,
|
||||
"level": 5,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "reaction",
|
||||
"condition": "taken when a creature you can see makes an attack roll or starts to cast a spell"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 120
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "round",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You target the triggering creature, which must succeed on a Wisdom saving throw or vanish, being thrown to another point in time and causing the attack to miss or the spell to be wasted. At the start of its next turn, the target reappears where it was or in the closest unoccupied space. The target doesn't remember you casting the spell or being affected by it."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 6th level or higher, you can target one additional creature for each slot level above 5th. All targets must be within 30 feet of each other."
|
||||
]
|
||||
}
|
||||
],
|
||||
"savingThrow": [
|
||||
"wisdom"
|
||||
],
|
||||
"miscTags": [
|
||||
"SCT"
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Tether Essence",
|
||||
"source": "EGW",
|
||||
"page": 189,
|
||||
"level": 7,
|
||||
"school": "N",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a spool of platinum cord worth at least 250 gp, which the spell consumes",
|
||||
"cost": 25000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"Two creatures you can see within range must make a Constitution saving throw, with disadvantage if they are within 30 feet of each other. Either creature can willingly fail the save. If either save succeeds, the spell has no effect. If both saves fail, the creatures are magically linked for the duration, regardless of the distance between them. When damage is dealt to one of them, the same damage is dealt to the other one. If hit points are restored to one of them, the same number of hit points are restored to the other one. If either of the tethered creatures is reduced to 0 hit points, the spell ends on both. If the spell ends on one creature, it ends on both."
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"MT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Time Ravage",
|
||||
"source": "EGW",
|
||||
"page": 189,
|
||||
"level": 9,
|
||||
"school": "N",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 90
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "an hourglass filled with diamond dust worth at least 5,000 gp, which the spell consumes",
|
||||
"cost": 500000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You target a creature you can see within range, putting its physical form through the devastation of rapid aging. The target must make a Constitution saving throw, taking {@damage 10d12} necrotic damage on a failed save, or half as much damage on a successful one. If the save fails, the target also ages to the point where it has only 30 days left before it dies of old age. In this aged state, the target has disadvantage on attack rolls, ability checks, and saving throws, and its walking speed is halved. Only the {@spell wish} spell or the {@spell greater restoration} cast with a 9th-level spell slot can end these effects and restore the target to its previous age."
|
||||
],
|
||||
"damageInflict": [
|
||||
"necrotic"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Wristpocket",
|
||||
"source": "EGW",
|
||||
"page": 190,
|
||||
"level": 2,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"ritual": true
|
||||
},
|
||||
"entries": [
|
||||
"You flick your wrist, causing one object in your hand to vanish. The object, which only you can be holding and can weigh no more than 5 pounds, is transported to an extradimensional space, where it remains for the duration.",
|
||||
"Until the spell ends, you can use your action to summon the object to your free hand, and you can use your action to return the object to the extradimensional space. An object still in the pocket plane when the spell ends appears in your space, at your feet."
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
449
src/ttfrog/five_e_tools/sources/spells/spells-ftd.json
Normal file
449
src/ttfrog/five_e_tools/sources/spells/spells-ftd.json
Normal file
|
@ -0,0 +1,449 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Ashardalon's Stride",
|
||||
"source": "FTD",
|
||||
"page": 19,
|
||||
"level": 3,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "bonus"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"The billowing flames of a dragon blast from your feet, granting you explosive speed. For the duration, your speed increases by 20 feet and moving doesn't provoke opportunity attacks.",
|
||||
"When you move within 5 feet of a creature or an object that isn't being worn or carried, it takes {@damage 1d6} fire damage from your trail of heat. A creature or object can take this damage only once during a turn."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 4th level or higher, increase your speed by 5 feet for each spell slot level above 3rd. The spell deals an additional {@scaledamage 1d6|3-9|1d6} fire damage for each slot level above 3rd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"fire"
|
||||
],
|
||||
"miscTags": [
|
||||
"OBJ"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Draconic Transformation",
|
||||
"source": "FTD",
|
||||
"page": 19,
|
||||
"level": 7,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "bonus"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a statuette of a dragon, worth at least 500 gp",
|
||||
"cost": 50000
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"With a roar, you draw on the magic of dragons to transform yourself, taking on draconic features. You gain the following benefits until the spell ends:",
|
||||
{
|
||||
"type": "list",
|
||||
"style": "list-hang-notitle",
|
||||
"items": [
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Blindsight",
|
||||
"entries": [
|
||||
"You have {@sense blindsight} with a range of 30 feet. Within that range, you can effectively see anything that isn't behind {@quickref Cover||3||total cover}, even if you're {@condition blinded} or in darkness. Moreover, you can see an {@condition invisible} creature, unless the creature successfully hides from you."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Breath Weapon",
|
||||
"entries": [
|
||||
"When you cast this spell, and as a bonus action on subsequent turns for the duration, you can exhale shimmering energy in a 60-foot cone. Each creature in that area must make a Dexterity saving throw, taking {@damage 6d8} force damage on a failed save, or half as much damage on a successful one."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Wings",
|
||||
"entries": [
|
||||
"Incorporeal wings sprout from your back, giving you a flying speed of 60 feet."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"force"
|
||||
],
|
||||
"savingThrow": [
|
||||
"dexterity"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT",
|
||||
"UBA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Fizban's Platinum Shield",
|
||||
"source": "FTD",
|
||||
"page": 20,
|
||||
"level": 6,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "bonus"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a platinum-plated dragon scale, worth at least 500 gp",
|
||||
"cost": 50000
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You create a field of silvery light that surrounds a creature of your choice within range (you can choose yourself). The field sheds dim light out to 5 feet. While surrounded by the field, a creature gains the following benefits:",
|
||||
{
|
||||
"type": "list",
|
||||
"style": "list-hang-notitle",
|
||||
"items": [
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Cover",
|
||||
"entries": [
|
||||
"The creature has {@quickref Cover||3||half cover}."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Damage Resistance",
|
||||
"entries": [
|
||||
"The creature has resistance to acid, cold, fire, lightning, and poison damage."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Evasion",
|
||||
"entries": [
|
||||
"If the creature is subjected to an effect that allows it to make a Dexterity saving throw to take only half damage, the creature instead takes no damage if it succeeds on the saving throw, and only half damage if it fails."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"As a bonus action on subsequent turns, you can move the field to another creature within 60 feet of the field."
|
||||
],
|
||||
"miscTags": [
|
||||
"UBA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Nathair's Mischief",
|
||||
"source": "FTD",
|
||||
"page": 20,
|
||||
"level": 2,
|
||||
"school": "I",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"s": true,
|
||||
"m": "a piece of crust from an apple pie"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You fill a 20-foot cube you can see within range with fey and draconic magic. Roll on the Mischievous Surge table to determine the magical effect produced, and roll again at the start of each of your turns until the spell ends. You can move the cube up to 10 feet before you roll.",
|
||||
{
|
||||
"type": "table",
|
||||
"caption": "Mischievous Surge",
|
||||
"colLabels": [
|
||||
"d4",
|
||||
"Effect"
|
||||
],
|
||||
"colStyles": [
|
||||
"col-2 text-center",
|
||||
"col-10"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
"1",
|
||||
"The smell of apple pie fills the air, and each creature in the cube must succeed on a Wisdom saving throw or become {@condition charmed} by you until the start of your next turn."
|
||||
],
|
||||
[
|
||||
"2",
|
||||
"Bouquets of flowers appear all around, and each creature in the cube must succeed on a Dexterity saving throw or be {@condition blinded} until the start of your next turn as the flowers spray water in their faces."
|
||||
],
|
||||
[
|
||||
"3",
|
||||
"Each creature in the cube must succeed on a Wisdom saving throw or begin giggling until the start of your next turn. A giggling creature is {@condition incapacitated} and uses all its movement to move in a random direction."
|
||||
],
|
||||
[
|
||||
"4",
|
||||
"Drops of molasses hover in the cube, making it {@quickref difficult terrain||3} until the start of your next turn."
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"savingThrow": [
|
||||
"dexterity",
|
||||
"wisdom"
|
||||
],
|
||||
"miscTags": [
|
||||
"RO",
|
||||
"SGT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Raulothim's Psychic Lance",
|
||||
"source": "FTD",
|
||||
"page": 21,
|
||||
"level": 4,
|
||||
"school": "E",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 120
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You unleash a shimmering lance of psychic power from your forehead at a creature that you can see within range. Alternatively, you can utter a creature's name. If the named target is within range, it becomes the spell's target even if you can't see it. If the named target isn't within range, the lance dissipates without effect.",
|
||||
"The target must make an Intelligence saving throw. On a failed save, the target takes {@damage 7d6} psychic damage and is {@condition incapacitated} until the start of your next turn. On a successful save, the creature takes half as much damage and isn't {@condition incapacitated}."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 5th level or higher, the damage increases by {@scaledamage 7d6|4-9|1d6} for each slot level above 4th."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"psychic"
|
||||
],
|
||||
"conditionInflict": [
|
||||
"incapacitated"
|
||||
],
|
||||
"savingThrow": [
|
||||
"intelligence"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Rime's Binding Ice",
|
||||
"source": "FTD",
|
||||
"page": 21,
|
||||
"level": 2,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "cone",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 30
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"s": true,
|
||||
"m": "a vial of meltwater"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"A burst of cold energy emanates from you in a 30-foot cone. Each creature in that area must make a Constitution saving throw. On a failed save, a creature takes {@damage 3d8} cold damage and is hindered by ice formations for 1 minute, or until it or another creature within reach of it uses an action to break away the ice. A creature hindered by ice has its speed reduced to 0. On a successful save, a creature takes half as much damage and isn't hindered by ice."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 3rd level or higher, increase the cold damage by {@scaledamage 3d8|2-9|1d8} for each slot level above 2nd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"cold"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"areaTags": [
|
||||
"N"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Summon Draconic Spirit",
|
||||
"source": "FTD",
|
||||
"page": 21,
|
||||
"reprintedAs": [
|
||||
"Summon Dragon|XPHB"
|
||||
],
|
||||
"level": 5,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "an object with the image of a dragon engraved on it, worth at least 500 gp",
|
||||
"cost": 50000
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You call forth a {@creature draconic spirit|FTD}. It manifests in an unoccupied space that you can see within range. This corporeal form uses the Draconic Spirit stat block. When you cast this spell, choose a family of dragon: chromatic, gem, or metallic. The creature resembles a dragon of the chosen family, which determines certain traits in its stat block. The creature disappears when it drops to 0 hit points or when the spell ends.",
|
||||
"The creature is an ally to you and your companions. In combat, the creature shares your initiative count, but it takes its turn immediately after yours. It obeys your verbal commands (no action required by you). If you don't issue any, it takes the {@action Dodge} action and uses its move to avoid danger."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 6th level or higher, use the higher level wherever the spell's level appears in the stat block."
|
||||
]
|
||||
}
|
||||
],
|
||||
"savingThrow": [
|
||||
"dexterity"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT",
|
||||
"SMN"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
41
src/ttfrog/five_e_tools/sources/spells/spells-ggr.json
Normal file
41
src/ttfrog/five_e_tools/sources/spells/spells-ggr.json
Normal file
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Encode Thoughts",
|
||||
"source": "GGR",
|
||||
"page": 47,
|
||||
"level": 0,
|
||||
"school": "E",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"Putting a finger to your head, you pull a memory, an idea, or a message from your mind and transform it into a tangible string of glowing energy called a thought strand, which persists for the duration or until you cast this spell again. The thought strand appears in an unoccupied space within 5 feet of you as a Tiny, weightless, semisolid object that can be held and carried like a ribbon. It is otherwise stationary.",
|
||||
"If you cast this spell while {@status concentration||concentrating} on a spell or an ability that allows you to read or manipulate the thoughts of others (such as {@spell detect thoughts} or {@spell modify memory}), you can transform the thoughts or memories you read, rather than your own, into a thought strand.",
|
||||
"Casting this spell while holding a thought strand allows you to instantly receive whatever memory, idea, or message the thought strand contains. (Casting {@spell detect thoughts} on the strand has the same effect.)"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
}
|
||||
]
|
||||
}
|
300
src/ttfrog/five_e_tools/sources/spells/spells-ghloe.json
Normal file
300
src/ttfrog/five_e_tools/sources/spells/spells-ghloe.json
Normal file
|
@ -0,0 +1,300 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Arboreal Curse",
|
||||
"source": "GHLoE",
|
||||
"level": 7,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a cup of sap"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You attempt to turn one creature that you can see within range into wood. If the target's body is made of flesh, the creature must make a Constitution saving throw. On a failed save, it is {@condition restrained} as its flesh begins to harden into bark. On a successful save, the creature isn't affected.",
|
||||
"A creature {@condition restrained} by this spell must make another Constitution saving throw at the end of each of its turns. If it successfully saves against this spell three times, the spell ends. If it fails its Constitution saving throw three times, it is turned into a tree and subjected to the {@condition petrified} condition for the duration. The successes and failures don't need to be consecutive; keep track of both until the target collects three of a kind.",
|
||||
"If the transformed creature is burned, chopped down, or otherwise destroyed while {@condition petrified}, the creature is slain.",
|
||||
"A creature remains transformed unless the effect is reversed within one year with {@spell greater restoration}, {@spell wish}, or similar magical effects. If the creature spends one year and a day as a tree, the transformation becomes permanent, and nothing can return the creature to its original form."
|
||||
],
|
||||
"conditionInflict": [
|
||||
"petrified",
|
||||
"restrained"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"PRM",
|
||||
"SGT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Consume Mind",
|
||||
"source": "GHLoE",
|
||||
"level": 4,
|
||||
"school": "N",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a 1-ounce fresh or magically preserved portion of another creature's brain"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You consume the brain of another creature's corpse, gaining its memories and knowledge. The corpse must have a brain and can't be undead. The spell fails if the corpse has been dead (and not preserved) for more than three days.",
|
||||
"Until the spell ends, you can attempt to recall any important fact known to the creature\u2014family history, recent events, building layouts, passwords, details of the creature's death, and similar information. To recall a piece of information, you must make an ability check using your spellcasting modifier. The DC is equal to the corpse's Intelligence score.",
|
||||
"Once the caster rolls to determine whether they recall a fact, they cannot attempt to recall it again."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Heartseeker",
|
||||
"source": "GHLoE",
|
||||
"level": 6,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 300
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a ruby worth at least 100 gp",
|
||||
"cost": 10000
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"As part of casting this spell you must expend six hit dice or the spell automatically fails. When you do, blood flows from your body then crystallizes into a barbed arrow, which launches at a creature of your choice within range. Make a ranged spell attack against the chosen creature. On a hit, roll the hit dice expended to cast this spell, and the creature takes piercing damage equal to the result.",
|
||||
"Once lodged in the creature, the bloody arrow begins to burrow toward its heart, rendering it vulnerable to further attacks. At the start of the creature's next turn, it must make a Constitution saving throw. On a failure, attacks against the creature score critical hits on a 19 or 20 on the attack roll.",
|
||||
"At the start of each of the creature's turns after that, it must repeat the Constitution saving throw or the critical hit range on attacks against the creature increases by 1 again. If the creature succeeds on three of these saving throws (these successes do not need to be consecutive) this spell ends. The increased critical hit range ends when the spell does."
|
||||
],
|
||||
"spellAttack": [
|
||||
"R"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Hunter Sense",
|
||||
"source": "GHLoE",
|
||||
"level": 0,
|
||||
"school": "D",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You touch one willing creature. While this spell is active, the target's senses are heightened. If the target rolls a 9 or below on the die when making a Wisdom ({@skill Perception}) check, they instead act as if they rolled a 10."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Magic Mirror",
|
||||
"source": "GHLoE",
|
||||
"level": 5,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "reaction",
|
||||
"condition": "which you take when you are targeted by a spell"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a polished silver marble"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"A momentary bubble of iridescent energy shimmers in the air between you and the caster of the triggering spell. The spell is redirected to a creature of your choice that you can see within 60 feet. If the spell is 5th level or lower, you are no longer a target of the spell and the chosen creature is instead. If the spell is 6th level or higher, make an ability check using your spellcasting ability. The DC equals 10 + the spell's level. On a successful check, you are no longer a target of the spell and the chosen creature is instead. On a failed check, you remain the target of the triggering spell."
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Suffocate",
|
||||
"source": "GHLoE",
|
||||
"level": 3,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a leather glove"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You create a pair of grasping hands made from {@condition invisible} forces. Make a spell attack against one creature that you can see within range. On a hit, the creature is {@condition restrained} as the hands crush their throat or bodily equivalent.",
|
||||
"If the creature requires air to breathe, it begins to suffocate. A suffocating creature can survive for a number of rounds equal to 1 + its Constitution modifier (minimum of 1 round). At the start of its next turn after those rounds are over, it becomes {@condition unconscious}, and it can't regain hit points until it can breathe again.",
|
||||
"A conscious creature {@condition restrained} by the hands can use its action to make a Strength or Dexterity check (its choice) against your spell save DC. On a success, the spell ends on the target."
|
||||
],
|
||||
"conditionInflict": [
|
||||
"invisible",
|
||||
"restrained",
|
||||
"unconscious"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Wrack",
|
||||
"source": "GHLoE",
|
||||
"level": 2,
|
||||
"school": "N",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 30
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a frayed piece of cord"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"Choose a creature that you can see within range. The target must succeed on a Constitution saving throw or be afflicted with excruciating muscle spasms for the duration. A creature affected in this way has its speed halved and rolls attacks with disadvantage. At the end of each of its turns, the target can make another Constitution saving throw. On a success, the spell ends on the target."
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
503
src/ttfrog/five_e_tools/sources/spells/spells-hwcs.json
Normal file
503
src/ttfrog/five_e_tools/sources/spells/spells-hwcs.json
Normal file
|
@ -0,0 +1,503 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Ambush Prey",
|
||||
"source": "HWCS",
|
||||
"page": 49,
|
||||
"level": 2,
|
||||
"school": "I",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a broken twig"
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You channel primal predatory energies to perfectly conceal your presence in order to surprise your target. You become {@condition invisible} for the spell's duration, granting advantage on all Dexterity ({@skill Stealth}) checks to remain hidden. The invisibility will last for the duration of the spell, however, moving 5 feet or more from your position when you cast the spell will end the effect.",
|
||||
"As long as you remain invisible, the first attack you make against any target who is unaware of your presence deals an additional {@damage 1d6} points of damage. This attack ends the spell."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot above 2nd level, the damage of your first attack increases by {@scaledamage 1d6|2-9|1d6} for every slot level above 2nd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"conditionInflict": [
|
||||
"invisible"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Elevated Sight",
|
||||
"source": "HWCS",
|
||||
"page": 49,
|
||||
"level": 1,
|
||||
"school": "D",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You cast your eyes skyward, granting you sight from a higher vantage point. You project your vision to see through an invisible sensor which appears in a spot up to 120 feet above you. You can see through the sensor as if you were flying, granting a full 360 degree view from its location.",
|
||||
"The sensor moves with you, retaining its height in relation to you. You can use a bonus action to adjust the sensor's height, but only to a maximum of 120 feet above you.",
|
||||
"While looking through this sensor you are {@condition blinded|PHB|blind}, though you can switch between seeing through the sensor or through your own eyes at any time during your turn."
|
||||
],
|
||||
"conditionInflict": [
|
||||
"blinded"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT",
|
||||
"UBA"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Feathered Reach",
|
||||
"source": "HWCS",
|
||||
"page": 50,
|
||||
"level": 3,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a small feather"
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You transform your arms into powerful wings, and your fingers into long, graceful feathers. The effects of this spell last 1 minute, at which point the feathers gradually fall out, causing you to float gently to the ground as your arms return to their original form. This spell confers a number of benefits upon the caster:",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"As a bonus action, you can fly up to double your movement speed. You must land once you finish your movement, although you do not take fall damage while this spell is active, as your feathered arms bear you gently to the ground.",
|
||||
"You can use your powerful feathered arms to propel yourself upward a distance equal to half your movement speed. You can do this once during your turn and may use it in conjunction with a regular jump.",
|
||||
"When falling, you can use your reaction to stiffen your arms, and glide on the wind. You may fly up to your movement speed, in any direction, choosing where you land.",
|
||||
"You gain advantage on all {@skill Athletics} checks used to make a long or high jump. You do not need to move 10 feet before you jump to gain distance, and you triple the distance you would jump normally."
|
||||
]
|
||||
},
|
||||
"In order to benefit from this spell your hands must be free of shields and heavy weapons, and you cannot be encumbered."
|
||||
],
|
||||
"miscTags": [
|
||||
"ADV",
|
||||
"UBA"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Globe of Twilight",
|
||||
"source": "HWCS",
|
||||
"page": 50,
|
||||
"level": 3,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "sphere",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 15
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a dab of pitch and a bag of glittering sand"
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 10
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You shroud the area surrounding you in a sphere of night sky, dotted with miniature stars. The twilight conceals your allies, but clearly illuminates your enemies.",
|
||||
"The area affected by this spell is lightly obscured by magical shadow, within which small constellations softly twinkle. Aside from these stars, only light produced by a spell of 3rd level or higher can properly illuminate any area inside the sphere. Nonmagical light does not function inside the sphere, and all other forms of magical radiance can only produce dim light in a 5-foot space.",
|
||||
"When you cast this spell you may designate any number of creatures you can see to be concealed by the supernatural shadows while in the sphere. A concealed creature has advantage on Dexterity ({@skill Stealth}) checks when inside the sphere and may attempt to hide at any time. Because the area of the spell is lightly obscured, creatures within the spell's area have disadvantage on Wisdom ({@skill Perception}) checks made to see those outside of it.",
|
||||
"All other creatures in the area are dazzled by the light of the miniature stars, causing them to have disadvantage on all perception checks inside the sphere. When such a creature enters the spell's area for the first time, or starts its turn there, it must make a Wisdom saving throw or be {@condition blinded} until the end of its turn."
|
||||
],
|
||||
"conditionInflict": [
|
||||
"blinded"
|
||||
],
|
||||
"savingThrow": [
|
||||
"wisdom"
|
||||
],
|
||||
"miscTags": [
|
||||
"ADV",
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"S"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Gust Barrier",
|
||||
"source": "HWCS",
|
||||
"page": 50,
|
||||
"level": 0,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "round",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You spread your arms wide, allowing yourself to become enveloped by the air around you. Until the end of your next turn, any ranged attack against you is made with disadvantage.",
|
||||
"Melee attackers who successfully hit you must make a Constitution saving throw against your spell save DC. On a failure, the attacker is flung away from you up to 10 feet and is knocked {@condition prone}."
|
||||
],
|
||||
"conditionInflict": [
|
||||
"prone"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Invoke the Amaranthine",
|
||||
"source": "HWCS",
|
||||
"page": 51,
|
||||
"level": 3,
|
||||
"school": "D",
|
||||
"time": [
|
||||
{
|
||||
"number": 10,
|
||||
"unit": "minute"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a holy symbol of the Amaranthine"
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 24
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You call upon the power of an Amaranthine to grant yourself insight into the Great Rhythm that moves all things. When you cast this spell, roll two {@dice d20}s, and record what you rolled. For each die, choose either attack roll, skill check, or saving throw. You can choose each option multiple times. For the next 24 hours, you may substitute any roll of an ally or enemy you can see within 60 feet with one of the recorded numbers that matches the type of roll you wish to replace (attack roll, skill check, or saving throw). The target still adds any relevant modifiers to this number, but otherwise treat the substituted number as the number they rolled.",
|
||||
"To do this, you must spend a reaction to present your holy symbol and invoke the name of the Amaranthine whose energies you called upon. You can do this anytime after the skill check, saving throw or attack has been rolled, but before the outcome of the event has been determined. The spell ends after 24 hours have passed, or when both dice have been expended."
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Shape Plants",
|
||||
"source": "HWCS",
|
||||
"page": 51,
|
||||
"level": 4,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You call upon gentle natural magics to alter the growth of plants. Any plant life you can see within range that fits within a 5-foot cube can take on whatever shape you desire. Additionally, if the plant is a bramble or capable of growing thorns, you may turn the affected area into difficult terrain, causing {@damage 2d4} points of piercing damage for every 5 feet moved through the area you shaped. You may also increase or decrease the number of flowers, vines, leaves, thorns, branches, or fruits produced by any plant you shape.",
|
||||
"After one hour, the magic of your spell fades, and the plant resumes its normal shape. If you can use speak with plants (or a similar ability) to communicate with the plant, you may persuade it to retain its new form. Different plants have different feelings and attitudes, and if the form is too different from its natural shape it is likely to decline. If the plant accepts, at the GM's discretion, it will retain the form you have sculpted it into, in which case the effect becomes permanent."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot above 4th level, the size of the cube of plant life you can affect with the spell increases by an additional 5 feet for every slot level above 4th."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"piercing"
|
||||
],
|
||||
"miscTags": [
|
||||
"PRM",
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"C"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Spiny Shield",
|
||||
"source": "HWCS",
|
||||
"page": 51,
|
||||
"level": 1,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "reaction"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a small quill"
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "round",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"An ethereal barrier of spikes, made of magical force, interposes itself between you and an attacker. Until your next turn, when you are hit by a melee attack, the barrier reduces the damage you are dealt by {@dice 2d4}, and deals the same amount of piercing damage to the attacker. The shield is ineffective against ranged attackers, but still provides a +2 bonus to AC (treat as {@quickref Cover||3||half cover}) against them for the duration."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot above 1st level, increase the spell's effect by an additional {@scaledice 2d4|1-9|1d4} for every slot level above 1st."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"piercing"
|
||||
],
|
||||
"miscTags": [
|
||||
"MAC"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Stellar Bodies",
|
||||
"source": "HWCS",
|
||||
"page": 52,
|
||||
"level": 4,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "special"
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You create two small stars that orbit you. They twinkle pleasantly, shedding dim light in a 10-foot radius centered on you. The stars protect you. If a creature within 5 feet of you hits you with a melee attack they must make a Wisdom saving throw or take {@damage 1d8} points of radiant damage for each star orbiting you.",
|
||||
"Once per round, on your turn, you may use your action to cause a star to streak towards an enemy, expending it as it explodes in a blinding flash. Make a ranged spell attack against an enemy within 120 feet, dealing {@damage 4d8} points of radiant damage on a hit. The target must then make a Constitution saving throw or be {@condition blinded} until the start of your next turn.",
|
||||
"The spell ends when either its duration expires, you fall {@condition unconscious}, or you have expended all of your stars."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot above 4th level, you may create one additional star for every two slot levels above 4th. For each additional star orbiting you, the radius of dim light centered on you increases by 5 feet."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"radiant"
|
||||
],
|
||||
"conditionInflict": [
|
||||
"blinded"
|
||||
],
|
||||
"spellAttack": [
|
||||
"R"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution",
|
||||
"wisdom"
|
||||
],
|
||||
"hasFluffImages": true
|
||||
},
|
||||
{
|
||||
"name": "Veil of Dusk",
|
||||
"source": "HWCS",
|
||||
"page": 52,
|
||||
"level": 1,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "bonus"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a pinch of soot"
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 10
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You incant towards a creature, cloaking them in a shadowy veil of darkness and silence. The target gains a +1 bonus to their armor class and makes {@skill Stealth} checks with advantage for the duration of the spell."
|
||||
],
|
||||
"hasFluffImages": true
|
||||
}
|
||||
]
|
||||
}
|
96
src/ttfrog/five_e_tools/sources/spells/spells-idrotf.json
Normal file
96
src/ttfrog/five_e_tools/sources/spells/spells-idrotf.json
Normal file
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Create Magen",
|
||||
"source": "IDRotF",
|
||||
"page": 318,
|
||||
"level": 7,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "hour"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a vial of quicksilver worth 500 gp and a life-sized human doll, both of which the spell consumes, and an intricate crystal rod worth at least 1,500 gp that is not consumed",
|
||||
"cost": 50000,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"While casting the spell, you place a vial of quicksilver in the chest of a life-sized human doll stuffed with ash or dust. You then stitch up the doll and drip your blood on it. At the end of the casting, you tap the doll with a crystal rod, transforming it into a {@filter magen|bestiary|search=magen|source=IDRotF} clothed in whatever the doll was wearing. The type of magen is chosen by you during the casting of the spell. See {@adventure appendix C|IDRotF|21} for different kinds of magen and their statistics.",
|
||||
"When the magen appears, your hit point maximum decreases by an amount equal to the magen's challenge rating (minimum reduction of 1). Only a {@spell wish} spell can undo this reduction to your hit point maximum.",
|
||||
"Any magen you create with this spell obeys your commands without question."
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Frost Fingers",
|
||||
"source": "IDRotF",
|
||||
"page": 318,
|
||||
"level": 1,
|
||||
"school": "V",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "cone",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 15
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"Freezing cold blasts from your fingertips in a 15-foot cone. Each creature in that area must make a Constitution saving throw, taking {@damage 2d8} cold damage on a failed save, or half as much damage on a successful one.",
|
||||
"The cold freezes nonmagical liquids in the area that aren't being worn or carried."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 2nd level or higher, the damage increases by {@dice 1d8} for each slot level above 1st."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"cold"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"areaTags": [
|
||||
"N"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
174
src/ttfrog/five_e_tools/sources/spells/spells-llk.json
Normal file
174
src/ttfrog/five_e_tools/sources/spells/spells-llk.json
Normal file
|
@ -0,0 +1,174 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Flock of Familiars",
|
||||
"source": "LLK",
|
||||
"page": 57,
|
||||
"level": 2,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "minute"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "touch"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You temporarily summon three familiars\u2014spirits that take animal forms of your choice. Each familiar uses the same rules and options for a familiar conjured by the {@spell find familiar} spell. All the familiars conjured by this spell must be the same type of creature (celestials, fey, or fiends; your choice). If you already have a familiar conjured by the {@spell find familiar} spell or similar means, then one fewer familiars are conjured by this spell.",
|
||||
"Familiars summoned by this spell can telepathically communicate with you and share their visual or auditory senses while they are within 1 mile of you.",
|
||||
"When you cast a spell with a range of touch, one of the familiars conjured by this spell can deliver the spell, as normal. However, you can cast a touch spell through only one familiar per turn."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 3rd level or higher, you conjure an additional familiar for each slot level above 2nd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"miscTags": [
|
||||
"SMN"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Galder's Speedy Courier",
|
||||
"source": "LLK",
|
||||
"page": 57,
|
||||
"level": 4,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 10
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "25 gold pieces, or mineral goods of equivalent value, which the spell consumes",
|
||||
"cost": 2500,
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You summon a Small air elemental to a spot within range. The air elemental is formless, nearly transparent, immune to all damage, and cannot interact with other creatures or objects. It carries an open, empty chest whose interior dimensions are 3 feet on each side. While the spell lasts, you can deposit as many items inside the chest as will fit. You can then name a living creature you have met and seen at least once before, or any creature for which you possess a body part, lock of hair, clipping from a nail, or similar portion of the creature's body.",
|
||||
"As soon as the lid of the chest is closed, the elemental and the chest disappear, then reappear adjacent to the target creature. If the target creature is on another plane, or if it is proofed against magical detection or location, the contents of the chest reappear on the ground at your feet.",
|
||||
"The target creature is made aware of the chest's contents before it chooses whether or not to open it, and knows how much of the spell's duration remains in which it can retrieve them. No other creature can open the chest and retrieve its contents. When the spell expires or when all the contents of the chest have been removed, the elemental and the chest disappear. The elemental also disappears if the target creature orders it to return the items to you. When the elemental disappears, any items not taken from the chest reappear on the ground at your feet."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using an 8th-level spell slot, you can send the chest to a creature on a different plane of existence from you."
|
||||
]
|
||||
}
|
||||
],
|
||||
"miscTags": [
|
||||
"SMN"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Galder's Tower",
|
||||
"source": "LLK",
|
||||
"page": 57,
|
||||
"level": 3,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 10,
|
||||
"unit": "minute"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 30
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a fragment of stone, wood, or other building material"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 24
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You conjure a two-story tower made of stone, wood, or similar suitably sturdy materials. The tower can be round or square in shape. Each level of the tower is 10 feet tall and has an area of up to 100 square feet. Access between levels consists of a simple ladder and hatch. Each level takes one of the following forms, chosen by you when you cast the spell:",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"A bedroom with a bed, chairs, chest, and magical fireplace",
|
||||
"A study with desks, books, bookshelves, parchments, ink, and ink pens",
|
||||
"A dining space with a table, chairs, magical fireplace, containers, and cooking utensils",
|
||||
"A lounge with couches, armchairs, side tables and footstools",
|
||||
"A washroom with toilets, washtubs, a magical brazier, and sauna benches",
|
||||
"An observatory with a telescope and maps of the night sky",
|
||||
"An unfurnished, empty room"
|
||||
]
|
||||
},
|
||||
"The interior of the tower is warm and dry, regardless of conditions outside. Any equipment or furnishings conjured with the tower dissipate into smoke if removed from it. At the end of the spell's duration, all creatures and objects within the tower that were not created by the spell appear safely outside on the ground, and all traces of the tower and its furnishings disappear.",
|
||||
"You can cast this spell again while it is active to maintain the tower's existence for another 24 hours. You can create a permanent tower by casting this spell in the same location and with the same configuration every day for one year."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 4th level or higher, the tower can have one additional story for each slot level beyond 3rd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"miscTags": [
|
||||
"OBJ",
|
||||
"PIR",
|
||||
"PRM"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
21102
src/ttfrog/five_e_tools/sources/spells/spells-phb.json
Normal file
21102
src/ttfrog/five_e_tools/sources/spells/spells-phb.json
Normal file
File diff suppressed because it is too large
Load Diff
95
src/ttfrog/five_e_tools/sources/spells/spells-sato.json
Normal file
95
src/ttfrog/five_e_tools/sources/spells/spells-sato.json
Normal file
|
@ -0,0 +1,95 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Gate Seal",
|
||||
"source": "SatO",
|
||||
"page": 12,
|
||||
"level": 4,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "minute"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a broken portal key, which the spell consumes",
|
||||
"consume": true
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 24
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You fortify the fabric of the planes in a 30-foot cube you can see within range. Within that area, portals close and can't be opened for the duration. Spells and other effects that allow planar travel or open portals, such as {@spell gate} or {@spell plane shift}, fail if used to enter or leave the area. The cube is stationary."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 6th level or higher, the spell lasts until dispelled."
|
||||
]
|
||||
}
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Warp Sense",
|
||||
"source": "SatO",
|
||||
"page": 12,
|
||||
"level": 2,
|
||||
"school": "D",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a razorvine leaf"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"For the duration, you sense the presence of portals, even inactive ones, within 30 feet of yourself.",
|
||||
"If you detect a portal in this way, you can use your action to study it. Make a DC 15 ability check using your spellcasting ability. On a successful check, you learn the destination plane of the portal and what portal key it requires, then the spell ends. On a failed check, you learn nothing and can't study that portal again using this spell until you cast it again.",
|
||||
"The spell can penetrate most barriers but is blocked by 1 foot of stone, 1 inch of common metal, a thin sheet of lead, or 3 feet of wood or dirt."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
229
src/ttfrog/five_e_tools/sources/spells/spells-scc.json
Normal file
229
src/ttfrog/five_e_tools/sources/spells/spells-scc.json
Normal file
|
@ -0,0 +1,229 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Borrowed Knowledge",
|
||||
"source": "SCC",
|
||||
"page": 37,
|
||||
"level": 2,
|
||||
"school": "D",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": {
|
||||
"text": "a book worth at least 25 gp",
|
||||
"cost": 2500
|
||||
}
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "hour",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You draw on knowledge from spirits of the past. Choose one skill in which you lack proficiency. For the spell's duration, you have proficiency in the chosen skill. The spell ends early if you cast it again."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Kinetic Jaunt",
|
||||
"source": "SCC",
|
||||
"page": 37,
|
||||
"level": 2,
|
||||
"school": "T",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "bonus"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 1
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You magically empower your movement with dance-like steps, giving yourself the following benefits for the duration.",
|
||||
{
|
||||
"type": "list",
|
||||
"items": [
|
||||
"Your walking speed increases by 10 feet.",
|
||||
"You don't provoke opportunity attacks.",
|
||||
"You can move through the space of another creature, and it doesn't count as {@quickref difficult terrain||3}. If you end your turn in another creature's space, you are shunted to the last unoccupied space you occupied, and you take {@damage 1d8} force damage."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"force"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Silvery Barbs",
|
||||
"source": "SCC",
|
||||
"page": 38,
|
||||
"level": 1,
|
||||
"school": "E",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "reaction",
|
||||
"condition": "which you take when a creature you can see within 60 feet of yourself succeeds on an attack roll, an ability check, or a saving throw"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You magically distract the triggering creature and turn its momentary uncertainty into encouragement for another creature. The triggering creature must reroll the {@dice d20} and use the lower roll.",
|
||||
"You can then choose a different creature you can see within range (you can choose yourself). The chosen creature has advantage on the next attack roll, ability check, or saving throw it makes within 1 minute. A creature can be empowered by only one use of this spell at a time."
|
||||
],
|
||||
"miscTags": [
|
||||
"ADV",
|
||||
"SGT"
|
||||
],
|
||||
"areaTags": [
|
||||
"ST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Vortex Warp",
|
||||
"source": "SCC",
|
||||
"page": 38,
|
||||
"level": 2,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 90
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You magically twist space around another creature you can see within range. The target must succeed on a Constitution saving throw (the target can choose to fail), or the target is teleported to an unoccupied space of your choice that you can see within range. The chosen space must be on a surface or in a liquid that can support the target without the target having to squeeze."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 3rd level or higher, the range of the spell increases by 30 feet for each slot level above 2nd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT",
|
||||
"TP"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Wither and Bloom",
|
||||
"source": "SCC",
|
||||
"page": 38,
|
||||
"level": 2,
|
||||
"school": "N",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 60
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a withered vine twisted into a loop"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You invoke both death and life upon a 10-foot-radius sphere centered on a point within range. Each creature of your choice in that area must make a Constitution saving throw, taking {@damage 2d6} necrotic damage on a failed save, or half as much damage on a successful one. Nonmagical vegetation in that area withers.",
|
||||
"In addition, one creature of your choice in that area can spend and roll one of its unspent Hit Dice and regain a number of hit points equal to the roll plus your spellcasting ability modifier."
|
||||
],
|
||||
"entriesHigherLevel": [
|
||||
{
|
||||
"type": "entries",
|
||||
"name": "At Higher Levels",
|
||||
"entries": [
|
||||
"When you cast this spell using a spell slot of 3rd level or higher, the damage increases by {@scaledamage 2d6|2-9|1d6} for each slot above the 2nd, and the number of Hit Dice that can be spent and added to the healing roll increases by one for each slot above 2nd."
|
||||
]
|
||||
}
|
||||
],
|
||||
"damageInflict": [
|
||||
"necrotic"
|
||||
],
|
||||
"savingThrow": [
|
||||
"constitution"
|
||||
],
|
||||
"miscTags": [
|
||||
"HL"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
1250
src/ttfrog/five_e_tools/sources/spells/spells-tce.json
Normal file
1250
src/ttfrog/five_e_tools/sources/spells/spells-tce.json
Normal file
File diff suppressed because it is too large
Load Diff
98
src/ttfrog/five_e_tools/sources/spells/spells-tdcsr.json
Normal file
98
src/ttfrog/five_e_tools/sources/spells/spells-tdcsr.json
Normal file
|
@ -0,0 +1,98 @@
|
|||
{
|
||||
"spell": [
|
||||
{
|
||||
"name": "Freedom of the Waves",
|
||||
"source": "TDCSR",
|
||||
"page": 176,
|
||||
"level": 3,
|
||||
"school": "C",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "feet",
|
||||
"amount": 120
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a strand of wet hair"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "instant"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"You conjure a deluge of seawater in a 15-foot-radius, 10-foot-tall cylinder centered on a point within range. This water takes the form of a tidal wave, a whirlpool, a waterspout, or another form of your choice. Each creature in the area must succeed on a Strength {@quickref saving throws|PHB|2|1|saving throw} against your spell save DC or take {@damage 2d8} bludgeoning damage and fall {@condition prone}. You can choose a number of creatures equal to your spellcasting modifier (minimum of 1) to automatically succeed on this {@quickref saving throws|PHB|2|1|saving throw}.",
|
||||
"If you are within the spell's area, as part of the action you use to cast the spell, you can vanish into the deluge and teleport to an unoccupied space that you can see within the spell's area."
|
||||
],
|
||||
"damageInflict": [
|
||||
"bludgeoning"
|
||||
],
|
||||
"conditionInflict": [
|
||||
"prone"
|
||||
],
|
||||
"savingThrow": [
|
||||
"strength"
|
||||
],
|
||||
"miscTags": [
|
||||
"SGT",
|
||||
"TP"
|
||||
],
|
||||
"areaTags": [
|
||||
"MT",
|
||||
"Y"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Freedom of the Winds",
|
||||
"source": "TDCSR",
|
||||
"page": 176,
|
||||
"level": 5,
|
||||
"school": "A",
|
||||
"time": [
|
||||
{
|
||||
"number": 1,
|
||||
"unit": "action"
|
||||
}
|
||||
],
|
||||
"range": {
|
||||
"type": "point",
|
||||
"distance": {
|
||||
"type": "self"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"v": true,
|
||||
"s": true,
|
||||
"m": "a scrap of sailcloth"
|
||||
},
|
||||
"duration": [
|
||||
{
|
||||
"type": "timed",
|
||||
"duration": {
|
||||
"type": "minute",
|
||||
"amount": 10
|
||||
},
|
||||
"concentration": true
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
"Wind wraps around your body, tugging at your hair and clothing as your feet lift off the ground. You gain a flying speed of 60 feet. Additionally, you have {@quickref Advantage and Disadvantage|PHB|2|0|advantage} on {@quickref ability checks|PHB|2|0} to avoid being {@condition grappled}, and on {@quickref saving throws|PHB|2|1} against being {@condition restrained} or {@condition paralyzed}.",
|
||||
"When you are targeted by a spell or attack while this spell is in effect, you can use a reaction to teleport up to 60 feet to an unoccupied space you can see. If this movement takes you out of range of the triggering spell or attack, you are unaffected by it. This spell then ends when you reappear."
|
||||
],
|
||||
"miscTags": [
|
||||
"ADV",
|
||||
"SGT",
|
||||
"TP"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
5767
src/ttfrog/five_e_tools/sources/spells/spells-xge.json
Normal file
5767
src/ttfrog/five_e_tools/sources/spells/spells-xge.json
Normal file
File diff suppressed because it is too large
Load Diff
21317
src/ttfrog/five_e_tools/sources/spells/spells-xphb.json
Normal file
21317
src/ttfrog/five_e_tools/sources/spells/spells-xphb.json
Normal file
File diff suppressed because it is too large
Load Diff
29
src/ttfrog/path.py
Normal file
29
src/ttfrog/path.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_setup_hint = "You may be able to solve this error by running 'ttfrog setup' or specifying the --root parameter."
|
||||
|
||||
|
||||
def database():
|
||||
path = Path(os.environ["DATA_PATH"]).expanduser()
|
||||
if not path.exists() or not path.is_dir():
|
||||
raise RuntimeError(f"DATA_PATH {path} doesn't exist or isn't a directory.\n\n{_setup_hint}")
|
||||
return path / Path("tabletop-frog.db")
|
||||
|
||||
|
||||
def assets():
|
||||
return Path(__file__).parent / "assets"
|
||||
|
||||
|
||||
def templates():
|
||||
try:
|
||||
return Path(os.environ["TEMPLATES_PATH"])
|
||||
except KeyError:
|
||||
return assets() / "templates"
|
||||
|
||||
|
||||
def static_files():
|
||||
try:
|
||||
return Path(os.environ["STATIC_FILES_PATH"])
|
||||
except KeyError:
|
||||
return assets() / "public"
|
0
src/ttfrog/webserver/__init__.py
Normal file
0
src/ttfrog/webserver/__init__.py
Normal file
26
src/ttfrog/webserver/application.py
Normal file
26
src/ttfrog/webserver/application.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
import logging
|
||||
from wsgiref.simple_server import make_server
|
||||
|
||||
from pyramid.config import Configurator
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
from ttfrog.webserver.routes import routes
|
||||
|
||||
|
||||
def configuration():
|
||||
config = Configurator(settings={"sqlalchemy.url": db.url, "jinja2.directories": "ttfrog.assets:templates/"})
|
||||
config.include("pyramid_tm")
|
||||
config.include("pyramid_sqlalchemy")
|
||||
config.include("pyramid_jinja2")
|
||||
config.add_static_view(name="/static", path="ttfrog.assets:static/")
|
||||
config.add_jinja2_renderer(".html", settings_prefix="jinja2.")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def start(host: str, port: int, debug: bool = False) -> None:
|
||||
logging.debug(f"Configuring webserver with {host=}, {port=}, {debug=}")
|
||||
config = configuration()
|
||||
config.include(routes)
|
||||
config.scan("ttfrog.webserver.views")
|
||||
make_server(host, int(port), config.make_wsgi_app()).serve_forever()
|
5
src/ttfrog/webserver/controllers/__init__.py
Normal file
5
src/ttfrog/webserver/controllers/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from .base import BaseController
|
||||
from .character_sheet import CharacterSheet
|
||||
from .json_data import JsonData
|
||||
|
||||
__all__ = [BaseController, CharacterSheet, JsonData]
|
13
src/ttfrog/webserver/controllers/ancestry.py
Normal file
13
src/ttfrog/webserver/controllers/ancestry.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from wtforms_alchemy import ModelForm
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
from ttfrog.db.schema import Ancestry
|
||||
|
||||
|
||||
class AncestryForm(ModelForm):
|
||||
class Meta:
|
||||
model = Ancestry
|
||||
exclude = ["slug"]
|
||||
|
||||
def get_session():
|
||||
return db.session
|
149
src/ttfrog/webserver/controllers/base.py
Normal file
149
src/ttfrog/webserver/controllers/base.py
Normal file
|
@ -0,0 +1,149 @@
|
|||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
from pyramid.interfaces import IRoutesMapper
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
|
||||
|
||||
def get_all_routes(request):
|
||||
routes = {
|
||||
"static": "/static",
|
||||
}
|
||||
uri_pattern = re.compile(r"^([^\{\*]+)")
|
||||
mapper = request.registry.queryUtility(IRoutesMapper)
|
||||
for route in mapper.get_routes():
|
||||
if route.name.startswith("__"):
|
||||
continue
|
||||
m = uri_pattern.search(route.pattern)
|
||||
if m:
|
||||
routes[route.name] = m.group(0)
|
||||
return routes
|
||||
|
||||
|
||||
class BaseController:
|
||||
model = None
|
||||
model_form = None
|
||||
|
||||
def __init__(self, request):
|
||||
self.request = request
|
||||
self.attrs = defaultdict(str)
|
||||
self._slug = None
|
||||
self._record = None
|
||||
self._form = None
|
||||
|
||||
self.config = {"static_url": "/static", "project_name": "TTFROG"}
|
||||
self.configure_for_model()
|
||||
|
||||
@property
|
||||
def slug(self):
|
||||
if not self._slug:
|
||||
parts = self.request.matchdict.get("uri", "").split("-")
|
||||
self._slug = parts[0].replace("/", "")
|
||||
return self._slug
|
||||
|
||||
@property
|
||||
def record(self):
|
||||
if not self._record and self.model:
|
||||
try:
|
||||
self._record = db.query(self.model).filter(self.model.slug == self.slug)[0]
|
||||
except IndexError:
|
||||
logging.warning(f"Could not load record with slug {self.slug}")
|
||||
self._record = self.model()
|
||||
return self._record
|
||||
|
||||
@property
|
||||
def form(self):
|
||||
if not self.model:
|
||||
return
|
||||
if not self.model_form:
|
||||
return
|
||||
if not self._form:
|
||||
if self.request.POST:
|
||||
self._form = self.model_form(self.request.POST, obj=self.record)
|
||||
else:
|
||||
self._form = self.model_form(obj=self.record)
|
||||
if not self.record.id:
|
||||
# apply the db schema defaults
|
||||
self._form.process()
|
||||
return self._form
|
||||
|
||||
@property
|
||||
def resources(self):
|
||||
return [
|
||||
{"type": "style", "uri": "css/styles.css"},
|
||||
]
|
||||
|
||||
def configure_for_model(self):
|
||||
if "all_records" not in self.attrs:
|
||||
self.attrs["all_records"] = db.query(self.model).all()
|
||||
|
||||
def template_context(self, **kwargs) -> dict:
|
||||
return dict(
|
||||
config=self.config,
|
||||
request=self.request,
|
||||
form=self.form,
|
||||
record=self.record,
|
||||
routes=get_all_routes(self.request),
|
||||
resources=self.resources,
|
||||
**self.attrs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def populate(self):
|
||||
self.form.populate_obj(self.record)
|
||||
|
||||
def populate_association(self, key, formdata):
|
||||
populated = []
|
||||
for field in formdata:
|
||||
map_id = field.pop("id")
|
||||
map_id = int(map_id) if map_id else 0
|
||||
if not field[key]:
|
||||
continue
|
||||
elif not map_id:
|
||||
populated.append(field)
|
||||
else:
|
||||
field["id"] = map_id
|
||||
populated.append(field)
|
||||
return populated
|
||||
|
||||
def validate(self):
|
||||
return self.form.validate()
|
||||
|
||||
def save(self):
|
||||
if not self.form.save.data:
|
||||
return
|
||||
if not self.validate():
|
||||
return
|
||||
logging.debug(f"{self.form.data = }")
|
||||
# previous = dict(self.record)
|
||||
logging.debug(f"{self.record = }")
|
||||
self.populate()
|
||||
# transaction_log.record(previous, self.record)
|
||||
with db.transaction():
|
||||
db.add(self.record)
|
||||
self.save_callback()
|
||||
logging.debug(f"Saved {self.record = }")
|
||||
location = self.request.current_route_path()
|
||||
if self.record.slug not in location:
|
||||
location = f"{location}/{self.record.uri}"
|
||||
logging.debug(f"Redirecting to {location}")
|
||||
return HTTPFound(location=location)
|
||||
|
||||
def delete(self):
|
||||
if not self.record.id:
|
||||
return
|
||||
with db.transaction():
|
||||
db.query(self.model).filter_by(id=self.record.id).delete()
|
||||
location = self.request.current_route_path()
|
||||
return HTTPFound(location=location)
|
||||
|
||||
def response(self):
|
||||
if not self.form:
|
||||
return
|
||||
elif self.form.save.data:
|
||||
return self.save()
|
||||
elif self.form.delete.data:
|
||||
return self.delete()
|
170
src/ttfrog/webserver/controllers/character_sheet.py
Normal file
170
src/ttfrog/webserver/controllers/character_sheet.py
Normal file
|
@ -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)
|
21
src/ttfrog/webserver/controllers/json_data.py
Normal file
21
src/ttfrog/webserver/controllers/json_data.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
from pyramid.httpexceptions import exception_response
|
||||
|
||||
from ttfrog.db import schema
|
||||
from ttfrog.db.manager import db
|
||||
|
||||
from .base import BaseController
|
||||
|
||||
|
||||
class JsonData(BaseController):
|
||||
model = None
|
||||
model_form = None
|
||||
|
||||
def configure_for_model(self):
|
||||
try:
|
||||
self.model = getattr(schema, self.request.matchdict.get("table_name"))
|
||||
except AttributeError:
|
||||
raise exception_response(404)
|
||||
|
||||
def response(self):
|
||||
query = db.query(self.model).filter_by(**self.request.params)
|
||||
return {"table_name": self.model.__tablename__, "records": query.all()}
|
21
src/ttfrog/webserver/forms.py
Normal file
21
src/ttfrog/webserver/forms.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
from wtforms.fields import SelectField, SelectMultipleField
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
|
||||
|
||||
class DeferredSelectMultipleField(SelectMultipleField):
|
||||
def __init__(self, *args, model=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.choices = [(rec.id, rec.name) for rec in db.query(model).all()]
|
||||
|
||||
|
||||
class DeferredSelectField(SelectField):
|
||||
def __init__(self, *args, model=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.choices = [(rec.id, getattr(rec, "name", str(rec))) for rec in db.query(model).all()]
|
||||
|
||||
|
||||
class NullableDeferredSelectField(DeferredSelectField):
|
||||
def __init__(self, *args, model=None, label="---", **kwargs):
|
||||
super().__init__(*args, model=model, **kwargs)
|
||||
self.choices = [(0, label)] + self.choices
|
4
src/ttfrog/webserver/routes.py
Normal file
4
src/ttfrog/webserver/routes.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
def routes(config):
|
||||
config.add_route("index", "/")
|
||||
config.add_route("sheet", "/c{uri:.*}", factory="ttfrog.webserver.controllers.CharacterSheet")
|
||||
config.add_route("data", "/_/{table_name}{uri:.*}", factory="ttfrog.webserver.controllers.JsonData")
|
26
src/ttfrog/webserver/views.py
Normal file
26
src/ttfrog/webserver/views.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from pyramid.response import Response
|
||||
from pyramid.view import view_config
|
||||
|
||||
from ttfrog.attribute_map import AttributeMap
|
||||
from ttfrog.db.manager import db
|
||||
from ttfrog.db.schema import Ancestry
|
||||
|
||||
|
||||
def response_from(controller):
|
||||
return controller.response() or AttributeMap.from_dict({"c": controller.template_context()})
|
||||
|
||||
|
||||
@view_config(route_name="index")
|
||||
def index(request):
|
||||
ancestries = [a.name for a in db.session.query(Ancestry).all()]
|
||||
return Response(",".join(ancestries))
|
||||
|
||||
|
||||
@view_config(route_name="sheet", renderer="character_sheet.html")
|
||||
def sheet(request):
|
||||
return response_from(request.context)
|
||||
|
||||
|
||||
@view_config(route_name="data", renderer="json")
|
||||
def data(request):
|
||||
return response_from(request.context)
|
140
test/conftest.py
Normal file
140
test/conftest.py
Normal file
|
@ -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()
|
19
test/fixtures/ancestry.json
vendored
Normal file
19
test/fixtures/ancestry.json
vendored
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"Ancestry": [
|
||||
{"id": 1, "name": "human", "creature_type": "humanoid"},
|
||||
{"id": 2, "name": "dragonborn", "creature_type": "humanoid"},
|
||||
{"id": 3, "name": "tiefling", "creature_type": "humanoid"},
|
||||
{"id": 4, "name": "elf", "creature_type": "humanoid"}
|
||||
],
|
||||
"AncestryTrait": [
|
||||
{"id": 1, "name": "+1 to All Ability Scores"},
|
||||
{"id": 2, "name": "Breath Weapon"},
|
||||
{"id": 3, "name": "Darkvision"}
|
||||
],
|
||||
"AncestryTraitMap": [
|
||||
{"ancestry_id": 1, "ancestry_trait_id": 1, "level": 1},
|
||||
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1},
|
||||
{"ancestry_id": 2, "ancestry_trait_id": 3, "level": 1},
|
||||
{"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1}
|
||||
]
|
||||
}
|
7
test/fixtures/ancestry_trait.json
vendored
Normal file
7
test/fixtures/ancestry_trait.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"Ancestry": [
|
||||
{"id": 1, "name": "+1 to All Ability Scores"},
|
||||
{"id": 2, "name": "Breath Weapon"},
|
||||
{"id": 3, "name": "Darkvision"}
|
||||
]
|
||||
}
|
45
test/fixtures/classes.json
vendored
Normal file
45
test/fixtures/classes.json
vendored
Normal file
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"CharacterClass": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "fighter",
|
||||
"hit_dice": "1d10",
|
||||
"hit_dice_stat": "CON",
|
||||
"proficiencies": "all armor, all shields, simple weapons, martial weapons",
|
||||
"saving_throws": ["STR, CON"],
|
||||
"skills": ["Acrobatics", "Animal Handling", "Athletics", "History", "Insight", "Intimidation", "Perception", "Survival"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "rogue",
|
||||
"hit_dice": "1d8",
|
||||
"hit_dice_stat": "DEX",
|
||||
"proficiencies": "simple weapons, hand crossbows, longswords, rapiers, shortswords",
|
||||
"saving_throws": ["DEX", "INT"],
|
||||
"skills": ["Acrobatics", "Athletics", "Deception", "Insight", "Intimidation", "Investigation", "Perception", "Performance", "Persuasion", "Sleight of Hand", "Stealth"]
|
||||
}
|
||||
],
|
||||
"ClassAttribute": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Fighting Style"
|
||||
}
|
||||
],
|
||||
"ClassAttributeMap": [
|
||||
{
|
||||
"class_attribute_id": 1,
|
||||
"character_class_id": 1,
|
||||
"level": 2
|
||||
}
|
||||
],
|
||||
"ClassAttributeOption": [
|
||||
{
|
||||
"attribute_id": 1,
|
||||
"name": "Archery"
|
||||
},
|
||||
{
|
||||
"attribute_id": 1,
|
||||
"name": "Battlemaster"
|
||||
}
|
||||
]
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user