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
@@ -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())
@@ -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
+ ]
@@ -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]