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
@@ -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)
@@ -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]
@@ -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
@@ -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