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.
Files changed (118) hide show
  1. dndkit/__init__.py +10 -0
  2. dndkit/__main__.py +6 -0
  3. dndkit/activity.py +68 -0
  4. dndkit/app.py +105 -0
  5. dndkit/config.py +104 -0
  6. dndkit/errors.py +48 -0
  7. dndkit/game/__init__.py +1 -0
  8. dndkit/game/areas.py +181 -0
  9. dndkit/game/battlefield.py +154 -0
  10. dndkit/game/bestiary.py +155 -0
  11. dndkit/game/characters.py +227 -0
  12. dndkit/game/combat.py +209 -0
  13. dndkit/game/combatants.py +188 -0
  14. dndkit/game/concentration.py +43 -0
  15. dndkit/game/dice.py +186 -0
  16. dndkit/game/effects.py +183 -0
  17. dndkit/game/grid.py +88 -0
  18. dndkit/game/journal.py +47 -0
  19. dndkit/game/movement.py +264 -0
  20. dndkit/game/pathing.py +153 -0
  21. dndkit/game/proficiencies.py +104 -0
  22. dndkit/game/progression.py +77 -0
  23. dndkit/game/readiness.py +214 -0
  24. dndkit/game/reference.py +123 -0
  25. dndkit/game/registry.py +172 -0
  26. dndkit/game/resources.py +96 -0
  27. dndkit/game/rests.py +98 -0
  28. dndkit/game/rules.py +196 -0
  29. dndkit/game/seating.py +71 -0
  30. dndkit/game/sight.py +142 -0
  31. dndkit/game/spellbook.py +97 -0
  32. dndkit/game/tables.py +216 -0
  33. dndkit/game/world.py +125 -0
  34. dndkit/models/__init__.py +69 -0
  35. dndkit/models/base.py +26 -0
  36. dndkit/models/battle.py +40 -0
  37. dndkit/models/bestiary.py +56 -0
  38. dndkit/models/campaign.py +209 -0
  39. dndkit/models/character.py +149 -0
  40. dndkit/models/world.py +62 -0
  41. dndkit/skills/__init__.py +14 -0
  42. dndkit/skills/catalog.py +113 -0
  43. dndkit/skills/dnd-battle-map/SKILL.md +78 -0
  44. dndkit/skills/dnd-game-master/SKILL.md +125 -0
  45. dndkit/skills/dnd-monsters/SKILL.md +85 -0
  46. dndkit/skills/extension.py +93 -0
  47. dndkit/storage/__init__.py +6 -0
  48. dndkit/storage/catalog.py +72 -0
  49. dndkit/storage/confirmations.py +54 -0
  50. dndkit/storage/files.py +55 -0
  51. dndkit/storage/sharing.py +67 -0
  52. dndkit/storage/store.py +278 -0
  53. dndkit/text.py +13 -0
  54. dndkit/tools/__init__.py +47 -0
  55. dndkit/tools/apps.py +47 -0
  56. dndkit/tools/battle.py +117 -0
  57. dndkit/tools/bestiary.py +121 -0
  58. dndkit/tools/campaign.py +208 -0
  59. dndkit/tools/characters.py +189 -0
  60. dndkit/tools/combat.py +169 -0
  61. dndkit/tools/deps.py +42 -0
  62. dndkit/tools/journal.py +102 -0
  63. dndkit/tools/locations.py +98 -0
  64. dndkit/tools/npcs.py +101 -0
  65. dndkit/tools/play.py +118 -0
  66. dndkit/tools/players.py +44 -0
  67. dndkit/tools/progression.py +41 -0
  68. dndkit/tools/quests.py +95 -0
  69. dndkit/tools/recorder.py +63 -0
  70. dndkit/tools/render.py +281 -0
  71. dndkit/tools/render_combat.py +121 -0
  72. dndkit/tools/render_map.py +141 -0
  73. dndkit/tools/resources.py +104 -0
  74. dndkit/tools/schema.py +47 -0
  75. dndkit/tools/screen.py +47 -0
  76. dndkit/tools/spells.py +67 -0
  77. dndkit/tools/utility.py +42 -0
  78. dndkit/ui/__init__.py +52 -0
  79. dndkit/ui/backstage.js +41 -0
  80. dndkit/ui/battlemap.js +70 -0
  81. dndkit/ui/character.html +23 -0
  82. dndkit/ui/combat.html +18 -0
  83. dndkit/ui/components.css +283 -0
  84. dndkit/ui/components.js +261 -0
  85. dndkit/ui/desk.js +91 -0
  86. dndkit/ui/dice.html +42 -0
  87. dndkit/ui/dicetray.js +99 -0
  88. dndkit/ui/gm.html +59 -0
  89. dndkit/ui/party.html +18 -0
  90. dndkit/ui/player.html +61 -0
  91. dndkit/ui/seating.html +18 -0
  92. dndkit/ui/shared.css +195 -0
  93. dndkit/ui/shared.js +242 -0
  94. dndkit/ui/sheet.js +75 -0
  95. dndkit/ui/table.html +41 -0
  96. dndkit/ui/words.js +120 -0
  97. dndkit/ui/world.js +56 -0
  98. dndkit/views/__init__.py +7 -0
  99. dndkit/views/backstage.py +49 -0
  100. dndkit/views/battle.py +92 -0
  101. dndkit/views/desk.py +76 -0
  102. dndkit/views/everyone.py +194 -0
  103. dndkit/views/public.py +89 -0
  104. dndkit/views/widgets.py +58 -0
  105. dndkit/views/world.py +70 -0
  106. dndkit/web/__init__.py +10 -0
  107. dndkit/web/access.py +136 -0
  108. dndkit/web/audiences.py +111 -0
  109. dndkit/web/changes.py +84 -0
  110. dndkit/web/feed.py +112 -0
  111. dndkit/web/routes.py +134 -0
  112. dndkit/web/server.py +131 -0
  113. dndkit-0.4.0.dist-info/METADATA +567 -0
  114. dndkit-0.4.0.dist-info/RECORD +118 -0
  115. dndkit-0.4.0.dist-info/WHEEL +5 -0
  116. dndkit-0.4.0.dist-info/entry_points.txt +2 -0
  117. dndkit-0.4.0.dist-info/licenses/LICENSE +22 -0
  118. 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]
@@ -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
+ }
@@ -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>