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/storage/store.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""The campaign store: one active campaign, saved as JSON, safe across threads
|
|
2
|
+
and across processes sharing the data directory (``sharing``)."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from dndkit.errors import (
|
|
13
|
+
AlreadyExistsError,
|
|
14
|
+
GameError,
|
|
15
|
+
InvalidChangeError,
|
|
16
|
+
NoActiveCampaignError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
)
|
|
19
|
+
from dndkit.models import Campaign, GameState, now
|
|
20
|
+
from dndkit.storage.catalog import CampaignEntry, modified_at, read_entry
|
|
21
|
+
from dndkit.storage.confirmations import Confirmations
|
|
22
|
+
from dndkit.storage.files import campaign_slug, read_json, same_slug, write_json_atomic
|
|
23
|
+
from dndkit.storage.sharing import DiskLock, Stamp, stamp
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
SETTING_SUFFIXES = (".md", ".txt")
|
|
28
|
+
MAX_SETTING_BYTES = 200_000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CampaignStore:
|
|
32
|
+
"""Holds the active campaign and writes every change to disk.
|
|
33
|
+
|
|
34
|
+
FastMCP runs synchronous tools in a thread pool, and voice models often
|
|
35
|
+
call several tools at once, so every access goes through one lock.
|
|
36
|
+
*on_change* is called, from the changing thread, after every saved change
|
|
37
|
+
and every switch of campaign: the web pages wait on it. Another process
|
|
38
|
+
may share the data directory: changes take turns with it, and the active
|
|
39
|
+
campaign is read again when its file changed elsewhere.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
data_dir: Path,
|
|
45
|
+
confirmations: Confirmations | None = None,
|
|
46
|
+
on_change: Callable[[], None] | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.data_dir = data_dir
|
|
49
|
+
self.campaigns_dir = data_dir / "campaigns"
|
|
50
|
+
self.settings_dir = data_dir / "settings"
|
|
51
|
+
self.trash_dir = data_dir / "trash"
|
|
52
|
+
self.confirmations = confirmations or Confirmations()
|
|
53
|
+
self.campaigns_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
self.settings_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
self._on_change = on_change or (lambda: None)
|
|
56
|
+
self._lock = threading.RLock()
|
|
57
|
+
self._disk = DiskLock(data_dir)
|
|
58
|
+
self._current: Campaign | None = None
|
|
59
|
+
self._current_path: Path | None = None
|
|
60
|
+
self._stamp: Stamp | None = None # the active file as we last read or wrote it
|
|
61
|
+
# When this server started: a roll call older than that was another session's.
|
|
62
|
+
self.opened_at = now()
|
|
63
|
+
self._open_most_recent()
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def current(self) -> Campaign | None:
|
|
67
|
+
"""The active campaign, if any."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
return self._current
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def current_file(self) -> Path | None:
|
|
73
|
+
"""Where the active campaign is saved, if any."""
|
|
74
|
+
with self._lock:
|
|
75
|
+
return self._current_path
|
|
76
|
+
|
|
77
|
+
def list_campaigns(self) -> list[CampaignEntry]:
|
|
78
|
+
"""A summary of every campaign file, most recently used first."""
|
|
79
|
+
with self._lock:
|
|
80
|
+
entries = [read_entry(path) for path in self.campaigns_dir.glob("*.json")]
|
|
81
|
+
return sorted(entries, key=lambda entry: entry.modified, reverse=True)
|
|
82
|
+
|
|
83
|
+
def create(
|
|
84
|
+
self,
|
|
85
|
+
name: str,
|
|
86
|
+
description: str,
|
|
87
|
+
dm_name: str | None = None,
|
|
88
|
+
setting: str | None = None,
|
|
89
|
+
) -> Campaign:
|
|
90
|
+
"""Create a campaign and make it active; an existing one is never replaced."""
|
|
91
|
+
slug = campaign_slug(name)
|
|
92
|
+
with self._lock, self._disk.held():
|
|
93
|
+
existing = self._file_for_slug(slug)
|
|
94
|
+
if existing is not None:
|
|
95
|
+
raise AlreadyExistsError(
|
|
96
|
+
f"A campaign named '{read_entry(existing).name}' already exists. "
|
|
97
|
+
"Open it with load_campaign or pick another name."
|
|
98
|
+
)
|
|
99
|
+
title = name.strip()
|
|
100
|
+
campaign = Campaign(
|
|
101
|
+
name=title,
|
|
102
|
+
description=description,
|
|
103
|
+
dm_name=dm_name,
|
|
104
|
+
setting=setting,
|
|
105
|
+
game_state=GameState(campaign_name=title),
|
|
106
|
+
)
|
|
107
|
+
path = self.campaigns_dir / f"{slug}.json"
|
|
108
|
+
self._write(campaign, path)
|
|
109
|
+
self._open(campaign, path)
|
|
110
|
+
logger.info("Created campaign %r in %s", title, path)
|
|
111
|
+
self._on_change()
|
|
112
|
+
return campaign
|
|
113
|
+
|
|
114
|
+
def load(self, name: str) -> Campaign:
|
|
115
|
+
"""Open a campaign by name and make it active."""
|
|
116
|
+
with self._lock:
|
|
117
|
+
path = self._require_file(name)
|
|
118
|
+
campaign = self._read(path)
|
|
119
|
+
path.touch() # the most recently opened campaign reopens at the next start
|
|
120
|
+
self._open(campaign, path)
|
|
121
|
+
self._on_change()
|
|
122
|
+
return campaign
|
|
123
|
+
|
|
124
|
+
def request_deletion(self, name: str) -> tuple[CampaignEntry, str]:
|
|
125
|
+
"""What deleting *name* would remove, and the code that confirms it."""
|
|
126
|
+
with self._lock:
|
|
127
|
+
path = self._require_file(name)
|
|
128
|
+
return read_entry(path), self.confirmations.issue(str(path))
|
|
129
|
+
|
|
130
|
+
def delete(self, name: str, code: str) -> tuple[CampaignEntry, Path]:
|
|
131
|
+
"""Move a campaign to the trash, given the code ``request_deletion`` issued.
|
|
132
|
+
|
|
133
|
+
Returns what was deleted and where its file went. Deleting the active
|
|
134
|
+
campaign leaves none active.
|
|
135
|
+
"""
|
|
136
|
+
with self._lock, self._disk.held():
|
|
137
|
+
path = self._require_file(name)
|
|
138
|
+
if not self.confirmations.consume(str(path), code):
|
|
139
|
+
raise InvalidChangeError(
|
|
140
|
+
"That confirmation code is wrong or expired, so nothing was deleted. "
|
|
141
|
+
"Call delete_campaign without a code to get a new one."
|
|
142
|
+
)
|
|
143
|
+
entry = read_entry(path)
|
|
144
|
+
self.trash_dir.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
target = self._trash_path(path.stem)
|
|
146
|
+
path.replace(target)
|
|
147
|
+
if self._current_path == path:
|
|
148
|
+
self._open(None, None)
|
|
149
|
+
logger.info("Moved campaign %r to %s", entry.name, target)
|
|
150
|
+
self._on_change()
|
|
151
|
+
return entry, target
|
|
152
|
+
|
|
153
|
+
def read_setting_file(self, file_name: str) -> str:
|
|
154
|
+
"""Text of a .md/.txt file placed directly in the settings directory."""
|
|
155
|
+
folder = self.settings_dir.resolve()
|
|
156
|
+
candidate = (folder / file_name).resolve()
|
|
157
|
+
if candidate.parent != folder or candidate.suffix.lower() not in SETTING_SUFFIXES:
|
|
158
|
+
raise InvalidChangeError(
|
|
159
|
+
f"A setting file is a .md or .txt file directly inside {folder}."
|
|
160
|
+
)
|
|
161
|
+
if not candidate.is_file():
|
|
162
|
+
files = [p for p in folder.iterdir() if p.is_file()]
|
|
163
|
+
available = sorted(p.name for p in files if p.suffix.lower() in SETTING_SUFFIXES)
|
|
164
|
+
raise NotFoundError(
|
|
165
|
+
f"No setting file '{file_name}' in {folder}. "
|
|
166
|
+
f"Available: {', '.join(available) or 'none'}."
|
|
167
|
+
)
|
|
168
|
+
if candidate.stat().st_size > MAX_SETTING_BYTES:
|
|
169
|
+
raise InvalidChangeError(f"'{file_name}' is over {MAX_SETTING_BYTES} bytes.")
|
|
170
|
+
try:
|
|
171
|
+
return candidate.read_text(encoding="utf-8")
|
|
172
|
+
except UnicodeDecodeError as exc:
|
|
173
|
+
raise InvalidChangeError(f"'{file_name}' is not UTF-8: save it as UTF-8.") from exc
|
|
174
|
+
|
|
175
|
+
@contextmanager
|
|
176
|
+
def transaction(self) -> Iterator[Campaign]:
|
|
177
|
+
"""The active campaign to change: saved on success, restored on any failure.
|
|
178
|
+
|
|
179
|
+
A tool that fails halfway leaves nothing behind, in memory or on disk,
|
|
180
|
+
so the model can retry without doubling an item or a hit.
|
|
181
|
+
"""
|
|
182
|
+
with self._lock, self._disk.held():
|
|
183
|
+
self._catch_up()
|
|
184
|
+
campaign, path = self._require_current()
|
|
185
|
+
snapshot = campaign.model_copy(deep=True)
|
|
186
|
+
try:
|
|
187
|
+
yield campaign
|
|
188
|
+
campaign.updated_at = now()
|
|
189
|
+
self._write(campaign, path)
|
|
190
|
+
self._stamp = stamp(path)
|
|
191
|
+
except BaseException:
|
|
192
|
+
self._current = snapshot
|
|
193
|
+
raise
|
|
194
|
+
self._on_change()
|
|
195
|
+
|
|
196
|
+
@contextmanager
|
|
197
|
+
def reading(self) -> Iterator[Campaign]:
|
|
198
|
+
"""The active campaign, to read while no tool changes it."""
|
|
199
|
+
with self._lock:
|
|
200
|
+
self._catch_up()
|
|
201
|
+
campaign, _ = self._require_current()
|
|
202
|
+
yield campaign
|
|
203
|
+
|
|
204
|
+
def close(self) -> None:
|
|
205
|
+
"""Let go of the lock file."""
|
|
206
|
+
self._disk.close()
|
|
207
|
+
|
|
208
|
+
def _open(self, campaign: Campaign | None, path: Path | None) -> None:
|
|
209
|
+
self._current, self._current_path = campaign, path
|
|
210
|
+
self._stamp = stamp(path) if path else None
|
|
211
|
+
|
|
212
|
+
def _catch_up(self) -> None:
|
|
213
|
+
"""Read the active campaign again if its file changed in another process."""
|
|
214
|
+
path = self._current_path
|
|
215
|
+
if path is None or stamp(path) == self._stamp:
|
|
216
|
+
return
|
|
217
|
+
if not path.exists():
|
|
218
|
+
logger.warning("Campaign file %s was removed by another program.", path)
|
|
219
|
+
self._open(None, None)
|
|
220
|
+
else:
|
|
221
|
+
logger.info("Campaign file %s changed in another program: read again.", path)
|
|
222
|
+
self._open(self._read(path), path)
|
|
223
|
+
self._on_change()
|
|
224
|
+
|
|
225
|
+
def _require_current(self) -> tuple[Campaign, Path]:
|
|
226
|
+
if self._current is None or self._current_path is None:
|
|
227
|
+
raise NoActiveCampaignError()
|
|
228
|
+
return self._current, self._current_path
|
|
229
|
+
|
|
230
|
+
def _require_file(self, name: str) -> Path:
|
|
231
|
+
path = self._file_for_slug(campaign_slug(name))
|
|
232
|
+
if path is None:
|
|
233
|
+
known = ", ".join(entry.name for entry in self.list_campaigns()) or "none yet"
|
|
234
|
+
raise NotFoundError(f"No campaign named '{name}'. Known campaigns: {known}.")
|
|
235
|
+
return path
|
|
236
|
+
|
|
237
|
+
def _trash_path(self, stem: str) -> Path:
|
|
238
|
+
stamp = now().strftime("%Y-%m-%d %H%M%S")
|
|
239
|
+
target = self.trash_dir / f"{stem} {stamp}.json"
|
|
240
|
+
number = 2
|
|
241
|
+
while target.exists():
|
|
242
|
+
target = self.trash_dir / f"{stem} {stamp} {number}.json"
|
|
243
|
+
number += 1
|
|
244
|
+
return target
|
|
245
|
+
|
|
246
|
+
def _file_for_slug(self, slug: str) -> Path | None:
|
|
247
|
+
for path in self.campaigns_dir.glob("*.json"):
|
|
248
|
+
if same_slug(path.stem, slug):
|
|
249
|
+
return path
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
def _open_most_recent(self) -> None:
|
|
253
|
+
paths = sorted(self.campaigns_dir.glob("*.json"), key=modified_at, reverse=True)
|
|
254
|
+
for path in paths:
|
|
255
|
+
try:
|
|
256
|
+
self._open(self._read(path), path)
|
|
257
|
+
except GameError as exc:
|
|
258
|
+
logger.error("Skipping campaign file %s: %s", path, exc)
|
|
259
|
+
continue
|
|
260
|
+
logger.info("Opened campaign %r from %s", path.stem, path)
|
|
261
|
+
return
|
|
262
|
+
logger.info("No campaign to open in %s", self.campaigns_dir)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _read(path: Path) -> Campaign:
|
|
266
|
+
try:
|
|
267
|
+
return Campaign.model_validate(read_json(path))
|
|
268
|
+
# JSONDecodeError and ValidationError are ValueErrors; absurdly deep JSON recurses.
|
|
269
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
270
|
+
detail = str(exc)
|
|
271
|
+
if isinstance(exc, ValidationError):
|
|
272
|
+
first = exc.errors()[0]
|
|
273
|
+
detail = f"{'.'.join(map(str, first['loc']))}: {first['msg']}"
|
|
274
|
+
raise GameError(f"Campaign file {path.name} cannot be read: {detail}") from exc
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _write(campaign: Campaign, path: Path) -> None:
|
|
278
|
+
write_json_atomic(path, campaign.model_dump(mode="json"))
|
dndkit/text.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""How two spellings of one name compare, for every layer (storage included)."""
|
|
2
|
+
|
|
3
|
+
import unicodedata
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize(name: str) -> str:
|
|
7
|
+
"""Casefolded, accent-free, single-spaced: "Élodie " and "elodie" are one name.
|
|
8
|
+
|
|
9
|
+
A voice transcript rarely spells a name the way it was typed.
|
|
10
|
+
"""
|
|
11
|
+
decomposed = unicodedata.normalize("NFKD", name)
|
|
12
|
+
bare = "".join(c for c in decomposed if not unicodedata.combining(c))
|
|
13
|
+
return " ".join(bare.casefold().split())
|
dndkit/tools/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""MCP tools: thin adapters from tool calls to the game logic.
|
|
2
|
+
|
|
3
|
+
Each module lists its tools in ``TOOLS``; ``ALL_TOOLS`` is what the server
|
|
4
|
+
registers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
from dndkit.tools import (
|
|
10
|
+
battle,
|
|
11
|
+
bestiary,
|
|
12
|
+
campaign,
|
|
13
|
+
characters,
|
|
14
|
+
combat,
|
|
15
|
+
journal,
|
|
16
|
+
locations,
|
|
17
|
+
npcs,
|
|
18
|
+
play,
|
|
19
|
+
players,
|
|
20
|
+
progression,
|
|
21
|
+
quests,
|
|
22
|
+
resources,
|
|
23
|
+
screen,
|
|
24
|
+
spells,
|
|
25
|
+
utility,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
ALL_TOOLS: list[Callable[..., str]] = [
|
|
29
|
+
*campaign.TOOLS,
|
|
30
|
+
*characters.TOOLS,
|
|
31
|
+
*resources.TOOLS,
|
|
32
|
+
*spells.TOOLS,
|
|
33
|
+
*progression.TOOLS,
|
|
34
|
+
*npcs.TOOLS,
|
|
35
|
+
*locations.TOOLS,
|
|
36
|
+
*quests.TOOLS,
|
|
37
|
+
*play.TOOLS,
|
|
38
|
+
*players.TOOLS,
|
|
39
|
+
*combat.TOOLS,
|
|
40
|
+
*battle.TOOLS,
|
|
41
|
+
*bestiary.TOOLS,
|
|
42
|
+
*journal.TOOLS,
|
|
43
|
+
*utility.TOOLS,
|
|
44
|
+
*screen.TOOLS,
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
__all__ = ["ALL_TOOLS"]
|
dndkit/tools/apps.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""MCP Apps: which tools open a widget, and the page each widget loads.
|
|
2
|
+
|
|
3
|
+
A tool whose ``_meta.ui.resourceUri`` names a ``ui://`` resource opens that
|
|
4
|
+
page in clients that support MCP Apps (roomkit-ui shows it in the chat);
|
|
5
|
+
other clients ignore the metadata. roomkit-ui hands a page only the tool's
|
|
6
|
+
text result, so each page reads its data from ``get_screen_data`` instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
from dndkit import ui
|
|
15
|
+
|
|
16
|
+
DICE_APP = "ui://dndkit/dice.html"
|
|
17
|
+
COMBAT_APP = "ui://dndkit/combat.html"
|
|
18
|
+
PARTY_APP = "ui://dndkit/party.html"
|
|
19
|
+
CHARACTER_APP = "ui://dndkit/character.html"
|
|
20
|
+
SEATING_APP = "ui://dndkit/seating.html"
|
|
21
|
+
|
|
22
|
+
_PAGES = {
|
|
23
|
+
DICE_APP: ("dice", "Dice tray: the latest roll, rolling."),
|
|
24
|
+
COMBAT_APP: ("combat", "Combat tracker: initiative, hit points, conditions, live."),
|
|
25
|
+
PARTY_APP: ("party", "Party board: hit points, conditions, location and quests, live."),
|
|
26
|
+
CHARACTER_APP: ("character", "Character sheet, live."),
|
|
27
|
+
SEATING_APP: ("seating", "The table: who plays which character tonight, live."),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def app_meta(uri: str) -> dict[str, Any]:
|
|
32
|
+
"""Tool metadata that opens the page at *uri*."""
|
|
33
|
+
return {"ui": {"resourceUri": uri}}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register_apps(server: FastMCP) -> None:
|
|
37
|
+
"""Serve every page as a ``ui://`` resource (MIME text/html;profile=mcp-app)."""
|
|
38
|
+
for uri, (name, description) in _PAGES.items():
|
|
39
|
+
server.resource(uri, name=f"{name}-app", description=description)(_loader(name))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _loader(name: str) -> Callable[[], str]:
|
|
43
|
+
def load() -> str:
|
|
44
|
+
return ui.page(name)
|
|
45
|
+
|
|
46
|
+
load.__name__ = f"{name}_app"
|
|
47
|
+
return load
|
dndkit/tools/battle.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Battle map tools: lay out the map, put combatants on it, move them by the rules."""
|
|
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 areas, battlefield, combat, movement, sight
|
|
11
|
+
from dndkit.game.areas import Aim, Shape
|
|
12
|
+
from dndkit.game.movement import Order
|
|
13
|
+
from dndkit.models import Placement
|
|
14
|
+
from dndkit.models.battle import MAX_SQUARES
|
|
15
|
+
from dndkit.storage import CampaignStore
|
|
16
|
+
from dndkit.tools import render_combat, render_map
|
|
17
|
+
from dndkit.tools.apps import COMBAT_APP, app_meta
|
|
18
|
+
from dndkit.tools.deps import READ_ONLY, get_store
|
|
19
|
+
|
|
20
|
+
Squares = Annotated[list[str] | None, Field(description='Squares: "C3", or "C1:C6" for a block')]
|
|
21
|
+
Creature = Annotated[str | None, Field(description="A combatant")]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@tool(meta=app_meta(COMBAT_APP))
|
|
25
|
+
def set_battlefield(
|
|
26
|
+
width: Annotated[int, Field(ge=1, le=MAX_SQUARES, description="Squares across, 5 ft each")],
|
|
27
|
+
height: Annotated[int, Field(ge=1, le=MAX_SQUARES, description="Squares down")],
|
|
28
|
+
walls: Squares = None,
|
|
29
|
+
difficult: Squares = None,
|
|
30
|
+
store: CampaignStore = Depends(get_store),
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Lay out the battle map (A1 top left). Whoever has no square goes to an edge:
|
|
33
|
+
the party left, foes right."""
|
|
34
|
+
with store.transaction() as campaign:
|
|
35
|
+
state = campaign.game_state
|
|
36
|
+
battlefield.lay_out(state, width, height, walls or [], difficult or [])
|
|
37
|
+
battlefield.place_the_rest(state)
|
|
38
|
+
return render_map.battle_map(campaign)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@tool
|
|
42
|
+
def place_tokens(
|
|
43
|
+
tokens: Annotated[list[Placement], Field(min_length=1, description="Who and where")],
|
|
44
|
+
store: CampaignStore = Depends(get_store),
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Put combatants on squares (big ones by their top-left square), or hide them from
|
|
47
|
+
the players (an ambush) and reveal them. remove_combatant takes one off."""
|
|
48
|
+
with store.transaction() as campaign:
|
|
49
|
+
state = campaign.game_state
|
|
50
|
+
for token in tokens:
|
|
51
|
+
combatant = combat.find(state, token.who)
|
|
52
|
+
if token.ally is not None:
|
|
53
|
+
combatant.ally = token.ally
|
|
54
|
+
if token.hidden is not None:
|
|
55
|
+
combatant.hidden = token.hidden
|
|
56
|
+
if token.at is not None:
|
|
57
|
+
combatant.position = None
|
|
58
|
+
battlefield.place(state, combatant, token.at)
|
|
59
|
+
if state.battlefield is None:
|
|
60
|
+
return render_combat.initiative(campaign)
|
|
61
|
+
return render_map.battle_map(campaign)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@tool
|
|
65
|
+
def move_token(
|
|
66
|
+
who: Annotated[str, Field(min_length=1, description="The combatant moving")],
|
|
67
|
+
to: Annotated[str | None, Field(description="Square, e.g. D7")] = None,
|
|
68
|
+
toward: Creature = None,
|
|
69
|
+
away_from: Creature = None,
|
|
70
|
+
feet: Annotated[int | None, Field(ge=5, description="At most this far")] = None,
|
|
71
|
+
dash: Annotated[bool, Field(description="Dash: double movement this turn")] = False,
|
|
72
|
+
disengage: Annotated[bool, Field(description="Disengage: no opportunity attacks")] = False,
|
|
73
|
+
forced: Annotated[bool, Field(description="Pushed, pulled or teleported")] = False,
|
|
74
|
+
store: CampaignStore = Depends(get_store),
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Move by the 5e rules; says the cost, movement left, opportunity attacks and who is
|
|
77
|
+
in reach. Refuses impossible moves; toward stops within reach."""
|
|
78
|
+
order = Order(to=to, toward=toward, away_from=away_from, feet=feet)
|
|
79
|
+
with store.transaction() as campaign:
|
|
80
|
+
done = movement.move(campaign, who, order, dash=dash, disengage=disengage, forced=forced)
|
|
81
|
+
return render_map.move(campaign, done)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@tool
|
|
85
|
+
def area_of_effect(
|
|
86
|
+
shape: Annotated[Shape, Field(description="sphere also for cylinders")],
|
|
87
|
+
feet: Annotated[int, Field(ge=5, description="Radius, cube side, or cone/line length")],
|
|
88
|
+
at: Annotated[str | None, Field(description="Centre: square or creature")] = None,
|
|
89
|
+
source: Annotated[str | None, Field(description="Caster of a cone, line or cube")] = None,
|
|
90
|
+
toward: Annotated[str | None, Field(description="Square or creature aimed at")] = None,
|
|
91
|
+
width: Annotated[int, Field(ge=5, description="Line width")] = 5,
|
|
92
|
+
store: CampaignStore = Depends(get_store),
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Who an area of effect catches; walls stop it. Shown until the next turn."""
|
|
95
|
+
aim = Aim(shape=shape, feet=feet, at=at, source=source, toward=toward, width=width)
|
|
96
|
+
with store.transaction() as campaign:
|
|
97
|
+
return render_map.area(areas.resolve(campaign.game_state, aim))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@tool(annotations=READ_ONLY)
|
|
101
|
+
def check_target(
|
|
102
|
+
attacker: Annotated[str, Field(min_length=1, description="Who attacks")],
|
|
103
|
+
target: Annotated[str, Field(min_length=1, description="Who is attacked")],
|
|
104
|
+
store: CampaignStore = Depends(get_store),
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Before an attack on the map: distance, reach, cover, and what gives disadvantage."""
|
|
107
|
+
with store.reading() as campaign:
|
|
108
|
+
return render_map.sightline(sight.look(campaign, attacker, target))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
TOOLS: list[Callable[..., str]] = [
|
|
112
|
+
set_battlefield,
|
|
113
|
+
place_tokens,
|
|
114
|
+
move_token,
|
|
115
|
+
area_of_effect,
|
|
116
|
+
check_target,
|
|
117
|
+
]
|
dndkit/tools/bestiary.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Bestiary and encounter tools."""
|
|
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 battlefield as battlefields
|
|
11
|
+
from dndkit.game import bestiary, combatants
|
|
12
|
+
from dndkit.models import EncounterGroup, Layout, Size
|
|
13
|
+
from dndkit.storage import CampaignStore
|
|
14
|
+
from dndkit.tools import render_combat
|
|
15
|
+
from dndkit.tools.deps import READ_ONLY, get_store, provided
|
|
16
|
+
|
|
17
|
+
Text = Annotated[str | None, Field(description="Free text")]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@tool
|
|
21
|
+
def save_monster(
|
|
22
|
+
name: Annotated[str, Field(min_length=1, description="Monster name, e.g. Goblin")],
|
|
23
|
+
armor_class: Annotated[int | None, Field(ge=0, description="AC")] = None,
|
|
24
|
+
hit_points: Annotated[int | None, Field(ge=1, description="Average HP")] = None,
|
|
25
|
+
challenge_rating: Annotated[str | None, Field(description="CR, e.g. 1/4 or 3")] = None,
|
|
26
|
+
xp: Annotated[int | None, Field(ge=0, description="XP; derived from CR if omitted")] = None,
|
|
27
|
+
initiative_bonus: Annotated[int | None, Field(description="Dexterity modifier")] = None,
|
|
28
|
+
speed: Annotated[str | None, Field(description="e.g. 30 ft., fly 60 ft.")] = None,
|
|
29
|
+
size: Annotated[Size | None, Field(description="Size")] = None,
|
|
30
|
+
actions: Annotated[str | None, Field(description="Attacks and abilities")] = None,
|
|
31
|
+
notes: Text = None,
|
|
32
|
+
boss: Annotated[bool | None, Field(description="The fight's boss")] = None,
|
|
33
|
+
legendary_actions: Annotated[int | None, Field(ge=0, le=5, description="Per round")] = None,
|
|
34
|
+
legendary_resistances: Annotated[int | None, Field(ge=0, le=5, description="Per day")] = None,
|
|
35
|
+
store: CampaignStore = Depends(get_store),
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Save or update a monster stat block; combatants named after it get its stats.
|
|
38
|
+
A book monster (goblin, orc) takes its book numbers. For one you invent, a challenge
|
|
39
|
+
rating alone sets AC, HP and attacks (DMG table, sturdy at low CR). A boss gets 3
|
|
40
|
+
legendary actions and resistances unless told."""
|
|
41
|
+
fields = provided(
|
|
42
|
+
armor_class=armor_class,
|
|
43
|
+
hit_points=hit_points,
|
|
44
|
+
challenge_rating=challenge_rating,
|
|
45
|
+
xp=xp,
|
|
46
|
+
initiative_bonus=initiative_bonus,
|
|
47
|
+
speed=speed,
|
|
48
|
+
size=size,
|
|
49
|
+
actions=actions,
|
|
50
|
+
notes=notes,
|
|
51
|
+
boss=boss,
|
|
52
|
+
legendary_actions=legendary_actions,
|
|
53
|
+
legendary_resistances=legendary_resistances,
|
|
54
|
+
)
|
|
55
|
+
with store.transaction() as campaign:
|
|
56
|
+
monster, created = bestiary.save_monster(campaign, name, fields)
|
|
57
|
+
caught_up = combatants.refresh(campaign) # a monster already fighting takes it
|
|
58
|
+
saved = f"{'Saved' if created else 'Updated'} {render_combat.monster(monster)}"
|
|
59
|
+
estimated = created and challenge_rating and (armor_class is None or hit_points is None)
|
|
60
|
+
return "\n".join([saved, *([ESTIMATED] if estimated else []), *caught_up])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
ESTIMATED = (
|
|
64
|
+
"AC and HP come from the challenge rating (DMG table). A book monster has its own: "
|
|
65
|
+
"save them with save_monster if it is one."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@tool(annotations=READ_ONLY)
|
|
70
|
+
def get_monsters(
|
|
71
|
+
name: Annotated[str | None, Field(description="One monster; omit to list all")] = None,
|
|
72
|
+
store: CampaignStore = Depends(get_store),
|
|
73
|
+
) -> str:
|
|
74
|
+
"""A monster's stat block, or the whole bestiary."""
|
|
75
|
+
with store.reading() as campaign:
|
|
76
|
+
if name:
|
|
77
|
+
return render_combat.monster(bestiary.find_monster(campaign, name))
|
|
78
|
+
monsters = sorted(campaign.monsters.values(), key=lambda m: m.name)
|
|
79
|
+
if not monsters:
|
|
80
|
+
return "The bestiary is empty; add monsters with save_monster."
|
|
81
|
+
return "\n".join(render_combat.monster(m).splitlines()[0] for m in monsters)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@tool
|
|
85
|
+
def plan_encounter(
|
|
86
|
+
name: Annotated[str, Field(min_length=1, description="Encounter name")],
|
|
87
|
+
monsters: Annotated[
|
|
88
|
+
list[EncounterGroup] | None, Field(description="Bestiary monsters and how many")
|
|
89
|
+
] = None,
|
|
90
|
+
description: Text = None,
|
|
91
|
+
location: Annotated[str | None, Field(description="Where it happens")] = None,
|
|
92
|
+
notes: Text = None,
|
|
93
|
+
battlefield: Annotated[
|
|
94
|
+
Layout | None, Field(description="Its battle map; monsters may then have squares")
|
|
95
|
+
] = None,
|
|
96
|
+
store: CampaignStore = Depends(get_store),
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Prepare (or change) a fight and rate its difficulty for the party.
|
|
99
|
+
Run it later with start_combat(encounter=...), which lays out its map."""
|
|
100
|
+
fields = provided(description=description, location=location, notes=notes)
|
|
101
|
+
if battlefield is not None:
|
|
102
|
+
fields["battlefield"] = battlefields.build(
|
|
103
|
+
battlefield.width, battlefield.height, battlefield.walls, battlefield.difficult
|
|
104
|
+
)
|
|
105
|
+
with store.transaction() as campaign:
|
|
106
|
+
encounter, created = bestiary.plan_encounter(campaign, name, monsters, fields)
|
|
107
|
+
text = render_combat.encounter(campaign, encounter)
|
|
108
|
+
return f"{'Planned' if created else 'Updated'} {text}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@tool(annotations=READ_ONLY)
|
|
112
|
+
def list_encounters(store: CampaignStore = Depends(get_store)) -> str:
|
|
113
|
+
"""Planned encounters with their monsters, status and difficulty."""
|
|
114
|
+
with store.reading() as campaign:
|
|
115
|
+
encounters = list(campaign.encounters.values())
|
|
116
|
+
if not encounters:
|
|
117
|
+
return "No encounter planned."
|
|
118
|
+
return "\n".join(f"- {render_combat.encounter(campaign, e)}" for e in encounters)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
TOOLS: list[Callable[..., str]] = [save_monster, get_monsters, plan_encounter, list_encounters]
|