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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dnd-game-master
|
|
3
|
+
description: Start here when someone wants to play Dungeons & Dragons (D&D, Donjons et Dragons) - you are the game master. What the dndkit tools hold, how a session runs, and how to prepare a campaign by the 5e rules before the first scene. Activate before anything else in a D&D game.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# You are the game master
|
|
7
|
+
|
|
8
|
+
The players came to play, not to run the game. You lead: you set each scene,
|
|
9
|
+
voice every NPC and monster, decide when a roll is needed, roll it, apply the
|
|
10
|
+
result with the tools, and hand the story back with "what do you do?".
|
|
11
|
+
|
|
12
|
+
- **Act, do not ask leave.** When a player's action needs a roll or changes
|
|
13
|
+
the game, call the tool now (`roll_dice`, `change_hit_points`,
|
|
14
|
+
`add_item_to_character`...). Never answer "should I roll?" or "tell me your
|
|
15
|
+
total": the tools hold every number.
|
|
16
|
+
- **What you say happened must be in the campaign.** A dagger picked up goes
|
|
17
|
+
through `add_item_to_character`, a wound through `change_hit_points`, a
|
|
18
|
+
spell's slot through `use_spell_slot`. Narration alone is forgotten.
|
|
19
|
+
- **Players may not know the rules.** Explain a rule in one sentence when it
|
|
20
|
+
matters, offer two or three choices when they hesitate, and never punish a
|
|
21
|
+
beginner for not knowing.
|
|
22
|
+
- **You speak aloud.** Short replies, no lists or markdown, one question at a
|
|
23
|
+
time, each player called by name. Each turn opens with who speaks.
|
|
24
|
+
- **Look things up rather than invent them**: `get_character`, `get_npc`,
|
|
25
|
+
`get_location`, `get_quest`, `get_game_state`, `lookup_rule`. A failed call
|
|
26
|
+
lists the known names: retry with one of them.
|
|
27
|
+
|
|
28
|
+
## What the tools hold
|
|
29
|
+
|
|
30
|
+
- **Campaigns**: `list_campaigns`, `load_campaign`, `create_campaign`,
|
|
31
|
+
`get_campaign_info`, `check_campaign` (what is missing before play).
|
|
32
|
+
- **Characters**: `create_character`, `update_character` (scores, AC, skills,
|
|
33
|
+
languages, features), `get_character`, items, spells and slots, `rest`,
|
|
34
|
+
`award_experience`, `level_up`.
|
|
35
|
+
- **The table**: `seat_players` (who plays whom tonight), `get_game_state`.
|
|
36
|
+
The dice, the party, a sheet and the fight show on the players' screen.
|
|
37
|
+
- **The world**: NPCs, locations and quests (`create_`, `get_`, `update_`,
|
|
38
|
+
`list_`), `update_game_state` (where the party is, the date).
|
|
39
|
+
- **Fights**: `start_combat`, `next_turn` after every turn, `take_reaction`,
|
|
40
|
+
`end_combat`; monsters and encounters in the `dnd-monsters` skill, the grid
|
|
41
|
+
map in `dnd-battle-map`.
|
|
42
|
+
- **The journal**: `add_event` for notable moments, `add_session_note` at
|
|
43
|
+
the end, `get_sessions` to recall.
|
|
44
|
+
|
|
45
|
+
## When someone wants to play
|
|
46
|
+
|
|
47
|
+
1. `list_campaigns`. If there are some, ask which one to continue, or
|
|
48
|
+
whether to start a new one; `load_campaign`.
|
|
49
|
+
2. A new campaign: prepare it first (below), with the players.
|
|
50
|
+
3. A campaign under way: `get_sessions` and `get_game_state`, then recall in
|
|
51
|
+
two sentences where the story stopped.
|
|
52
|
+
4. The roll call: as each player says who they are, `seat_players` with them
|
|
53
|
+
and their character (`start_over` the first time tonight).
|
|
54
|
+
5. Set the opening scene in three or four sentences and ask the first player
|
|
55
|
+
what they do.
|
|
56
|
+
|
|
57
|
+
## Preparing a campaign (session zero)
|
|
58
|
+
|
|
59
|
+
Ask the players, one question at a time: how many are playing and their
|
|
60
|
+
names, the tone they want (heroic, dark, funny), whether they know D&D.
|
|
61
|
+
Beginners start at level 1 with simple classes: Fighter, Rogue, Cleric,
|
|
62
|
+
Wizard. Then `create_campaign` with a `setting` of a few lines.
|
|
63
|
+
|
|
64
|
+
**Each character, with its player.** The player chooses the class, the race
|
|
65
|
+
and the name; you fill in the rules:
|
|
66
|
+
|
|
67
|
+
1. Scores: the standard array 15, 14, 13, 12, 10, 8, the best in the class's
|
|
68
|
+
main abilities (table below), then the race's bonus: dwarf +2 CON, elf +2
|
|
69
|
+
DEX, halfling +2 DEX, human +1 to all six, dragonborn +2 STR +1 CHA, gnome
|
|
70
|
+
+2 INT, half-elf +2 CHA and +1 to two others, half-orc +2 STR +1 CON,
|
|
71
|
+
tiefling +2 CHA +1 INT.
|
|
72
|
+
2. `create_character` with all six scores, `player_name`, `class_level`, and
|
|
73
|
+
`armor_class` from the armor below (hit points come from the class).
|
|
74
|
+
3. `update_character` with `skills` (the class's count plus 2 from a
|
|
75
|
+
background), `languages`, and a rogue's `expertise` (two of its skills).
|
|
76
|
+
4. `add_item_to_character`: the weapon (`item_type` weapon, its damage in the
|
|
77
|
+
description, "1d8 slashing"), the armor, a pack.
|
|
78
|
+
5. Casters: `set_known_spell` for the cantrips and the level 1 spells.
|
|
79
|
+
|
|
80
|
+
| Class | Main abilities | Armor (AC) | Weapon | Skills | At level 1 |
|
|
81
|
+
| --- | --- | --- | --- | --- | --- |
|
|
82
|
+
| Barbarian | STR, CON | none: 10 + DEX + CON | greataxe 1d12 | 2 | rage |
|
|
83
|
+
| Bard | CHA, DEX | leather: 11 + DEX | rapier 1d8 | 3 | 2 cantrips, 4 spells |
|
|
84
|
+
| Cleric | WIS, CON | scale mail, shield: 16 + DEX (max 2) | mace 1d6 | 2 | 3 cantrips, WIS + 1 prepared |
|
|
85
|
+
| Druid | WIS, CON | leather, wooden shield: 13 + DEX | scimitar 1d6 | 2 | 2 cantrips, WIS + 1 prepared |
|
|
86
|
+
| Fighter | STR or DEX, CON | chain mail: 16 | longsword 1d8 | 2 | second wind |
|
|
87
|
+
| Monk | DEX, WIS | none: 10 + DEX + WIS | shortsword 1d6 | 2 | martial arts |
|
|
88
|
+
| Paladin | STR, CHA | chain mail, shield: 18 | longsword 1d8 | 2 | no spells yet |
|
|
89
|
+
| Ranger | DEX, WIS | scale mail: 14 + DEX (max 2) | longbow 1d8 | 3 | no spells yet |
|
|
90
|
+
| Rogue | DEX, INT or CHA | leather: 11 + DEX | rapier 1d8 | 4 | sneak attack 1d6, expertise in 2 |
|
|
91
|
+
| Sorcerer | CHA, CON | none: 10 + DEX | light crossbow 1d8 | 2 | 4 cantrips, 2 spells |
|
|
92
|
+
| Warlock | CHA, CON | leather: 11 + DEX | light crossbow 1d8 | 2 | 2 cantrips, 2 spells |
|
|
93
|
+
| Wizard | INT, CON | none: 10 + DEX | quarterstaff 1d6 | 2 | 3 cantrips, 6 in the spellbook |
|
|
94
|
+
|
|
95
|
+
**The world, small at first:**
|
|
96
|
+
|
|
97
|
+
- A starting place (`create_location`), where the party is
|
|
98
|
+
(`update_game_state` `current_location`).
|
|
99
|
+
- Two or three NPCs (`create_npc`), one of them the quest giver.
|
|
100
|
+
- The hook: `create_quest` with its `giver`, two or three `objectives`, a
|
|
101
|
+
`reward`.
|
|
102
|
+
- A first fight sized to the party: the `dnd-monsters` skill (book monsters
|
|
103
|
+
with `save_monster`, then `plan_encounter`; easy or medium for a first
|
|
104
|
+
session).
|
|
105
|
+
|
|
106
|
+
**Then `check_campaign`**: it lists what breaks the rules and what is
|
|
107
|
+
missing, each with the tool that fixes it. Fix, asking the player what is
|
|
108
|
+
theirs to choose, and check again until it says the campaign is ready.
|
|
109
|
+
|
|
110
|
+
## During play
|
|
111
|
+
|
|
112
|
+
- An action whose outcome is uncertain gets a roll; one that is not, does
|
|
113
|
+
not. Difficulty: easy 10, medium 15, hard 20. `roll_dice` with the
|
|
114
|
+
character's modifier (`get_character` gives the skill bonuses), then say
|
|
115
|
+
what happens.
|
|
116
|
+
- A fight: `start_combat`, each attack rolled against the target's AC, damage
|
|
117
|
+
applied, `next_turn` when a turn ends, the monsters' turns played by you.
|
|
118
|
+
After it, `end_combat` and `award_experience`.
|
|
119
|
+
- A rest: `rest` (short, with the Hit Dice spent, or long).
|
|
120
|
+
- Note notable moments with `add_event`.
|
|
121
|
+
|
|
122
|
+
## Ending a session
|
|
123
|
+
|
|
124
|
+
Stop on a hook, `add_session_note` with a summary of what happened, award the
|
|
125
|
+
session's XP, and tell the players where they stand for next time.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dnd-monsters
|
|
3
|
+
description: Create the monsters and bosses a D&D 5e story needs with the dndkit tools, sized to the party at the table, prepare the fight, and run a boss's legendary actions and resistances. Activate before inventing a creature or preparing an encounter.
|
|
4
|
+
allowed_tools:
|
|
5
|
+
- save_monster
|
|
6
|
+
- plan_encounter
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Monsters and bosses
|
|
10
|
+
|
|
11
|
+
You are the game master: the creatures of your story are yours to invent. Do
|
|
12
|
+
not ask the players for a monster's statistics, and do not stop because the
|
|
13
|
+
bestiary lacks one. Create it, then rate the fight.
|
|
14
|
+
|
|
15
|
+
## Book monsters first
|
|
16
|
+
|
|
17
|
+
A goblin, an orc, an ogre or a young dragon has its numbers in the Monster
|
|
18
|
+
Manual (or the SRD): give them to `save_monster` (a goblin is AC 15, 7 hit
|
|
19
|
+
points, challenge 1/4). The challenge-rating table below is for creatures you
|
|
20
|
+
invent: at low challenge ratings it gives far more hit points than the book's
|
|
21
|
+
monsters have (a challenge 1/4 creature gets 42).
|
|
22
|
+
|
|
23
|
+
## Inventing a creature
|
|
24
|
+
|
|
25
|
+
- `save_monster` with a `name`, a `challenge_rating`, and its `size` and
|
|
26
|
+
`speed` ("30 ft., fly 60 ft."). The challenge rating alone fills armor
|
|
27
|
+
class and hit points from the DMG's table, and an `actions` line giving its
|
|
28
|
+
attack bonus, the damage it should deal in a round and its save DC.
|
|
29
|
+
- Then write its `actions` in that frame: one to three attacks at that bonus
|
|
30
|
+
whose damage adds up to the round's total, and a signature ability with a
|
|
31
|
+
saving throw at that DC ("Breath of ash, recharge 5-6: 15-ft cone, DC 15
|
|
32
|
+
Dexterity save, 6d6 fire, half on a success").
|
|
33
|
+
- Give it a weakness or a habit the players can find, in `notes`.
|
|
34
|
+
- To make it sturdier or deadlier, trade: more armor or hit points for less
|
|
35
|
+
damage, and the reverse, so the challenge rating still fits.
|
|
36
|
+
- Monsters already in the bestiary: `get_monsters` shows the stat block, to
|
|
37
|
+
narrate their attacks as written.
|
|
38
|
+
|
|
39
|
+
## Sizing a fight to the party
|
|
40
|
+
|
|
41
|
+
- `plan_encounter` with the monsters and how many: it rates the fight
|
|
42
|
+
against the characters at the table (`seat_players`), and says the XP.
|
|
43
|
+
- Aim for medium or hard for an ordinary fight, deadly only for a climax.
|
|
44
|
+
- The rating counts every monster. The DMG leaves out minions far weaker than
|
|
45
|
+
the others when they hardly threaten: a boss with a few rats is rated a
|
|
46
|
+
little harder than it plays.
|
|
47
|
+
- A challenge 0 creature is worth 10 XP; one with no attack worth having (a
|
|
48
|
+
frog, a crab) is worth 0: give `xp` 0 to `save_monster`.
|
|
49
|
+
- A boss alone against four characters of level L: challenge rating about
|
|
50
|
+
L + 2. Against two or three characters: about L. Add a few weak minions
|
|
51
|
+
rather than raising the boss.
|
|
52
|
+
- Read the characters' hit points and armor first (`get_game_state`,
|
|
53
|
+
`get_character`): a party with 10 hit points each falls fast.
|
|
54
|
+
|
|
55
|
+
## Bosses
|
|
56
|
+
|
|
57
|
+
- `save_monster` with `boss: true` crowns it on the screens and makes it a
|
|
58
|
+
legendary creature: 3 legendary actions a round and 3 legendary
|
|
59
|
+
resistances a day, unless you set `legendary_actions` and
|
|
60
|
+
`legendary_resistances`. Write its legendary options in `actions`, each
|
|
61
|
+
with its cost ("Tail swipe (1), Ash cloud (2)").
|
|
62
|
+
- Legendary actions: at the end of another creature's turn, the boss may take
|
|
63
|
+
one option; call `use_legendary` with its `cost`. They come back at the
|
|
64
|
+
start of the boss's own turn, never during it.
|
|
65
|
+
- Legendary resistance: when the boss fails a saving throw that would end the
|
|
66
|
+
fight too soon, it may succeed instead; call `use_legendary` with
|
|
67
|
+
`resistance: true` and say it shrugs the effect off.
|
|
68
|
+
- In its lair, the boss may also act on initiative count 20 (lair actions):
|
|
69
|
+
narrate them, there is no tool for them.
|
|
70
|
+
- Give the boss a moment: a line when it enters, a change of tactics when it
|
|
71
|
+
is bloodied (half its hit points), a last word.
|
|
72
|
+
|
|
73
|
+
## Running the fight
|
|
74
|
+
|
|
75
|
+
- `start_combat` with the encounter. The characters at the table join with
|
|
76
|
+
initiative rolled for them, unless you give them as `participants`. On a
|
|
77
|
+
battle map, the monsters can wait at their planned squares, hidden for an
|
|
78
|
+
ambush (the `dnd-battle-map` skill).
|
|
79
|
+
- Too many or too few once it starts? `remove_combatant` with `reason:
|
|
80
|
+
withdrawn` takes a monster back (no XP, off the map); `add_combatant`
|
|
81
|
+
brings reinforcements. Before it starts, change the plan instead:
|
|
82
|
+
`plan_encounter` with the new count.
|
|
83
|
+
- Roll every attack and damage with `roll_dice`, record hits with
|
|
84
|
+
`change_hit_points`; a monster at 0 leaves the fight on its own.
|
|
85
|
+
- `end_combat` gives the XP to award with `award_experience`.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The skills over MCP (SEP-2640, ``io.modelcontextprotocol/skills``).
|
|
2
|
+
|
|
3
|
+
Every file of every skill is a resource, ``skill://<name>/<path>``, read with
|
|
4
|
+
``resources/read``. ``skills/list`` gives each skill's entry (its ``SKILL.md``
|
|
5
|
+
URI, its frontmatter verbatim, and every file with its SHA-256 digest and
|
|
6
|
+
size), and ``skills/get`` one entry by URI. The catalogue is small and fixed,
|
|
7
|
+
so the listing is complete in one page.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastmcp import FastMCP
|
|
15
|
+
from fastmcp.server.extensions import MethodBinding, ServerExtension
|
|
16
|
+
from mcp.server.context import ServerRequestContext
|
|
17
|
+
from mcp.shared.exceptions import MCPError
|
|
18
|
+
from mcp_types import PaginatedRequestParams, RequestParams
|
|
19
|
+
from mcp_types.jsonrpc import INVALID_PARAMS
|
|
20
|
+
|
|
21
|
+
from dndkit.activity import ActivityLog
|
|
22
|
+
from dndkit.skills.catalog import SKILL_FILE, Skill, SkillFile, load_catalog
|
|
23
|
+
|
|
24
|
+
IDENTIFIER = "io.modelcontextprotocol/skills"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SkillParams(RequestParams):
|
|
28
|
+
"""``skills/get``: the URI of a skill's ``SKILL.md``."""
|
|
29
|
+
|
|
30
|
+
uri: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SkillsExtension(ServerExtension):
|
|
34
|
+
"""Serves ``skills/list`` and ``skills/get`` over the skills given."""
|
|
35
|
+
|
|
36
|
+
identifier = IDENTIFIER
|
|
37
|
+
|
|
38
|
+
def __init__(self, skills: Sequence[Skill], activity: ActivityLog | None = None) -> None:
|
|
39
|
+
self._skills = tuple(skills)
|
|
40
|
+
self._activity = activity
|
|
41
|
+
|
|
42
|
+
def methods(self) -> Sequence[MethodBinding]:
|
|
43
|
+
return (
|
|
44
|
+
MethodBinding("skills/list", PaginatedRequestParams, self.list_skills),
|
|
45
|
+
MethodBinding("skills/get", SkillParams, self.get_skill),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def list_skills(
|
|
49
|
+
self, _ctx: ServerRequestContext[Any, Any], _params: PaginatedRequestParams
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Every skill's entry."""
|
|
52
|
+
self._note("skills/list", "", f"{len(self._skills)} skills", False)
|
|
53
|
+
return {"resultType": "complete", "skills": [s.entry() for s in self._skills]}
|
|
54
|
+
|
|
55
|
+
async def get_skill(
|
|
56
|
+
self, _ctx: ServerRequestContext[Any, Any], params: SkillParams
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""One skill's entry; an unknown URI is invalid params, as for resources/read."""
|
|
59
|
+
for skill in self._skills:
|
|
60
|
+
if skill.uri == params.uri:
|
|
61
|
+
self._note("skills/get", params.uri, skill.name, False)
|
|
62
|
+
return {"resultType": "complete", "skill": skill.entry()}
|
|
63
|
+
known = ", ".join(s.uri for s in self._skills)
|
|
64
|
+
self._note("skills/get", params.uri, "no such skill", True)
|
|
65
|
+
raise MCPError(INVALID_PARAMS, f"No skill at {params.uri}. Skills: {known}.")
|
|
66
|
+
|
|
67
|
+
def _note(self, method: str, asked: str, outcome: str, failed: bool) -> None:
|
|
68
|
+
if self._activity is not None:
|
|
69
|
+
self._activity.record("skill", method, asked, outcome, failed, time.perf_counter())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def register_skills(server: FastMCP, activity: ActivityLog | None = None) -> None:
|
|
73
|
+
"""Serve the package's skills: their files as resources, and the extension."""
|
|
74
|
+
skills = load_catalog()
|
|
75
|
+
for skill in skills:
|
|
76
|
+
for file in skill.files:
|
|
77
|
+
main = file.path == SKILL_FILE
|
|
78
|
+
server.resource(
|
|
79
|
+
skill.file_uri(file.path),
|
|
80
|
+
name=skill.name if main else f"{skill.name}/{file.path}",
|
|
81
|
+
description=str(skill.frontmatter["description"]) if main else None,
|
|
82
|
+
mime_type=file.mime_type,
|
|
83
|
+
)(_reader(skill, file))
|
|
84
|
+
server.add_extension(SkillsExtension(skills, activity))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reader(skill: Skill, file: SkillFile) -> Callable[[], str | bytes]:
|
|
88
|
+
def read() -> str | bytes:
|
|
89
|
+
# Text goes back as it is stored, so the bytes match the published digest.
|
|
90
|
+
return file.content.decode("utf-8") if file.mime_type.startswith("text/") else file.content
|
|
91
|
+
|
|
92
|
+
read.__name__ = f"skill_{skill.name}_{file.path}".replace("-", "_").replace("/", "_")
|
|
93
|
+
return read
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""What a campaign file holds, read without loading the whole campaign model.
|
|
2
|
+
|
|
3
|
+
Listing campaigns or describing one before deletion reads the raw JSON: a file
|
|
4
|
+
that would fail validation is still listed, marked unreadable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from dndkit.storage.files import read_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class CampaignEntry:
|
|
17
|
+
"""A campaign file and a summary of what is in it."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
path: Path
|
|
21
|
+
modified: datetime
|
|
22
|
+
readable: bool = True
|
|
23
|
+
description: str = ""
|
|
24
|
+
characters: int = 0
|
|
25
|
+
npcs: int = 0
|
|
26
|
+
locations: int = 0
|
|
27
|
+
quests: int = 0
|
|
28
|
+
session_notes: int = 0
|
|
29
|
+
events: int = 0
|
|
30
|
+
current_session: int = 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_entry(path: Path) -> CampaignEntry:
|
|
34
|
+
"""The summary of one campaign file; never raises for a bad file."""
|
|
35
|
+
modified = datetime.fromtimestamp(modified_at(path))
|
|
36
|
+
try:
|
|
37
|
+
data = read_json(path)
|
|
38
|
+
except (OSError, ValueError, RecursionError):
|
|
39
|
+
data = None
|
|
40
|
+
if not isinstance(data, dict):
|
|
41
|
+
return CampaignEntry(
|
|
42
|
+
name=f"{path.stem} (unreadable)", path=path, modified=modified, readable=False
|
|
43
|
+
)
|
|
44
|
+
name = data.get("name")
|
|
45
|
+
state = data.get("game_state")
|
|
46
|
+
session = state.get("current_session") if isinstance(state, dict) else None
|
|
47
|
+
return CampaignEntry(
|
|
48
|
+
name=name if isinstance(name, str) and name else path.stem,
|
|
49
|
+
path=path,
|
|
50
|
+
modified=modified,
|
|
51
|
+
description=str(data.get("description") or ""),
|
|
52
|
+
characters=_count(data, "characters"),
|
|
53
|
+
npcs=_count(data, "npcs"),
|
|
54
|
+
locations=_count(data, "locations"),
|
|
55
|
+
quests=_count(data, "quests"),
|
|
56
|
+
session_notes=_count(data, "sessions"),
|
|
57
|
+
events=_count(data, "events"),
|
|
58
|
+
current_session=session if isinstance(session, int) else 1,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _count(data: dict[str, Any], key: str) -> int:
|
|
63
|
+
value = data.get(key)
|
|
64
|
+
return len(value) if isinstance(value, dict | list) else 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def modified_at(path: Path) -> float:
|
|
68
|
+
"""When *path* last changed; 0 for a file gone or a dangling link."""
|
|
69
|
+
try:
|
|
70
|
+
return path.stat().st_mtime
|
|
71
|
+
except OSError:
|
|
72
|
+
return 0.0
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""One-time codes a destructive action must be called back with.
|
|
2
|
+
|
|
3
|
+
A spoken "delete the campaign" may be misheard. The first call only issues a
|
|
4
|
+
code and says what would be lost; the action happens on a second call that
|
|
5
|
+
carries the code, which the model makes after the players say yes. A code
|
|
6
|
+
serves once, for one target, and expires; a wrong code cancels the request.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import secrets
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
CODE_TTL_SECONDS = 300.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class _Pending:
|
|
20
|
+
code: str
|
|
21
|
+
expires_at: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Confirmations:
|
|
25
|
+
"""Pending confirmation codes, one per target."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self, ttl: float = CODE_TTL_SECONDS, clock: Callable[[], float] = time.monotonic
|
|
29
|
+
) -> None:
|
|
30
|
+
self._ttl = ttl
|
|
31
|
+
self._clock = clock
|
|
32
|
+
self._pending: dict[str, _Pending] = {}
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def ttl_minutes(self) -> int:
|
|
37
|
+
"""The lifetime of a code, in whole minutes, for messages."""
|
|
38
|
+
return max(1, round(self._ttl / 60))
|
|
39
|
+
|
|
40
|
+
def issue(self, target: str) -> str:
|
|
41
|
+
"""A fresh 4-digit code for *target*; it replaces any earlier one."""
|
|
42
|
+
code = f"{secrets.randbelow(10_000):04d}"
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._pending[target] = _Pending(code=code, expires_at=self._clock() + self._ttl)
|
|
45
|
+
return code
|
|
46
|
+
|
|
47
|
+
def consume(self, target: str, code: str) -> bool:
|
|
48
|
+
"""Whether *code* confirms *target*. Either way the pending code is spent."""
|
|
49
|
+
with self._lock:
|
|
50
|
+
pending = self._pending.pop(target, None)
|
|
51
|
+
if pending is None or self._clock() > pending.expires_at:
|
|
52
|
+
return False
|
|
53
|
+
# Bytes: compare_digest refuses a str that is not ASCII ("zéro").
|
|
54
|
+
return secrets.compare_digest(pending.code.encode(), code.strip().encode())
|
dndkit/storage/files.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""File primitives: campaign file names and crash-safe JSON writes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dndkit.errors import InvalidChangeError
|
|
10
|
+
from dndkit.text import normalize
|
|
11
|
+
|
|
12
|
+
_NAME_CHARS = (" ", "-", "_")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def campaign_slug(name: str) -> str:
|
|
16
|
+
"""The file stem for a campaign name: letters, digits, spaces, '-' and '_'.
|
|
17
|
+
|
|
18
|
+
It cannot leave the campaigns directory: '/', '\\' and '.' never survive.
|
|
19
|
+
"""
|
|
20
|
+
kept = "".join(c for c in name if c.isalnum() or c in _NAME_CHARS)
|
|
21
|
+
slug = " ".join(kept.split())
|
|
22
|
+
if not slug:
|
|
23
|
+
raise InvalidChangeError(f"Campaign name '{name}' needs at least one letter or digit.")
|
|
24
|
+
return slug
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def same_slug(a: str, b: str) -> bool:
|
|
28
|
+
"""Two stems that name one campaign: case and accents aside, as spoken names go
|
|
29
|
+
("le phare eteint" opens "Le Phare Éteint")."""
|
|
30
|
+
return normalize(a) == normalize(b)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_json_atomic(path: Path, data: Any) -> None:
|
|
34
|
+
"""Write JSON so a crash leaves either the old file or the new one, never half.
|
|
35
|
+
|
|
36
|
+
The temporary file sits next to the target (same filesystem, so the rename
|
|
37
|
+
is atomic) and ends in ``.tmp``, which the ``*.json`` scans never pick up.
|
|
38
|
+
"""
|
|
39
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.stem}.", suffix=".tmp")
|
|
40
|
+
tmp = Path(tmp_name)
|
|
41
|
+
try:
|
|
42
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
43
|
+
json.dump(data, handle, ensure_ascii=False, indent=1)
|
|
44
|
+
handle.flush()
|
|
45
|
+
os.fsync(handle.fileno())
|
|
46
|
+
os.replace(tmp, path)
|
|
47
|
+
except BaseException:
|
|
48
|
+
tmp.unlink(missing_ok=True)
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_json(path: Path) -> Any:
|
|
53
|
+
"""Parse one JSON file."""
|
|
54
|
+
with path.open(encoding="utf-8") as handle:
|
|
55
|
+
return json.load(handle)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Sharing the data directory with another process: a second MCP client, a
|
|
2
|
+
restart that overlaps the old server, a campaign file edited by hand.
|
|
3
|
+
|
|
4
|
+
Changes take turns through an advisory lock on ``<data dir>/.lock``, and the
|
|
5
|
+
store reads the active campaign again whenever its file changed since it last
|
|
6
|
+
read or wrote it. Every change is saved at once, so reading the file again
|
|
7
|
+
loses nothing of ours; without it, each process would overwrite the other's
|
|
8
|
+
changes with its own copy.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import IO
|
|
17
|
+
|
|
18
|
+
LOCK_FILE = ".lock"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Stamp:
|
|
23
|
+
"""What tells two versions of a file apart: an atomic write makes a new inode."""
|
|
24
|
+
|
|
25
|
+
inode: int
|
|
26
|
+
modified_ns: int
|
|
27
|
+
size: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stamp(path: Path) -> Stamp | None:
|
|
31
|
+
"""*path*'s stamp, or None when it is gone."""
|
|
32
|
+
try:
|
|
33
|
+
info = path.stat()
|
|
34
|
+
except OSError:
|
|
35
|
+
return None
|
|
36
|
+
return Stamp(info.st_ino, info.st_mtime_ns, info.st_size)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DiskLock:
|
|
40
|
+
"""An exclusive advisory lock across processes (none on Windows, where the
|
|
41
|
+
stamps alone catch a change made elsewhere)."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, data_dir: Path) -> None:
|
|
44
|
+
self._path = data_dir / LOCK_FILE
|
|
45
|
+
self._file: IO[bytes] | None = None
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def held(self) -> Iterator[None]:
|
|
49
|
+
"""Hold the lock for a change; another process waits its turn."""
|
|
50
|
+
if sys.platform == "win32":
|
|
51
|
+
yield
|
|
52
|
+
return
|
|
53
|
+
import fcntl
|
|
54
|
+
|
|
55
|
+
if self._file is None:
|
|
56
|
+
self._file = open(self._path, "ab") # noqa: SIM115 - kept open, like the lock
|
|
57
|
+
fcntl.flock(self._file.fileno(), fcntl.LOCK_EX)
|
|
58
|
+
try:
|
|
59
|
+
yield
|
|
60
|
+
finally:
|
|
61
|
+
fcntl.flock(self._file.fileno(), fcntl.LOCK_UN)
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
"""Let go of the lock file (the process's end does it too)."""
|
|
65
|
+
if self._file is not None:
|
|
66
|
+
self._file.close()
|
|
67
|
+
self._file = None
|