dndkit 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. dndkit/__init__.py +10 -0
  2. dndkit/__main__.py +6 -0
  3. dndkit/activity.py +68 -0
  4. dndkit/app.py +105 -0
  5. dndkit/config.py +104 -0
  6. dndkit/errors.py +48 -0
  7. dndkit/game/__init__.py +1 -0
  8. dndkit/game/areas.py +181 -0
  9. dndkit/game/battlefield.py +154 -0
  10. dndkit/game/bestiary.py +155 -0
  11. dndkit/game/characters.py +227 -0
  12. dndkit/game/combat.py +209 -0
  13. dndkit/game/combatants.py +188 -0
  14. dndkit/game/concentration.py +43 -0
  15. dndkit/game/dice.py +186 -0
  16. dndkit/game/effects.py +183 -0
  17. dndkit/game/grid.py +88 -0
  18. dndkit/game/journal.py +47 -0
  19. dndkit/game/movement.py +264 -0
  20. dndkit/game/pathing.py +153 -0
  21. dndkit/game/proficiencies.py +104 -0
  22. dndkit/game/progression.py +77 -0
  23. dndkit/game/readiness.py +214 -0
  24. dndkit/game/reference.py +123 -0
  25. dndkit/game/registry.py +172 -0
  26. dndkit/game/resources.py +96 -0
  27. dndkit/game/rests.py +98 -0
  28. dndkit/game/rules.py +196 -0
  29. dndkit/game/seating.py +71 -0
  30. dndkit/game/sight.py +142 -0
  31. dndkit/game/spellbook.py +97 -0
  32. dndkit/game/tables.py +216 -0
  33. dndkit/game/world.py +125 -0
  34. dndkit/models/__init__.py +69 -0
  35. dndkit/models/base.py +26 -0
  36. dndkit/models/battle.py +40 -0
  37. dndkit/models/bestiary.py +56 -0
  38. dndkit/models/campaign.py +209 -0
  39. dndkit/models/character.py +149 -0
  40. dndkit/models/world.py +62 -0
  41. dndkit/skills/__init__.py +14 -0
  42. dndkit/skills/catalog.py +113 -0
  43. dndkit/skills/dnd-battle-map/SKILL.md +78 -0
  44. dndkit/skills/dnd-game-master/SKILL.md +125 -0
  45. dndkit/skills/dnd-monsters/SKILL.md +85 -0
  46. dndkit/skills/extension.py +93 -0
  47. dndkit/storage/__init__.py +6 -0
  48. dndkit/storage/catalog.py +72 -0
  49. dndkit/storage/confirmations.py +54 -0
  50. dndkit/storage/files.py +55 -0
  51. dndkit/storage/sharing.py +67 -0
  52. dndkit/storage/store.py +278 -0
  53. dndkit/text.py +13 -0
  54. dndkit/tools/__init__.py +47 -0
  55. dndkit/tools/apps.py +47 -0
  56. dndkit/tools/battle.py +117 -0
  57. dndkit/tools/bestiary.py +121 -0
  58. dndkit/tools/campaign.py +208 -0
  59. dndkit/tools/characters.py +189 -0
  60. dndkit/tools/combat.py +169 -0
  61. dndkit/tools/deps.py +42 -0
  62. dndkit/tools/journal.py +102 -0
  63. dndkit/tools/locations.py +98 -0
  64. dndkit/tools/npcs.py +101 -0
  65. dndkit/tools/play.py +118 -0
  66. dndkit/tools/players.py +44 -0
  67. dndkit/tools/progression.py +41 -0
  68. dndkit/tools/quests.py +95 -0
  69. dndkit/tools/recorder.py +63 -0
  70. dndkit/tools/render.py +281 -0
  71. dndkit/tools/render_combat.py +121 -0
  72. dndkit/tools/render_map.py +141 -0
  73. dndkit/tools/resources.py +104 -0
  74. dndkit/tools/schema.py +47 -0
  75. dndkit/tools/screen.py +47 -0
  76. dndkit/tools/spells.py +67 -0
  77. dndkit/tools/utility.py +42 -0
  78. dndkit/ui/__init__.py +52 -0
  79. dndkit/ui/backstage.js +41 -0
  80. dndkit/ui/battlemap.js +70 -0
  81. dndkit/ui/character.html +23 -0
  82. dndkit/ui/combat.html +18 -0
  83. dndkit/ui/components.css +283 -0
  84. dndkit/ui/components.js +261 -0
  85. dndkit/ui/desk.js +91 -0
  86. dndkit/ui/dice.html +42 -0
  87. dndkit/ui/dicetray.js +99 -0
  88. dndkit/ui/gm.html +59 -0
  89. dndkit/ui/party.html +18 -0
  90. dndkit/ui/player.html +61 -0
  91. dndkit/ui/seating.html +18 -0
  92. dndkit/ui/shared.css +195 -0
  93. dndkit/ui/shared.js +242 -0
  94. dndkit/ui/sheet.js +75 -0
  95. dndkit/ui/table.html +41 -0
  96. dndkit/ui/words.js +120 -0
  97. dndkit/ui/world.js +56 -0
  98. dndkit/views/__init__.py +7 -0
  99. dndkit/views/backstage.py +49 -0
  100. dndkit/views/battle.py +92 -0
  101. dndkit/views/desk.py +76 -0
  102. dndkit/views/everyone.py +194 -0
  103. dndkit/views/public.py +89 -0
  104. dndkit/views/widgets.py +58 -0
  105. dndkit/views/world.py +70 -0
  106. dndkit/web/__init__.py +10 -0
  107. dndkit/web/access.py +136 -0
  108. dndkit/web/audiences.py +111 -0
  109. dndkit/web/changes.py +84 -0
  110. dndkit/web/feed.py +112 -0
  111. dndkit/web/routes.py +134 -0
  112. dndkit/web/server.py +131 -0
  113. dndkit-0.4.0.dist-info/METADATA +567 -0
  114. dndkit-0.4.0.dist-info/RECORD +118 -0
  115. dndkit-0.4.0.dist-info/WHEEL +5 -0
  116. dndkit-0.4.0.dist-info/entry_points.txt +2 -0
  117. dndkit-0.4.0.dist-info/licenses/LICENSE +22 -0
  118. dndkit-0.4.0.dist-info/top_level.txt +1 -0
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")
@@ -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