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,209 @@
1
+ """The campaign container, its live game state and its journal.
2
+
3
+ Older saves keyed characters, NPCs, locations and quests by name and kept a
4
+ combat order of free-form dicts; the validators here read those saves as-is.
5
+ """
6
+
7
+ import re
8
+ from datetime import datetime
9
+ from enum import StrEnum
10
+ from typing import Any
11
+
12
+ from pydantic import Field, ValidationError, field_validator
13
+
14
+ from dndkit.models.base import GameModel, new_id, now
15
+ from dndkit.models.battle import Battlefield
16
+ from dndkit.models.bestiary import Encounter, Monster, Size
17
+ from dndkit.models.character import Character
18
+ from dndkit.models.world import NPC, Location, Quest
19
+
20
+
21
+ class Combatant(GameModel):
22
+ """One entry in the initiative order.
23
+
24
+ A party character is linked by ``character_id`` and its hit points,
25
+ conditions and speed live on its sheet; any other combatant (monster, NPC)
26
+ keeps its own here, when they are known. ``ally`` puts a non-party
27
+ combatant on the party's side. On a battle map, ``position`` is the
28
+ top-left square it occupies, and the movement fields last one turn.
29
+ """
30
+
31
+ name: str = Field(min_length=1)
32
+ initiative: int
33
+ character_id: str | None = None
34
+ monster: str | None = None # bestiary entry it was built from
35
+ hit_points: int | None = Field(default=None, ge=0)
36
+ hit_points_max: int | None = Field(default=None, ge=1)
37
+ armor_class: int | None = Field(default=None, ge=0)
38
+ xp: int = Field(default=0, ge=0)
39
+ conditions: list[str] = Field(default_factory=list)
40
+ condition_rounds: dict[str, int] = Field(default_factory=dict) # turns left, if timed
41
+ concentration: str | None = None # the spell held (a character's is on its sheet)
42
+ ally: bool = False
43
+ size: Size = "medium"
44
+ speed: int | None = Field(default=None, ge=0) # feet; None: 30
45
+ reach: int = Field(default=5, ge=5)
46
+ position: str | None = None
47
+ hidden: bool = False # unseen by the players: kept off their screens
48
+ boss: bool = False
49
+ legendary_actions: int = Field(default=0, ge=0) # a round's worth, from the stat block
50
+ legendary_actions_left: int = Field(default=0, ge=0) # back at the start of its turn
51
+ legendary_resistances_left: int = Field(default=0, ge=0)
52
+ movement_used: int = Field(default=0, ge=0)
53
+ dashed: bool = False
54
+ disengaged: bool = False
55
+ reaction_used: bool = False # one a round, back at the start of its turn
56
+
57
+
58
+ class Participant(GameModel):
59
+ """Someone joining a fight, as a tool call describes them."""
60
+
61
+ name: str = Field(min_length=1, description="Character, monster or NPC name")
62
+ initiative: int = Field(description="Initiative roll")
63
+ hit_points: int | None = Field(default=None, ge=1, description="HP, if not in bestiary")
64
+ armor_class: int | None = Field(default=None, ge=0, description="AC, if not in bestiary")
65
+
66
+
67
+ class Seat(GameModel):
68
+ """A player at the table, as a tool call describes them."""
69
+
70
+ player: str = Field(min_length=1, description="Person's name, as their voice is named")
71
+ character: str | None = Field(default=None, description="Character they play")
72
+
73
+
74
+ class CombatRecord(GameModel):
75
+ """What the last fight was, kept once it ends."""
76
+
77
+ rounds: int = 0
78
+ fighters: list[str] = Field(default_factory=list)
79
+ defeated: list[str] = Field(default_factory=list)
80
+ xp: int = 0
81
+ ended: datetime = Field(default_factory=now)
82
+
83
+
84
+ class GameState(GameModel):
85
+ """Where the party is and what is happening right now."""
86
+
87
+ campaign_name: str
88
+ current_session: int = Field(default=1, ge=1)
89
+ current_date_in_game: str | None = None
90
+ current_location: str | None = None
91
+ party_level: int = Field(default=1, ge=1, le=20) # legacy: each sheet has its level
92
+ party_funds: str = "0 gp"
93
+ in_combat: bool = False
94
+ initiative_order: list[Combatant] = Field(default_factory=list)
95
+ turn_index: int = Field(default=0, ge=0)
96
+ combat_round: int = Field(default=0, ge=0)
97
+ defeated: list[Combatant] = Field(default_factory=list) # fallen this combat
98
+ encounter_id: str | None = None # the planned encounter being fought
99
+ players_present: list[str] = Field(default_factory=list) # the roll call, in seat order
100
+ roll_call_at: datetime | None = None # when seat_players last changed it
101
+ battlefield: Battlefield | None = None
102
+ last_combat: CombatRecord | None = None
103
+ notes: str = ""
104
+ updated_at: datetime = Field(default_factory=now)
105
+
106
+ @field_validator("initiative_order", mode="before")
107
+ @classmethod
108
+ def _keep_playable_legacy_entries(cls, value: Any) -> Any:
109
+ # Older versions stored any dict the model sent ({"initiative": "15 (Dex)"},
110
+ # no name...): what cannot take a turn must not stop the campaign from loading.
111
+ if not isinstance(value, list):
112
+ return value
113
+ kept: list[Any] = []
114
+ for entry in value:
115
+ if isinstance(entry, dict):
116
+ entry = {**entry, "initiative": _legacy_initiative(entry.get("initiative"))}
117
+ try:
118
+ Combatant.model_validate(entry)
119
+ except ValidationError:
120
+ continue
121
+ kept.append(entry)
122
+ return kept
123
+
124
+ @property
125
+ def current_combatant(self) -> Combatant | None:
126
+ """Whose turn it is, when a fight is running."""
127
+ if not self.in_combat or not self.initiative_order:
128
+ return None
129
+ return self.initiative_order[self.turn_index % len(self.initiative_order)]
130
+
131
+
132
+ def _legacy_initiative(value: Any) -> int:
133
+ """An old save's initiative as a number: 15, "15 (Dex)", or 0 when missing."""
134
+ if isinstance(value, int | float) and not isinstance(value, bool):
135
+ return int(value)
136
+ match = re.match(r"\s*(-?\d+)", str(value or ""))
137
+ return int(match.group(1)) if match else 0
138
+
139
+
140
+ class SessionNote(GameModel):
141
+ """A summary of one play session."""
142
+
143
+ id: str = Field(default_factory=new_id)
144
+ session_number: int = Field(ge=1)
145
+ date: datetime = Field(default_factory=now)
146
+ title: str | None = None
147
+ summary: str
148
+ events: list[str] = Field(default_factory=list)
149
+ characters_present: list[str] = Field(default_factory=list)
150
+ experience_gained: int | None = Field(default=None, ge=0)
151
+ treasure_found: list[str] = Field(default_factory=list)
152
+ notes: str = ""
153
+
154
+
155
+ class EventType(StrEnum):
156
+ """Categories of the adventure log."""
157
+
158
+ COMBAT = "combat"
159
+ ROLEPLAY = "roleplay"
160
+ EXPLORATION = "exploration"
161
+ QUEST = "quest"
162
+ CHARACTER = "character"
163
+ WORLD = "world"
164
+ SESSION = "session"
165
+
166
+
167
+ class AdventureEvent(GameModel):
168
+ """One entry of the campaign's adventure log."""
169
+
170
+ id: str = Field(default_factory=new_id)
171
+ event_type: EventType
172
+ title: str
173
+ description: str
174
+ timestamp: datetime = Field(default_factory=now)
175
+ session_number: int | None = None
176
+ characters_involved: list[str] = Field(default_factory=list)
177
+ location: str | None = None
178
+ tags: list[str] = Field(default_factory=list)
179
+ importance: int = Field(default=3, ge=1, le=5) # 1 minor .. 5 major
180
+
181
+
182
+ class Campaign(GameModel):
183
+ """Everything one campaign holds; saved as one JSON file."""
184
+
185
+ id: str = Field(default_factory=new_id)
186
+ name: str = Field(min_length=1)
187
+ description: str
188
+ dm_name: str | None = None
189
+ setting: str | None = None
190
+ characters: dict[str, Character] = Field(default_factory=dict)
191
+ npcs: dict[str, NPC] = Field(default_factory=dict)
192
+ locations: dict[str, Location] = Field(default_factory=dict)
193
+ quests: dict[str, Quest] = Field(default_factory=dict)
194
+ monsters: dict[str, Monster] = Field(default_factory=dict)
195
+ encounters: dict[str, Encounter] = Field(default_factory=dict)
196
+ sessions: list[SessionNote] = Field(default_factory=list)
197
+ events: list[AdventureEvent] = Field(default_factory=list)
198
+ game_state: GameState
199
+ world_notes: str = ""
200
+ created_at: datetime = Field(default_factory=now)
201
+ updated_at: datetime = Field(default_factory=now)
202
+
203
+ @field_validator(
204
+ "characters", "npcs", "locations", "quests", "monsters", "encounters", mode="after"
205
+ )
206
+ @classmethod
207
+ def _key_by_id(cls, value: dict[str, Any]) -> dict[str, Any]:
208
+ # Entities are keyed by ID: names change and two spellings may collide.
209
+ return {entity.id: entity for entity in value.values()}
@@ -0,0 +1,149 @@
1
+ """Player characters and what they carry."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import Field
7
+
8
+ from dndkit.models.base import GameModel, new_id, now
9
+
10
+ ABILITIES = ("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma")
11
+ CONDITIONS = (
12
+ "blinded",
13
+ "charmed",
14
+ "deafened",
15
+ "frightened",
16
+ "grappled",
17
+ "incapacitated",
18
+ "invisible",
19
+ "paralyzed",
20
+ "petrified",
21
+ "poisoned",
22
+ "prone",
23
+ "restrained",
24
+ "stunned",
25
+ "unconscious",
26
+ )
27
+
28
+
29
+ class AbilityScore(GameModel):
30
+ """One ability score; the modifier is derived from it."""
31
+
32
+ score: int = Field(ge=1, le=30, description="Raw ability score")
33
+
34
+ @property
35
+ def mod(self) -> int:
36
+ """The 5e ability modifier."""
37
+ return (self.score - 10) // 2
38
+
39
+
40
+ class CharacterClass(GameModel):
41
+ """Class, level and hit dice."""
42
+
43
+ name: str
44
+ level: int = Field(default=1, ge=1, le=20)
45
+ hit_dice: str = "1d8"
46
+ subclass: str | None = None
47
+
48
+
49
+ class Race(GameModel):
50
+ """Race (species) and its traits."""
51
+
52
+ name: str
53
+ subrace: str | None = None
54
+ traits: list[str] = Field(default_factory=list)
55
+
56
+
57
+ class Item(GameModel):
58
+ """An inventory entry; identical items stack through ``quantity``."""
59
+
60
+ id: str = Field(default_factory=new_id)
61
+ name: str = Field(min_length=1)
62
+ description: str | None = None
63
+ quantity: int = Field(default=1, ge=1)
64
+ weight: float | None = Field(default=None, ge=0)
65
+ value: str | None = None # e.g. "50 gp"
66
+ item_type: str = "misc" # weapon, armor, consumable, misc
67
+ properties: dict[str, Any] = Field(default_factory=dict)
68
+
69
+
70
+ class Spell(GameModel):
71
+ """A known spell; only the name and level are required."""
72
+
73
+ id: str = Field(default_factory=new_id)
74
+ name: str = Field(min_length=1)
75
+ level: int = Field(ge=0, le=9) # 0 is a cantrip
76
+ school: str = ""
77
+ casting_time: str = ""
78
+ range: int | str = "" # free text: "Self", "Touch", "120 feet"
79
+ duration: str = ""
80
+ components: list[str] = Field(default_factory=list)
81
+ description: str = ""
82
+ material_components: str | None = None
83
+ prepared: bool = False
84
+
85
+
86
+ def _default_abilities() -> dict[str, AbilityScore]:
87
+ return {ability: AbilityScore(score=10) for ability in ABILITIES}
88
+
89
+
90
+ class Character(GameModel):
91
+ """A complete character sheet.
92
+
93
+ ``hit_points_current`` above ``hit_points_max`` is refused by the game
94
+ logic, not here: a save written by an older version may hold one, and it
95
+ must still load.
96
+ """
97
+
98
+ id: str = Field(default_factory=new_id)
99
+ name: str = Field(min_length=1)
100
+ player_name: str | None = None
101
+ character_class: CharacterClass
102
+ race: Race
103
+ background: str | None = None
104
+ alignment: str | None = None
105
+ description: str | None = None # appearance and demeanor
106
+ bio: str | None = None # backstory, personality, motivations
107
+
108
+ abilities: dict[str, AbilityScore] = Field(default_factory=_default_abilities)
109
+
110
+ armor_class: int = Field(default=10, ge=0)
111
+ speed: int = Field(default=30, ge=0) # walking, in feet
112
+ hit_points_max: int = Field(default=1, ge=1)
113
+ hit_points_current: int = Field(default=1, ge=0)
114
+ temporary_hit_points: int = Field(default=0, ge=0)
115
+ hit_dice_remaining: str = "1d8"
116
+ death_saves_success: int = Field(default=0, ge=0, le=3)
117
+ death_saves_failure: int = Field(default=0, ge=0, le=3)
118
+ stable: bool = False # at 0 HP without death saves (three successes, a Medicine check)
119
+ conditions: list[str] = Field(default_factory=list) # names from CONDITIONS
120
+ condition_rounds: dict[str, int] = Field(default_factory=dict) # turns left, if timed
121
+ concentration: str | None = None # the spell held
122
+ exhaustion: int = Field(default=0, ge=0, le=6)
123
+ experience_points: int = Field(default=0, ge=0)
124
+
125
+ proficiency_bonus: int = 2
126
+ skill_proficiencies: list[str] = Field(default_factory=list)
127
+ skill_expertise: list[str] = Field(default_factory=list) # proficiency counted twice
128
+ saving_throw_proficiencies: list[str] = Field(default_factory=list)
129
+
130
+ inventory: list[Item] = Field(default_factory=list)
131
+ equipment: dict[str, Item | None] = Field(default_factory=dict)
132
+
133
+ spellcasting_ability: str | None = None
134
+ spell_slots: dict[int, int] = Field(default_factory=dict) # level -> max slots
135
+ spell_slots_used: dict[int, int] = Field(default_factory=dict) # level -> used
136
+ spells_known: list[Spell] = Field(default_factory=list)
137
+
138
+ features_and_traits: list[str] = Field(default_factory=list)
139
+ languages: list[str] = Field(default_factory=list)
140
+
141
+ inspiration: bool = False
142
+ notes: str = ""
143
+ created_at: datetime = Field(default_factory=now)
144
+ updated_at: datetime = Field(default_factory=now)
145
+
146
+ @property
147
+ def hp_maximum(self) -> int:
148
+ """The hit point maximum now: halved from exhaustion level 4 (PHB appendix A)."""
149
+ return max(1, self.hit_points_max // 2) if self.exhaustion >= 4 else self.hit_points_max
dndkit/models/world.py ADDED
@@ -0,0 +1,62 @@
1
+ """The world around the party: NPCs, places and quests."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import Field
7
+
8
+ from dndkit.models.base import GameModel, new_id, now
9
+
10
+ Attitude = Literal["friendly", "neutral", "hostile", "unknown"]
11
+ QuestStatus = Literal["active", "completed", "failed", "on_hold"]
12
+
13
+
14
+ class NPC(GameModel):
15
+ """A non-player character."""
16
+
17
+ id: str = Field(default_factory=new_id)
18
+ name: str = Field(min_length=1)
19
+ description: str | None = None # public
20
+ bio: str | None = None # private: backstory, motivations, secrets
21
+ race: str | None = None
22
+ occupation: str | None = None
23
+ location: str | None = None
24
+ attitude: Attitude | None = None
25
+ notes: str = ""
26
+ stats: dict[str, Any] | None = None
27
+ relationships: dict[str, str] = Field(default_factory=dict) # who -> relationship
28
+
29
+
30
+ class Location(GameModel):
31
+ """A place: settlement, dungeon, region."""
32
+
33
+ id: str = Field(default_factory=new_id)
34
+ name: str = Field(min_length=1)
35
+ location_type: str # city, town, village, dungeon, forest...
36
+ description: str
37
+ population: int | None = Field(default=None, ge=0)
38
+ government: str | None = None
39
+ notable_features: list[str] = Field(default_factory=list)
40
+ npcs: list[str] = Field(default_factory=list)
41
+ connections: list[str] = Field(default_factory=list)
42
+ notes: str = ""
43
+
44
+
45
+ class Quest(GameModel):
46
+ """A quest and its objectives."""
47
+
48
+ id: str = Field(default_factory=new_id)
49
+ title: str = Field(min_length=1)
50
+ description: str
51
+ giver: str | None = None
52
+ status: QuestStatus = "active"
53
+ objectives: list[str] = Field(default_factory=list)
54
+ completed_objectives: list[str] = Field(default_factory=list)
55
+ reward: str | None = None
56
+ notes: str = ""
57
+ created_at: datetime = Field(default_factory=now)
58
+
59
+ @property
60
+ def name(self) -> str:
61
+ """The title, so quests are looked up like every other entity."""
62
+ return self.title
@@ -0,0 +1,14 @@
1
+ """Agent Skills shipped with the server, and their publication over MCP.
2
+
3
+ Each folder here holding a ``SKILL.md`` is a skill (agentskills.io). The
4
+ server publishes them by the MCP Skills extension (SEP-2640,
5
+ ``io.modelcontextprotocol/skills``): every file as a ``skill://<name>/<path>``
6
+ resource, and the ``skills/list`` and ``skills/get`` methods. A skill's
7
+ ``allowed_tools`` frontmatter is roomkit's: it keeps those tools out of the
8
+ model's catalogue until the skill is activated.
9
+ """
10
+
11
+ from dndkit.skills.catalog import Skill, load_catalog
12
+ from dndkit.skills.extension import SkillsExtension, register_skills
13
+
14
+ __all__ = ["Skill", "SkillsExtension", "load_catalog", "register_skills"]
@@ -0,0 +1,113 @@
1
+ """The skills in this package: their frontmatter, their files, and the digests of those.
2
+
3
+ A skill is a folder with a ``SKILL.md`` whose YAML frontmatter names it; the
4
+ name must be the folder's (Agent Skills specification). Every file under the
5
+ folder belongs to the skill. Files are read once, at load: the digests then
6
+ describe exactly what the server serves.
7
+ """
8
+
9
+ import hashlib
10
+ import json
11
+ import mimetypes
12
+ from dataclasses import dataclass
13
+ from functools import cache
14
+ from importlib.resources import files
15
+ from importlib.resources.abc import Traversable
16
+ from typing import Any
17
+
18
+ import yaml
19
+
20
+ SKILL_FILE = "SKILL.md"
21
+ SCHEME = "skill://"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SkillFile:
26
+ """One file of a skill, as served."""
27
+
28
+ path: str # relative to the skill's folder, "/"-separated
29
+ content: bytes
30
+
31
+ @property
32
+ def digest(self) -> str:
33
+ """``sha256:<hex>`` of the raw bytes (SEP-2640)."""
34
+ return "sha256:" + hashlib.sha256(self.content).hexdigest()
35
+
36
+ @property
37
+ def mime_type(self) -> str:
38
+ if self.path.endswith(".md"):
39
+ return "text/markdown"
40
+ return mimetypes.guess_type(self.path)[0] or "application/octet-stream"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Skill:
45
+ """A skill: its frontmatter as written, and all its files."""
46
+
47
+ name: str
48
+ frontmatter: dict[str, Any]
49
+ files: tuple[SkillFile, ...]
50
+
51
+ @property
52
+ def uri(self) -> str:
53
+ """The URI of its ``SKILL.md``, which identifies the skill."""
54
+ return self.file_uri(SKILL_FILE)
55
+
56
+ def file_uri(self, path: str) -> str:
57
+ return f"{SCHEME}{self.name}/{path}"
58
+
59
+ def entry(self) -> dict[str, Any]:
60
+ """Its ``skills/list`` entry: uri, verbatim frontmatter, every file's digest and size."""
61
+ return {
62
+ "uri": self.uri,
63
+ "frontmatter": self.frontmatter,
64
+ "resources": [
65
+ {"uri": self.file_uri(f.path), "digest": f.digest, "size": len(f.content)}
66
+ for f in self.files
67
+ ],
68
+ }
69
+
70
+
71
+ @cache
72
+ def load_catalog() -> tuple[Skill, ...]:
73
+ """Every skill shipped in this package, by name."""
74
+ root = files(__name__.rsplit(".", 1)[0])
75
+ skills = [_load(folder) for folder in root.iterdir() if folder.joinpath(SKILL_FILE).is_file()]
76
+ return tuple(sorted(skills, key=lambda s: s.name))
77
+
78
+
79
+ def frontmatter_of(text: str) -> dict[str, Any]:
80
+ """The YAML frontmatter of a ``SKILL.md``, as a JSON-ready mapping: between a
81
+ first line ``---`` and the next line that is ``---`` alone."""
82
+ lines = text.splitlines()
83
+ if not lines or lines[0].strip() != "---":
84
+ raise ValueError("SKILL.md must begin with YAML frontmatter.")
85
+ end = next((i for i, line in enumerate(lines[1:], 1) if line.strip() == "---"), None)
86
+ if end is None:
87
+ raise ValueError("SKILL.md frontmatter has no closing '---'.")
88
+ data = yaml.safe_load("\n".join(lines[1:end]))
89
+ if not isinstance(data, dict) or not data.get("name") or not data.get("description"):
90
+ raise ValueError("SKILL.md frontmatter needs a name and a description.")
91
+ # skills/list sends it as JSON: a YAML date becomes its text.
92
+ return dict(json.loads(json.dumps(data, default=str)))
93
+
94
+
95
+ def _load(folder: Traversable) -> Skill:
96
+ frontmatter = frontmatter_of(folder.joinpath(SKILL_FILE).read_text(encoding="utf-8"))
97
+ if frontmatter["name"] != folder.name:
98
+ raise ValueError(f"Skill folder {folder.name} holds a skill named {frontmatter['name']}.")
99
+ found = sorted(_walk(folder, ""), key=lambda f: (f.path != SKILL_FILE, f.path))
100
+ return Skill(name=folder.name, frontmatter=frontmatter, files=tuple(found))
101
+
102
+
103
+ def _walk(folder: Traversable, prefix: str) -> list[SkillFile]:
104
+ found: list[SkillFile] = []
105
+ for entry in folder.iterdir():
106
+ if entry.name.startswith((".", "__")):
107
+ continue
108
+ path = f"{prefix}{entry.name}"
109
+ if entry.is_dir():
110
+ found += _walk(entry, f"{path}/")
111
+ else:
112
+ found.append(SkillFile(path, entry.read_bytes()))
113
+ return found
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: dnd-battle-map
3
+ description: Run a D&D 5e fight on a grid battle map with the dndkit tools - lay out the room, place and hide combatants, move them by the rules (speed, terrain, opportunity attacks), areas of effect, cover. Activate when a fight starts where positions matter.
4
+ allowed_tools:
5
+ - set_battlefield
6
+ - place_tokens
7
+ - move_token
8
+ - area_of_effect
9
+ - check_target
10
+ ---
11
+
12
+ # Fighting on the battle map
13
+
14
+ The map is a grid of 5-foot squares named like a chessboard: a column letter
15
+ and a row number, A1 at the top left. The players see it on the table screen
16
+ and on their phones; you only read it through the tools, so never guess a
17
+ position or a distance: `get_game_state` lists every square and the distances
18
+ from whoever is acting, and every move says who is now in reach.
19
+
20
+ ## Set the scene
21
+
22
+ - Use a map when position matters: several foes, cover, a corridor, a ledge.
23
+ A duel or a brawl can stay in words.
24
+ - Turn your description into `set_battlefield`: width and height in squares
25
+ (a 30-foot room is 6 squares), walls and pillars as `walls`, rubble, mud or
26
+ undergrowth as `difficult`. Blocks are written `C1:C6`.
27
+ - `start_combat` places everyone with no square yet: the party on the left
28
+ edge, its foes on the right. Use `place_tokens` for a formation; a big
29
+ creature is placed by its top-left square.
30
+ - A planned encounter can carry its map and its monsters' squares
31
+ (`plan_encounter` with `battlefield`, and `at` per group): `start_combat`
32
+ with that encounter lays them out.
33
+ - Changed your mind about who is in the fight? `remove_combatant` with
34
+ `reason: withdrawn` takes a creature off the map and out of the initiative,
35
+ as if it never joined (no XP); `add_combatant` brings one in, at its side's
36
+ edge, and `place_tokens` puts it where you want. A redrawn map
37
+ (`set_battlefield`) keeps everyone who still fits.
38
+ - Foes the players have not noticed are `hidden` (in the plan, or with
39
+ `place_tokens`): they stay off the players' screens and out of the
40
+ initiative they see. Reveal them with `place_tokens` (`hidden: false`) when
41
+ they are spotted or attack.
42
+ - Save monsters with their `size` and `speed` (`save_monster`) first: a Large
43
+ ogre holds 2x2 squares, and the speed decides how far it goes.
44
+
45
+ ## Each turn
46
+
47
+ - When a player says where they go, call `move_token`:
48
+ - "I charge the goblin" -> `toward`: it stops within reach, or as close as
49
+ it can and says how far it still is;
50
+ - "I back off" -> `away_from`, with `feet` if they say how far;
51
+ - "I go to D7" -> `to`; it is refused if they cannot afford it: say so and
52
+ offer the Dash action, or to go toward it.
53
+ - Dash (`dash`) doubles this turn's movement; Disengage (`disengage`) means
54
+ no opportunity attack. A prone character stands up first, for half their
55
+ speed; grappled or restrained ones cannot move.
56
+ - When the result lists opportunity attacks, resolve them right away: the
57
+ foe attacks the mover (`roll_dice`), then the move goes on.
58
+ - Shoves, Thunderwave, Misty Step and other pushes, pulls or teleports use
59
+ `forced`: no movement spent, no opportunity attack. A push stops at a wall
60
+ or a creature.
61
+ - Move monsters on their turns too, sensibly: toward the nearest or weakest
62
+ character, away when they flee.
63
+ - Before an attack, `check_target`: the distance (normal or long range),
64
+ reach, cover (+2 or +5 AC and Dexterity saves, or no target at all), a foe
65
+ adjacent to a ranged attacker (disadvantage), hidden attackers or targets.
66
+ - For a spell or breath with an area, `area_of_effect`: a `sphere` (or
67
+ cylinder, radius) `at` a square or a creature, a `cone`, `line` or `cube`
68
+ from its `source` `toward` a square or a creature. It names everyone caught,
69
+ allies too, and walls stop it; it shows on the map until the next turn.
70
+ Each creature caught then makes its saving throw (`roll_dice`). Fireball
71
+ goes around corners: rule by hand when it matters.
72
+ - `next_turn` gives the next combatant its full movement.
73
+
74
+ ## Speaking about it
75
+
76
+ Say squares only when the players use them; otherwise describe ("you reach
77
+ the goblin by the pillar"). The players' French books count in metres: 5 feet
78
+ is 1.5 metres, so 30 feet is 9 metres.