dndkit 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dndkit/__init__.py +10 -0
- dndkit/__main__.py +6 -0
- dndkit/activity.py +68 -0
- dndkit/app.py +105 -0
- dndkit/config.py +104 -0
- dndkit/errors.py +48 -0
- dndkit/game/__init__.py +1 -0
- dndkit/game/areas.py +181 -0
- dndkit/game/battlefield.py +154 -0
- dndkit/game/bestiary.py +155 -0
- dndkit/game/characters.py +227 -0
- dndkit/game/combat.py +209 -0
- dndkit/game/combatants.py +188 -0
- dndkit/game/concentration.py +43 -0
- dndkit/game/dice.py +186 -0
- dndkit/game/effects.py +183 -0
- dndkit/game/grid.py +88 -0
- dndkit/game/journal.py +47 -0
- dndkit/game/movement.py +264 -0
- dndkit/game/pathing.py +153 -0
- dndkit/game/proficiencies.py +104 -0
- dndkit/game/progression.py +77 -0
- dndkit/game/readiness.py +214 -0
- dndkit/game/reference.py +123 -0
- dndkit/game/registry.py +172 -0
- dndkit/game/resources.py +96 -0
- dndkit/game/rests.py +98 -0
- dndkit/game/rules.py +196 -0
- dndkit/game/seating.py +71 -0
- dndkit/game/sight.py +142 -0
- dndkit/game/spellbook.py +97 -0
- dndkit/game/tables.py +216 -0
- dndkit/game/world.py +125 -0
- dndkit/models/__init__.py +69 -0
- dndkit/models/base.py +26 -0
- dndkit/models/battle.py +40 -0
- dndkit/models/bestiary.py +56 -0
- dndkit/models/campaign.py +209 -0
- dndkit/models/character.py +149 -0
- dndkit/models/world.py +62 -0
- dndkit/skills/__init__.py +14 -0
- dndkit/skills/catalog.py +113 -0
- dndkit/skills/dnd-battle-map/SKILL.md +78 -0
- dndkit/skills/dnd-game-master/SKILL.md +125 -0
- dndkit/skills/dnd-monsters/SKILL.md +85 -0
- dndkit/skills/extension.py +93 -0
- dndkit/storage/__init__.py +6 -0
- dndkit/storage/catalog.py +72 -0
- dndkit/storage/confirmations.py +54 -0
- dndkit/storage/files.py +55 -0
- dndkit/storage/sharing.py +67 -0
- dndkit/storage/store.py +278 -0
- dndkit/text.py +13 -0
- dndkit/tools/__init__.py +47 -0
- dndkit/tools/apps.py +47 -0
- dndkit/tools/battle.py +117 -0
- dndkit/tools/bestiary.py +121 -0
- dndkit/tools/campaign.py +208 -0
- dndkit/tools/characters.py +189 -0
- dndkit/tools/combat.py +169 -0
- dndkit/tools/deps.py +42 -0
- dndkit/tools/journal.py +102 -0
- dndkit/tools/locations.py +98 -0
- dndkit/tools/npcs.py +101 -0
- dndkit/tools/play.py +118 -0
- dndkit/tools/players.py +44 -0
- dndkit/tools/progression.py +41 -0
- dndkit/tools/quests.py +95 -0
- dndkit/tools/recorder.py +63 -0
- dndkit/tools/render.py +281 -0
- dndkit/tools/render_combat.py +121 -0
- dndkit/tools/render_map.py +141 -0
- dndkit/tools/resources.py +104 -0
- dndkit/tools/schema.py +47 -0
- dndkit/tools/screen.py +47 -0
- dndkit/tools/spells.py +67 -0
- dndkit/tools/utility.py +42 -0
- dndkit/ui/__init__.py +52 -0
- dndkit/ui/backstage.js +41 -0
- dndkit/ui/battlemap.js +70 -0
- dndkit/ui/character.html +23 -0
- dndkit/ui/combat.html +18 -0
- dndkit/ui/components.css +283 -0
- dndkit/ui/components.js +261 -0
- dndkit/ui/desk.js +91 -0
- dndkit/ui/dice.html +42 -0
- dndkit/ui/dicetray.js +99 -0
- dndkit/ui/gm.html +59 -0
- dndkit/ui/party.html +18 -0
- dndkit/ui/player.html +61 -0
- dndkit/ui/seating.html +18 -0
- dndkit/ui/shared.css +195 -0
- dndkit/ui/shared.js +242 -0
- dndkit/ui/sheet.js +75 -0
- dndkit/ui/table.html +41 -0
- dndkit/ui/words.js +120 -0
- dndkit/ui/world.js +56 -0
- dndkit/views/__init__.py +7 -0
- dndkit/views/backstage.py +49 -0
- dndkit/views/battle.py +92 -0
- dndkit/views/desk.py +76 -0
- dndkit/views/everyone.py +194 -0
- dndkit/views/public.py +89 -0
- dndkit/views/widgets.py +58 -0
- dndkit/views/world.py +70 -0
- dndkit/web/__init__.py +10 -0
- dndkit/web/access.py +136 -0
- dndkit/web/audiences.py +111 -0
- dndkit/web/changes.py +84 -0
- dndkit/web/feed.py +112 -0
- dndkit/web/routes.py +134 -0
- dndkit/web/server.py +131 -0
- dndkit-0.4.0.dist-info/METADATA +567 -0
- dndkit-0.4.0.dist-info/RECORD +118 -0
- dndkit-0.4.0.dist-info/WHEEL +5 -0
- dndkit-0.4.0.dist-info/entry_points.txt +2 -0
- dndkit-0.4.0.dist-info/licenses/LICENSE +22 -0
- dndkit-0.4.0.dist-info/top_level.txt +1 -0
dndkit/tools/schema.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Compact the JSON schemas advertised to clients.
|
|
2
|
+
|
|
3
|
+
An optional parameter (``int | None = None``) is advertised by Pydantic as
|
|
4
|
+
``{"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null}``. A
|
|
5
|
+
model leaves an optional parameter out rather than sending null, so the
|
|
6
|
+
advertised form keeps only the real type: about a third fewer schema tokens,
|
|
7
|
+
which keeps the catalogue under roomkit's Tool Search threshold, and no
|
|
8
|
+
``anyOf`` for Gemini to stumble on. A model parameter (``Participant``) also
|
|
9
|
+
loses its docstring, which repeats the parameter's own description.
|
|
10
|
+
Validation is untouched: FastMCP checks calls against the Python signature,
|
|
11
|
+
which still accepts null.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
_NULL = {"type": "null"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compact(schema: dict[str, Any]) -> dict[str, Any]:
|
|
20
|
+
"""A copy of *schema* with nullable optionals reduced to their real type."""
|
|
21
|
+
result = dict(schema)
|
|
22
|
+
properties = schema.get("properties")
|
|
23
|
+
if isinstance(properties, dict):
|
|
24
|
+
result["properties"] = {name: _property(prop) for name, prop in properties.items()}
|
|
25
|
+
items = schema.get("items")
|
|
26
|
+
if isinstance(items, dict):
|
|
27
|
+
result["items"] = compact(items)
|
|
28
|
+
definitions = schema.get("$defs")
|
|
29
|
+
if isinstance(definitions, dict): # models such as Participant; FastMCP inlines them later
|
|
30
|
+
result["$defs"] = {name: _definition(d) for name, d in definitions.items()}
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _definition(model: dict[str, Any]) -> dict[str, Any]:
|
|
35
|
+
# A model's docstring would repeat what the parameter using it already says.
|
|
36
|
+
return {key: value for key, value in compact(model).items() if key != "description"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _property(prop: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
variants = prop.get("anyOf")
|
|
41
|
+
if isinstance(variants, list) and len(variants) == 2 and _NULL in variants:
|
|
42
|
+
real = next(v for v in variants if v != _NULL)
|
|
43
|
+
rest = {key: value for key, value in prop.items() if key != "anyOf"}
|
|
44
|
+
if rest.get("default", ...) is None:
|
|
45
|
+
del rest["default"]
|
|
46
|
+
prop = {**real, **rest}
|
|
47
|
+
return compact(prop)
|
dndkit/tools/screen.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The data tool the MCP Apps widgets read; the conversation never needs it."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
|
+
|
|
7
|
+
from fastmcp.dependencies import Depends
|
|
8
|
+
from fastmcp.tools import tool
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from dndkit.errors import InvalidChangeError
|
|
12
|
+
from dndkit.game.dice import DiceLog
|
|
13
|
+
from dndkit.models import Campaign
|
|
14
|
+
from dndkit.storage import CampaignStore
|
|
15
|
+
from dndkit.tools.deps import READ_ONLY, get_dice_log, get_store
|
|
16
|
+
from dndkit.views import everyone, widgets
|
|
17
|
+
|
|
18
|
+
View = Literal["dice", "combat", "party", "character", "seating"]
|
|
19
|
+
CAMPAIGN_VIEWS: dict[str, Callable[[Campaign], everyone.Json]] = {
|
|
20
|
+
"combat": widgets.combat,
|
|
21
|
+
"party": everyone.party,
|
|
22
|
+
"seating": everyone.seating_chart,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@tool(annotations=READ_ONLY)
|
|
27
|
+
def get_screen_data(
|
|
28
|
+
view: Annotated[View, Field(description="Which widget")],
|
|
29
|
+
name: Annotated[str | None, Field(description="Character, for the character view")] = None,
|
|
30
|
+
store: CampaignStore = Depends(get_store),
|
|
31
|
+
log: DiceLog = Depends(get_dice_log),
|
|
32
|
+
) -> str:
|
|
33
|
+
"""JSON for the on-screen widgets. Never needed in conversation."""
|
|
34
|
+
if view == "dice":
|
|
35
|
+
data = everyone.dice(log)
|
|
36
|
+
elif view == "character" and not name:
|
|
37
|
+
raise InvalidChangeError("The character view needs a name.")
|
|
38
|
+
else:
|
|
39
|
+
with store.reading() as campaign:
|
|
40
|
+
if view == "character" and name:
|
|
41
|
+
data = everyone.character(campaign, name)
|
|
42
|
+
else:
|
|
43
|
+
data = CAMPAIGN_VIEWS[view](campaign)
|
|
44
|
+
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
TOOLS: list[Callable[..., str]] = [get_screen_data]
|
dndkit/tools/spells.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Spell tools: slots and the spells a character knows."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastmcp.dependencies import Depends
|
|
7
|
+
from fastmcp.tools import tool
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from dndkit.game import spellbook
|
|
11
|
+
from dndkit.storage import CampaignStore
|
|
12
|
+
from dndkit.tools.deps import NameOrId, get_store, provided
|
|
13
|
+
|
|
14
|
+
SpellLevel = Annotated[int, Field(ge=1, le=9, description="Spell slot level (1-9)")]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@tool
|
|
18
|
+
def set_spell_slots(
|
|
19
|
+
character_name_or_id: NameOrId,
|
|
20
|
+
level: SpellLevel,
|
|
21
|
+
max_slots: Annotated[int, Field(ge=0, le=9, description="Slots at this level; 0 removes")],
|
|
22
|
+
store: CampaignStore = Depends(get_store),
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Set how many spell slots a character has at one level (level_up does it by class)."""
|
|
25
|
+
with store.transaction() as campaign:
|
|
26
|
+
character = spellbook.set_spell_slots(campaign, character_name_or_id, level, max_slots)
|
|
27
|
+
return f"{character.name}'s spell slots: {spellbook.describe_slots(character)}."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@tool
|
|
31
|
+
def use_spell_slot(
|
|
32
|
+
character_name_or_id: NameOrId,
|
|
33
|
+
level: SpellLevel,
|
|
34
|
+
store: CampaignStore = Depends(get_store),
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Spend one spell slot when a character casts a levelled spell."""
|
|
37
|
+
with store.transaction() as campaign:
|
|
38
|
+
character, left = spellbook.use_spell_slot(campaign, character_name_or_id, level)
|
|
39
|
+
return f"{character.name} has {left} level {level} slot(s) left."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@tool
|
|
43
|
+
def set_known_spell(
|
|
44
|
+
character_name_or_id: NameOrId,
|
|
45
|
+
spell: Annotated[str, Field(min_length=1, description="Spell name")],
|
|
46
|
+
level: Annotated[int | None, Field(ge=0, le=9, description="0 for a cantrip")] = None,
|
|
47
|
+
prepared: Annotated[bool | None, Field(description="Prepared today")] = None,
|
|
48
|
+
description: Annotated[str | None, Field(description="Short reminder of its effect")] = None,
|
|
49
|
+
forget: Annotated[bool, Field(description="Remove it from the list")] = False,
|
|
50
|
+
store: CampaignStore = Depends(get_store),
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Add, change or remove (forget=true) a spell on a character's spell list."""
|
|
53
|
+
with store.transaction() as campaign:
|
|
54
|
+
if forget:
|
|
55
|
+
character, known = spellbook.forget_spell(campaign, character_name_or_id, spell)
|
|
56
|
+
return f"{character.name} no longer knows {known.name}."
|
|
57
|
+
changes = provided(level=level, prepared=prepared, description=description)
|
|
58
|
+
character, known, created = spellbook.set_known_spell(
|
|
59
|
+
campaign, character_name_or_id, spell, changes
|
|
60
|
+
)
|
|
61
|
+
verb = "learns" if created else "updates"
|
|
62
|
+
level_text = "cantrip" if known.level == 0 else f"level {known.level}"
|
|
63
|
+
prepared_text = ", prepared" if known.prepared else ""
|
|
64
|
+
return f"{character.name} {verb} {known.name} ({level_text}{prepared_text})."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
TOOLS: list[Callable[..., str]] = [set_spell_slots, use_spell_slot, set_known_spell]
|
dndkit/tools/utility.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Utility tools that need no campaign: dice and rules reminders."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastmcp.dependencies import Depends
|
|
7
|
+
from fastmcp.tools import tool
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from dndkit.game import dice, reference
|
|
11
|
+
from dndkit.game.dice import DiceLog
|
|
12
|
+
from dndkit.tools.apps import DICE_APP, app_meta
|
|
13
|
+
from dndkit.tools.deps import READ_ONLY, get_dice_log
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@tool(meta=app_meta(DICE_APP))
|
|
17
|
+
def roll_dice(
|
|
18
|
+
notation: Annotated[
|
|
19
|
+
str, Field(min_length=1, description="Dice to roll, e.g. d20, 1d20+5, 2d6+1d4+3")
|
|
20
|
+
],
|
|
21
|
+
advantage: Annotated[bool, Field(description="Roll a d20 twice, keep the higher")] = False,
|
|
22
|
+
disadvantage: Annotated[bool, Field(description="Roll a d20 twice, keep the lower")] = False,
|
|
23
|
+
secret: Annotated[bool, Field(description="Kept off the players' screens")] = False,
|
|
24
|
+
log: DiceLog = Depends(get_dice_log),
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Roll dice (shown rolling on screen). Advantage and disadvantage cancel out. A secret
|
|
27
|
+
roll (a hidden foe's Stealth, an NPC's lie) shows on the backstage only."""
|
|
28
|
+
result = dice.roll(notation, advantage=advantage, disadvantage=disadvantage)
|
|
29
|
+
log.record(result, secret=secret)
|
|
30
|
+
return f"{'Secret: ' if secret else ''}{result.notation}: {result.detail}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@tool(annotations=READ_ONLY)
|
|
34
|
+
def lookup_rule(
|
|
35
|
+
topic: Annotated[str, Field(description="A condition or rule, e.g. prone, cover, à terre")],
|
|
36
|
+
) -> str:
|
|
37
|
+
"""A short reminder of a 5e rule: conditions, death saves, rests, cover, grapple..."""
|
|
38
|
+
key, text = reference.lookup(topic)
|
|
39
|
+
return f"{key}: {text}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
TOOLS: list[Callable[..., str]] = [roll_dice, lookup_rule]
|
dndkit/ui/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The pages: MCP Apps widgets and the web pages.
|
|
2
|
+
|
|
3
|
+
Each ``<name>.html`` holds one page; the shared styles and scripts replace its
|
|
4
|
+
``<!--gm:shared-->`` marker, so every page served is self-contained:
|
|
5
|
+
roomkit-ui serves a widget's HTML alone, relative URLs 404, and sessions may
|
|
6
|
+
be offline. A web page also gets ``window.GM_WEB``, which makes ``shared.js``
|
|
7
|
+
read the server's event stream instead of calling MCP tools.
|
|
8
|
+
|
|
9
|
+
The scripts are written as ES modules, so tools (knip) can follow who uses
|
|
10
|
+
what; a page inlines them as one classic script, their import lines dropped and
|
|
11
|
+
their exports left as plain declarations, in ``SCRIPTS`` order (a module after
|
|
12
|
+
those it imports).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from functools import cache
|
|
17
|
+
from importlib.resources import files
|
|
18
|
+
|
|
19
|
+
_MARKER = "<!--gm:shared-->"
|
|
20
|
+
_STYLES = ("shared.css", "components.css")
|
|
21
|
+
SCRIPTS = (
|
|
22
|
+
"words.js",
|
|
23
|
+
"shared.js",
|
|
24
|
+
"dicetray.js",
|
|
25
|
+
"battlemap.js",
|
|
26
|
+
"backstage.js",
|
|
27
|
+
"world.js",
|
|
28
|
+
"sheet.js",
|
|
29
|
+
"desk.js",
|
|
30
|
+
"components.js",
|
|
31
|
+
)
|
|
32
|
+
_WEB = '<script>window.GM_WEB = { feed: "feed" };</script>\n'
|
|
33
|
+
IMPORT = re.compile(r'^import \{[^}]*\} from "\./([\w-]+\.js)";\n', re.MULTILINE)
|
|
34
|
+
_EXPORT = re.compile(r"^export (?=(?:const|function|class) )", re.MULTILINE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@cache
|
|
38
|
+
def page(name: str, web: bool = False) -> str:
|
|
39
|
+
"""The complete HTML of page *name*; *web* for a page served by the web server."""
|
|
40
|
+
css = "".join(_read(style) for style in _STYLES)
|
|
41
|
+
js = "\n".join(_inline(_read(script)) for script in SCRIPTS)
|
|
42
|
+
shared = f"<style>\n{css}</style>\n{_WEB if web else ''}<script>\n{js}</script>"
|
|
43
|
+
return _read(f"{name}.html").replace(_MARKER, shared, 1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _inline(module: str) -> str:
|
|
47
|
+
"""A module as part of the page's one classic script."""
|
|
48
|
+
return _EXPORT.sub("", IMPORT.sub("", module))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read(name: str) -> str:
|
|
52
|
+
return files(__name__).joinpath(name).read_text(encoding="utf-8")
|
dndkit/ui/backstage.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* The game master's plans: encounters still to fight, and every quest with
|
|
2
|
+
* its objectives, reward and notes. */
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
import { GM } from "./shared.js";
|
|
6
|
+
|
|
7
|
+
export function Backstage() {
|
|
8
|
+
const { h, fill, t } = GM;
|
|
9
|
+
const section = (title, ...content) => h("div", { class: "section" }, h("h4", {}, title), ...content);
|
|
10
|
+
const empty = (key) => h("div", { class: "empty small" }, t(key));
|
|
11
|
+
const encounters = h("div");
|
|
12
|
+
const quests = h("div");
|
|
13
|
+
const el = h("div", { class: "card backstage" },
|
|
14
|
+
h("div", { class: "head" }, h("span", { class: "title" }, t("backstage"))),
|
|
15
|
+
section(t("encounters"), encounters), section(t("allQuests"), quests));
|
|
16
|
+
const note = (label, text) => (text ? h("div", { class: "note small" }, h("b", {}, `${label} `), text) : null);
|
|
17
|
+
const line = (...parts) => parts.filter(Boolean).join(" — ");
|
|
18
|
+
|
|
19
|
+
const encounter = (e) => h("div", { class: "entry" + (e.status === "done" ? " done" : "") },
|
|
20
|
+
h("div", { class: "row" }, h("b", {}, e.name), h("span", { class: "chip" }, t(e.status)),
|
|
21
|
+
e.difficulty ? h("span", { class: `chip rating ${e.difficulty}` }, `${t(e.difficulty)} · ${e.xp} ${t("xp")}`) : null),
|
|
22
|
+
h("div", { class: "muted small" }, line(e.monsters.join(", "), e.location)),
|
|
23
|
+
e.description ? h("div", { class: "small" }, e.description) : null,
|
|
24
|
+
note(t("notes"), e.notes));
|
|
25
|
+
|
|
26
|
+
const quest = (q) => h("div", { class: "entry" },
|
|
27
|
+
h("div", { class: "row" }, h("b", {}, q.title), h("span", { class: "chip" }, t(q.status)),
|
|
28
|
+
q.total ? h("span", { class: "muted small" }, `${q.done}/${q.total}`) : null),
|
|
29
|
+
q.giver || q.description ? h("div", { class: "small" },
|
|
30
|
+
q.giver ? h("span", { class: "muted" }, `${t("from", { name: q.giver })}. `) : null, q.description) : null,
|
|
31
|
+
q.objectives.length ? h("ul", { class: "objectives small" },
|
|
32
|
+
q.objectives.map((o) => h("li", { class: o.done ? "done" : "" }, o.text))) : null,
|
|
33
|
+
note(t("reward"), q.reward), note(t("notes"), q.notes));
|
|
34
|
+
|
|
35
|
+
function render(data) {
|
|
36
|
+
fill(encounters, data.encounters.length ? data.encounters.map(encounter) : empty("noEncounter"));
|
|
37
|
+
fill(quests, data.quests.length ? data.quests.map(quest) : empty("noQuest"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { el, render };
|
|
41
|
+
}
|
dndkit/ui/battlemap.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* The battle map in SVG: 5-foot squares named like a chessboard (A1 top left),
|
|
2
|
+
* walls, difficult terrain, a token per combatant (the party gold, its foes
|
|
3
|
+
* red, the hidden ones faint on the game master's screen), the squares the
|
|
4
|
+
* acting creature can still reach this turn, and the last area of effect. */
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
export function BattleMap(map, me) {
|
|
8
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
9
|
+
const CELL = 40;
|
|
10
|
+
const GUTTER = 22;
|
|
11
|
+
const at = (n) => GUTTER + n * CELL;
|
|
12
|
+
|
|
13
|
+
function s(tag, attrs, ...children) {
|
|
14
|
+
const el = document.createElementNS(NS, tag);
|
|
15
|
+
for (const [key, value] of Object.entries(attrs || {})) {
|
|
16
|
+
if (value !== null && value !== undefined && value !== false) el.setAttribute(key, value);
|
|
17
|
+
}
|
|
18
|
+
el.append(...children.flat().filter((child) => child !== null && child !== undefined));
|
|
19
|
+
return el;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const square = (cls) => ([x, y]) => s("rect", { x: at(x), y: at(y), width: CELL, height: CELL, class: cls });
|
|
23
|
+
|
|
24
|
+
function initials(name) {
|
|
25
|
+
const words = name.trim().split(/\s+/);
|
|
26
|
+
const last = words[words.length - 1];
|
|
27
|
+
if (words.length > 1 && /^\d+$/.test(last)) return words[0].charAt(0).toUpperCase() + last;
|
|
28
|
+
if (words.length > 1) return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase();
|
|
29
|
+
return name.slice(0, 2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function token(t) {
|
|
33
|
+
const radius = (t.size * CELL) / 2;
|
|
34
|
+
const cx = at(t.x) + radius;
|
|
35
|
+
const cy = at(t.y) + radius;
|
|
36
|
+
const classes = ["token", t.party ? "party" : "foe", t.current && "current", t.down && "down",
|
|
37
|
+
t.hidden && "hidden", t.boss && "boss", me && t.name === me && "me", t.health].filter(Boolean).join(" ");
|
|
38
|
+
const ratio = t.hp_max ? Math.max(0, Math.min(1, t.hp / t.hp_max)) : null;
|
|
39
|
+
const bar = ratio === null ? null : [
|
|
40
|
+
s("rect", { x: cx - radius + 6, y: cy + radius - 7, width: 2 * radius - 12, height: 4, class: "life" }),
|
|
41
|
+
s("rect", { x: cx - radius + 6, y: cy + radius - 7, width: (2 * radius - 12) * ratio, height: 4,
|
|
42
|
+
class: "life-left" + (ratio > 0.5 ? "" : ratio > 0.25 ? " mid" : " low") }),
|
|
43
|
+
];
|
|
44
|
+
return s("g", { class: classes }, s("title", {}, t.name),
|
|
45
|
+
s("circle", { cx, cy, r: radius - 5 }),
|
|
46
|
+
s("text", { x: cx, y: cy + 4.5 }, initials(t.name)), bar,
|
|
47
|
+
t.boss ? s("text", { x: cx, y: cy - radius + 9, class: "crown" }, "♛") : null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const width = at(map.width);
|
|
51
|
+
const height = at(map.height);
|
|
52
|
+
const lines = [];
|
|
53
|
+
for (let x = 0; x <= map.width; x++) lines.push(`M${at(x)},${GUTTER}V${height}`);
|
|
54
|
+
for (let y = 0; y <= map.height; y++) lines.push(`M${GUTTER},${at(y)}H${width}`);
|
|
55
|
+
|
|
56
|
+
return s("svg", { viewBox: `0 0 ${width + 2} ${height + 2}`, class: "battlemap", role: "img" },
|
|
57
|
+
s("defs", {}, s("pattern", { id: "gm-rough", width: 8, height: 8, patternUnits: "userSpaceOnUse" },
|
|
58
|
+
s("path", { d: "M0,8 L8,0 M-2,2 L2,-2 M6,10 L10,6", class: "hatch" }))),
|
|
59
|
+
s("rect", { x: GUTTER, y: GUTTER, width: width - GUTTER, height: height - GUTTER, class: "floor" }),
|
|
60
|
+
map.moves.map(square("reach")),
|
|
61
|
+
map.difficult.map(square("rough")),
|
|
62
|
+
s("path", { d: lines.join(""), class: "grid" }),
|
|
63
|
+
map.walls.map(square("wall")),
|
|
64
|
+
(map.area || []).map(square("area")),
|
|
65
|
+
Array.from({ length: map.width }, (_, x) =>
|
|
66
|
+
s("text", { x: at(x) + CELL / 2, y: GUTTER - 7, class: "label" }, String.fromCharCode(65 + x))),
|
|
67
|
+
Array.from({ length: map.height }, (_, y) =>
|
|
68
|
+
s("text", { x: GUTTER - 5, y: at(y) + CELL / 2 + 4, class: "label row" }, String(y + 1))),
|
|
69
|
+
map.tokens.map(token));
|
|
70
|
+
}
|
dndkit/ui/character.html
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Character sheet</title>
|
|
7
|
+
<!--gm:shared-->
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<div id="status"></div>
|
|
12
|
+
<script>
|
|
13
|
+
const sheet = Views.character();
|
|
14
|
+
document.getElementById("app").append(sheet.el);
|
|
15
|
+
GM.start(async () => {
|
|
16
|
+
const args = await GM.toolInput();
|
|
17
|
+
const name = args.name_or_id;
|
|
18
|
+
if (!name) return GM.status("?");
|
|
19
|
+
GM.live("character", { name }, sheet.render, 2500);
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
dndkit/ui/combat.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Combat tracker</title>
|
|
7
|
+
<!--gm:shared-->
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<div id="status"></div>
|
|
12
|
+
<script>
|
|
13
|
+
const tracker = Views.combat();
|
|
14
|
+
document.getElementById("app").append(tracker.el);
|
|
15
|
+
GM.start(() => GM.live("combat", null, tracker.render, 1500));
|
|
16
|
+
</script>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|