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/journal.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Journal tools: session notes and the adventure log."""
|
|
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 journal
|
|
11
|
+
from dndkit.models import AdventureEvent, EventType, SessionNote
|
|
12
|
+
from dndkit.storage import CampaignStore
|
|
13
|
+
from dndkit.tools import render
|
|
14
|
+
from dndkit.tools.deps import READ_ONLY, get_store
|
|
15
|
+
|
|
16
|
+
Names = Annotated[list[str] | None, Field(description="Character names")]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@tool
|
|
20
|
+
def add_session_note(
|
|
21
|
+
session_number: Annotated[int, Field(ge=1, description="Session number")],
|
|
22
|
+
summary: Annotated[str, Field(min_length=1, description="What happened")],
|
|
23
|
+
title: Annotated[str | None, Field(description="Session title")] = None,
|
|
24
|
+
events: Annotated[list[str] | None, Field(description="Key moments")] = None,
|
|
25
|
+
characters_present: Names = None,
|
|
26
|
+
experience_gained: Annotated[int | None, Field(ge=0, description="XP earned")] = None,
|
|
27
|
+
treasure_found: Annotated[list[str] | None, Field(description="Loot")] = None,
|
|
28
|
+
notes: Annotated[str, Field(description="Notes")] = "",
|
|
29
|
+
store: CampaignStore = Depends(get_store),
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Save the summary of a play session."""
|
|
32
|
+
note = SessionNote(
|
|
33
|
+
session_number=session_number,
|
|
34
|
+
summary=summary,
|
|
35
|
+
title=title,
|
|
36
|
+
events=events or [],
|
|
37
|
+
characters_present=characters_present or [],
|
|
38
|
+
experience_gained=experience_gained,
|
|
39
|
+
treasure_found=treasure_found or [],
|
|
40
|
+
notes=notes,
|
|
41
|
+
)
|
|
42
|
+
with store.transaction() as campaign:
|
|
43
|
+
journal.add_session_note(campaign, note)
|
|
44
|
+
return f"Saved notes for session {session_number}."
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@tool(annotations=READ_ONLY)
|
|
48
|
+
def get_sessions(store: CampaignStore = Depends(get_store)) -> str:
|
|
49
|
+
"""Session notes in play order, summaries shortened."""
|
|
50
|
+
with store.reading() as campaign:
|
|
51
|
+
notes = journal.sessions(campaign)
|
|
52
|
+
if not notes:
|
|
53
|
+
return "No session notes yet."
|
|
54
|
+
return "\n".join(render.session_line(note) for note in notes)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@tool
|
|
58
|
+
def add_event(
|
|
59
|
+
event_type: Annotated[EventType, Field(description="Kind of event")],
|
|
60
|
+
title: Annotated[str, Field(min_length=1, description="Short title")],
|
|
61
|
+
description: Annotated[str, Field(description="What happened")],
|
|
62
|
+
session_number: Annotated[
|
|
63
|
+
int | None, Field(ge=1, description="Session; defaults to the current one")
|
|
64
|
+
] = None,
|
|
65
|
+
characters_involved: Names = None,
|
|
66
|
+
location: Annotated[str | None, Field(description="Where it happened")] = None,
|
|
67
|
+
importance: Annotated[int, Field(ge=1, le=5, description="1 minor to 5 major")] = 3,
|
|
68
|
+
tags: Annotated[list[str] | None, Field(description="Tags for search")] = None,
|
|
69
|
+
store: CampaignStore = Depends(get_store),
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Log a notable moment in the active campaign's adventure log."""
|
|
72
|
+
event = AdventureEvent(
|
|
73
|
+
event_type=event_type,
|
|
74
|
+
title=title,
|
|
75
|
+
description=description,
|
|
76
|
+
session_number=session_number,
|
|
77
|
+
characters_involved=characters_involved or [],
|
|
78
|
+
location=location,
|
|
79
|
+
importance=importance,
|
|
80
|
+
tags=tags or [],
|
|
81
|
+
)
|
|
82
|
+
with store.transaction() as campaign:
|
|
83
|
+
journal.add_event(campaign, event)
|
|
84
|
+
return f"Logged {event.event_type} event '{event.title}'."
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@tool(annotations=READ_ONLY)
|
|
88
|
+
def get_events(
|
|
89
|
+
limit: Annotated[int | None, Field(ge=1, description="At most this many")] = 10,
|
|
90
|
+
event_type: Annotated[EventType | None, Field(description="Only this kind")] = None,
|
|
91
|
+
search: Annotated[str | None, Field(description="Text in title, description or tags")] = None,
|
|
92
|
+
store: CampaignStore = Depends(get_store),
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Recent adventure log entries, newest first; filters combine."""
|
|
95
|
+
with store.reading() as campaign:
|
|
96
|
+
events = journal.find_events(campaign, event_type=event_type, search=search, limit=limit)
|
|
97
|
+
if not events:
|
|
98
|
+
return "No matching events."
|
|
99
|
+
return "\n".join(render.event_line(event) for event in events)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
TOOLS: list[Callable[..., str]] = [add_session_note, get_sessions, add_event, get_events]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Location 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.errors import InvalidChangeError
|
|
11
|
+
from dndkit.game import world
|
|
12
|
+
from dndkit.models import Location
|
|
13
|
+
from dndkit.storage import CampaignStore
|
|
14
|
+
from dndkit.tools import render
|
|
15
|
+
from dndkit.tools.deps import READ_ONLY, NameOrId, get_store, provided
|
|
16
|
+
|
|
17
|
+
Text = Annotated[str | None, Field(description="New value")]
|
|
18
|
+
Population = Annotated[int | None, Field(ge=0, description="Population")]
|
|
19
|
+
Features = Annotated[list[str] | None, Field(description="Notable features")]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@tool
|
|
23
|
+
def create_location(
|
|
24
|
+
name: Annotated[str, Field(min_length=1, description="Location name")],
|
|
25
|
+
location_type: Annotated[str, Field(description="city, town, village, dungeon, forest...")],
|
|
26
|
+
description: Annotated[str, Field(description="What it looks and feels like")],
|
|
27
|
+
population: Population = None,
|
|
28
|
+
government: Annotated[str | None, Field(description="Who rules it")] = None,
|
|
29
|
+
notable_features: Features = None,
|
|
30
|
+
notes: Annotated[str, Field(description="Notes")] = "",
|
|
31
|
+
store: CampaignStore = Depends(get_store),
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Create a location. The name must be new; use update_location to change one."""
|
|
34
|
+
location = Location(
|
|
35
|
+
name=name.strip(),
|
|
36
|
+
location_type=location_type,
|
|
37
|
+
description=description,
|
|
38
|
+
population=population,
|
|
39
|
+
government=government,
|
|
40
|
+
notable_features=notable_features or [],
|
|
41
|
+
notes=notes,
|
|
42
|
+
)
|
|
43
|
+
with store.transaction() as campaign:
|
|
44
|
+
world.create_location(campaign, location)
|
|
45
|
+
return f"Created location {location.name} ({location.location_type}, id {location.id})."
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@tool(annotations=READ_ONLY)
|
|
49
|
+
def get_location(name_or_id: NameOrId, store: CampaignStore = Depends(get_store)) -> str:
|
|
50
|
+
"""A location's description, features, NPCs and connections."""
|
|
51
|
+
with store.reading() as campaign:
|
|
52
|
+
return render.location(world.find_location(campaign, name_or_id))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@tool
|
|
56
|
+
def update_location(
|
|
57
|
+
name_or_id: NameOrId,
|
|
58
|
+
name: Annotated[str | None, Field(description="New name")] = None,
|
|
59
|
+
location_type: Text = None,
|
|
60
|
+
description: Text = None,
|
|
61
|
+
population: Population = None,
|
|
62
|
+
government: Text = None,
|
|
63
|
+
notable_features: Features = None,
|
|
64
|
+
npcs: Annotated[list[str] | None, Field(description="NPC names found here")] = None,
|
|
65
|
+
connections: Annotated[list[str] | None, Field(description="Linked locations")] = None,
|
|
66
|
+
notes: Text = None,
|
|
67
|
+
store: CampaignStore = Depends(get_store),
|
|
68
|
+
) -> str:
|
|
69
|
+
"""Change a location. Lists given here replace the stored ones."""
|
|
70
|
+
changes = provided(
|
|
71
|
+
name=name,
|
|
72
|
+
location_type=location_type,
|
|
73
|
+
description=description,
|
|
74
|
+
population=population,
|
|
75
|
+
government=government,
|
|
76
|
+
notable_features=notable_features,
|
|
77
|
+
npcs=npcs,
|
|
78
|
+
connections=connections,
|
|
79
|
+
notes=notes,
|
|
80
|
+
)
|
|
81
|
+
if not changes:
|
|
82
|
+
raise InvalidChangeError("Nothing to change: give at least one field.")
|
|
83
|
+
with store.transaction() as campaign:
|
|
84
|
+
location = world.update_location(campaign, name_or_id, changes)
|
|
85
|
+
return f"Updated location {location.name}: {', '.join(changes)}."
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@tool(annotations=READ_ONLY)
|
|
89
|
+
def list_locations(store: CampaignStore = Depends(get_store)) -> str:
|
|
90
|
+
"""List locations with their type."""
|
|
91
|
+
with store.reading() as campaign:
|
|
92
|
+
places = sorted(campaign.locations.values(), key=lambda loc: loc.name)
|
|
93
|
+
if not places:
|
|
94
|
+
return "No locations yet."
|
|
95
|
+
return "Locations:\n" + "\n".join(f"- {p.name} ({p.location_type})" for p in places)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
TOOLS: list[Callable[..., str]] = [create_location, get_location, update_location, list_locations]
|
dndkit/tools/npcs.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""NPC 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.errors import InvalidChangeError
|
|
11
|
+
from dndkit.game import world
|
|
12
|
+
from dndkit.models import NPC, Attitude
|
|
13
|
+
from dndkit.storage import CampaignStore
|
|
14
|
+
from dndkit.tools import render
|
|
15
|
+
from dndkit.tools.deps import READ_ONLY, NameOrId, get_store, provided
|
|
16
|
+
|
|
17
|
+
Text = Annotated[str | None, Field(description="New value")]
|
|
18
|
+
Description = Annotated[str | None, Field(description="Public description")]
|
|
19
|
+
Bio = Annotated[str | None, Field(description="Private bio, secrets included")]
|
|
20
|
+
NpcAttitude = Annotated[Attitude | None, Field(description="Attitude towards the party")]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@tool
|
|
24
|
+
def create_npc(
|
|
25
|
+
name: Annotated[str, Field(min_length=1, description="NPC name")],
|
|
26
|
+
description: Description = None,
|
|
27
|
+
bio: Bio = None,
|
|
28
|
+
race: Annotated[str | None, Field(description="Race or species")] = None,
|
|
29
|
+
occupation: Annotated[str | None, Field(description="Occupation")] = None,
|
|
30
|
+
location: Annotated[str | None, Field(description="Where the NPC is")] = None,
|
|
31
|
+
attitude: NpcAttitude = None,
|
|
32
|
+
notes: Annotated[str, Field(description="Notes")] = "",
|
|
33
|
+
store: CampaignStore = Depends(get_store),
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Create an NPC. The name must be new; use update_npc to change one."""
|
|
36
|
+
npc = NPC(
|
|
37
|
+
name=name.strip(),
|
|
38
|
+
description=description,
|
|
39
|
+
bio=bio,
|
|
40
|
+
race=race,
|
|
41
|
+
occupation=occupation,
|
|
42
|
+
location=location,
|
|
43
|
+
attitude=attitude,
|
|
44
|
+
notes=notes,
|
|
45
|
+
)
|
|
46
|
+
with store.transaction() as campaign:
|
|
47
|
+
world.create_npc(campaign, npc)
|
|
48
|
+
return f"Created NPC {npc.name} (id {npc.id})."
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@tool(annotations=READ_ONLY)
|
|
52
|
+
def get_npc(name_or_id: NameOrId, store: CampaignStore = Depends(get_store)) -> str:
|
|
53
|
+
"""An NPC's details, private bio included (for the game master only)."""
|
|
54
|
+
with store.reading() as campaign:
|
|
55
|
+
return render.npc(world.find_npc(campaign, name_or_id))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@tool
|
|
59
|
+
def update_npc(
|
|
60
|
+
name_or_id: NameOrId,
|
|
61
|
+
name: Annotated[str | None, Field(description="New name")] = None,
|
|
62
|
+
description: Description = None,
|
|
63
|
+
bio: Bio = None,
|
|
64
|
+
race: Text = None,
|
|
65
|
+
occupation: Text = None,
|
|
66
|
+
location: Text = None,
|
|
67
|
+
attitude: NpcAttitude = None,
|
|
68
|
+
notes: Text = None,
|
|
69
|
+
store: CampaignStore = Depends(get_store),
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Change an NPC: attitude, location, notes, secrets..."""
|
|
72
|
+
changes = provided(
|
|
73
|
+
name=name,
|
|
74
|
+
description=description,
|
|
75
|
+
bio=bio,
|
|
76
|
+
race=race,
|
|
77
|
+
occupation=occupation,
|
|
78
|
+
location=location,
|
|
79
|
+
attitude=attitude,
|
|
80
|
+
notes=notes,
|
|
81
|
+
)
|
|
82
|
+
if not changes:
|
|
83
|
+
raise InvalidChangeError("Nothing to change: give at least one field.")
|
|
84
|
+
with store.transaction() as campaign:
|
|
85
|
+
npc = world.update_npc(campaign, name_or_id, changes)
|
|
86
|
+
return f"Updated NPC {npc.name}: {', '.join(changes)}."
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@tool(annotations=READ_ONLY)
|
|
90
|
+
def list_npcs(store: CampaignStore = Depends(get_store)) -> str:
|
|
91
|
+
"""List NPCs with where they are."""
|
|
92
|
+
with store.reading() as campaign:
|
|
93
|
+
npcs = sorted(campaign.npcs.values(), key=lambda n: n.name)
|
|
94
|
+
if not npcs:
|
|
95
|
+
return "No NPCs yet."
|
|
96
|
+
return "NPCs:\n" + "\n".join(
|
|
97
|
+
f"- {n.name}{f', at {n.location}' if n.location else ''}" for n in npcs
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
TOOLS: list[Callable[..., str]] = [create_npc, get_npc, update_npc, list_npcs]
|
dndkit/tools/play.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Tools used at the table: game state, hit points and conditions."""
|
|
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 effects
|
|
12
|
+
from dndkit.models import now
|
|
13
|
+
from dndkit.storage import CampaignStore
|
|
14
|
+
from dndkit.tools import render
|
|
15
|
+
from dndkit.tools.apps import PARTY_APP, app_meta
|
|
16
|
+
from dndkit.tools.deps import READ_ONLY, get_store, provided
|
|
17
|
+
|
|
18
|
+
Targets = Annotated[
|
|
19
|
+
list[str], Field(min_length=1, description="Characters or combatants, e.g. 'Goblin 2'")
|
|
20
|
+
]
|
|
21
|
+
Condition = Literal[
|
|
22
|
+
"blinded", "charmed", "deafened", "frightened", "grappled", "incapacitated", "invisible",
|
|
23
|
+
"paralyzed", "petrified", "poisoned", "prone", "restrained", "stunned", "unconscious",
|
|
24
|
+
] # fmt: skip
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@tool(annotations=READ_ONLY, meta=app_meta(PARTY_APP))
|
|
28
|
+
def get_game_state(store: CampaignStore = Depends(get_store)) -> str:
|
|
29
|
+
"""Where the party is: session, location, date, funds, party HP, quests, combat."""
|
|
30
|
+
with store.reading() as campaign:
|
|
31
|
+
return render.game_state(campaign, store.opened_at)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@tool
|
|
35
|
+
def update_game_state(
|
|
36
|
+
current_location: Annotated[str | None, Field(description="Party's location")] = None,
|
|
37
|
+
current_session: Annotated[int | None, Field(ge=1, description="Session number")] = None,
|
|
38
|
+
current_date_in_game: Annotated[str | None, Field(description="In-game date")] = None,
|
|
39
|
+
party_funds: Annotated[str | None, Field(description="Shared funds, e.g. '120 gp'")] = None,
|
|
40
|
+
notes: Annotated[str | None, Field(description="Current situation, replaces notes")] = None,
|
|
41
|
+
store: CampaignStore = Depends(get_store),
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Record where the party is, the date, the session, funds or the situation."""
|
|
44
|
+
changes = provided(
|
|
45
|
+
current_location=current_location,
|
|
46
|
+
current_session=current_session,
|
|
47
|
+
current_date_in_game=current_date_in_game,
|
|
48
|
+
party_funds=party_funds,
|
|
49
|
+
notes=notes,
|
|
50
|
+
)
|
|
51
|
+
if not changes:
|
|
52
|
+
raise InvalidChangeError("Nothing to change: give at least one field.")
|
|
53
|
+
with store.transaction() as campaign:
|
|
54
|
+
state = campaign.game_state
|
|
55
|
+
for field, value in changes.items():
|
|
56
|
+
setattr(state, field, value)
|
|
57
|
+
state.updated_at = now()
|
|
58
|
+
return f"Game state updated: {', '.join(changes)}."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@tool
|
|
62
|
+
def change_hit_points(
|
|
63
|
+
names: Targets,
|
|
64
|
+
amount: Annotated[int, Field(description="Negative for damage, positive for healing")],
|
|
65
|
+
temporary: Annotated[
|
|
66
|
+
bool, Field(description="Grant temporary HP instead (they don't stack)")
|
|
67
|
+
] = False,
|
|
68
|
+
store: CampaignStore = Depends(get_store),
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Damage or heal characters and monsters. Temporary HP absorb damage first; a monster
|
|
71
|
+
at 0 HP leaves the fight; a character at 0 HP falls unconscious."""
|
|
72
|
+
if amount == 0:
|
|
73
|
+
raise InvalidChangeError("The amount must not be 0.")
|
|
74
|
+
with store.transaction() as campaign:
|
|
75
|
+
if temporary:
|
|
76
|
+
if amount < 0:
|
|
77
|
+
raise InvalidChangeError(
|
|
78
|
+
"Temporary hit points are granted with a positive amount."
|
|
79
|
+
)
|
|
80
|
+
return "\n".join(effects.grant_temporary_hp(campaign, names, amount))
|
|
81
|
+
return "\n".join(effects.change_hit_points(campaign, names, amount))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@tool
|
|
85
|
+
def set_condition(
|
|
86
|
+
names: Targets,
|
|
87
|
+
condition: Annotated[Condition, Field(description="5e condition")],
|
|
88
|
+
active: Annotated[bool, Field(description="false to remove it")] = True,
|
|
89
|
+
rounds: Annotated[
|
|
90
|
+
int | None, Field(ge=1, le=100, description="Ends after its Nth turn (1 minute: 10)")
|
|
91
|
+
] = None,
|
|
92
|
+
store: CampaignStore = Depends(get_store),
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Add or remove a condition (poisoned, prone...) on characters or monsters. With
|
|
95
|
+
rounds, next_turn ends it at the end of the target's turn that many turns on."""
|
|
96
|
+
with store.transaction() as campaign:
|
|
97
|
+
return "\n".join(effects.set_condition(campaign, names, condition, active, rounds))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@tool
|
|
101
|
+
def set_concentration(
|
|
102
|
+
name: Annotated[str, Field(min_length=1, description="Character or monster")],
|
|
103
|
+
spell: Annotated[str | None, Field(description="The spell held; omit to end it")] = None,
|
|
104
|
+
store: CampaignStore = Depends(get_store),
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Concentrate on a spell (ending any other), or stop. Damage then asks for the
|
|
107
|
+
Constitution save; 0 HP or incapacitation ends it."""
|
|
108
|
+
with store.transaction() as campaign:
|
|
109
|
+
return effects.set_concentration(campaign, name, spell)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
TOOLS: list[Callable[..., str]] = [
|
|
113
|
+
get_game_state,
|
|
114
|
+
update_game_state,
|
|
115
|
+
change_hit_points,
|
|
116
|
+
set_condition,
|
|
117
|
+
set_concentration,
|
|
118
|
+
]
|
dndkit/tools/players.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The table: who plays tonight, and which character each plays."""
|
|
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 seating
|
|
11
|
+
from dndkit.models import Seat, now
|
|
12
|
+
from dndkit.storage import CampaignStore
|
|
13
|
+
from dndkit.tools import render
|
|
14
|
+
from dndkit.tools.apps import SEATING_APP, app_meta
|
|
15
|
+
from dndkit.tools.deps import get_store
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@tool(meta=app_meta(SEATING_APP))
|
|
19
|
+
def seat_players(
|
|
20
|
+
players: Annotated[
|
|
21
|
+
list[Seat] | None, Field(description="People sitting down, with their character")
|
|
22
|
+
] = None,
|
|
23
|
+
leaving: Annotated[list[str] | None, Field(description="Players who left")] = None,
|
|
24
|
+
start_over: Annotated[
|
|
25
|
+
bool, Field(description="New session: forget who was here before")
|
|
26
|
+
] = False,
|
|
27
|
+
store: CampaignStore = Depends(get_store),
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Who is at the table and which character each plays (shown on screen).
|
|
30
|
+
Without arguments, just shows it."""
|
|
31
|
+
if not (players or leaving or start_over):
|
|
32
|
+
with store.reading() as campaign:
|
|
33
|
+
return render.table(campaign, store.opened_at)
|
|
34
|
+
with store.transaction() as campaign:
|
|
35
|
+
if start_over:
|
|
36
|
+
seating.clear(campaign)
|
|
37
|
+
notes = [seating.seat(campaign, s.player, s.character) for s in players or []]
|
|
38
|
+
for player in leaving or []:
|
|
39
|
+
seating.leave(campaign, player)
|
|
40
|
+
campaign.game_state.roll_call_at = now()
|
|
41
|
+
return "\n".join([*(note for note in notes if note), render.table(campaign)])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
TOOLS: list[Callable[..., str]] = [seat_players]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Progression tools: experience and levelling up."""
|
|
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 progression
|
|
11
|
+
from dndkit.storage import CampaignStore
|
|
12
|
+
from dndkit.tools.deps import NameOrId, get_store
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@tool
|
|
16
|
+
def award_experience(
|
|
17
|
+
total_xp: Annotated[int, Field(ge=1, description="XP to split, e.g. the monsters' total")],
|
|
18
|
+
names_or_ids: Annotated[
|
|
19
|
+
list[str] | None, Field(description="Who shares it; omit for the whole party")
|
|
20
|
+
] = None,
|
|
21
|
+
store: CampaignStore = Depends(get_store),
|
|
22
|
+
) -> str:
|
|
23
|
+
"""Split XP evenly and say who has enough for a new level."""
|
|
24
|
+
with store.transaction() as campaign:
|
|
25
|
+
return "\n".join(progression.award_experience(campaign, names_or_ids or [], total_xp))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@tool
|
|
29
|
+
def level_up(
|
|
30
|
+
name_or_id: NameOrId,
|
|
31
|
+
hit_die_roll: Annotated[
|
|
32
|
+
int | None, Field(ge=1, le=12, description="The hit die roll; omit for the average")
|
|
33
|
+
] = None,
|
|
34
|
+
store: CampaignStore = Depends(get_store),
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Raise a character one level: HP (die + CON), proficiency, hit dice, spell slots."""
|
|
37
|
+
with store.transaction() as campaign:
|
|
38
|
+
return progression.level_up(campaign, name_or_id, hit_die_roll)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
TOOLS: list[Callable[..., str]] = [award_experience, level_up]
|
dndkit/tools/quests.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Quest 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.errors import InvalidChangeError
|
|
11
|
+
from dndkit.game import world
|
|
12
|
+
from dndkit.models import Quest, QuestStatus
|
|
13
|
+
from dndkit.storage import CampaignStore
|
|
14
|
+
from dndkit.tools import render
|
|
15
|
+
from dndkit.tools.deps import READ_ONLY, NameOrId, get_store
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@tool
|
|
19
|
+
def create_quest(
|
|
20
|
+
title: Annotated[str, Field(min_length=1, description="Quest title")],
|
|
21
|
+
description: Annotated[str, Field(description="What the quest is about")],
|
|
22
|
+
giver: Annotated[str | None, Field(description="Who gave it (NPC name)")] = None,
|
|
23
|
+
objectives: Annotated[list[str] | None, Field(description="Objectives, in order")] = None,
|
|
24
|
+
reward: Annotated[str | None, Field(description="Promised reward")] = None,
|
|
25
|
+
notes: Annotated[str, Field(description="Notes")] = "",
|
|
26
|
+
store: CampaignStore = Depends(get_store),
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Create an active quest. The title must be new."""
|
|
29
|
+
quest = Quest(
|
|
30
|
+
title=title.strip(),
|
|
31
|
+
description=description,
|
|
32
|
+
giver=giver,
|
|
33
|
+
objectives=objectives or [],
|
|
34
|
+
reward=reward,
|
|
35
|
+
notes=notes,
|
|
36
|
+
)
|
|
37
|
+
with store.transaction() as campaign:
|
|
38
|
+
world.create_quest(campaign, quest)
|
|
39
|
+
return f"Created quest '{quest.title}' with {len(quest.objectives)} objective(s)."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@tool(annotations=READ_ONLY)
|
|
43
|
+
def get_quest(title_or_id: NameOrId, store: CampaignStore = Depends(get_store)) -> str:
|
|
44
|
+
"""A quest with its numbered objectives, each marked done or open."""
|
|
45
|
+
with store.reading() as campaign:
|
|
46
|
+
return render.quest(world.find_quest(campaign, title_or_id))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@tool
|
|
50
|
+
def update_quest(
|
|
51
|
+
title_or_id: NameOrId,
|
|
52
|
+
status: Annotated[QuestStatus | None, Field(description="New status")] = None,
|
|
53
|
+
complete_objective: Annotated[
|
|
54
|
+
str | None, Field(description="Objective done: its number (1, 2...) or its text")
|
|
55
|
+
] = None,
|
|
56
|
+
add_objective: Annotated[str | None, Field(description="New objective to add")] = None,
|
|
57
|
+
notes: Annotated[str | None, Field(description="Replaces the notes")] = None,
|
|
58
|
+
store: CampaignStore = Depends(get_store),
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Change a quest's status, complete or add an objective, or rewrite its notes."""
|
|
61
|
+
if not any((status, complete_objective, add_objective, notes is not None)):
|
|
62
|
+
raise InvalidChangeError("Nothing to change: give at least one field.")
|
|
63
|
+
with store.transaction() as campaign:
|
|
64
|
+
quest, changes = world.update_quest(
|
|
65
|
+
campaign,
|
|
66
|
+
title_or_id,
|
|
67
|
+
status=status,
|
|
68
|
+
complete_objective=complete_objective,
|
|
69
|
+
add_objective=add_objective,
|
|
70
|
+
notes=notes,
|
|
71
|
+
)
|
|
72
|
+
return f"Quest '{quest.title}': {'; '.join(changes) or 'unchanged'}."
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@tool(annotations=READ_ONLY)
|
|
76
|
+
def list_quests(
|
|
77
|
+
status: Annotated[
|
|
78
|
+
QuestStatus | None, Field(description="Only quests with this status")
|
|
79
|
+
] = None,
|
|
80
|
+
store: CampaignStore = Depends(get_store),
|
|
81
|
+
) -> str:
|
|
82
|
+
"""List quests with their status and progress."""
|
|
83
|
+
with store.reading() as campaign:
|
|
84
|
+
quests = [q for q in campaign.quests.values() if status is None or q.status == status]
|
|
85
|
+
if not quests:
|
|
86
|
+
return f"No {status} quests." if status else "No quests yet."
|
|
87
|
+
rows = [
|
|
88
|
+
f"- {q.title} [{q.status}], "
|
|
89
|
+
f"{len(q.completed_objectives)}/{len(q.objectives)} objectives done"
|
|
90
|
+
for q in sorted(quests, key=lambda q: q.created_at)
|
|
91
|
+
]
|
|
92
|
+
return "Quests:\n" + "\n".join(rows)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
TOOLS: list[Callable[..., str]] = [create_quest, get_quest, update_quest, list_quests]
|
dndkit/tools/recorder.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The middleware that writes every tool call and skill read to the activity log.
|
|
2
|
+
|
|
3
|
+
The widgets' own data calls (``get_screen_data``, every second or two) are
|
|
4
|
+
left out: they say nothing about what the model does.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
|
|
12
|
+
from fastmcp.tools.base import ToolResult
|
|
13
|
+
from mcp import types as mt
|
|
14
|
+
|
|
15
|
+
from dndkit.activity import ActivityLog
|
|
16
|
+
|
|
17
|
+
QUIET_TOOLS = frozenset({"get_screen_data"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Recorder(Middleware):
|
|
21
|
+
"""Records the model's calls in *log*."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, log: ActivityLog) -> None:
|
|
24
|
+
self.log = log
|
|
25
|
+
|
|
26
|
+
async def on_call_tool(
|
|
27
|
+
self,
|
|
28
|
+
context: MiddlewareContext[mt.CallToolRequestParams],
|
|
29
|
+
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
|
|
30
|
+
) -> ToolResult:
|
|
31
|
+
name = context.message.name
|
|
32
|
+
if name in QUIET_TOOLS:
|
|
33
|
+
return await call_next(context)
|
|
34
|
+
arguments = json.dumps(context.message.arguments or {}, ensure_ascii=False)
|
|
35
|
+
started = time.perf_counter()
|
|
36
|
+
try:
|
|
37
|
+
result = await call_next(context)
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
self.log.record("tool", name, arguments, str(exc), True, started)
|
|
40
|
+
raise
|
|
41
|
+
self.log.record("tool", name, arguments, _text(result), False, started)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
async def on_read_resource(
|
|
45
|
+
self,
|
|
46
|
+
context: MiddlewareContext[mt.ReadResourceRequestParams],
|
|
47
|
+
call_next: CallNext[mt.ReadResourceRequestParams, Any],
|
|
48
|
+
) -> Any:
|
|
49
|
+
uri = str(context.message.uri)
|
|
50
|
+
if not uri.startswith("skill://"):
|
|
51
|
+
return await call_next(context)
|
|
52
|
+
started = time.perf_counter()
|
|
53
|
+
try:
|
|
54
|
+
result = await call_next(context)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
self.log.record("skill", "resources/read", uri, str(exc), True, started)
|
|
57
|
+
raise
|
|
58
|
+
self.log.record("skill", "resources/read", uri, "read", False, started)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _text(result: ToolResult) -> str:
|
|
63
|
+
return "\n".join(getattr(block, "text", "") for block in result.content)
|