dndkit 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dndkit/__init__.py +10 -0
- dndkit/__main__.py +6 -0
- dndkit/activity.py +68 -0
- dndkit/app.py +105 -0
- dndkit/config.py +104 -0
- dndkit/errors.py +48 -0
- dndkit/game/__init__.py +1 -0
- dndkit/game/areas.py +181 -0
- dndkit/game/battlefield.py +154 -0
- dndkit/game/bestiary.py +155 -0
- dndkit/game/characters.py +227 -0
- dndkit/game/combat.py +209 -0
- dndkit/game/combatants.py +188 -0
- dndkit/game/concentration.py +43 -0
- dndkit/game/dice.py +186 -0
- dndkit/game/effects.py +183 -0
- dndkit/game/grid.py +88 -0
- dndkit/game/journal.py +47 -0
- dndkit/game/movement.py +264 -0
- dndkit/game/pathing.py +153 -0
- dndkit/game/proficiencies.py +104 -0
- dndkit/game/progression.py +77 -0
- dndkit/game/readiness.py +214 -0
- dndkit/game/reference.py +123 -0
- dndkit/game/registry.py +172 -0
- dndkit/game/resources.py +96 -0
- dndkit/game/rests.py +98 -0
- dndkit/game/rules.py +196 -0
- dndkit/game/seating.py +71 -0
- dndkit/game/sight.py +142 -0
- dndkit/game/spellbook.py +97 -0
- dndkit/game/tables.py +216 -0
- dndkit/game/world.py +125 -0
- dndkit/models/__init__.py +69 -0
- dndkit/models/base.py +26 -0
- dndkit/models/battle.py +40 -0
- dndkit/models/bestiary.py +56 -0
- dndkit/models/campaign.py +209 -0
- dndkit/models/character.py +149 -0
- dndkit/models/world.py +62 -0
- dndkit/skills/__init__.py +14 -0
- dndkit/skills/catalog.py +113 -0
- dndkit/skills/dnd-battle-map/SKILL.md +78 -0
- dndkit/skills/dnd-game-master/SKILL.md +125 -0
- dndkit/skills/dnd-monsters/SKILL.md +85 -0
- dndkit/skills/extension.py +93 -0
- dndkit/storage/__init__.py +6 -0
- dndkit/storage/catalog.py +72 -0
- dndkit/storage/confirmations.py +54 -0
- dndkit/storage/files.py +55 -0
- dndkit/storage/sharing.py +67 -0
- dndkit/storage/store.py +278 -0
- dndkit/text.py +13 -0
- dndkit/tools/__init__.py +47 -0
- dndkit/tools/apps.py +47 -0
- dndkit/tools/battle.py +117 -0
- dndkit/tools/bestiary.py +121 -0
- dndkit/tools/campaign.py +208 -0
- dndkit/tools/characters.py +189 -0
- dndkit/tools/combat.py +169 -0
- dndkit/tools/deps.py +42 -0
- dndkit/tools/journal.py +102 -0
- dndkit/tools/locations.py +98 -0
- dndkit/tools/npcs.py +101 -0
- dndkit/tools/play.py +118 -0
- dndkit/tools/players.py +44 -0
- dndkit/tools/progression.py +41 -0
- dndkit/tools/quests.py +95 -0
- dndkit/tools/recorder.py +63 -0
- dndkit/tools/render.py +281 -0
- dndkit/tools/render_combat.py +121 -0
- dndkit/tools/render_map.py +141 -0
- dndkit/tools/resources.py +104 -0
- dndkit/tools/schema.py +47 -0
- dndkit/tools/screen.py +47 -0
- dndkit/tools/spells.py +67 -0
- dndkit/tools/utility.py +42 -0
- dndkit/ui/__init__.py +52 -0
- dndkit/ui/backstage.js +41 -0
- dndkit/ui/battlemap.js +70 -0
- dndkit/ui/character.html +23 -0
- dndkit/ui/combat.html +18 -0
- dndkit/ui/components.css +283 -0
- dndkit/ui/components.js +261 -0
- dndkit/ui/desk.js +91 -0
- dndkit/ui/dice.html +42 -0
- dndkit/ui/dicetray.js +99 -0
- dndkit/ui/gm.html +59 -0
- dndkit/ui/party.html +18 -0
- dndkit/ui/player.html +61 -0
- dndkit/ui/seating.html +18 -0
- dndkit/ui/shared.css +195 -0
- dndkit/ui/shared.js +242 -0
- dndkit/ui/sheet.js +75 -0
- dndkit/ui/table.html +41 -0
- dndkit/ui/words.js +120 -0
- dndkit/ui/world.js +56 -0
- dndkit/views/__init__.py +7 -0
- dndkit/views/backstage.py +49 -0
- dndkit/views/battle.py +92 -0
- dndkit/views/desk.py +76 -0
- dndkit/views/everyone.py +194 -0
- dndkit/views/public.py +89 -0
- dndkit/views/widgets.py +58 -0
- dndkit/views/world.py +70 -0
- dndkit/web/__init__.py +10 -0
- dndkit/web/access.py +136 -0
- dndkit/web/audiences.py +111 -0
- dndkit/web/changes.py +84 -0
- dndkit/web/feed.py +112 -0
- dndkit/web/routes.py +134 -0
- dndkit/web/server.py +131 -0
- dndkit-0.4.0.dist-info/METADATA +567 -0
- dndkit-0.4.0.dist-info/RECORD +118 -0
- dndkit-0.4.0.dist-info/WHEEL +5 -0
- dndkit-0.4.0.dist-info/entry_points.txt +2 -0
- dndkit-0.4.0.dist-info/licenses/LICENSE +22 -0
- dndkit-0.4.0.dist-info/top_level.txt +1 -0
dndkit/game/tables.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""D&D 5e (2014) reference tables: numbers only, read by ``rules``."""
|
|
2
|
+
|
|
3
|
+
# Monster XP by challenge rating (DMG ch. 9 / basic rules).
|
|
4
|
+
XP_BY_CHALLENGE: dict[str, int] = {
|
|
5
|
+
"0": 10, "1/8": 25, "1/4": 50, "1/2": 100, "1": 200, "2": 450, "3": 700,
|
|
6
|
+
"4": 1100, "5": 1800, "6": 2300, "7": 2900, "8": 3900, "9": 5000, "10": 5900,
|
|
7
|
+
"11": 7200, "12": 8400, "13": 10000, "14": 11500, "15": 13000, "16": 15000,
|
|
8
|
+
"17": 18000, "18": 20000, "19": 22000, "20": 25000, "21": 33000, "22": 41000,
|
|
9
|
+
"23": 50000, "24": 62000, "25": 75000, "26": 90000, "27": 105000,
|
|
10
|
+
"28": 120000, "29": 135000, "30": 155000,
|
|
11
|
+
} # fmt: skip
|
|
12
|
+
|
|
13
|
+
# XP thresholds per character level: easy, medium, hard, deadly (DMG ch. 3).
|
|
14
|
+
DIFFICULTY_THRESHOLDS: dict[int, tuple[int, int, int, int]] = {
|
|
15
|
+
1: (25, 50, 75, 100), 2: (50, 100, 150, 200), 3: (75, 150, 225, 400),
|
|
16
|
+
4: (125, 250, 375, 500), 5: (250, 500, 750, 1100), 6: (300, 600, 900, 1400),
|
|
17
|
+
7: (350, 750, 1100, 1700), 8: (450, 900, 1400, 2100), 9: (550, 1100, 1600, 2400),
|
|
18
|
+
10: (600, 1200, 1900, 2800), 11: (800, 1600, 2400, 3600),
|
|
19
|
+
12: (1000, 2000, 3000, 4500), 13: (1100, 2200, 3400, 5100),
|
|
20
|
+
14: (1250, 2500, 3800, 5700), 15: (1400, 2800, 4300, 6400),
|
|
21
|
+
16: (1600, 3200, 4800, 7200), 17: (2000, 3900, 5900, 8800),
|
|
22
|
+
18: (2100, 4200, 6300, 9500), 19: (2400, 4900, 7300, 10900),
|
|
23
|
+
20: (2800, 5700, 8500, 12700),
|
|
24
|
+
} # fmt: skip
|
|
25
|
+
|
|
26
|
+
# Encounter multipliers, from one monster up (DMG ch. 3); a small party uses the
|
|
27
|
+
# next step up, a large party the next step down.
|
|
28
|
+
ENCOUNTER_MULTIPLIERS = (0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0)
|
|
29
|
+
|
|
30
|
+
# XP needed to reach each level (PHB ch. 1).
|
|
31
|
+
XP_FOR_LEVEL: dict[int, int] = {
|
|
32
|
+
1: 0, 2: 300, 3: 900, 4: 2700, 5: 6500, 6: 14000, 7: 23000, 8: 34000,
|
|
33
|
+
9: 48000, 10: 64000, 11: 85000, 12: 100000, 13: 120000, 14: 140000,
|
|
34
|
+
15: 165000, 16: 195000, 17: 225000, 18: 265000, 19: 305000, 20: 355000,
|
|
35
|
+
} # fmt: skip
|
|
36
|
+
|
|
37
|
+
# Spell slots per slot level for a full caster of each level (PHB ch. 3).
|
|
38
|
+
FULL_CASTER_SLOTS: dict[int, tuple[int, ...]] = {
|
|
39
|
+
1: (2,), 2: (3,), 3: (4, 2), 4: (4, 3), 5: (4, 3, 2), 6: (4, 3, 3),
|
|
40
|
+
7: (4, 3, 3, 1), 8: (4, 3, 3, 2), 9: (4, 3, 3, 3, 1), 10: (4, 3, 3, 3, 2),
|
|
41
|
+
11: (4, 3, 3, 3, 2, 1), 12: (4, 3, 3, 3, 2, 1), 13: (4, 3, 3, 3, 2, 1, 1),
|
|
42
|
+
14: (4, 3, 3, 3, 2, 1, 1), 15: (4, 3, 3, 3, 2, 1, 1, 1),
|
|
43
|
+
16: (4, 3, 3, 3, 2, 1, 1, 1), 17: (4, 3, 3, 3, 2, 1, 1, 1, 1),
|
|
44
|
+
18: (4, 3, 3, 3, 3, 1, 1, 1, 1), 19: (4, 3, 3, 3, 3, 2, 1, 1, 1),
|
|
45
|
+
20: (4, 3, 3, 3, 3, 2, 2, 1, 1),
|
|
46
|
+
} # fmt: skip
|
|
47
|
+
|
|
48
|
+
# Warlock pact magic per level: (number of slots, slot level).
|
|
49
|
+
PACT_SLOTS: dict[int, tuple[int, int]] = {
|
|
50
|
+
1: (1, 1), 2: (2, 1), 3: (2, 2), 4: (2, 2), 5: (2, 3), 6: (2, 3), 7: (2, 4),
|
|
51
|
+
8: (2, 4), 9: (2, 5), 10: (2, 5), 11: (3, 5), 12: (3, 5), 13: (3, 5),
|
|
52
|
+
14: (3, 5), 15: (3, 5), 16: (3, 5), 17: (4, 5), 18: (4, 5), 19: (4, 5),
|
|
53
|
+
20: (4, 5),
|
|
54
|
+
} # fmt: skip
|
|
55
|
+
|
|
56
|
+
# Class -> (hit die, spellcasting progression).
|
|
57
|
+
CLASSES: dict[str, tuple[int, str]] = {
|
|
58
|
+
"artificer": (8, "half_up"),
|
|
59
|
+
"barbarian": (12, "none"),
|
|
60
|
+
"bard": (8, "full"),
|
|
61
|
+
"cleric": (8, "full"),
|
|
62
|
+
"druid": (8, "full"),
|
|
63
|
+
"fighter": (10, "none"),
|
|
64
|
+
"monk": (8, "none"),
|
|
65
|
+
"paladin": (10, "half"),
|
|
66
|
+
"ranger": (10, "half"),
|
|
67
|
+
"rogue": (8, "none"),
|
|
68
|
+
"sorcerer": (6, "full"),
|
|
69
|
+
"warlock": (8, "pact"),
|
|
70
|
+
"wizard": (6, "full"),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Class names as players say them, French included, in both genders (and both
|
|
74
|
+
# translations of warlock).
|
|
75
|
+
CLASS_ALIASES: dict[str, str] = {
|
|
76
|
+
"artificier": "artificer",
|
|
77
|
+
"artificiere": "artificer",
|
|
78
|
+
"barbare": "barbarian",
|
|
79
|
+
"barde": "bard",
|
|
80
|
+
"clerc": "cleric",
|
|
81
|
+
"pretre": "cleric",
|
|
82
|
+
"pretresse": "cleric",
|
|
83
|
+
"druide": "druid",
|
|
84
|
+
"druidesse": "druid",
|
|
85
|
+
"guerrier": "fighter",
|
|
86
|
+
"guerriere": "fighter",
|
|
87
|
+
"moine": "monk",
|
|
88
|
+
"moniale": "monk",
|
|
89
|
+
"paladine": "paladin",
|
|
90
|
+
"rodeur": "ranger",
|
|
91
|
+
"rodeuse": "ranger",
|
|
92
|
+
"roublard": "rogue",
|
|
93
|
+
"roublarde": "rogue",
|
|
94
|
+
"voleur": "rogue",
|
|
95
|
+
"voleuse": "rogue",
|
|
96
|
+
"ensorceleur": "sorcerer",
|
|
97
|
+
"ensorceleuse": "sorcerer",
|
|
98
|
+
"occultiste": "warlock",
|
|
99
|
+
"sorcier": "warlock",
|
|
100
|
+
"sorciere": "warlock",
|
|
101
|
+
"magicien": "wizard",
|
|
102
|
+
"magicienne": "wizard",
|
|
103
|
+
"mage": "wizard",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Walking speed 25 feet (PHB races); every other race walks 30.
|
|
107
|
+
SLOW_RACES = frozenset({"dwarf", "nain", "naine", "halfling", "halfelin", "halfeline", "gnome"})
|
|
108
|
+
|
|
109
|
+
# DMG ch. 9, "Monster Statistics by Challenge Rating": armor class, hit points
|
|
110
|
+
# (low, high), attack bonus, damage per round (low, high), save DC.
|
|
111
|
+
MONSTER_STATS: dict[str, tuple[int, tuple[int, int], int, tuple[int, int], int]] = {
|
|
112
|
+
"0": (13, (1, 6), 3, (0, 1), 13),
|
|
113
|
+
"1/8": (13, (7, 35), 3, (2, 3), 13),
|
|
114
|
+
"1/4": (13, (36, 49), 3, (4, 5), 13),
|
|
115
|
+
"1/2": (13, (50, 70), 3, (6, 8), 13),
|
|
116
|
+
"1": (13, (71, 85), 3, (9, 14), 13),
|
|
117
|
+
"2": (13, (86, 100), 3, (15, 20), 13),
|
|
118
|
+
"3": (13, (101, 115), 4, (21, 26), 13),
|
|
119
|
+
"4": (14, (116, 130), 5, (27, 32), 14),
|
|
120
|
+
"5": (15, (131, 145), 6, (33, 38), 15),
|
|
121
|
+
"6": (15, (146, 160), 6, (39, 44), 15),
|
|
122
|
+
"7": (15, (161, 175), 6, (45, 50), 15),
|
|
123
|
+
"8": (16, (176, 190), 7, (51, 56), 16),
|
|
124
|
+
"9": (16, (191, 205), 7, (57, 62), 16),
|
|
125
|
+
"10": (17, (206, 220), 7, (63, 68), 16),
|
|
126
|
+
"11": (17, (221, 235), 8, (69, 74), 17),
|
|
127
|
+
"12": (17, (236, 250), 8, (75, 80), 17),
|
|
128
|
+
"13": (18, (251, 265), 8, (81, 86), 18),
|
|
129
|
+
"14": (18, (266, 280), 8, (87, 92), 18),
|
|
130
|
+
"15": (18, (281, 295), 8, (93, 98), 18),
|
|
131
|
+
"16": (18, (296, 310), 9, (99, 104), 18),
|
|
132
|
+
"17": (19, (311, 325), 10, (105, 110), 19),
|
|
133
|
+
"18": (19, (326, 340), 10, (111, 116), 19),
|
|
134
|
+
"19": (19, (341, 355), 10, (117, 122), 19),
|
|
135
|
+
"20": (19, (356, 400), 10, (123, 140), 19),
|
|
136
|
+
"21": (19, (401, 445), 11, (141, 158), 20),
|
|
137
|
+
"22": (19, (446, 490), 11, (159, 176), 20),
|
|
138
|
+
"23": (19, (491, 535), 11, (177, 194), 20),
|
|
139
|
+
"24": (19, (536, 580), 12, (195, 212), 21),
|
|
140
|
+
"25": (19, (581, 625), 12, (213, 230), 21),
|
|
141
|
+
"26": (19, (626, 670), 12, (231, 248), 21),
|
|
142
|
+
"27": (19, (671, 715), 13, (249, 266), 22),
|
|
143
|
+
"28": (19, (716, 760), 13, (267, 284), 22),
|
|
144
|
+
"29": (19, (761, 805), 13, (285, 302), 22),
|
|
145
|
+
"30": (19, (806, 850), 14, (303, 320), 23),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# The ability each class casts its spells with (PHB ch. 3).
|
|
149
|
+
CASTING_ABILITY: dict[str, str] = {
|
|
150
|
+
"artificer": "intelligence",
|
|
151
|
+
"bard": "charisma",
|
|
152
|
+
"cleric": "wisdom",
|
|
153
|
+
"druid": "wisdom",
|
|
154
|
+
"paladin": "charisma",
|
|
155
|
+
"ranger": "wisdom",
|
|
156
|
+
"sorcerer": "charisma",
|
|
157
|
+
"warlock": "charisma",
|
|
158
|
+
"wizard": "intelligence",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# PHB ch. 7: each skill and its ability.
|
|
163
|
+
SKILLS: dict[str, str] = {
|
|
164
|
+
"Acrobatics": "dexterity",
|
|
165
|
+
"Animal Handling": "wisdom",
|
|
166
|
+
"Arcana": "intelligence",
|
|
167
|
+
"Athletics": "strength",
|
|
168
|
+
"Deception": "charisma",
|
|
169
|
+
"History": "intelligence",
|
|
170
|
+
"Insight": "wisdom",
|
|
171
|
+
"Intimidation": "charisma",
|
|
172
|
+
"Investigation": "intelligence",
|
|
173
|
+
"Medicine": "wisdom",
|
|
174
|
+
"Nature": "intelligence",
|
|
175
|
+
"Perception": "wisdom",
|
|
176
|
+
"Performance": "charisma",
|
|
177
|
+
"Persuasion": "charisma",
|
|
178
|
+
"Religion": "intelligence",
|
|
179
|
+
"Sleight of Hand": "dexterity",
|
|
180
|
+
"Stealth": "dexterity",
|
|
181
|
+
"Survival": "wisdom",
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# The French names (official translation) of the skills that change.
|
|
185
|
+
SKILL_ALIASES: dict[str, str] = {
|
|
186
|
+
"acrobaties": "Acrobatics",
|
|
187
|
+
"dressage": "Animal Handling",
|
|
188
|
+
"arcanes": "Arcana",
|
|
189
|
+
"athletisme": "Athletics",
|
|
190
|
+
"tromperie": "Deception",
|
|
191
|
+
"histoire": "History",
|
|
192
|
+
"perspicacite": "Insight",
|
|
193
|
+
"intuition": "Insight",
|
|
194
|
+
"medecine": "Medicine",
|
|
195
|
+
"representation": "Performance",
|
|
196
|
+
"escamotage": "Sleight of Hand",
|
|
197
|
+
"discretion": "Stealth",
|
|
198
|
+
"survie": "Survival",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# PHB ch. 3: the two saving throws each class is proficient in.
|
|
202
|
+
CLASS_SAVES: dict[str, tuple[str, str]] = {
|
|
203
|
+
"artificer": ("constitution", "intelligence"),
|
|
204
|
+
"barbarian": ("strength", "constitution"),
|
|
205
|
+
"bard": ("dexterity", "charisma"),
|
|
206
|
+
"cleric": ("wisdom", "charisma"),
|
|
207
|
+
"druid": ("intelligence", "wisdom"),
|
|
208
|
+
"fighter": ("strength", "constitution"),
|
|
209
|
+
"monk": ("strength", "dexterity"),
|
|
210
|
+
"paladin": ("wisdom", "charisma"),
|
|
211
|
+
"ranger": ("strength", "dexterity"),
|
|
212
|
+
"rogue": ("dexterity", "intelligence"),
|
|
213
|
+
"sorcerer": ("constitution", "charisma"),
|
|
214
|
+
"warlock": ("wisdom", "charisma"),
|
|
215
|
+
"wizard": ("intelligence", "wisdom"),
|
|
216
|
+
}
|
dndkit/game/world.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""NPCs, locations and quests, and removing any campaign entity."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from dndkit.errors import NotFoundError
|
|
7
|
+
from dndkit.game import combat, registry
|
|
8
|
+
from dndkit.models import NPC, Campaign, Location, Quest, QuestStatus
|
|
9
|
+
|
|
10
|
+
EntityKind = Literal["character", "npc", "location", "quest"]
|
|
11
|
+
NPC_KIND = "NPC"
|
|
12
|
+
LOCATION_KIND = "location"
|
|
13
|
+
QUEST_KIND = "quest"
|
|
14
|
+
|
|
15
|
+
# kind -> (Campaign attribute, label used in messages)
|
|
16
|
+
_COLLECTIONS: dict[str, tuple[str, str]] = {
|
|
17
|
+
"character": ("characters", "character"),
|
|
18
|
+
"npc": ("npcs", NPC_KIND),
|
|
19
|
+
"location": ("locations", LOCATION_KIND),
|
|
20
|
+
"quest": ("quests", QUEST_KIND),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def find_entity(campaign: Campaign, kind: EntityKind, ref: str) -> registry.Named:
|
|
25
|
+
"""The character, NPC, location or quest *ref* names."""
|
|
26
|
+
attribute, label = _COLLECTIONS[kind]
|
|
27
|
+
found: registry.Named = registry.find(getattr(campaign, attribute), ref, label)
|
|
28
|
+
return found
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def remove_entity(campaign: Campaign, kind: EntityKind, ref: str) -> str:
|
|
32
|
+
"""Remove a character, NPC, location or quest; returns its name. A character
|
|
33
|
+
leaves the running fight too."""
|
|
34
|
+
attribute, label = _COLLECTIONS[kind]
|
|
35
|
+
removed: registry.Named = registry.remove(getattr(campaign, attribute), ref, label)
|
|
36
|
+
state = campaign.game_state
|
|
37
|
+
for fighter in [c for c in state.initiative_order if c.character_id == removed.id]:
|
|
38
|
+
combat.remove(state, fighter.name, overcome=False)
|
|
39
|
+
return removed.name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_npc(campaign: Campaign, ref: str) -> NPC:
|
|
43
|
+
"""The NPC *ref* names."""
|
|
44
|
+
return registry.find(campaign.npcs, ref, NPC_KIND)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_npc(campaign: Campaign, npc: NPC) -> NPC:
|
|
48
|
+
"""Add an NPC; the name must be free."""
|
|
49
|
+
return registry.add(campaign.npcs, npc, NPC_KIND)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def update_npc(campaign: Campaign, ref: str, changes: Mapping[str, Any]) -> NPC:
|
|
53
|
+
"""Change an NPC's fields."""
|
|
54
|
+
return registry.update(campaign.npcs, ref, NPC_KIND, changes)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_location(campaign: Campaign, ref: str) -> Location:
|
|
58
|
+
"""The location *ref* names."""
|
|
59
|
+
return registry.find(campaign.locations, ref, LOCATION_KIND)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_location(campaign: Campaign, location: Location) -> Location:
|
|
63
|
+
"""Add a location; the name must be free."""
|
|
64
|
+
return registry.add(campaign.locations, location, LOCATION_KIND)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def update_location(campaign: Campaign, ref: str, changes: Mapping[str, Any]) -> Location:
|
|
68
|
+
"""Change a location's fields."""
|
|
69
|
+
return registry.update(campaign.locations, ref, LOCATION_KIND, changes)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_quest(campaign: Campaign, ref: str) -> Quest:
|
|
73
|
+
"""The quest *ref* names (title or ID)."""
|
|
74
|
+
return registry.find(campaign.quests, ref, QUEST_KIND)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_quest(campaign: Campaign, quest: Quest) -> Quest:
|
|
78
|
+
"""Add a quest; the title must be free."""
|
|
79
|
+
return registry.add(campaign.quests, quest, QUEST_KIND)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def update_quest(
|
|
83
|
+
campaign: Campaign,
|
|
84
|
+
ref: str,
|
|
85
|
+
*,
|
|
86
|
+
status: QuestStatus | None = None,
|
|
87
|
+
complete_objective: str | None = None,
|
|
88
|
+
add_objective: str | None = None,
|
|
89
|
+
notes: str | None = None,
|
|
90
|
+
) -> tuple[Quest, list[str]]:
|
|
91
|
+
"""Change a quest; returns it with a line per change made."""
|
|
92
|
+
quest = find_quest(campaign, ref)
|
|
93
|
+
changes: list[str] = []
|
|
94
|
+
if add_objective and add_objective not in quest.objectives:
|
|
95
|
+
quest.objectives = [*quest.objectives, add_objective]
|
|
96
|
+
changes.append(f"new objective '{add_objective}'")
|
|
97
|
+
if complete_objective:
|
|
98
|
+
objective = _match_objective(quest, complete_objective)
|
|
99
|
+
if objective not in quest.completed_objectives:
|
|
100
|
+
quest.completed_objectives = [*quest.completed_objectives, objective]
|
|
101
|
+
changes.append(f"objective done: '{objective}'")
|
|
102
|
+
if status:
|
|
103
|
+
quest.status = status
|
|
104
|
+
changes.append(f"status {status}")
|
|
105
|
+
if notes is not None:
|
|
106
|
+
quest.notes = notes
|
|
107
|
+
changes.append("notes updated")
|
|
108
|
+
return quest, changes
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _match_objective(quest: Quest, ref: str) -> str:
|
|
112
|
+
"""An objective by number ("2"), exact text, or a fragment only it contains."""
|
|
113
|
+
text = ref.strip()
|
|
114
|
+
if text.isdigit() and 1 <= int(text) <= len(quest.objectives):
|
|
115
|
+
return quest.objectives[int(text) - 1]
|
|
116
|
+
wanted = registry.normalize(text)
|
|
117
|
+
exact = [o for o in quest.objectives if registry.normalize(o) == wanted]
|
|
118
|
+
partial = [o for o in quest.objectives if wanted and wanted in registry.normalize(o)]
|
|
119
|
+
matches = exact or partial
|
|
120
|
+
if len(matches) == 1:
|
|
121
|
+
return matches[0]
|
|
122
|
+
listed = "; ".join(f"{i}. {o}" for i, o in enumerate(quest.objectives, 1)) or "none"
|
|
123
|
+
raise NotFoundError(
|
|
124
|
+
f"Quest '{quest.title}' has no single objective matching '{ref}'. Objectives: {listed}."
|
|
125
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Pydantic models for campaigns, characters and the world."""
|
|
2
|
+
|
|
3
|
+
from dndkit.models.base import GameModel, new_id, now
|
|
4
|
+
from dndkit.models.battle import Battlefield, Layout, Placement
|
|
5
|
+
from dndkit.models.bestiary import (
|
|
6
|
+
Encounter,
|
|
7
|
+
EncounterGroup,
|
|
8
|
+
EncounterStatus,
|
|
9
|
+
Monster,
|
|
10
|
+
Size,
|
|
11
|
+
)
|
|
12
|
+
from dndkit.models.campaign import (
|
|
13
|
+
AdventureEvent,
|
|
14
|
+
Campaign,
|
|
15
|
+
Combatant,
|
|
16
|
+
CombatRecord,
|
|
17
|
+
EventType,
|
|
18
|
+
GameState,
|
|
19
|
+
Participant,
|
|
20
|
+
Seat,
|
|
21
|
+
SessionNote,
|
|
22
|
+
)
|
|
23
|
+
from dndkit.models.character import (
|
|
24
|
+
ABILITIES,
|
|
25
|
+
CONDITIONS,
|
|
26
|
+
AbilityScore,
|
|
27
|
+
Character,
|
|
28
|
+
CharacterClass,
|
|
29
|
+
Item,
|
|
30
|
+
Race,
|
|
31
|
+
Spell,
|
|
32
|
+
)
|
|
33
|
+
from dndkit.models.world import NPC, Attitude, Location, Quest, QuestStatus
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ABILITIES",
|
|
37
|
+
"CONDITIONS",
|
|
38
|
+
"NPC",
|
|
39
|
+
"AbilityScore",
|
|
40
|
+
"AdventureEvent",
|
|
41
|
+
"Attitude",
|
|
42
|
+
"Battlefield",
|
|
43
|
+
"Campaign",
|
|
44
|
+
"Character",
|
|
45
|
+
"CharacterClass",
|
|
46
|
+
"Combatant",
|
|
47
|
+
"CombatRecord",
|
|
48
|
+
"Encounter",
|
|
49
|
+
"EncounterGroup",
|
|
50
|
+
"EncounterStatus",
|
|
51
|
+
"EventType",
|
|
52
|
+
"GameModel",
|
|
53
|
+
"GameState",
|
|
54
|
+
"Item",
|
|
55
|
+
"Layout",
|
|
56
|
+
"Location",
|
|
57
|
+
"Monster",
|
|
58
|
+
"Participant",
|
|
59
|
+
"Placement",
|
|
60
|
+
"Quest",
|
|
61
|
+
"QuestStatus",
|
|
62
|
+
"Race",
|
|
63
|
+
"Seat",
|
|
64
|
+
"SessionNote",
|
|
65
|
+
"Size",
|
|
66
|
+
"Spell",
|
|
67
|
+
"new_id",
|
|
68
|
+
"now",
|
|
69
|
+
]
|
dndkit/models/base.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Shared base for the game's data models."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
from shortuuid import random as short_random
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def new_id() -> str:
|
|
10
|
+
"""A random 8-character ID."""
|
|
11
|
+
return short_random(length=8)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def now() -> datetime:
|
|
15
|
+
"""The current local time, for created/updated stamps."""
|
|
16
|
+
return datetime.now()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GameModel(BaseModel):
|
|
20
|
+
"""Assignments are validated; keys left by older versions are ignored on load.
|
|
21
|
+
|
|
22
|
+
No validator may assign to ``self``: with ``validate_assignment`` that
|
|
23
|
+
assignment would validate again and recurse.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(validate_assignment=True, extra="ignore")
|
dndkit/models/battle.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The battle map a fight is played on."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from dndkit.models.base import GameModel
|
|
6
|
+
|
|
7
|
+
MAX_SQUARES = 26 # columns are single letters, A to Z
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Battlefield(GameModel):
|
|
11
|
+
"""A grid of 5-foot squares named like "D7": column letter, row number, A1 top left.
|
|
12
|
+
|
|
13
|
+
Terrain lists squares by name; who stands where lives on each combatant.
|
|
14
|
+
``area`` is the last area of effect, drawn until the next turn.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
width: int = Field(ge=1, le=MAX_SQUARES)
|
|
18
|
+
height: int = Field(ge=1, le=MAX_SQUARES)
|
|
19
|
+
walls: list[str] = Field(default_factory=list) # blocked squares
|
|
20
|
+
difficult: list[str] = Field(default_factory=list) # each costs 10 feet to enter
|
|
21
|
+
area: list[str] = Field(default_factory=list)
|
|
22
|
+
area_label: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Layout(GameModel):
|
|
26
|
+
"""A battle map as a tool call describes it."""
|
|
27
|
+
|
|
28
|
+
width: int = Field(ge=1, le=MAX_SQUARES, description="Squares across")
|
|
29
|
+
height: int = Field(ge=1, le=MAX_SQUARES, description="Squares down")
|
|
30
|
+
walls: list[str] = Field(default_factory=list, description='e.g. "C3" or "C1:C6"')
|
|
31
|
+
difficult: list[str] = Field(default_factory=list, description="Difficult terrain")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Placement(GameModel):
|
|
35
|
+
"""A combatant put on a square, or hidden or revealed, as a tool call describes it."""
|
|
36
|
+
|
|
37
|
+
who: str = Field(min_length=1, description="Combatant name")
|
|
38
|
+
at: str | None = Field(default=None, description="Square, e.g. D7")
|
|
39
|
+
ally: bool | None = Field(default=None, description="Fights with the party")
|
|
40
|
+
hidden: bool | None = Field(default=None, description="Unseen by the players")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The campaign's bestiary and its planned encounters."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from dndkit.models.base import GameModel, new_id
|
|
8
|
+
from dndkit.models.battle import Battlefield
|
|
9
|
+
|
|
10
|
+
EncounterStatus = Literal["planned", "running", "done"]
|
|
11
|
+
Size = Literal["tiny", "small", "medium", "large", "huge", "gargantuan"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Monster(GameModel):
|
|
15
|
+
"""A stat block the game master saved, used to fill combatants in."""
|
|
16
|
+
|
|
17
|
+
id: str = Field(default_factory=new_id)
|
|
18
|
+
name: str = Field(min_length=1)
|
|
19
|
+
armor_class: int = Field(ge=0)
|
|
20
|
+
hit_points: int = Field(ge=1)
|
|
21
|
+
challenge_rating: str | None = None # "1/4", "2", ...
|
|
22
|
+
xp: int = Field(default=0, ge=0)
|
|
23
|
+
initiative_bonus: int = 0
|
|
24
|
+
speed: str | None = None # "30 ft., fly 60 ft."
|
|
25
|
+
size: Size = "medium"
|
|
26
|
+
actions: str = "" # free text: attacks, damage, special abilities
|
|
27
|
+
notes: str = ""
|
|
28
|
+
boss: bool = False
|
|
29
|
+
legendary_actions: int = Field(default=0, ge=0, le=5) # a round's worth
|
|
30
|
+
legendary_resistances: int = Field(default=0, ge=0, le=5) # a day's worth
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EncounterGroup(GameModel):
|
|
34
|
+
"""How many of one bestiary monster an encounter holds."""
|
|
35
|
+
|
|
36
|
+
monster: str = Field(min_length=1, description="Bestiary monster name")
|
|
37
|
+
count: int = Field(default=1, ge=1, le=50, description="How many")
|
|
38
|
+
at: list[str] = Field(default_factory=list, description="Their squares, if a map")
|
|
39
|
+
hidden: bool = Field(default=False, description="Unseen at first (ambush)")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Encounter(GameModel):
|
|
43
|
+
"""A fight prepared in advance, started with start_combat(encounter=...).
|
|
44
|
+
|
|
45
|
+
Older saves stored an unused ``CombatEncounter`` under the same key; its
|
|
46
|
+
extra fields are ignored and its ``description`` still fits.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
id: str = Field(default_factory=new_id)
|
|
50
|
+
name: str = Field(min_length=1)
|
|
51
|
+
description: str = ""
|
|
52
|
+
monsters: list[EncounterGroup] = Field(default_factory=list)
|
|
53
|
+
location: str | None = None
|
|
54
|
+
status: EncounterStatus = "planned"
|
|
55
|
+
notes: str = ""
|
|
56
|
+
battlefield: Battlefield | None = None # laid out when the fight starts
|