Compare commits

...

68 Commits

Author SHA1 Message Date
evilchili
f85c5f6bd3 rename readme 2024-10-13 00:27:17 -07:00
evilchili
3269320569 starting 5e.tools importer 2024-10-13 00:15:41 -07:00
evilchili
d5b81dafb4 move spell management to CharacterSpellInventory 2024-09-21 20:45:18 -07:00
evilchili
9bece1550d formatting 2024-09-21 16:00:17 -07:00
evilchili
1b9ff9b393 reimplementing inventories 2024-09-21 15:59:40 -07:00
evilchili
926d2fdaf6 fixup test 2024-09-02 19:45:52 -07:00
evilchili
17a951b1b2 add ability to move items between inventories 2024-09-02 14:53:49 -07:00
evilchili
d9b3c4500e make inventory maps proxy items and containers propxy inventories 2024-09-02 14:02:16 -07:00
evilchili
b09b07d172 make inventories recursive 2024-09-02 12:39:59 -07:00
evilchili
01a4360dca adding containers 2024-08-29 17:10:39 -07:00
evilchili
709b0f5ad0 make character.inventories a dict 2024-08-29 16:51:02 -07:00
evilchili
68a8f4920b move inventory verbs to inventory map from character 2024-08-29 16:41:15 -07:00
evilchili
0eda35b90d ancestry speeds should be configurable at init 2024-08-29 16:14:29 -07:00
evilchili
5c80565264 adding weapons with charges 2024-08-29 15:14:47 -07:00
evilchili
5d9fde949d resolve warnings 2024-08-21 14:14:37 -07:00
evilchili
9f75630c74 add tests 2024-08-20 09:30:23 -07:00
evilchili
b7732f1581 adding weapons and attunement 2024-08-03 17:46:12 -07:00
evilchili
708d6fe9e9 add spell inventory ui, refined level up ui, fixed bugs 2024-07-30 23:17:20 -07:00
evilchili
26bb645d22 added inventory management ui 2024-07-28 20:47:42 -07:00
evilchili
3f45dbe9b9 adding inventories 2024-07-28 13:55:19 -07:00
evilchili
b68dda2b77 improve conditions test 2024-07-13 13:08:47 -07:00
evilchili
e2ff1eb027 Implement conditions 2024-07-13 12:30:43 -07:00
evilchili
da1b4223ea rename ClassAttribute ClassFeature 2024-07-05 17:45:27 -07:00
evilchili
4dd72d47d0 adding tests 2024-07-05 14:42:11 -07:00
evilchili
551140b5bc Add json import/export 2024-06-30 23:21:23 -07:00
evilchili
68251ff4e9 fix tests 2024-06-30 16:09:20 -07:00
evilchili
a8bb6de008 adding hit dice and defenses 2024-05-14 20:15:42 -07:00
evilchili
d2bed7c859 rename property module 2024-05-12 11:20:52 -07:00
evilchili
09549bf68c add support for modifier overrides with proficiency and expertise 2024-05-08 01:40:19 -07:00
evilchili
9a2d28ae75 add support for skills 2024-05-06 00:13:52 -07:00
evilchili
b574dacfa1 fix schemas 2024-05-04 13:16:20 -07:00
evilchili
3292b11d89 modifiable columns subclass int/str 2024-04-29 01:09:58 -07:00
evilchili
3980be5f07 convert to modern MappedAsDataclass models 2024-04-28 14:30:47 -07:00
evilchili
1ff0e5ca7d fixing modifier bugs, fixing traits, adding speed attrs 2024-04-23 00:15:13 -07:00
evilchili
5db6e40eae refactor modifiers 2024-04-21 21:30:24 -07:00
evilchili
36f6f831d9 implemented modifiers on char stats 2024-04-21 02:17:47 -07:00
evilchili
5ec27e9344 default attributes on db object are now queries 2024-04-20 23:40:28 -07:00
evilchili
12c643c542 adding size/speed to ancestry 2024-04-20 23:33:36 -07:00
evilchili
a520ea249e adding tests for ancestries 2024-04-20 23:27:47 -07:00
evilchili
46ef48669d typo 2024-04-20 20:39:13 -07:00
evilchili
a9593e83a2 formatting 2024-04-20 20:35:24 -07:00
evilchili
412efe2aec Make objects iterable by default, add tests, refactoring 2024-04-20 20:35:07 -07:00
evilchili
44cd8fe9c9 wip 2024-04-14 11:37:34 -07:00
evilchili
dbb9461b7a adding tests and helper UX to schema 2024-03-26 21:58:04 -07:00
evilchili
78115023bb restructuring for poetry-slam 2024-03-26 00:53:21 -07:00
evilchili
b1d7639a62 Adding tests of character schema 2024-03-24 16:56:13 -07:00
evilchili
dba8bb315a Fixing UX of class attributes 2024-03-24 16:55:51 -07:00
evilchili
b92ff868a5 fix multiclass submissions 2024-03-23 13:46:56 -07:00
evilchili
304a4d9c79 adding support for managing class attributes 2024-02-26 01:12:45 -08:00
evilchili
75b9aec28e fix deletes 2024-02-23 11:02:36 -08:00
evilchili
9757d3bee0 fix multiclassing 2024-02-23 10:45:38 -08:00
evilchili
ba0e66f9af modeling many-to-many relationships 2024-02-18 19:30:41 -08:00
evilchili
e231828425 layout updates, added json views, fixed relationships in schema 2024-02-16 01:19:25 -08:00
evilchili
1baf73a338 fixed edit bug 2024-02-08 23:26:19 -08:00
evilchili
8bde2ab5f3 adding defaults, modifiers, traits and attributes 2024-02-08 23:04:28 -08:00
evilchili
da6255a86a added transaction log, UX scaffolding 2024-02-08 01:14:35 -08:00
evilchili
2dcaa3fac6 Switching from broken QuerySelectFields 2024-02-04 16:12:03 -08:00
evilchili
99ef4d61f9 adding deletes 2024-02-04 15:22:54 -08:00
evilchili
669c9b46d6 fixing form handling for relationships 2024-02-04 11:40:30 -08:00
evilchili
32d9c42847 fixing slugs 2024-02-02 15:40:45 -08:00
evilchili
9cdf28502a sample macro 2024-02-01 00:28:35 -08:00
evilchili
9277494a05 adding create 2024-02-01 00:28:17 -08:00
evilchili
5de3f74a88 simplified post handling 2024-01-31 23:05:46 -08:00
evilchili
3444f83c91 rewrite using pyramid and wtforms 2024-01-31 22:39:54 -08:00
evilchili
5faf5c97c1 rewrite in pyramid 2024-01-30 01:25:02 -08:00
evilchili
8f17ddfb05 Adding slugs, refactoring 2024-01-28 22:14:50 -08:00
evilchili
64451ddf8b adding character sheets 2024-01-28 14:31:50 -08:00
evilchili
17da4a73ee
Merge pull request #1 from evilchili/mainline
Initial import
2024-01-28 11:02:39 -08:00
114 changed files with 285641 additions and 283 deletions

View File

@ -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

View File

@ -0,0 +1,252 @@
body {
width: 100%;
padding: 0;
margin: 0;
}
.disabled {
position : relative;
opacity: 0.3;
}
.disabled:after {
position :absolute;
left : 0;
top : 0;
width : 100%;
height : 100%;
content :' ';
}
#content {
margin: 1rem auto;
max-width: 1280px;
}
a {
text-decoration: none;
color: #000055;
}
a:visited, a:active {
color: #000055;
}
ul.nav {
background: #e7e7e7;
margin: 0;
padding: 0.5rem;
list-style-type: none;
margin-bottom: 0.5rem;
}
ul.nav li {
display: inline;
text-align: center;
background: #FFF;
padding: 0 0.5rem;
}
#sheet_container {
display: block;
}
#character_sheet {
margin-bottom:3rem;
display: grid;
grid-gap: 1rem;
grid-template-columns: 1fr 1fr;
}
#sheet_container .banner {
display: grid;
grid-template-columns: 64px 1fr;
grid-gap: 1rem;
}
#sheet_container .banner #portrait {
width: 64px;
height: 64px;
background: #e7e7e7;
}
#controls {
float: right;
display: inline-block;
}
.temp_hp input {
font-size: 0.75rem !important;
}
#sheet_container h1 {
margin: 0;
}
#sheet_container .sidebar {
grid-column-start: 2;
grid-row-start: 1;
}
#sheet_container .sidebar .card {
margin-bottom: 1rem;
}
#sheet_container .sidebar ul {
list-style-type: none;
margin: 0;
padding: 0;
}
#sheet_container .sidebar ul > li {
margin: 0;
padding: 0;
}
#hp {
grid-row-start: 1;
grid-column: 7;
grid-column-end: 9;
}
#saves {
grid-row-start: 2;
grid-column: 3;
grid-column-start: span 2;
}
#proficiency {
grid-row-start: 2;
grid-column: 5;
}
#initiative {
grid-row-start: 2;
grid-column: 6;
}
#ac {
grid-row-start: 2;
grid-column: 7;
}
#speed {
grid-row-start: 2;
grid-column: 8;
}
#skills {
grid-row-start: 2;
grid-row-end: 50;
grid-column: 1;
grid-column-start: span 2;
text-align:left;
}
#actions {
grid-row-start: 3;
grid-column: 4;
grid-column-start: span 6;
}
table {
display: grid;
grid-template-columns: minmax(50px, 150px) 1fr;
grid-gap: 0rem;
}
table th {
grid-column-start: span 4;
white-space: nowrap;
padding-right: 1rem;
text-align: left;
}
table td {
padding-right: 1rem;
white-space: nowrap;
}
.note {
font-size: 0.75em;
font-style: italic;
}
#sheet_container input,
#sheet_container select,
#sheet_container textarea {
font-weight: bold;
border: 0;
}
#sheet_container input#name {
font-size: 2.0rem;
font-weight: bold;
width: 100%;
}
.stats {
display: grid;
grid-template-columns: repeat(8, minmax(6rem, 1fr));
grid-gap: 1rem;
}
.card {
border: 2px solid #e7e7e7;
border-radius: 4px;
padding: .5rem;
text-align: center;
}
.label {
text-align: center;
text-transform: uppercase;
font-size: 0.75rem;
}
.card input {
font-size: 1.25em;
text-align: center;
padding: 0;
margin: 0;
}
ul.multiclass {
display: inline;
list-style: none;
margin: 0;
padding: 0;
}
.multiclass li {
display: inline;
}
.multiclass label {
display: none;
}
ul#class_attributes {
list-style-type: none;
list-style: none;
margin: 0;
padding: 0;
}
ul#class_attributes li {
display: grid;
grid-template-columns: min-content 1fr 1fr;
}
ul#class_attributes span,
ul#class_attributes label {
margin-right: 0.5rem;
}
ul#class_attributes label {
text-align: left;
font-weight: bold;
}
ul#class_attributes span select {
width: 100%;
}

View 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();
})();

View File

@ -0,0 +1,30 @@
{% from "list.html" import build_list %}
<!doctype html>
<html lang="en">
<head>
<title>{{ c.config.project_name }}{% block title %}{% endblock %}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="og:provider_name" content="{{ c.config.project_name }}">
{% for resource in c.resources %}
<link rel='preload' href="{{c.routes.static}}/{{resource['uri']}}" as="{{resource['type']}}"/>
{% if resource['type'] == 'style' %}
<link rel='stylesheet' href="{{c.routes.static}}/{{resource['uri']}}" />
{% endif %}
{% endfor %}
{% block headers %}{% endblock %}
</head>
<body>
{{ build_list(c) }}
<div id='content'>
{% block content %}{% endblock %}
</div>
{% block debug %}{% endblock %}
{% block script %}{% endblock %}
{% for resource in c.resources %}
{% if resource['type'] == 'script' %}
<script type="text/javascript" src="{{c.routes.static}}/{{resource['uri']}}"></script>
{% endif %}
{% endfor %}
</body>
</html>

View 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 }} &nbsp; {{ c.form.delete }}
</div>
</div>
</div>
</div>
<div id='character_sheet' {% if not c.record.id %}class='disabled'{% endif %} >
<div class='stats'>
{% for stat in ['str', 'dex', 'con', 'int', 'wis', 'cha'] %}
<div class='card'>
<div class='label'>{{ c.form[stat].label }}</div>
{{ field(stat, DISABLED) }}
<div id='{{stat}}_bonus'></div>
</div>
{% endfor %}
<div id='hp' class='card'>
<div class='label'>HP</div>
{{ field('hit_points', DISABLED) }} / {{ field('max_hit_points', DISABLED) }}
<div id='temp_hp'>
<span class='label'>TEMP</span> {{ field('temp_hit_points', DISABLED) }}
</div>
</div>
<div id='skills'>
<div class='label'>Skills</div>
<table>
{% for skill in c.record.skills %}
<tr><td>{{ skill }}</td><td>3</td></tr>
{% endfor %}
</table>
</div>
<div id='saves' class='card'>
<div class='label'>Saving Throws</div>
{% for save in c.record.saving_throws %}
{{ save }} 3&nbsp;
{% endfor %}
</div>
<div id='proficiency' class='card'>
<div class='label'>PROF</div>
<div id='proficiency_bonus'></div>
<div class='label'>BONUS</div>
</div>
<div id="ac" class='card'>
<div class='label'>Armor</div>
{{ field('armor_class', DISABLED) }}
<div class='label'>Class</div>
</div>
<div id='initiative' class='card'>
<div class='label'>Initiative</div>
<span id='initiative_bonus'>3 </span>
<div class='label'>Bonus</div>
</div>
<div id='speed' class='card'>
<div class='label'>Speed</div>
{{ field('speed', DISABLED) }}
</div>
<div id="actions" class='card'>
<table>
<tr>
<td class='label' colspan='2'>Actions</td>
<td class='label'>To Hit</td>
<td class='label'>Range</td>
<td class='label'>Targets</td>
<td class='label'>Damage</td>
</tr>
<tr>
<th>Attack</th>
<td>Dagger</td>
<td>+7</td>
<td>5</td>
<td>1</td>
<td>1d4+3 slashing</td>
</tr>
<tr>
<th>Attack</th>
<td>Sabetha's Fans</td>
<td>+7</td>
<td>5</td>
<td>1</td>
<td>2d6 slashing</td>
</tr>
<tr>
<th>Spell</th>
<td>Eldritch Blast</td>
<td>+5</td>
<td>120</td>
<td>1</td>
<td>1d10 force</td>
</tr>
<tr>
<td class='label' colspan='2'>Bonus Actions</td>
<td class='label'>To Hit</td>
<td class='label'>Range</td>
<td class='label'>Targets</td>
<td class='label'>Damage</td>
</tr>
</table>
<p>
<span class='note'>
Attack (1 per Action), Cast a Spell, Dash, Disengage, Dodge, Grapple,<br>Help, Hide, Improvise, Ready, Search, Shove, or Use an Object
</span>
</p>
</div>
</div>
<!-- SIDEBAR -->
<div class='sidebar'>
<div class='card'>
<div class='label'>Inspiration</div>
<ul>
</ul>
</div>
<div class='card'>
<div class='label'>Conditions</div>
<ul>
</ul>
</div>
<div class='card'>
<div class='label'>Attributes</div>
{% if c.record.attribute_list %}
{{ field('attribute_list') }}
{% endif %}
</div>
<div class='card'>
<div class='label'>Defenses</div>
<ul>
<li>Vulnerable to Fire</li>
<li>Immune to Cold</li>
<li>Resistant to Poison</li>
</ul>
</div>
</div>
</div>
<hr>
{{ c.form.csrf_token }}
</form>
{% endblock %}
{% block debug %}
<div style='clear:both;display:block;'>
<h2>Debug</h2>
<code>
{% for field, msg in c.form.errors.items() %}
{{ field }}: {{ msg }}
{% endfor %}
</code>
{{ c.record }}
</code>
{% endblock %}
{% block script %}
<script type='text/javascript'>
const TRAITS = {
{% for trait_desc, traits in [] %}
'{{ trait_desc }}': [
{% for trait in traits %}
{
"type": "{{ trait['type'] }}",
"target": "{{ trait.target }}",
"value": "{{ trait.value }}",
},
{% endfor %}
],
{% endfor %}
};
</script>
{% endblock %}

View File

@ -0,0 +1,8 @@
{% macro build_list(c) %}
<ul class='nav'>
<li><a href="{{ c.routes.sheet }}">Create a Character</a></li>
{% for rec in c.all_records %}
<li><a href="{{ c.routes.sheet }}/{{ rec.uri }}">{{ rec.name }}</a></li>
{% endfor %}
</ul>
{% endmacro %}

View File

@ -0,0 +1,71 @@
from collections.abc import Mapping
from dataclasses import dataclass, field
@dataclass
class AttributeMap(Mapping):
"""
AttributeMap is a data class that is also a mapping, converting a dict
into an object with attributes. Example:
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
>>> amap.foo
True
>>> amap.bar
False
Instantiating an AttributeMap using the from_dict() class method will
recursively transform dictionary members sinto AttributeMaps:
>>> nested_dict = {'foo': {'bar': {'baz': True}, 'boz': False}}
>>> amap = AttributeMap.from_dict(nested_dict)
>>> amap.foo.bar.baz
True
>>> amap.foo.boz
False
The dictionary can be accessed directly via 'attributes':
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
>>> list(amap.attributes.keys()):
>>>['foo', 'bar']
Because AttributeMap is a mapping, you can use it anywhere you would use
a regular mapping, like a dict:
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
>>> 'foo' in amap
True
>>> "{foo}, {bar}".format(**amap)
True, False
"""
attributes: field(default_factory=dict)
def __getattr__(self, attr):
if attr in self.attributes:
return self.attributes[attr]
return self.__getattribute__(attr)
def __len__(self):
return len(self.attributes)
def __getitem__(self, key):
return self.attributes[key]
def __iter__(self):
return iter(self.attributes)
@classmethod
def from_dict(cls, kwargs: dict):
"""
Create a new AttributeMap object using keyword arguments. Dicts are
recursively converted to AttributeMap objects; everything else is
passed as-is.
"""
attrs = {}
for k, v in sorted(kwargs.items()):
attrs[k] = AttributeMap.from_dict(v) if type(v) is dict else v
return cls(attributes=attrs)

View File

@ -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
View 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))

View 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
}
]
}

View 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
View 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)

View 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 *

View 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)

View 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]))

View 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"

View 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)

View File

@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text
from ttfrog.db.base import BaseObject
__all__ = ["TransactionLog"]
class TransactionLog(BaseObject):
__tablename__ = "transaction_log"
id = Column(Integer, primary_key=True, autoincrement=True)
source_table_name = Column(String, index=True, nullable=False)
primary_key = Column(Integer, index=True)
diff = Column(Text)

View 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}"

View 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?

View File

@ -0,0 +1,17 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject
__all__ = [
"Skill",
]
class Skill(BaseObject):
__tablename__ = "skill"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
base_id: Mapped[int] = mapped_column(ForeignKey("skill.id"), nullable=True, default=None)
parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False)

View File

@ -0,0 +1,37 @@
import json
import logging
from ttfrog.db.manager import db
from ttfrog.db.schema import TransactionLog
def record(previous, new):
logging.debug(f"{previous = }, {new = }")
diff = list((set(previous.items()) ^ set(dict(new).items())))
if not diff:
return
rec = TransactionLog(
source_table_name=new.__tablename__,
primary_key=new.id,
diff=json.dumps(diff),
)
with db.transaction():
db.add(rec)
logging.debug(f"Saved restore point: {dict(rec)}")
return rec
def restore(rec, log_id=None):
if log_id:
log = db.query(TransactionLog).filter_by(id=log_id).one()
else:
log = db.query(TransactionLog).filter_by(source_table_name=rec.__tablename__, primary_key=rec.id).one()
logging.debug(f"Located restore point {log = }")
diff = json.loads(log.diff)
updates = dict(diff[::2])
if not updates:
return
logging.debug(f"{updates = }")
with db.transaction():
db.query(db.tables[log.source_table_name]).update(updates)

View File

View 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()

View 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

View 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()

View 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

View 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

View File

@ -0,0 +1,4 @@
Everything here is forked from
https://github.com/5etools-mirror-3/5etools-src

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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(&quot;hello world&quot;)} 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 &lt;name&gt;|&lt;source&gt;|&lt;chapterIndex&gt;|&lt;headerIndex&gt;|&lt;displayText&gt;}: {@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 &lt;m|r|a&gt;[&lt;w|s&gt;][,&lt;m|r|a&gt;[&lt;w|s&gt;][,...]]'"
]
},
"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
}
]
}
]
}
]
}

View 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."
]
}
]
}
]
}
]
}

View 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."
]
}
]
}

View 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"
}

File diff suppressed because it is too large Load Diff

View 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}."
]
}
]
}

View 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"
]
}
]
}

View 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"
]
}
]
}

View 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"
]
}
]
}

View 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"
]
}
]
}

View 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"
]
}
]
}

View 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"
]
}
]
}

View 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
}
]
}

View 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"
]
}
]
}

View 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
}
]
}

View 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"
]
}
]
}

View 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"
]
}
]
}

File diff suppressed because it is too large Load Diff

View 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."
]
}
]
}

View 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"
]
}
]
}

File diff suppressed because it is too large Load Diff

View 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"
]
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

29
src/ttfrog/path.py Normal file
View File

@ -0,0 +1,29 @@
import os
from pathlib import Path
_setup_hint = "You may be able to solve this error by running 'ttfrog setup' or specifying the --root parameter."
def database():
path = Path(os.environ["DATA_PATH"]).expanduser()
if not path.exists() or not path.is_dir():
raise RuntimeError(f"DATA_PATH {path} doesn't exist or isn't a directory.\n\n{_setup_hint}")
return path / Path("tabletop-frog.db")
def assets():
return Path(__file__).parent / "assets"
def templates():
try:
return Path(os.environ["TEMPLATES_PATH"])
except KeyError:
return assets() / "templates"
def static_files():
try:
return Path(os.environ["STATIC_FILES_PATH"])
except KeyError:
return assets() / "public"

View File

View File

@ -0,0 +1,26 @@
import logging
from wsgiref.simple_server import make_server
from pyramid.config import Configurator
from ttfrog.db.manager import db
from ttfrog.webserver.routes import routes
def configuration():
config = Configurator(settings={"sqlalchemy.url": db.url, "jinja2.directories": "ttfrog.assets:templates/"})
config.include("pyramid_tm")
config.include("pyramid_sqlalchemy")
config.include("pyramid_jinja2")
config.add_static_view(name="/static", path="ttfrog.assets:static/")
config.add_jinja2_renderer(".html", settings_prefix="jinja2.")
return config
def start(host: str, port: int, debug: bool = False) -> None:
logging.debug(f"Configuring webserver with {host=}, {port=}, {debug=}")
config = configuration()
config.include(routes)
config.scan("ttfrog.webserver.views")
make_server(host, int(port), config.make_wsgi_app()).serve_forever()

View File

@ -0,0 +1,5 @@
from .base import BaseController
from .character_sheet import CharacterSheet
from .json_data import JsonData
__all__ = [BaseController, CharacterSheet, JsonData]

View 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

View File

@ -0,0 +1,149 @@
import logging
import re
from collections import defaultdict
from pyramid.httpexceptions import HTTPFound
from pyramid.interfaces import IRoutesMapper
from ttfrog.db.manager import db
def get_all_routes(request):
routes = {
"static": "/static",
}
uri_pattern = re.compile(r"^([^\{\*]+)")
mapper = request.registry.queryUtility(IRoutesMapper)
for route in mapper.get_routes():
if route.name.startswith("__"):
continue
m = uri_pattern.search(route.pattern)
if m:
routes[route.name] = m.group(0)
return routes
class BaseController:
model = None
model_form = None
def __init__(self, request):
self.request = request
self.attrs = defaultdict(str)
self._slug = None
self._record = None
self._form = None
self.config = {"static_url": "/static", "project_name": "TTFROG"}
self.configure_for_model()
@property
def slug(self):
if not self._slug:
parts = self.request.matchdict.get("uri", "").split("-")
self._slug = parts[0].replace("/", "")
return self._slug
@property
def record(self):
if not self._record and self.model:
try:
self._record = db.query(self.model).filter(self.model.slug == self.slug)[0]
except IndexError:
logging.warning(f"Could not load record with slug {self.slug}")
self._record = self.model()
return self._record
@property
def form(self):
if not self.model:
return
if not self.model_form:
return
if not self._form:
if self.request.POST:
self._form = self.model_form(self.request.POST, obj=self.record)
else:
self._form = self.model_form(obj=self.record)
if not self.record.id:
# apply the db schema defaults
self._form.process()
return self._form
@property
def resources(self):
return [
{"type": "style", "uri": "css/styles.css"},
]
def configure_for_model(self):
if "all_records" not in self.attrs:
self.attrs["all_records"] = db.query(self.model).all()
def template_context(self, **kwargs) -> dict:
return dict(
config=self.config,
request=self.request,
form=self.form,
record=self.record,
routes=get_all_routes(self.request),
resources=self.resources,
**self.attrs,
**kwargs,
)
def populate(self):
self.form.populate_obj(self.record)
def populate_association(self, key, formdata):
populated = []
for field in formdata:
map_id = field.pop("id")
map_id = int(map_id) if map_id else 0
if not field[key]:
continue
elif not map_id:
populated.append(field)
else:
field["id"] = map_id
populated.append(field)
return populated
def validate(self):
return self.form.validate()
def save(self):
if not self.form.save.data:
return
if not self.validate():
return
logging.debug(f"{self.form.data = }")
# previous = dict(self.record)
logging.debug(f"{self.record = }")
self.populate()
# transaction_log.record(previous, self.record)
with db.transaction():
db.add(self.record)
self.save_callback()
logging.debug(f"Saved {self.record = }")
location = self.request.current_route_path()
if self.record.slug not in location:
location = f"{location}/{self.record.uri}"
logging.debug(f"Redirecting to {location}")
return HTTPFound(location=location)
def delete(self):
if not self.record.id:
return
with db.transaction():
db.query(self.model).filter_by(id=self.record.id).delete()
location = self.request.current_route_path()
return HTTPFound(location=location)
def response(self):
if not self.form:
return
elif self.form.save.data:
return self.save()
elif self.form.delete.data:
return self.delete()

View 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)

View 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()}

View File

@ -0,0 +1,21 @@
from wtforms.fields import SelectField, SelectMultipleField
from ttfrog.db.manager import db
class DeferredSelectMultipleField(SelectMultipleField):
def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs)
self.choices = [(rec.id, rec.name) for rec in db.query(model).all()]
class DeferredSelectField(SelectField):
def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs)
self.choices = [(rec.id, getattr(rec, "name", str(rec))) for rec in db.query(model).all()]
class NullableDeferredSelectField(DeferredSelectField):
def __init__(self, *args, model=None, label="---", **kwargs):
super().__init__(*args, model=model, **kwargs)
self.choices = [(0, label)] + self.choices

View File

@ -0,0 +1,4 @@
def routes(config):
config.add_route("index", "/")
config.add_route("sheet", "/c{uri:.*}", factory="ttfrog.webserver.controllers.CharacterSheet")
config.add_route("data", "/_/{table_name}{uri:.*}", factory="ttfrog.webserver.controllers.JsonData")

View File

@ -0,0 +1,26 @@
from pyramid.response import Response
from pyramid.view import view_config
from ttfrog.attribute_map import AttributeMap
from ttfrog.db.manager import db
from ttfrog.db.schema import Ancestry
def response_from(controller):
return controller.response() or AttributeMap.from_dict({"c": controller.template_context()})
@view_config(route_name="index")
def index(request):
ancestries = [a.name for a in db.session.query(Ancestry).all()]
return Response(",".join(ancestries))
@view_config(route_name="sheet", renderer="character_sheet.html")
def sheet(request):
return response_from(request.context)
@view_config(route_name="data", renderer="json")
def data(request):
return response_from(request.context)

140
test/conftest.py Normal file
View 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
View File

@ -0,0 +1,19 @@
{
"Ancestry": [
{"id": 1, "name": "human", "creature_type": "humanoid"},
{"id": 2, "name": "dragonborn", "creature_type": "humanoid"},
{"id": 3, "name": "tiefling", "creature_type": "humanoid"},
{"id": 4, "name": "elf", "creature_type": "humanoid"}
],
"AncestryTrait": [
{"id": 1, "name": "+1 to All Ability Scores"},
{"id": 2, "name": "Breath Weapon"},
{"id": 3, "name": "Darkvision"}
],
"AncestryTraitMap": [
{"ancestry_id": 1, "ancestry_trait_id": 1, "level": 1},
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1},
{"ancestry_id": 2, "ancestry_trait_id": 3, "level": 1},
{"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1}
]
}

7
test/fixtures/ancestry_trait.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"Ancestry": [
{"id": 1, "name": "+1 to All Ability Scores"},
{"id": 2, "name": "Breath Weapon"},
{"id": 3, "name": "Darkvision"}
]
}

45
test/fixtures/classes.json vendored Normal file
View File

@ -0,0 +1,45 @@
{
"CharacterClass": [
{
"id": 1,
"name": "fighter",
"hit_dice": "1d10",
"hit_dice_stat": "CON",
"proficiencies": "all armor, all shields, simple weapons, martial weapons",
"saving_throws": ["STR, CON"],
"skills": ["Acrobatics", "Animal Handling", "Athletics", "History", "Insight", "Intimidation", "Perception", "Survival"]
},
{
"id": 2,
"name": "rogue",
"hit_dice": "1d8",
"hit_dice_stat": "DEX",
"proficiencies": "simple weapons, hand crossbows, longswords, rapiers, shortswords",
"saving_throws": ["DEX", "INT"],
"skills": ["Acrobatics", "Athletics", "Deception", "Insight", "Intimidation", "Investigation", "Perception", "Performance", "Persuasion", "Sleight of Hand", "Stealth"]
}
],
"ClassAttribute": [
{
"id": 1,
"name": "Fighting Style"
}
],
"ClassAttributeMap": [
{
"class_attribute_id": 1,
"character_class_id": 1,
"level": 2
}
],
"ClassAttributeOption": [
{
"attribute_id": 1,
"name": "Archery"
},
{
"attribute_id": 1,
"name": "Battlemaster"
}
]
}

Some files were not shown because too many files have changed in this diff Show More