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,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"]
|
dndkit/skills/catalog.py
ADDED
|
@@ -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.
|