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/readiness.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""What a campaign still lacks before it is played, by the 5e rules (SRD 5.1).
|
|
2
|
+
|
|
3
|
+
The checks read the campaign and say what is wrong or missing, each with the
|
|
4
|
+
tool that fixes it. They change nothing: a game master may keep a fighter
|
|
5
|
+
without armor on purpose. ``certain`` findings break a rule; the others are
|
|
6
|
+
what a first campaign usually forgets.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from dndkit.game import bestiary, registry, rules
|
|
13
|
+
from dndkit.models import ABILITIES, Campaign, Character, Encounter
|
|
14
|
+
|
|
15
|
+
# The armor in each class's starting equipment, for a sheet still at 10 + DEX.
|
|
16
|
+
STARTING_ARMOR = {
|
|
17
|
+
"artificer": "studded leather (AC 12 + DEX) or scale mail (14 + DEX, max 2)",
|
|
18
|
+
"bard": "leather armor (AC 11 + DEX)",
|
|
19
|
+
"cleric": "scale mail and a shield (AC 16 + DEX, max 2)",
|
|
20
|
+
"druid": "leather armor and a wooden shield (AC 13 + DEX)",
|
|
21
|
+
"fighter": "chain mail (AC 16, 18 with a shield)",
|
|
22
|
+
"paladin": "chain mail (AC 16, 18 with a shield)",
|
|
23
|
+
"ranger": "scale mail (AC 14 + DEX, max 2) or leather (11 + DEX)",
|
|
24
|
+
"rogue": "leather armor (AC 11 + DEX)",
|
|
25
|
+
"warlock": "leather armor (AC 11 + DEX)",
|
|
26
|
+
}
|
|
27
|
+
# Unarmored Defense: AC 10 + DEX + this ability's modifier.
|
|
28
|
+
UNARMORED_DEFENSE = {"barbarian": "constitution", "monk": "wisdom"}
|
|
29
|
+
STANDARD_ARRAY = "15, 14, 13, 12, 10, 8"
|
|
30
|
+
# Hit points a feature may add per level (Tough, a hill dwarf's toughness).
|
|
31
|
+
EXTRA_HP_PER_LEVEL = 3
|
|
32
|
+
# Expertise in two skills at each of these class levels.
|
|
33
|
+
EXPERTISE_LEVELS = {"rogue": (1, 6), "bard": (3, 10)}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Finding:
|
|
38
|
+
subject: str # a character, a quest, a monster, or the campaign
|
|
39
|
+
problem: str
|
|
40
|
+
fix: str # the call that fixes it
|
|
41
|
+
certain: bool # breaks a rule, rather than likely forgotten
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check(campaign: Campaign) -> list[Finding]:
|
|
45
|
+
"""Everything worth fixing before play: the campaign, each character, the bestiary."""
|
|
46
|
+
found = _world(campaign)
|
|
47
|
+
for character in campaign.characters.values():
|
|
48
|
+
found += [f for check in CHARACTER_CHECKS if (f := check(character))]
|
|
49
|
+
return found + _bestiary(campaign)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _world(campaign: Campaign) -> list[Finding]:
|
|
53
|
+
name = campaign.name
|
|
54
|
+
found: list[Finding] = []
|
|
55
|
+
if not campaign.characters:
|
|
56
|
+
found.append(Finding(name, "no characters", "create_character for each player", True))
|
|
57
|
+
if not any(q.status == "active" for q in campaign.quests.values()):
|
|
58
|
+
found.append(Finding(name, "no active quest: the story's hook", "create_quest", False))
|
|
59
|
+
if not campaign.npcs:
|
|
60
|
+
found.append(Finding(name, "no NPC to meet first", "create_npc", False))
|
|
61
|
+
found += _whereabouts(campaign)
|
|
62
|
+
for quest in campaign.quests.values():
|
|
63
|
+
if quest.giver and not registry.find_exact(campaign.npcs, quest.giver):
|
|
64
|
+
problem = f"given by {quest.giver}, who is no NPC"
|
|
65
|
+
found.append(Finding(f"quest {quest.title}", problem, "create_npc", False))
|
|
66
|
+
return found
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _whereabouts(campaign: Campaign) -> list[Finding]:
|
|
70
|
+
here = campaign.game_state.current_location
|
|
71
|
+
if here and registry.find_exact(campaign.locations, here):
|
|
72
|
+
return []
|
|
73
|
+
if here:
|
|
74
|
+
problem, fix = f"the party is at {here}, which is no location", f"create_location {here}"
|
|
75
|
+
elif campaign.locations:
|
|
76
|
+
problem, fix = "the party is nowhere", "update_game_state current_location"
|
|
77
|
+
else:
|
|
78
|
+
problem, fix = "no location", "create_location, then update_game_state current_location"
|
|
79
|
+
return [Finding(campaign.name, problem, fix, False)]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _class(c: Character) -> Finding | None:
|
|
83
|
+
if rules.class_key(c.character_class.name):
|
|
84
|
+
return None
|
|
85
|
+
problem = (
|
|
86
|
+
f"{c.character_class.name} is no 5e class: its hit dice, saves and spells are guesses"
|
|
87
|
+
)
|
|
88
|
+
fix = "remove_from_campaign, then create_character with a class from the book"
|
|
89
|
+
return Finding(c.name, problem, fix, True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _armor(c: Character) -> Finding | None:
|
|
93
|
+
key = rules.class_key(c.character_class.name) or ""
|
|
94
|
+
unarmored = 10 + c.abilities["dexterity"].mod
|
|
95
|
+
if key in UNARMORED_DEFENSE:
|
|
96
|
+
ability = UNARMORED_DEFENSE[key]
|
|
97
|
+
least = unarmored + c.abilities[ability].mod
|
|
98
|
+
if c.armor_class < least:
|
|
99
|
+
short = ability[:3].upper()
|
|
100
|
+
problem = f"AC {c.armor_class}, below Unarmored Defense (10 + DEX + {short} = {least})"
|
|
101
|
+
return Finding(c.name, problem, "update_character armor_class", True)
|
|
102
|
+
elif key in STARTING_ARMOR and c.armor_class <= unarmored:
|
|
103
|
+
problem = f"AC {c.armor_class} is no armor; a {key} starts with {STARTING_ARMOR[key]}"
|
|
104
|
+
return Finding(c.name, problem, "update_character armor_class", False)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _abilities(c: Character) -> Finding | None:
|
|
109
|
+
unset = [a[:3].upper() for a in ABILITIES if c.abilities[a].score == 10]
|
|
110
|
+
if len(unset) < 3:
|
|
111
|
+
return None
|
|
112
|
+
problem = f"{', '.join(unset)} still 10, as if never set (standard array {STANDARD_ARRAY})"
|
|
113
|
+
return Finding(c.name, problem, "update_character with the scores", False)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _hit_points(c: Character) -> Finding | None:
|
|
117
|
+
"""Within what the hit dice allow: the whole die at level 1, then 1 to the die."""
|
|
118
|
+
cls = c.character_class
|
|
119
|
+
if not rules.class_key(cls.name):
|
|
120
|
+
return None
|
|
121
|
+
die, con, level = rules.hit_die(cls.name), c.abilities["constitution"].mod, cls.level
|
|
122
|
+
least = max(1, die + con) + (level - 1) * max(1, 1 + con)
|
|
123
|
+
most = level * (die + con + EXTRA_HP_PER_LEVEL)
|
|
124
|
+
if least <= c.hit_points_max <= most:
|
|
125
|
+
return None
|
|
126
|
+
average = rules.starting_hit_points(cls.name, level, con)
|
|
127
|
+
problem = (
|
|
128
|
+
f"{c.hit_points_max} hit points, outside what a level {level} {cls.name} with CON "
|
|
129
|
+
f"{con:+d} can have ({least} to {most}); by the averages, {average}"
|
|
130
|
+
)
|
|
131
|
+
return Finding(c.name, problem, "update_character hit_points_max", True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _expertise(c: Character) -> Finding | None:
|
|
135
|
+
"""A rogue's or a bard's Expertise (or thieves' tools for a rogue, hence not certain)."""
|
|
136
|
+
levels = EXPERTISE_LEVELS.get(rules.class_key(c.character_class.name) or "", ())
|
|
137
|
+
due = 2 * sum(c.character_class.level >= level for level in levels)
|
|
138
|
+
if len(c.skill_expertise) >= due:
|
|
139
|
+
return None
|
|
140
|
+
problem = f"Expertise in {due} skills by its level, has {len(c.skill_expertise)}"
|
|
141
|
+
return Finding(c.name, problem, "update_character expertise", False)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _gear(c: Character) -> Finding | None:
|
|
145
|
+
items = [*c.inventory, *(i for i in c.equipment.values() if i)]
|
|
146
|
+
if not items:
|
|
147
|
+
problem = "carries nothing, not even a weapon or a pack"
|
|
148
|
+
elif not any(i.item_type == "weapon" for i in items):
|
|
149
|
+
problem = "carries no weapon"
|
|
150
|
+
else:
|
|
151
|
+
return None
|
|
152
|
+
return Finding(c.name, problem, "add_item_to_character with its item_type", False)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _spells(c: Character) -> Finding | None:
|
|
156
|
+
if not any(c.spell_slots.values()) or c.spells_known:
|
|
157
|
+
return None
|
|
158
|
+
problem = "has spell slots but knows no spells, not even a cantrip"
|
|
159
|
+
return Finding(c.name, problem, "set_known_spell for each", True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _skills(c: Character) -> Finding | None:
|
|
163
|
+
if c.skill_proficiencies:
|
|
164
|
+
return None
|
|
165
|
+
problem = "no skill proficiencies (2 to 4 from the class, 2 from the background)"
|
|
166
|
+
return Finding(c.name, problem, "update_character skills", False)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _player(c: Character) -> Finding | None:
|
|
170
|
+
if c.player_name:
|
|
171
|
+
return None
|
|
172
|
+
problem = "no player: the roll call cannot seat it"
|
|
173
|
+
return Finding(c.name, problem, "update_character player_name", False)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
CHARACTER_CHECKS: tuple[Callable[[Character], Finding | None], ...] = (
|
|
177
|
+
_class,
|
|
178
|
+
_player,
|
|
179
|
+
_abilities,
|
|
180
|
+
_hit_points,
|
|
181
|
+
_armor,
|
|
182
|
+
_skills,
|
|
183
|
+
_expertise,
|
|
184
|
+
_gear,
|
|
185
|
+
_spells,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _bestiary(campaign: Campaign) -> list[Finding]:
|
|
190
|
+
found = [
|
|
191
|
+
Finding(m.name, "no actions: its attacks and their damage", "save_monster actions", False)
|
|
192
|
+
for m in campaign.monsters.values()
|
|
193
|
+
if not m.actions.strip()
|
|
194
|
+
]
|
|
195
|
+
levels = [c.character_class.level for c in campaign.characters.values()]
|
|
196
|
+
planned = [e for e in campaign.encounters.values() if e.status == "planned"]
|
|
197
|
+
return found + [f for e in planned if (f := _encounter(campaign, e, levels))]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _encounter(campaign: Campaign, encounter: Encounter, levels: list[int]) -> Finding | None:
|
|
201
|
+
"""A planned fight whose monsters are missing, or rated deadly or trivial for the party."""
|
|
202
|
+
subject = f"encounter {encounter.name}"
|
|
203
|
+
monsters = [(g, bestiary.monster_for(campaign, g.monster)) for g in encounter.monsters]
|
|
204
|
+
missing = [g.monster for g, m in monsters if m is None]
|
|
205
|
+
if missing:
|
|
206
|
+
return Finding(subject, f"{', '.join(missing)} not in the bestiary", "save_monster", True)
|
|
207
|
+
if not levels:
|
|
208
|
+
return None
|
|
209
|
+
xps = [m.xp for g, m in monsters if m for _ in range(g.count)]
|
|
210
|
+
rated = rules.encounter_difficulty(levels, xps).rating
|
|
211
|
+
if rated not in ("deadly", "trivial"):
|
|
212
|
+
return None
|
|
213
|
+
problem = f"{rated} for the whole party" + (", for a climax only" if rated == "deadly" else "")
|
|
214
|
+
return Finding(subject, problem, "plan_encounter with another count", False)
|
dndkit/game/reference.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Short rules reminders (D&D 5e 2014), paraphrased, findable by English or French name."""
|
|
2
|
+
|
|
3
|
+
from dndkit.errors import NotFoundError
|
|
4
|
+
from dndkit.text import normalize
|
|
5
|
+
|
|
6
|
+
RULES: dict[str, str] = {
|
|
7
|
+
"blinded": "Can't see; fails any check that needs sight. Attacks against it have "
|
|
8
|
+
"advantage, its own attacks have disadvantage.",
|
|
9
|
+
"charmed": "Can't attack the charmer or target it with harmful abilities or magic. "
|
|
10
|
+
"The charmer has advantage on social checks against it.",
|
|
11
|
+
"deafened": "Can't hear; fails any check that needs hearing.",
|
|
12
|
+
"frightened": "Disadvantage on ability checks and attack rolls while the source of its "
|
|
13
|
+
"fear is in sight; can't willingly move closer to it.",
|
|
14
|
+
"grappled": "Speed 0. Ends if the grappler is incapacitated or an effect moves the "
|
|
15
|
+
"creature out of reach. Escape: an action, Athletics or Acrobatics against the "
|
|
16
|
+
"grappler's Athletics.",
|
|
17
|
+
"incapacitated": "Can't take actions or reactions.",
|
|
18
|
+
"invisible": "Can't be seen without magic or a special sense. Attacks against it have "
|
|
19
|
+
"disadvantage, its own attacks have advantage.",
|
|
20
|
+
"paralyzed": "Incapacitated, can't move or speak; fails Strength and Dexterity saves. "
|
|
21
|
+
"Attacks against it have advantage, and a hit from within 5 feet is a critical hit.",
|
|
22
|
+
"petrified": "Turned to stone: incapacitated, can't move or speak, unaware. Attacks "
|
|
23
|
+
"against it have advantage; fails Strength and Dexterity saves; resistance to all "
|
|
24
|
+
"damage; immune to poison and disease (any already in it is suspended).",
|
|
25
|
+
"poisoned": "Disadvantage on attack rolls and ability checks.",
|
|
26
|
+
"prone": "Can only crawl, or stand up for half its movement. Disadvantage on its "
|
|
27
|
+
"attacks. Attacks against it have advantage within 5 feet, disadvantage beyond.",
|
|
28
|
+
"restrained": "Speed 0. Attacks against it have advantage, its attacks have "
|
|
29
|
+
"disadvantage; disadvantage on Dexterity saves.",
|
|
30
|
+
"stunned": "Incapacitated, can't move, speaks falteringly; fails Strength and Dexterity "
|
|
31
|
+
"saves. Attacks against it have advantage.",
|
|
32
|
+
"unconscious": "Incapacitated, can't move or speak, unaware; drops what it holds and "
|
|
33
|
+
"falls prone; fails Strength and Dexterity saves. Attacks against it have advantage, "
|
|
34
|
+
"and a hit from within 5 feet is a critical hit.",
|
|
35
|
+
"exhaustion": "Levels add up: 1 disadvantage on ability checks; 2 speed halved; "
|
|
36
|
+
"3 disadvantage on attacks and saves; 4 hit point maximum halved; 5 speed 0; "
|
|
37
|
+
"6 death. A long rest with food and drink removes one level.",
|
|
38
|
+
"advantage": "Roll two d20 and keep the higher (disadvantage: the lower). Several "
|
|
39
|
+
"sources don't stack; having both cancels them out.",
|
|
40
|
+
"death saves": "At 0 HP, roll a d20 at the start of each turn: 10 or more is a "
|
|
41
|
+
"success, less a failure. Three successes: stable. Three failures: dead. A 1 counts "
|
|
42
|
+
"as two failures; a 20 brings the creature back with 1 HP. Taking damage at 0 HP is "
|
|
43
|
+
"a failure (two on a critical hit). Damage left over after 0 HP that equals or "
|
|
44
|
+
"exceeds the hit point maximum kills outright.",
|
|
45
|
+
"stabilizing": "An action and a DC 10 Wisdom (Medicine) check stabilize a creature at "
|
|
46
|
+
"0 HP. A stable creature regains 1 HP after 1d4 hours.",
|
|
47
|
+
"short rest": "At least one hour. Each character may spend Hit Dice: roll each, add "
|
|
48
|
+
"the Constitution modifier, regain that many hit points. Warlocks regain pact slots.",
|
|
49
|
+
"long rest": "At least eight hours, once per 24 hours, starting with at least 1 HP: "
|
|
50
|
+
"regain all hit points, spell slots and half the total Hit Dice (at least one).",
|
|
51
|
+
"cover": "Half cover: +2 to AC and Dexterity saves. Three-quarters cover: +5. Total "
|
|
52
|
+
"cover: can't be targeted directly.",
|
|
53
|
+
"concentration": "One concentration spell at a time. Taking damage calls for a "
|
|
54
|
+
"Constitution save, DC 10 or half the damage if higher; failing ends the spell. "
|
|
55
|
+
"Being incapacitated or killed ends it too.",
|
|
56
|
+
"opportunity attack": "When a visible enemy leaves your reach, you may use your "
|
|
57
|
+
"reaction for one melee attack. The Disengage action, teleporting or being moved "
|
|
58
|
+
"against one's will doesn't provoke it.",
|
|
59
|
+
"grapple": "Replaces one attack: Athletics against the target's Athletics or "
|
|
60
|
+
"Acrobatics; the target is at most one size larger. Success: the target is grappled.",
|
|
61
|
+
"shove": "Replaces one attack: Athletics against Athletics or Acrobatics; the "
|
|
62
|
+
"target is at most one size larger. Success knocks it prone or pushes it 5 feet away.",
|
|
63
|
+
"surprise": "Creatures that notice no threat at the start are surprised: no move, "
|
|
64
|
+
"action or reaction on their first turn.",
|
|
65
|
+
"initiative": "Each combatant makes a Dexterity check (d20 + Dexterity modifier); "
|
|
66
|
+
"turns go from highest to lowest. The game master settles ties.",
|
|
67
|
+
"actions": "In combat a creature can Attack, Cast a Spell, Dash (extra movement), "
|
|
68
|
+
"Disengage (no opportunity attacks), Dodge (attacks against it have disadvantage), "
|
|
69
|
+
"Help (an ally gains advantage), Hide, Ready a reaction, Search or Use an Object, plus "
|
|
70
|
+
"its movement and possibly one bonus action and one reaction per round.",
|
|
71
|
+
"two-weapon fighting": "After attacking with a light melee weapon, a bonus action "
|
|
72
|
+
"attack with another light melee weapon; no ability modifier to its damage unless "
|
|
73
|
+
"negative.",
|
|
74
|
+
"critical hit": "A natural 20 on an attack roll always hits; roll all the damage dice "
|
|
75
|
+
"twice, then add modifiers once.",
|
|
76
|
+
"falling": "1d6 bludgeoning damage per 10 feet fallen, up to 20d6; the creature lands prone.",
|
|
77
|
+
"suffocating": "Breath can be held for 1 + Constitution modifier minutes (at least 30 "
|
|
78
|
+
"seconds); after that, the creature lasts Constitution modifier rounds (at least 1) "
|
|
79
|
+
"before dropping to 0 HP.",
|
|
80
|
+
"difficult terrain": "Each foot of movement costs one extra foot.",
|
|
81
|
+
"obscured areas": "Lightly obscured (dim light, fog): disadvantage on Perception by "
|
|
82
|
+
"sight. Heavily obscured (darkness, thick fog): effectively blinded.",
|
|
83
|
+
"inspiration": "A character with inspiration may spend it for advantage on one attack "
|
|
84
|
+
"roll, saving throw or ability check.",
|
|
85
|
+
"encounter difficulty": "Add the monsters' XP, multiply by a factor for their number "
|
|
86
|
+
"(x1 for one, x1.5 for two, x2 for 3-6, x2.5 for 7-10, x3 for 11-14, x4 for 15+), "
|
|
87
|
+
"and compare with the party's easy, medium, hard and deadly thresholds.",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
ALIASES: dict[str, str] = {
|
|
91
|
+
"aveugle": "blinded", "charme": "charmed", "assourdi": "deafened", "sourd": "deafened",
|
|
92
|
+
"effraye": "frightened", "terrorise": "frightened", "agrippe": "grappled",
|
|
93
|
+
"empoigne": "grappled", "neutralise": "incapacitated", "incapable d'agir": "incapacitated",
|
|
94
|
+
"paralyse": "paralyzed", "petrifie": "petrified", "empoisonne": "poisoned",
|
|
95
|
+
"a terre": "prone", "entrave": "restrained", "etourdi": "stunned",
|
|
96
|
+
"inconscient": "unconscious", "epuisement": "exhaustion", "desavantage": "advantage",
|
|
97
|
+
"disadvantage": "advantage", "jets contre la mort": "death saves",
|
|
98
|
+
"jet contre la mort": "death saves", "death saving throws": "death saves",
|
|
99
|
+
"stabiliser": "stabilizing", "repos court": "short rest", "repos long": "long rest",
|
|
100
|
+
"abri": "cover", "couverture": "cover", "attaque d'opportunite": "opportunity attack",
|
|
101
|
+
"lutte": "grapple", "agripper": "grapple", "bousculer": "shove", "pousser": "shove",
|
|
102
|
+
"combat a deux armes": "two-weapon fighting", "coup critique": "critical hit",
|
|
103
|
+
"critique": "critical hit", "chute": "falling", "suffocation": "suffocating",
|
|
104
|
+
"asphyxie": "suffocating", "terrain difficile": "difficult terrain",
|
|
105
|
+
"visibilite": "obscured areas", "obscurite": "obscured areas",
|
|
106
|
+
"actions en combat": "actions", "difficulte des rencontres": "encounter difficulty",
|
|
107
|
+
} # fmt: skip
|
|
108
|
+
|
|
109
|
+
_INDEX = {normalize(key): key for key in RULES} | {
|
|
110
|
+
normalize(alias): key for alias, key in ALIASES.items()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def lookup(topic: str) -> tuple[str, str]:
|
|
115
|
+
"""The rule a topic names, as (topic, text); plural and French names work too."""
|
|
116
|
+
wanted = normalize(topic)
|
|
117
|
+
key = _INDEX.get(wanted) or _INDEX.get(wanted.rstrip("s"))
|
|
118
|
+
if key is None:
|
|
119
|
+
matches = [k for name, k in _INDEX.items() if wanted and wanted in name]
|
|
120
|
+
key = matches[0] if len(set(matches)) == 1 else None
|
|
121
|
+
if key is None:
|
|
122
|
+
raise NotFoundError(f"No rule note on '{topic}'. Topics: {', '.join(RULES)}.")
|
|
123
|
+
return key, RULES[key]
|
dndkit/game/registry.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Find, add, change and remove campaign entities by ID or by spoken name.
|
|
2
|
+
|
|
3
|
+
A voice transcript rarely spells a name the way it was typed ("aria",
|
|
4
|
+
"Élodie" heard as "Elodie"), so names match without case or accents. When
|
|
5
|
+
nothing matches, the error lists the known names so the model can retry.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import difflib
|
|
9
|
+
from collections.abc import Iterable, Mapping, MutableMapping
|
|
10
|
+
from typing import Any, Protocol, cast
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ValidationError
|
|
13
|
+
|
|
14
|
+
from dndkit.errors import AlreadyExistsError, InvalidChangeError, NotFoundError
|
|
15
|
+
from dndkit.text import normalize as normalize # callers use registry.normalize
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Spoken(Protocol):
|
|
19
|
+
"""Anything with a display name."""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def name(self) -> str: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Named(Spoken, Protocol):
|
|
26
|
+
"""An entity with an ID and a display name."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def id(self) -> str: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def known_names(entities: Iterable[Named]) -> str:
|
|
33
|
+
"""Sorted names for an error message."""
|
|
34
|
+
names = sorted(entity.name for entity in entities)
|
|
35
|
+
return ", ".join(names) if names else "none yet"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find[E: Named](items: Mapping[str, E], ref: str, kind: str, hint: str = "") -> E:
|
|
39
|
+
"""The entity whose ID or name is *ref*, or the only one whose name contains it.
|
|
40
|
+
|
|
41
|
+
Spoken names get shortened ("the lighthouse" for "The Dark Lighthouse"): a
|
|
42
|
+
part of a name (3 characters or more) is enough when no other name shares it.
|
|
43
|
+
"""
|
|
44
|
+
found = find_exact(items, ref)
|
|
45
|
+
if found is not None:
|
|
46
|
+
return found
|
|
47
|
+
partial = partial_matches(items.values(), ref)
|
|
48
|
+
if len(partial) == 1:
|
|
49
|
+
return partial[0]
|
|
50
|
+
near = did_you_mean([e.name for e in items.values()], ref)
|
|
51
|
+
known = known_names(items.values())
|
|
52
|
+
raise NotFoundError(f"No {kind} named '{ref}'.{near} Known {kind}s: {known}.{hint}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def closest(names: Iterable[str], ref: str) -> str | None:
|
|
56
|
+
"""The name *ref* reads like a slip for (a translated name, a misheard one), if
|
|
57
|
+
one is close enough. Only ever a hint: the model chooses, nothing is guessed."""
|
|
58
|
+
by_key = {normalize(name): name for name in names}
|
|
59
|
+
close = difflib.get_close_matches(normalize(ref), list(by_key), n=1, cutoff=0.75)
|
|
60
|
+
return by_key[close[0]] if close else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def did_you_mean(names: Iterable[str], ref: str) -> str:
|
|
64
|
+
""" " Did you mean 'Gobelin'?", or nothing."""
|
|
65
|
+
near = closest(names, ref)
|
|
66
|
+
return f" Did you mean '{near}'?" if near else ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def quoted(names: Iterable[str], ref: str) -> str:
|
|
70
|
+
""" "'Goblin' (did you mean 'Gobelin'?)", or "'Goblin'": an unknown name in a list."""
|
|
71
|
+
near = closest(names, ref)
|
|
72
|
+
return f"'{ref}' (did you mean '{near}'?)" if near else f"'{ref}'"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def find_exact[E: Named](items: Mapping[str, E], ref: str) -> E | None:
|
|
76
|
+
"""The entity whose ID or whole name is *ref*, or None. Creating or linking
|
|
77
|
+
goes by this: "Wolf" must not update or become the "Dire Wolf"."""
|
|
78
|
+
if ref in items:
|
|
79
|
+
return items[ref]
|
|
80
|
+
wanted = normalize(ref)
|
|
81
|
+
return next((e for e in items.values() if normalize(e.name) == wanted), None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def partial_matches[E: Spoken](entities: Iterable[E], ref: str) -> list[E]:
|
|
85
|
+
"""The entities whose name holds *ref* (3 characters or more)."""
|
|
86
|
+
wanted = normalize(ref)
|
|
87
|
+
if len(wanted) < 3:
|
|
88
|
+
return []
|
|
89
|
+
return [e for e in entities if wanted in normalize(e.name)]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def find_many[E: Named](
|
|
93
|
+
items: Mapping[str, E], refs: Iterable[str], kind: str, hint: str = ""
|
|
94
|
+
) -> list[E]:
|
|
95
|
+
"""Every entity in *refs*, or one error naming all the unknown ones."""
|
|
96
|
+
found: list[E] = []
|
|
97
|
+
missing: list[str] = []
|
|
98
|
+
for ref in refs:
|
|
99
|
+
try:
|
|
100
|
+
entity = find(items, ref, kind)
|
|
101
|
+
except NotFoundError:
|
|
102
|
+
missing.append(ref)
|
|
103
|
+
continue
|
|
104
|
+
if entity not in found:
|
|
105
|
+
found.append(entity)
|
|
106
|
+
if missing:
|
|
107
|
+
names = [e.name for e in items.values()]
|
|
108
|
+
unknown = ", ".join(quoted(names, ref) for ref in missing)
|
|
109
|
+
raise NotFoundError(
|
|
110
|
+
f"Unknown {kind}s: {unknown}. Known {kind}s: {known_names(items.values())}.{hint}"
|
|
111
|
+
)
|
|
112
|
+
return found
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ensure_name_free(
|
|
116
|
+
items: Mapping[str, Named], name: str, kind: str, *, exclude_id: str | None = None
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Refuse a name another entity of this kind already answers to."""
|
|
119
|
+
wanted = normalize(name)
|
|
120
|
+
if not wanted:
|
|
121
|
+
raise InvalidChangeError(f"A {kind} needs a name.")
|
|
122
|
+
for entity in items.values():
|
|
123
|
+
if entity.id != exclude_id and normalize(entity.name) == wanted:
|
|
124
|
+
raise AlreadyExistsError(
|
|
125
|
+
f"A {kind} named '{entity.name}' already exists (id {entity.id}). "
|
|
126
|
+
"Update that one, or choose another name."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def add[E: Named](items: MutableMapping[str, E], entity: E, kind: str) -> E:
|
|
131
|
+
"""Store a new entity under its ID; its name must be free."""
|
|
132
|
+
ensure_name_free(items, entity.name, kind)
|
|
133
|
+
items[entity.id] = entity
|
|
134
|
+
return entity
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def remove[E: Named](items: MutableMapping[str, E], ref: str, kind: str) -> E:
|
|
138
|
+
"""Remove and return the entity *ref* names."""
|
|
139
|
+
entity = find(items, ref, kind)
|
|
140
|
+
del items[entity.id]
|
|
141
|
+
return entity
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def revised[M: BaseModel](model: M, changes: Mapping[str, Any]) -> M:
|
|
145
|
+
"""A validated copy of *model* with *changes*; the original stays untouched."""
|
|
146
|
+
data = model.model_dump()
|
|
147
|
+
data.update(changes)
|
|
148
|
+
try:
|
|
149
|
+
return type(model).model_validate(data)
|
|
150
|
+
except ValidationError as exc:
|
|
151
|
+
problems = "; ".join(
|
|
152
|
+
f"{'.'.join(map(str, error['loc']))}: {error['msg']}" for error in exc.errors()
|
|
153
|
+
)
|
|
154
|
+
raise InvalidChangeError(f"Invalid change: {problems}.") from exc
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def update[E: Named](
|
|
158
|
+
items: MutableMapping[str, E],
|
|
159
|
+
ref: str,
|
|
160
|
+
kind: str,
|
|
161
|
+
changes: Mapping[str, Any],
|
|
162
|
+
*,
|
|
163
|
+
name_field: str = "name",
|
|
164
|
+
) -> E:
|
|
165
|
+
"""Apply *changes* to the entity *ref* names, keeping names unique."""
|
|
166
|
+
entity = find(items, ref, kind)
|
|
167
|
+
new_name = changes.get(name_field)
|
|
168
|
+
if new_name is not None:
|
|
169
|
+
ensure_name_free(items, new_name, kind, exclude_id=entity.id)
|
|
170
|
+
updated = cast(E, revised(cast(BaseModel, entity), changes))
|
|
171
|
+
items[entity.id] = updated
|
|
172
|
+
return updated
|
dndkit/game/resources.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""What characters carry and how they hang on: items, death saves."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from dndkit.errors import InvalidChangeError, NotFoundError
|
|
6
|
+
from dndkit.game import characters, registry
|
|
7
|
+
from dndkit.models import Campaign, Character, Item, now
|
|
8
|
+
|
|
9
|
+
DeathSave = Literal["success", "failure", "nat20", "nat1", "stabilized"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_item(campaign: Campaign, ref: str, item: Item) -> tuple[Character, Item]:
|
|
13
|
+
"""Give an item; one already held (same name and type) stacks."""
|
|
14
|
+
character = characters.find(campaign, ref)
|
|
15
|
+
held = _held(character, item.name)
|
|
16
|
+
if held is not None and held.item_type == item.item_type:
|
|
17
|
+
held.quantity += item.quantity
|
|
18
|
+
item = held
|
|
19
|
+
else:
|
|
20
|
+
character.inventory.append(item)
|
|
21
|
+
character.updated_at = now()
|
|
22
|
+
return character, item
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def remove_item(
|
|
26
|
+
campaign: Campaign, ref: str, item_name: str, quantity: int
|
|
27
|
+
) -> tuple[Character, Item, int]:
|
|
28
|
+
"""Take *quantity* of an item; returns the character, the item and what is left."""
|
|
29
|
+
character = characters.find(campaign, ref)
|
|
30
|
+
held = _held(character, item_name)
|
|
31
|
+
if held is None:
|
|
32
|
+
raise NotFoundError(
|
|
33
|
+
f"{character.name} has no '{item_name}'. "
|
|
34
|
+
f"Inventory: {registry.known_names(character.inventory)}."
|
|
35
|
+
)
|
|
36
|
+
if quantity > held.quantity:
|
|
37
|
+
raise InvalidChangeError(f"{character.name} has only {held.quantity} x {held.name}.")
|
|
38
|
+
remaining = held.quantity - quantity
|
|
39
|
+
if remaining:
|
|
40
|
+
held.quantity = remaining
|
|
41
|
+
else:
|
|
42
|
+
character.inventory = [item for item in character.inventory if item is not held]
|
|
43
|
+
character.updated_at = now()
|
|
44
|
+
return character, held, remaining
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _held(character: Character, name: str) -> Item | None:
|
|
48
|
+
wanted = registry.normalize(name)
|
|
49
|
+
return next((i for i in character.inventory if registry.normalize(i.name) == wanted), None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def record_death_save(campaign: Campaign, ref: str, outcome: DeathSave) -> str:
|
|
53
|
+
"""Record one death saving throw (PHB ch. 9) and say where it leaves the character."""
|
|
54
|
+
character = characters.find(campaign, ref)
|
|
55
|
+
if character.hit_points_current > 0:
|
|
56
|
+
raise InvalidChangeError(
|
|
57
|
+
f"{character.name} has {character.hit_points_current} HP; "
|
|
58
|
+
"death saves are rolled at 0 HP only."
|
|
59
|
+
)
|
|
60
|
+
if character.death_saves_failure >= 3:
|
|
61
|
+
raise InvalidChangeError(f"{character.name} is dead (3 failed death saves).")
|
|
62
|
+
if character.stable:
|
|
63
|
+
raise InvalidChangeError(
|
|
64
|
+
f"{character.name} is stable: no more death saves. They wake with 1 HP after "
|
|
65
|
+
"1d4 hours, or when healed."
|
|
66
|
+
)
|
|
67
|
+
character.updated_at = now()
|
|
68
|
+
if outcome == "stabilized": # Spare the Dying, a Medicine check (DC 10)
|
|
69
|
+
reset_death_saves(character)
|
|
70
|
+
character.stable = True
|
|
71
|
+
return f"{character.name} is stable at 0 HP: no more death saves."
|
|
72
|
+
if outcome == "nat20":
|
|
73
|
+
character.hit_points_current = 1
|
|
74
|
+
character.conditions = [c for c in character.conditions if c != "unconscious"]
|
|
75
|
+
reset_death_saves(character)
|
|
76
|
+
return f"{character.name} rolls a natural 20, regains 1 HP and is conscious."
|
|
77
|
+
if outcome == "success":
|
|
78
|
+
character.death_saves_success += 1
|
|
79
|
+
else:
|
|
80
|
+
failures = 2 if outcome == "nat1" else 1
|
|
81
|
+
character.death_saves_failure = min(3, character.death_saves_failure + failures)
|
|
82
|
+
if character.death_saves_failure >= 3:
|
|
83
|
+
return f"{character.name} dies (3 failed death saves)."
|
|
84
|
+
if character.death_saves_success >= 3:
|
|
85
|
+
reset_death_saves(character)
|
|
86
|
+
character.stable = True
|
|
87
|
+
return f"{character.name} is stable at 0 HP: no more death saves."
|
|
88
|
+
return (
|
|
89
|
+
f"{character.name}: {character.death_saves_success} successes, "
|
|
90
|
+
f"{character.death_saves_failure} failures."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def reset_death_saves(character: Character) -> None:
|
|
95
|
+
character.death_saves_success = 0
|
|
96
|
+
character.death_saves_failure = 0
|