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/campaign.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Campaign tools: create, open, list, describe, edit and delete campaigns."""
|
|
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.errors import InvalidChangeError
|
|
11
|
+
from dndkit.game import readiness, world
|
|
12
|
+
from dndkit.game.world import EntityKind
|
|
13
|
+
from dndkit.storage import CampaignStore
|
|
14
|
+
from dndkit.tools import render
|
|
15
|
+
from dndkit.tools.deps import (
|
|
16
|
+
DESTRUCTIVE,
|
|
17
|
+
READ_ONLY,
|
|
18
|
+
ConfirmationCode,
|
|
19
|
+
NameOrId,
|
|
20
|
+
get_store,
|
|
21
|
+
provided,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
SETTING_PREVIEW = 1500 # a voice model reads the rest when it needs it
|
|
25
|
+
Setting = Annotated[str | None, Field(description="World and tone, in prose or markdown")]
|
|
26
|
+
SettingFile = Annotated[
|
|
27
|
+
str | None,
|
|
28
|
+
Field(description="Name of a .md or .txt file in the server's settings folder"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _setting_text(
|
|
33
|
+
store: CampaignStore, setting: str | None, setting_file: str | None
|
|
34
|
+
) -> str | None:
|
|
35
|
+
if setting and setting_file:
|
|
36
|
+
raise InvalidChangeError("Give either setting or setting_file, not both.")
|
|
37
|
+
return store.read_setting_file(setting_file) if setting_file else setting
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@tool
|
|
41
|
+
def create_campaign(
|
|
42
|
+
name: Annotated[str, Field(min_length=1, max_length=100, description="Campaign name")],
|
|
43
|
+
description: Annotated[str, Field(description="Pitch shown to the players, no spoilers")],
|
|
44
|
+
dm_name: Annotated[str | None, Field(description="Game master's name")] = None,
|
|
45
|
+
setting: Setting = None,
|
|
46
|
+
setting_file: SettingFile = None,
|
|
47
|
+
store: CampaignStore = Depends(get_store),
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Create a campaign and make it active. An existing campaign is never replaced."""
|
|
50
|
+
text = _setting_text(store, setting, setting_file)
|
|
51
|
+
campaign = store.create(name, description, dm_name=dm_name, setting=text)
|
|
52
|
+
return f"Campaign '{campaign.name}' created and active."
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@tool(annotations=READ_ONLY)
|
|
56
|
+
def get_campaign_info(
|
|
57
|
+
full: Annotated[bool, Field(description="The whole setting, not its start")] = False,
|
|
58
|
+
store: CampaignStore = Depends(get_store),
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Describe the active campaign: pitch, setting, sizes and world notes."""
|
|
61
|
+
with store.reading() as c:
|
|
62
|
+
setting = c.setting or "not described yet"
|
|
63
|
+
if not full and len(setting) > SETTING_PREVIEW:
|
|
64
|
+
rest = len(setting) - SETTING_PREVIEW
|
|
65
|
+
setting = f"{setting[:SETTING_PREVIEW]}… ({rest} more characters: full=true)"
|
|
66
|
+
lines = [
|
|
67
|
+
f"{c.name} (id {c.id}): {c.description}",
|
|
68
|
+
f"Game master: {c.dm_name or 'unknown'}.",
|
|
69
|
+
f"{len(c.characters)} characters, {len(c.npcs)} NPCs, {len(c.locations)} locations, "
|
|
70
|
+
f"{len(c.quests)} quests, {len(c.sessions)} session notes, "
|
|
71
|
+
f"{len(c.events)} log events.",
|
|
72
|
+
f"Setting: {setting}",
|
|
73
|
+
]
|
|
74
|
+
if c.world_notes:
|
|
75
|
+
lines.append(f"World notes: {c.world_notes}")
|
|
76
|
+
return "\n".join(lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@tool(annotations=READ_ONLY)
|
|
80
|
+
def check_campaign(store: CampaignStore = Depends(get_store)) -> str:
|
|
81
|
+
"""Before play: what breaks the 5e rules or is missing (sheets, quest, places, fights)."""
|
|
82
|
+
with store.reading() as campaign:
|
|
83
|
+
found = readiness.check(campaign)
|
|
84
|
+
name, players = campaign.name, len(campaign.characters)
|
|
85
|
+
if not found:
|
|
86
|
+
return (
|
|
87
|
+
f"{name} is ready to play, {players} characters. At the session's start, "
|
|
88
|
+
"ask who is at the table (seat_players)."
|
|
89
|
+
)
|
|
90
|
+
lines = [f"Before playing {name}:"]
|
|
91
|
+
for certain, title in ((True, "Against the rules:"), (False, "Probably forgotten:")):
|
|
92
|
+
group = [f for f in found if f.certain is certain]
|
|
93
|
+
if group:
|
|
94
|
+
lines += [title, *(f"- {f.subject}: {f.problem}. Fix: {f.fix}." for f in group)]
|
|
95
|
+
lines.append(
|
|
96
|
+
"Fix them with those tools, asking the player what is theirs to choose "
|
|
97
|
+
"(scores, gear, spells), then call check_campaign again."
|
|
98
|
+
)
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@tool(annotations=READ_ONLY)
|
|
103
|
+
def list_campaigns(store: CampaignStore = Depends(get_store)) -> str:
|
|
104
|
+
"""List saved campaigns, most recently used first, with their size and session."""
|
|
105
|
+
entries = store.list_campaigns()
|
|
106
|
+
if not entries:
|
|
107
|
+
return f"No campaign saved yet in {store.campaigns_dir}."
|
|
108
|
+
active = store.current.name if store.current else None
|
|
109
|
+
return "Campaigns:\n" + "\n".join(
|
|
110
|
+
f"- {render.campaign_entry(e, e.name == active)}" for e in entries
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@tool
|
|
115
|
+
def load_campaign(
|
|
116
|
+
name: Annotated[str, Field(min_length=1, description="Campaign name")],
|
|
117
|
+
store: CampaignStore = Depends(get_store),
|
|
118
|
+
) -> str:
|
|
119
|
+
"""Open a saved campaign and make it active."""
|
|
120
|
+
campaign = store.load(name)
|
|
121
|
+
return f"Campaign '{campaign.name}' is active."
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@tool
|
|
125
|
+
def update_campaign(
|
|
126
|
+
description: Annotated[str | None, Field(description="New pitch, shown to players")] = None,
|
|
127
|
+
dm_name: Annotated[str | None, Field(description="Game master's name")] = None,
|
|
128
|
+
setting: Setting = None,
|
|
129
|
+
setting_file: SettingFile = None,
|
|
130
|
+
world_notes: Annotated[str | None, Field(description="Replaces the world notes")] = None,
|
|
131
|
+
store: CampaignStore = Depends(get_store),
|
|
132
|
+
) -> str:
|
|
133
|
+
"""Edit the active campaign's pitch, game master, setting or world notes."""
|
|
134
|
+
changes = provided(
|
|
135
|
+
description=description,
|
|
136
|
+
dm_name=dm_name,
|
|
137
|
+
setting=_setting_text(store, setting, setting_file),
|
|
138
|
+
world_notes=world_notes,
|
|
139
|
+
)
|
|
140
|
+
if not changes:
|
|
141
|
+
raise InvalidChangeError("Nothing to change: give at least one field.")
|
|
142
|
+
with store.transaction() as campaign:
|
|
143
|
+
for field, value in changes.items():
|
|
144
|
+
setattr(campaign, field, value)
|
|
145
|
+
return f"Campaign '{campaign.name}' updated: {', '.join(changes)}."
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@tool(annotations=DESTRUCTIVE)
|
|
149
|
+
def remove_from_campaign(
|
|
150
|
+
kind: Annotated[EntityKind, Field(description="What to remove")],
|
|
151
|
+
name_or_id: NameOrId,
|
|
152
|
+
confirmation_code: ConfirmationCode = None,
|
|
153
|
+
store: CampaignStore = Depends(get_store),
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Permanently remove a character, NPC, location or quest in two calls. The first only
|
|
156
|
+
returns a code: ask the user, and call again with it only after a clear yes."""
|
|
157
|
+
with store.reading() as campaign:
|
|
158
|
+
entity = world.find_entity(campaign, kind, name_or_id)
|
|
159
|
+
target = f"{campaign.id}/{kind}/{entity.id}"
|
|
160
|
+
if confirmation_code is None:
|
|
161
|
+
code = store.confirmations.issue(target)
|
|
162
|
+
return (
|
|
163
|
+
f"Nothing removed yet. Ask the user to confirm removing the {kind} "
|
|
164
|
+
f"'{entity.name}'. Only if they clearly say yes, call remove_from_campaign again "
|
|
165
|
+
f"with confirmation_code '{code}' (valid {store.confirmations.ttl_minutes} minutes)."
|
|
166
|
+
)
|
|
167
|
+
if not store.confirmations.consume(target, confirmation_code):
|
|
168
|
+
raise InvalidChangeError(
|
|
169
|
+
"That confirmation code is wrong or expired, so nothing was removed. "
|
|
170
|
+
"Call remove_from_campaign without a code to get a new one."
|
|
171
|
+
)
|
|
172
|
+
with store.transaction() as campaign:
|
|
173
|
+
name = world.remove_entity(campaign, kind, entity.id)
|
|
174
|
+
return f"Removed {kind} '{name}'."
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@tool(annotations=DESTRUCTIVE)
|
|
178
|
+
def delete_campaign(
|
|
179
|
+
name: Annotated[str, Field(min_length=1, description="Campaign name")],
|
|
180
|
+
confirmation_code: ConfirmationCode = None,
|
|
181
|
+
store: CampaignStore = Depends(get_store),
|
|
182
|
+
) -> str:
|
|
183
|
+
"""Delete a campaign in two calls. The first only returns a code and what would be lost:
|
|
184
|
+
ask the user out loud, and call again with the code only after a clear yes."""
|
|
185
|
+
if confirmation_code is None:
|
|
186
|
+
entry, code = store.request_deletion(name)
|
|
187
|
+
return (
|
|
188
|
+
f"Nothing deleted yet. Campaign '{entry.name}' holds "
|
|
189
|
+
f"{render.campaign_contents(entry)}. Ask the user to confirm the deletion. "
|
|
190
|
+
f"Only if they clearly say yes, call delete_campaign again with "
|
|
191
|
+
f"confirmation_code '{code}' (valid {store.confirmations.ttl_minutes} minutes)."
|
|
192
|
+
)
|
|
193
|
+
entry, moved_to = store.delete(name, confirmation_code)
|
|
194
|
+
active = store.current.name if store.current else None
|
|
195
|
+
after = f" '{active}' is still active." if active else " No campaign is active now."
|
|
196
|
+
return f"Campaign '{entry.name}' deleted (file moved to {moved_to}).{after}"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
TOOLS: list[Callable[..., str]] = [
|
|
200
|
+
create_campaign,
|
|
201
|
+
get_campaign_info,
|
|
202
|
+
check_campaign,
|
|
203
|
+
list_campaigns,
|
|
204
|
+
load_campaign,
|
|
205
|
+
update_campaign,
|
|
206
|
+
delete_campaign,
|
|
207
|
+
remove_from_campaign,
|
|
208
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Character tools: create, read and change player characters."""
|
|
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.errors import InvalidChangeError
|
|
11
|
+
from dndkit.game import characters, combatants, rules
|
|
12
|
+
from dndkit.models import AbilityScore, Character, CharacterClass, Race
|
|
13
|
+
from dndkit.storage import CampaignStore
|
|
14
|
+
from dndkit.tools import render
|
|
15
|
+
from dndkit.tools.apps import CHARACTER_APP, app_meta
|
|
16
|
+
from dndkit.tools.deps import READ_ONLY, NameOrId, get_store, provided
|
|
17
|
+
|
|
18
|
+
Score = Annotated[int, Field(ge=1, le=30, description="Ability score (1-30)")]
|
|
19
|
+
NewScore = Annotated[int | None, Field(ge=1, le=30, description="New score (1-30)")]
|
|
20
|
+
Text = Annotated[str | None, Field(description="New value")]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@tool
|
|
24
|
+
def create_character(
|
|
25
|
+
name: Annotated[str, Field(min_length=1, description="Character name")],
|
|
26
|
+
character_class: Annotated[str, Field(min_length=1, description="Class, e.g. Wizard")],
|
|
27
|
+
race: Annotated[str, Field(min_length=1, description="Race or species, e.g. Elf")],
|
|
28
|
+
class_level: Annotated[int, Field(ge=1, le=20, description="Class level")] = 1,
|
|
29
|
+
hit_points_max: Annotated[
|
|
30
|
+
int | None, Field(ge=1, description="Max HP; from class and CON if omitted")
|
|
31
|
+
] = None,
|
|
32
|
+
armor_class: Annotated[
|
|
33
|
+
int | None, Field(ge=0, description="AC; 10 + DEX (no armor) if omitted")
|
|
34
|
+
] = None,
|
|
35
|
+
speed: Annotated[int | None, Field(ge=0, description="Feet; from race if omitted")] = None,
|
|
36
|
+
player_name: Annotated[str | None, Field(description="Player controlling it")] = None,
|
|
37
|
+
description: Annotated[str | None, Field(description="Appearance and demeanor")] = None,
|
|
38
|
+
bio: Annotated[str | None, Field(description="Backstory and motivations")] = None,
|
|
39
|
+
background: Annotated[str | None, Field(description="Background, e.g. Sage")] = None,
|
|
40
|
+
alignment: Annotated[str | None, Field(description="Alignment")] = None,
|
|
41
|
+
strength: Score = 10,
|
|
42
|
+
dexterity: Score = 10,
|
|
43
|
+
constitution: Score = 10,
|
|
44
|
+
intelligence: Score = 10,
|
|
45
|
+
wisdom: Score = 10,
|
|
46
|
+
charisma: Score = 10,
|
|
47
|
+
store: CampaignStore = Depends(get_store),
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Create a player character at full hit points. The name must be new."""
|
|
50
|
+
scores = dict(
|
|
51
|
+
strength=strength,
|
|
52
|
+
dexterity=dexterity,
|
|
53
|
+
constitution=constitution,
|
|
54
|
+
intelligence=intelligence,
|
|
55
|
+
wisdom=wisdom,
|
|
56
|
+
charisma=charisma,
|
|
57
|
+
)
|
|
58
|
+
mods = {key: (value - 10) // 2 for key, value in scores.items()}
|
|
59
|
+
if hit_points_max is None:
|
|
60
|
+
hit_points_max = rules.starting_hit_points(
|
|
61
|
+
character_class, class_level, mods["constitution"]
|
|
62
|
+
)
|
|
63
|
+
if armor_class is None:
|
|
64
|
+
armor_class = 10 + mods["dexterity"]
|
|
65
|
+
character = Character(
|
|
66
|
+
name=name.strip(),
|
|
67
|
+
player_name=player_name,
|
|
68
|
+
character_class=CharacterClass(name=character_class, level=class_level),
|
|
69
|
+
race=Race(name=race),
|
|
70
|
+
background=background,
|
|
71
|
+
alignment=alignment,
|
|
72
|
+
description=description,
|
|
73
|
+
bio=bio,
|
|
74
|
+
armor_class=armor_class,
|
|
75
|
+
speed=speed if speed is not None else rules.base_speed(race),
|
|
76
|
+
hit_points_max=hit_points_max,
|
|
77
|
+
hit_points_current=hit_points_max,
|
|
78
|
+
abilities={k: AbilityScore(score=v) for k, v in scores.items()},
|
|
79
|
+
)
|
|
80
|
+
with store.transaction() as campaign:
|
|
81
|
+
characters.create(campaign, character)
|
|
82
|
+
caught_up = combatants.refresh(campaign) # a fighter of that name joins its sheet
|
|
83
|
+
casting = render.spellcasting_line(character)
|
|
84
|
+
return " ".join([f"Created {render.character_line(character)}.", casting, *caught_up]).strip()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@tool(annotations=READ_ONLY, meta=app_meta(CHARACTER_APP))
|
|
88
|
+
def get_character(
|
|
89
|
+
name_or_id: Annotated[
|
|
90
|
+
str | None, Field(description="Name or ID; omit for whose turn it is")
|
|
91
|
+
] = None,
|
|
92
|
+
store: CampaignStore = Depends(get_store),
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Full character sheet: abilities, hit points, spell attack and DC, spells, inventory."""
|
|
95
|
+
with store.reading() as campaign:
|
|
96
|
+
return render.character_sheet(characters.pick(campaign, name_or_id))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@tool(annotations=READ_ONLY)
|
|
100
|
+
def list_characters(store: CampaignStore = Depends(get_store)) -> str:
|
|
101
|
+
"""List the party's characters with level, class and hit points."""
|
|
102
|
+
with store.reading() as campaign:
|
|
103
|
+
party = sorted(campaign.characters.values(), key=lambda c: c.name)
|
|
104
|
+
if not party:
|
|
105
|
+
return "No characters yet."
|
|
106
|
+
lines = [f"- {render.character_line(c)}" for c in party]
|
|
107
|
+
return "\n".join(["Characters:", *lines, render.table(campaign, store.opened_at)])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@tool
|
|
111
|
+
def update_character(
|
|
112
|
+
name_or_id: NameOrId,
|
|
113
|
+
name: Annotated[str | None, Field(description="New name")] = None,
|
|
114
|
+
player_name: Text = None,
|
|
115
|
+
description: Text = None,
|
|
116
|
+
bio: Text = None,
|
|
117
|
+
background: Text = None,
|
|
118
|
+
alignment: Text = None,
|
|
119
|
+
class_level: Annotated[
|
|
120
|
+
int | None, Field(ge=1, le=20, description="Corrected level; level_up gains one")
|
|
121
|
+
] = None,
|
|
122
|
+
hit_points_current: Annotated[int | None, Field(ge=0, description="Current HP")] = None,
|
|
123
|
+
hit_points_max: Annotated[int | None, Field(ge=1, description="Maximum HP")] = None,
|
|
124
|
+
temporary_hit_points: Annotated[int | None, Field(ge=0, description="Temporary HP")] = None,
|
|
125
|
+
armor_class: Annotated[int | None, Field(ge=0, description="Armor class")] = None,
|
|
126
|
+
speed: Annotated[int | None, Field(ge=0, description="Walking speed, feet")] = None,
|
|
127
|
+
inspiration: Annotated[bool | None, Field(description="Has inspiration")] = None,
|
|
128
|
+
exhaustion: Annotated[int | None, Field(ge=0, le=6, description="Exhaustion level")] = None,
|
|
129
|
+
notes: Text = None,
|
|
130
|
+
skills: Annotated[list[str] | None, Field(description="Proficient skills")] = None,
|
|
131
|
+
expertise: Annotated[
|
|
132
|
+
list[str] | None, Field(description="Skills with double proficiency")
|
|
133
|
+
] = None,
|
|
134
|
+
saving_throws: Annotated[list[str] | None, Field(description="Proficient saves: DEX")] = None,
|
|
135
|
+
languages: Annotated[list[str] | None, Field(description="Languages spoken")] = None,
|
|
136
|
+
features: Annotated[list[str] | None, Field(description="Features, traits, feats")] = None,
|
|
137
|
+
strength: NewScore = None,
|
|
138
|
+
dexterity: NewScore = None,
|
|
139
|
+
constitution: NewScore = None,
|
|
140
|
+
intelligence: NewScore = None,
|
|
141
|
+
wisdom: NewScore = None,
|
|
142
|
+
charisma: NewScore = None,
|
|
143
|
+
store: CampaignStore = Depends(get_store),
|
|
144
|
+
) -> str:
|
|
145
|
+
"""Set character sheet values (absolute; a list replaces the old one). For damage or
|
|
146
|
+
healing use change_hit_points. Skills and saves take English or French names."""
|
|
147
|
+
changes = provided(
|
|
148
|
+
name=name,
|
|
149
|
+
player_name=player_name,
|
|
150
|
+
description=description,
|
|
151
|
+
bio=bio,
|
|
152
|
+
background=background,
|
|
153
|
+
alignment=alignment,
|
|
154
|
+
class_level=class_level,
|
|
155
|
+
hit_points_current=hit_points_current,
|
|
156
|
+
hit_points_max=hit_points_max,
|
|
157
|
+
temporary_hit_points=temporary_hit_points,
|
|
158
|
+
armor_class=armor_class,
|
|
159
|
+
speed=speed,
|
|
160
|
+
inspiration=inspiration,
|
|
161
|
+
exhaustion=exhaustion,
|
|
162
|
+
notes=notes,
|
|
163
|
+
skill_proficiencies=skills,
|
|
164
|
+
skill_expertise=expertise,
|
|
165
|
+
saving_throw_proficiencies=saving_throws,
|
|
166
|
+
languages=languages,
|
|
167
|
+
features_and_traits=features,
|
|
168
|
+
)
|
|
169
|
+
abilities = provided(
|
|
170
|
+
strength=strength,
|
|
171
|
+
dexterity=dexterity,
|
|
172
|
+
constitution=constitution,
|
|
173
|
+
intelligence=intelligence,
|
|
174
|
+
wisdom=wisdom,
|
|
175
|
+
charisma=charisma,
|
|
176
|
+
)
|
|
177
|
+
if not changes and not abilities:
|
|
178
|
+
raise InvalidChangeError("Nothing to change: give at least one field.")
|
|
179
|
+
with store.transaction() as campaign:
|
|
180
|
+
character = characters.update(campaign, name_or_id, changes, abilities)
|
|
181
|
+
return f"Updated {', '.join([*changes, *abilities])}. Now: {render.character_line(character)}."
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
TOOLS: list[Callable[..., str]] = [
|
|
185
|
+
create_character,
|
|
186
|
+
get_character,
|
|
187
|
+
list_characters,
|
|
188
|
+
update_character,
|
|
189
|
+
]
|
dndkit/tools/combat.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Combat tools: the initiative order, turns, newcomers and the end of the fight."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Annotated, Literal
|
|
5
|
+
|
|
6
|
+
from fastmcp.dependencies import Depends
|
|
7
|
+
from fastmcp.tools import tool
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from dndkit.errors import InvalidChangeError
|
|
11
|
+
from dndkit.game import battlefield, bestiary, combat, combatants, effects, pathing
|
|
12
|
+
from dndkit.game.dice import DiceLog
|
|
13
|
+
from dndkit.models import Participant
|
|
14
|
+
from dndkit.storage import CampaignStore
|
|
15
|
+
from dndkit.tools import render_combat
|
|
16
|
+
from dndkit.tools.apps import COMBAT_APP, app_meta
|
|
17
|
+
from dndkit.tools.deps import get_dice_log, get_store
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@tool(meta=app_meta(COMBAT_APP))
|
|
21
|
+
def start_combat(
|
|
22
|
+
participants: Annotated[
|
|
23
|
+
list[Participant] | None,
|
|
24
|
+
Field(description="Who fights, with initiative; the party rolls if left out"),
|
|
25
|
+
] = None,
|
|
26
|
+
encounter: Annotated[
|
|
27
|
+
str | None, Field(description="Planned encounter: its monsters join, initiative rolled")
|
|
28
|
+
] = None,
|
|
29
|
+
store: CampaignStore = Depends(get_store),
|
|
30
|
+
log: DiceLog = Depends(get_dice_log),
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Start a combat. Party characters use their sheet; bestiary monsters get their stats;
|
|
33
|
+
same-named foes become 'Goblin 1', 'Goblin 2'."""
|
|
34
|
+
with store.transaction() as campaign:
|
|
35
|
+
fighters = [combatants.build(campaign, p) for p in participants or []]
|
|
36
|
+
planned = bestiary.find_encounter(campaign, encounter) if encounter else None
|
|
37
|
+
state = campaign.game_state
|
|
38
|
+
if planned is not None:
|
|
39
|
+
fighters += combatants.from_encounter(campaign, planned)
|
|
40
|
+
planned.status = "running"
|
|
41
|
+
if planned.battlefield is not None:
|
|
42
|
+
state.battlefield = planned.battlefield.model_copy(deep=True)
|
|
43
|
+
if not fighters:
|
|
44
|
+
raise InvalidChangeError("Give participants, an encounter, or both.")
|
|
45
|
+
party = any(f.character_id for f in fighters)
|
|
46
|
+
rolled = [] if party else combatants.party_at_the_table(campaign)
|
|
47
|
+
fighters += [fighter for fighter, _ in rolled]
|
|
48
|
+
first = combat.start(state, fighters, planned.id if planned else None)
|
|
49
|
+
if state.battlefield is not None:
|
|
50
|
+
battlefield.settle(state)
|
|
51
|
+
battlefield.place_the_rest(state)
|
|
52
|
+
order = render_combat.initiative(campaign)
|
|
53
|
+
for _, result in rolled:
|
|
54
|
+
log.record(result)
|
|
55
|
+
rolls = ", ".join(f"{c.name} {r.total}" for c, r in rolled)
|
|
56
|
+
note = f"Initiative rolled for the party: {rolls}.\n" if rolls else ""
|
|
57
|
+
return f"Combat started. {first.name} acts first.\n{note}{order}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@tool
|
|
61
|
+
def add_combatant(
|
|
62
|
+
participant: Annotated[Participant, Field(description="The newcomer and its initiative")],
|
|
63
|
+
store: CampaignStore = Depends(get_store),
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Bring someone into the running fight (reinforcements, a late arrival)."""
|
|
66
|
+
with store.transaction() as campaign:
|
|
67
|
+
added = combat.add(campaign.game_state, combatants.build(campaign, participant))
|
|
68
|
+
battlefield.place_the_rest(campaign.game_state)
|
|
69
|
+
order = render_combat.initiative(campaign)
|
|
70
|
+
return f"{added.name} joins the fight.\n{order}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@tool
|
|
74
|
+
def next_turn(store: CampaignStore = Depends(get_store)) -> str:
|
|
75
|
+
"""End the current turn and give the next combatant theirs."""
|
|
76
|
+
with store.transaction() as campaign:
|
|
77
|
+
state = campaign.game_state
|
|
78
|
+
caught_up = combatants.refresh(campaign)
|
|
79
|
+
ending = state.current_combatant
|
|
80
|
+
caught_up += effects.end_of_turn(campaign, ending) if ending else []
|
|
81
|
+
current = combat.next_turn(state)
|
|
82
|
+
prefix = f"Round {state.combat_round} begins. " if state.turn_index == 0 else ""
|
|
83
|
+
line = render_combat.combatant_line(campaign, current)
|
|
84
|
+
hint = render_combat.turn_hint(campaign, current)
|
|
85
|
+
return "\n".join([*caught_up, f"{prefix}{current.name}'s turn: {line}", *filter(None, [hint])])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@tool
|
|
89
|
+
def remove_combatant(
|
|
90
|
+
name: Annotated[str, Field(min_length=1, description="Combatant name, e.g. 'Goblin 2'")],
|
|
91
|
+
reason: Annotated[
|
|
92
|
+
Literal["defeated", "fled", "withdrawn"],
|
|
93
|
+
Field(description="withdrawn: a mistake or change of plan, no XP"),
|
|
94
|
+
] = "defeated",
|
|
95
|
+
store: CampaignStore = Depends(get_store),
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Take a combatant out of the fight and off the battle map. Defeated or fled foes
|
|
98
|
+
count for XP; a withdrawn one is as if it never joined."""
|
|
99
|
+
with store.transaction() as campaign:
|
|
100
|
+
removed, acting = combat.remove(campaign.game_state, name, overcome=reason != "withdrawn")
|
|
101
|
+
gone = (
|
|
102
|
+
f"{removed.name} withdrawn: as if it never joined, no XP."
|
|
103
|
+
if reason == "withdrawn"
|
|
104
|
+
else f"{removed.name} removed ({reason})."
|
|
105
|
+
)
|
|
106
|
+
if acting is None:
|
|
107
|
+
return f"{gone} No one is left: call end_combat."
|
|
108
|
+
return f"{gone} Current turn: {acting.name}."
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@tool
|
|
112
|
+
def use_legendary(
|
|
113
|
+
name: Annotated[str, Field(min_length=1, description="The legendary creature")],
|
|
114
|
+
resistance: Annotated[bool, Field(description="Turn a failed save into a success")] = False,
|
|
115
|
+
cost: Annotated[int, Field(ge=1, le=3, description="Actions the option costs")] = 1,
|
|
116
|
+
store: CampaignStore = Depends(get_store),
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Spend a legendary action (at the end of another creature's turn) or resistance."""
|
|
119
|
+
with store.transaction() as campaign:
|
|
120
|
+
creature = combat.use_legendary(campaign.game_state, name, resistance, cost)
|
|
121
|
+
if resistance:
|
|
122
|
+
return (
|
|
123
|
+
f"{creature.name} succeeds instead; "
|
|
124
|
+
f"{creature.legendary_resistances_left} legendary resistance(s) left."
|
|
125
|
+
)
|
|
126
|
+
return (
|
|
127
|
+
f"{creature.name} takes a legendary action; "
|
|
128
|
+
f"{creature.legendary_actions_left} left until its turn."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@tool
|
|
133
|
+
def take_reaction(
|
|
134
|
+
name: Annotated[str, Field(min_length=1, description="Who reacts")],
|
|
135
|
+
reaction: Annotated[str, Field(description="e.g. opportunity attack, Shield")] = "",
|
|
136
|
+
store: CampaignStore = Depends(get_store),
|
|
137
|
+
) -> str:
|
|
138
|
+
"""Spend a creature's reaction: one a round, back at the start of its turn. One that
|
|
139
|
+
has reacted provokes no more opportunity attacks this round."""
|
|
140
|
+
with store.transaction() as campaign:
|
|
141
|
+
state = campaign.game_state
|
|
142
|
+
able = pathing.can_react(campaign, combat.find(state, name))
|
|
143
|
+
creature = combat.use_reaction(state, name, able)
|
|
144
|
+
what = f" ({reaction})" if reaction else ""
|
|
145
|
+
return f"{creature.name} uses its reaction{what}; it comes back at the start of its turn."
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@tool
|
|
149
|
+
def end_combat(store: CampaignStore = Depends(get_store)) -> str:
|
|
150
|
+
"""End the combat: says who was overcome and the XP they are worth."""
|
|
151
|
+
with store.transaction() as campaign:
|
|
152
|
+
encounter_id = campaign.game_state.encounter_id
|
|
153
|
+
summary = combat.end(campaign.game_state)
|
|
154
|
+
if encounter_id and encounter_id in campaign.encounters:
|
|
155
|
+
campaign.encounters[encounter_id].status = "done"
|
|
156
|
+
defeated = ", ".join(c.name for c in summary.defeated) or "no one"
|
|
157
|
+
award = f" Award it with award_experience ({summary.xp} XP)." if summary.xp else ""
|
|
158
|
+
return f"Combat over after {summary.rounds} round(s). Overcome: {defeated}.{award}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
TOOLS: list[Callable[..., str]] = [
|
|
162
|
+
start_combat,
|
|
163
|
+
add_combatant,
|
|
164
|
+
next_turn,
|
|
165
|
+
remove_combatant,
|
|
166
|
+
use_legendary,
|
|
167
|
+
take_reaction,
|
|
168
|
+
end_combat,
|
|
169
|
+
]
|
dndkit/tools/deps.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""What tools share: the campaign store dependency and common parameter types."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
from fastmcp import Context
|
|
6
|
+
from fastmcp.dependencies import CurrentContext
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from dndkit.game.dice import DiceLog
|
|
10
|
+
from dndkit.storage import CampaignStore
|
|
11
|
+
|
|
12
|
+
STORE_KEY = "store"
|
|
13
|
+
DICE_LOG_KEY = "dice_log"
|
|
14
|
+
|
|
15
|
+
READ_ONLY: dict[str, Any] = {"readOnlyHint": True}
|
|
16
|
+
DESTRUCTIVE: dict[str, Any] = {"destructiveHint": True}
|
|
17
|
+
|
|
18
|
+
NameOrId = Annotated[str, Field(min_length=1, description="Name or ID")]
|
|
19
|
+
ConfirmationCode = Annotated[
|
|
20
|
+
str | None, Field(description="Code from the first call, once the user has said yes")
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_store(ctx: Context = CurrentContext()) -> CampaignStore:
|
|
25
|
+
"""The store opened by the server's lifespan (see ``app.build_server``)."""
|
|
26
|
+
store = ctx.lifespan_context.get(STORE_KEY)
|
|
27
|
+
if not isinstance(store, CampaignStore):
|
|
28
|
+
raise RuntimeError("The campaign store is not open: the server lifespan did not run.")
|
|
29
|
+
return store
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_dice_log(ctx: Context = CurrentContext()) -> DiceLog:
|
|
33
|
+
"""The session's dice log, opened by the server's lifespan."""
|
|
34
|
+
log = ctx.lifespan_context.get(DICE_LOG_KEY)
|
|
35
|
+
if not isinstance(log, DiceLog):
|
|
36
|
+
raise RuntimeError("The dice log is not open: the server lifespan did not run.")
|
|
37
|
+
return log
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def provided(**fields: Any) -> dict[str, Any]:
|
|
41
|
+
"""The fields the model actually sent: None means "leave unchanged"."""
|
|
42
|
+
return {name: value for name, value in fields.items() if value is not None}
|