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