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/journal.py ADDED
@@ -0,0 +1,47 @@
1
+ """Session notes and the campaign's adventure log."""
2
+
3
+ from dndkit.models import AdventureEvent, Campaign, EventType, SessionNote
4
+ from dndkit.text import normalize
5
+
6
+
7
+ def add_session_note(campaign: Campaign, note: SessionNote) -> SessionNote:
8
+ """Record a session summary."""
9
+ campaign.sessions.append(note)
10
+ return note
11
+
12
+
13
+ def sessions(campaign: Campaign) -> list[SessionNote]:
14
+ """Session notes in play order."""
15
+ return sorted(campaign.sessions, key=lambda note: (note.session_number, note.date))
16
+
17
+
18
+ def add_event(campaign: Campaign, event: AdventureEvent) -> AdventureEvent:
19
+ """Log an event; it belongs to the current session unless it says otherwise."""
20
+ if event.session_number is None:
21
+ event.session_number = campaign.game_state.current_session
22
+ campaign.events.append(event)
23
+ return event
24
+
25
+
26
+ def find_events(
27
+ campaign: Campaign,
28
+ *,
29
+ event_type: EventType | None = None,
30
+ search: str | None = None,
31
+ limit: int | None = None,
32
+ ) -> list[AdventureEvent]:
33
+ """Events newest first, filtered by type and by text in title, description or tags."""
34
+ events = campaign.events
35
+ if event_type is not None:
36
+ events = [e for e in events if e.event_type == event_type]
37
+ if search:
38
+ needle = normalize(search)
39
+ events = [
40
+ e
41
+ for e in events
42
+ if needle in normalize(e.title)
43
+ or needle in normalize(e.description)
44
+ or any(needle in normalize(tag) for tag in e.tags)
45
+ ]
46
+ events = sorted(events, key=lambda e: e.timestamp, reverse=True)
47
+ return events[:limit] if limit else events
@@ -0,0 +1,264 @@
1
+ """Moving a combatant on the battle map by the 5e rules (PHB ch. 9).
2
+
3
+ A turn's movement is the creature's speed, doubled by Dash: 0 when grappled,
4
+ restrained, stunned, paralyzed, petrified or unconscious, halved from
5
+ exhaustion level 2 and 0 from level 5. A prone mover stands up first, for
6
+ half its speed. A move to a square the mover cannot afford is refused; a move
7
+ toward someone goes as far as it can. Forced movement (a shove, a spell)
8
+ spends nothing and provokes nothing.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from dndkit.errors import InvalidChangeError
14
+ from dndkit.game import battlefield, combat, grid, pathing
15
+ from dndkit.game.grid import Square
16
+ from dndkit.game.pathing import Ground
17
+ from dndkit.models import Campaign, Combatant
18
+
19
+ HALTED = {"grappled", "restrained", "paralyzed", "petrified", "stunned", "unconscious"}
20
+ DEFAULT_SPEED = 30
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Move:
25
+ """What a move did, for the model to narrate."""
26
+
27
+ mover: Combatant
28
+ start: str
29
+ feet: int # movement spent; for forced movement, distance moved
30
+ left: int # movement left this turn
31
+ provoked: list[str]
32
+ stood_up: bool = False
33
+ forced: bool = False
34
+ short_of: tuple[str, int] | None = None # a creature still out of reach, and how far
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Order:
39
+ """Where a move goes: one of a square, toward someone, away from someone."""
40
+
41
+ to: str | None = None
42
+ toward: str | None = None
43
+ away_from: str | None = None
44
+ feet: int | None = None # at most this far
45
+
46
+
47
+ def speed(campaign: Campaign, combatant: Combatant) -> int:
48
+ """Walking speed now, conditions and exhaustion applied."""
49
+ character = campaign.characters.get(combatant.character_id or "")
50
+ if character is not None:
51
+ base, conditions, exhaustion = character.speed, character.conditions, character.exhaustion
52
+ else:
53
+ base = combatant.speed if combatant.speed is not None else DEFAULT_SPEED
54
+ conditions, exhaustion = combatant.conditions, 0
55
+ if HALTED & set(conditions) or exhaustion >= 5:
56
+ return 0
57
+ return base // 2 if exhaustion >= 2 else base
58
+
59
+
60
+ def movement_left(campaign: Campaign, combatant: Combatant) -> int:
61
+ """Feet of movement left this turn."""
62
+ allowed = speed(campaign, combatant) * (2 if combatant.dashed else 1)
63
+ return max(0, allowed - combatant.movement_used)
64
+
65
+
66
+ def move(
67
+ campaign: Campaign, who: str, order: Order, dash: bool, disengage: bool, forced: bool
68
+ ) -> Move:
69
+ """Move *who* as *order* says; the combatant's square and movement are updated."""
70
+ state = campaign.game_state
71
+ mover = combat.find(state, who)
72
+ start = battlefield.square_of(state, mover)
73
+ if start is None:
74
+ raise InvalidChangeError(f"{mover.name} is not on the map: place_tokens first.")
75
+ given = sum(target is not None for target in (order.to, order.toward, order.away_from))
76
+ if given != 1:
77
+ raise InvalidChangeError(
78
+ "Give only one of to, toward and away_from."
79
+ if given
80
+ else "Say where: to a square, toward someone or away_from someone."
81
+ )
82
+ if forced:
83
+ return _forced(campaign, mover, start, order)
84
+ mover.dashed = mover.dashed or dash
85
+ mover.disengaged = mover.disengaged or disengage
86
+ stood_up = _stand_up(campaign, mover)
87
+ if movement_left(campaign, mover) == 0:
88
+ raise InvalidChangeError(_stuck(campaign, mover))
89
+ ground = Ground(campaign, mover, provokes=not mover.disengaged)
90
+ found = pathing.routes(ground, start)
91
+ limit = movement_left(campaign, mover)
92
+ if order.feet is not None:
93
+ limit = min(limit, order.feet)
94
+ end, short_of = _destination(campaign, ground, found, start, order, limit)
95
+ route = pathing.path(found, end)
96
+ mover.position = grid.name(end)
97
+ mover.movement_used += found[end].feet
98
+ return Move(
99
+ mover=mover,
100
+ start=grid.name(start),
101
+ feet=found[end].feet,
102
+ left=movement_left(campaign, mover),
103
+ provoked=pathing.provoked_along(ground, route),
104
+ stood_up=stood_up,
105
+ short_of=short_of,
106
+ )
107
+
108
+
109
+ def _destination(
110
+ campaign: Campaign,
111
+ ground: Ground,
112
+ found: dict[Square, pathing.Route],
113
+ start: Square,
114
+ order: Order,
115
+ limit: int,
116
+ ) -> tuple[Square, tuple[str, int] | None]:
117
+ if order.to is not None:
118
+ return _to_square(ground, found, order.to, limit), None
119
+ if order.toward is not None:
120
+ return _toward(campaign, ground, found, start, order.toward, limit)
121
+ away = _held(campaign, order.away_from or "")
122
+ reachable = [s for s, r in found.items() if r.feet <= limit and ground.standable(s)]
123
+ best = max(
124
+ [start, *reachable],
125
+ key=lambda s: (grid.steps_between(list(ground.squares(s)), away), -found[s].feet),
126
+ )
127
+ return best, None
128
+
129
+
130
+ def _to_square(ground: Ground, found: dict[Square, pathing.Route], to: str, limit: int) -> Square:
131
+ target = grid.parse(to, ground.field)
132
+ side = grid.SIZE_SQUARES[ground.mover.size]
133
+ options = [
134
+ (target[0] - dx, target[1] - dy)
135
+ for dx in range(side)
136
+ for dy in range(side)
137
+ if (target[0] - dx, target[1] - dy) in found
138
+ and ground.standable((target[0] - dx, target[1] - dy))
139
+ ]
140
+ name = ground.mover.name
141
+ if not options:
142
+ raise InvalidChangeError(f"{name} cannot get to {to.upper()}: it is blocked or taken.")
143
+ best = min(options, key=lambda s: (found[s].feet, found[s].provocations))
144
+ if found[best].feet > limit:
145
+ raise InvalidChangeError(
146
+ f"{to.upper()} is {found[best].feet} feet of movement away; {name} has {limit} left. "
147
+ "Move toward it, or Dash."
148
+ )
149
+ return best
150
+
151
+
152
+ def _toward(
153
+ campaign: Campaign,
154
+ ground: Ground,
155
+ found: dict[Square, pathing.Route],
156
+ start: Square,
157
+ toward: str,
158
+ limit: int,
159
+ ) -> tuple[Square, tuple[str, int] | None]:
160
+ target = combat.find(campaign.game_state, toward)
161
+ held = _held(campaign, toward)
162
+ reach = max(ground.mover.reach, grid.FEET)
163
+
164
+ def gap(s: Square) -> int:
165
+ return grid.feet_between(list(ground.squares(s)), held)
166
+
167
+ def _offset(s: Square) -> float:
168
+ mine = list(ground.squares(s))
169
+ return sum((_centre(mine, axis) - _centre(held, axis)) ** 2 for axis in (0, 1))
170
+
171
+ if gap(start) <= reach:
172
+ return start, None
173
+ near = [s for s in found if ground.standable(s) and gap(s) <= reach]
174
+ if near: # cheapest first; of equals, the one facing the target squarely
175
+ goal = min(near, key=lambda s: (found[s].feet, found[s].provocations, _offset(s)))
176
+ if found[goal].feet <= limit:
177
+ return goal, None
178
+ else: # walled off: head for the closest square there is
179
+ goal = min(found, key=lambda s: (gap(s), found[s].feet))
180
+ end = start
181
+ for square in pathing.path(found, goal):
182
+ if found[square].feet > limit:
183
+ break
184
+ if ground.standable(square):
185
+ end = square
186
+ return end, (target.name, gap(end))
187
+
188
+
189
+ def _stuck(campaign: Campaign, mover: Combatant) -> str:
190
+ character = campaign.characters.get(mover.character_id or "")
191
+ conditions = set(character.conditions if character else mover.conditions)
192
+ halted = sorted(HALTED & conditions)
193
+ if halted:
194
+ return f"{mover.name} cannot move while {', '.join(halted)}."
195
+ if speed(campaign, mover) == 0:
196
+ return f"{mover.name} has a speed of 0."
197
+ return f"{mover.name} has no movement left this turn (Dash doubles it)."
198
+
199
+
200
+ def _held(campaign: Campaign, name: str) -> list[Square]:
201
+ state = campaign.game_state
202
+ other = combat.find(state, name)
203
+ held = battlefield.footprint_of(state, other)
204
+ if held is None:
205
+ raise InvalidChangeError(f"{other.name} is not on the map: place_tokens first.")
206
+ return held
207
+
208
+
209
+ def _stand_up(campaign: Campaign, mover: Combatant) -> bool:
210
+ character = campaign.characters.get(mover.character_id or "")
211
+ conditions = character.conditions if character else mover.conditions
212
+ if "prone" not in conditions:
213
+ return False
214
+ cost = speed(campaign, mover) // 2
215
+ if speed(campaign, mover) == 0 or movement_left(campaign, mover) < cost:
216
+ raise InvalidChangeError(f"{mover.name} is prone and lacks the movement to stand up.")
217
+ mover.movement_used += cost
218
+ standing = [c for c in conditions if c != "prone"]
219
+ if character is not None:
220
+ character.conditions = standing
221
+ else:
222
+ mover.conditions = standing
223
+ return True
224
+
225
+
226
+ def _forced(campaign: Campaign, mover: Combatant, start: Square, order: Order) -> Move:
227
+ ground = Ground(campaign, mover, provokes=False)
228
+ if order.to is not None:
229
+ end = grid.parse(order.to, ground.field)
230
+ if not ground.standable(end):
231
+ raise InvalidChangeError(f"{mover.name} cannot be put on {order.to.upper()}.")
232
+ else:
233
+ source = _held(campaign, order.away_from or order.toward or "")
234
+ away = 1 if order.away_from is not None else -1
235
+ end = _push(ground, start, source, away, (order.feet or grid.FEET) // grid.FEET)
236
+ mover.position = grid.name(end)
237
+ moved = grid.steps_between([start], [end]) * grid.FEET
238
+ left = movement_left(campaign, mover)
239
+ return Move(
240
+ mover=mover, start=grid.name(start), feet=moved, left=left, provoked=[], forced=True
241
+ )
242
+
243
+
244
+ def _push(ground: Ground, start: Square, source: list[Square], away: int, steps: int) -> Square:
245
+ mine = list(ground.squares(start))
246
+ dx, dy = (_sign(_centre(mine, axis) - _centre(source, axis)) * away for axis in (0, 1))
247
+ if not (dx or dy):
248
+ raise InvalidChangeError("They stand on the same spot: say where they go with to.")
249
+ here = start
250
+ for _ in range(steps):
251
+ there = (here[0] + dx, here[1] + dy)
252
+ if not ground.standable(there): # a wall, the edge, or someone in the way
253
+ break
254
+ here = there
255
+ return here
256
+
257
+
258
+ def _centre(squares: list[Square], axis: int) -> float:
259
+ values = [s[axis] for s in squares]
260
+ return (min(values) + max(values)) / 2
261
+
262
+
263
+ def _sign(value: float) -> int:
264
+ return (value > 0) - (value < 0)
dndkit/game/pathing.py ADDED
@@ -0,0 +1,153 @@
1
+ """Routes across the battle map for one mover (PHB ch. 9, "Movement and Position").
2
+
3
+ Entering a square costs 5 feet, diagonals included, and 10 in difficult
4
+ terrain or in another creature's space. A mover passes through allies, and
5
+ through foes only when two sizes apart; it never ends a move on anyone, and
6
+ never squeezes diagonally between two walls. Leaving a foe's reach provokes
7
+ an opportunity attack unless the mover Disengaged or is moved by force; of
8
+ two routes as short, the one provoking fewer is taken.
9
+ """
10
+
11
+ import heapq
12
+ from dataclasses import dataclass
13
+
14
+ from dndkit.game import battlefield, grid
15
+ from dndkit.game.grid import Square
16
+ from dndkit.models import Campaign, Combatant
17
+
18
+ NO_REACTION = {"incapacitated", "paralyzed", "petrified", "stunned", "unconscious"}
19
+ DIRECTIONS = [(dx, dy) for dx in (-1, 0, 1) for dy in (-1, 0, 1) if dx or dy]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Other:
24
+ """Another creature on the map, as the mover meets it."""
25
+
26
+ combatant: Combatant
27
+ squares: frozenset[Square]
28
+ passable: bool # the mover may cross its space
29
+ threat_reach: int | None # feet of reach in which leaving provokes; None: never
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Route:
34
+ """The cheapest way found to a square."""
35
+
36
+ feet: int
37
+ provocations: int
38
+ previous: Square | None
39
+
40
+
41
+ class Ground:
42
+ """The map as one mover sees it: walls, difficult terrain, the others."""
43
+
44
+ def __init__(
45
+ self, campaign: Campaign, mover: Combatant, provokes: bool, as_players_see: bool = False
46
+ ) -> None:
47
+ state = campaign.game_state
48
+ self.field = battlefield.require(state)
49
+ self.walls = battlefield.wall_squares(self.field)
50
+ self.difficult = battlefield.difficult_squares(self.field)
51
+ self.mover = mover
52
+ self.others = [
53
+ _meet(campaign, mover, other, held, provokes)
54
+ for other, held in battlefield.others(state, mover)
55
+ # The players' screens must not give a hidden foe away by a hole in the map.
56
+ if not (as_players_see and other.hidden and not battlefield.is_party(other))
57
+ ]
58
+
59
+ def squares(self, top_left: Square) -> frozenset[Square]:
60
+ """What the mover holds standing at *top_left*."""
61
+ return frozenset(grid.footprint(top_left, self.mover.size))
62
+
63
+ def crossable(self, top_left: Square) -> bool:
64
+ """Whether the mover may pass through *top_left*."""
65
+ held = self.squares(top_left)
66
+ if any(not grid.inside(s, self.field) or s in self.walls for s in held):
67
+ return False
68
+ return all(o.passable or not held & o.squares for o in self.others)
69
+
70
+ def standable(self, top_left: Square) -> bool:
71
+ """Whether the mover may end its move at *top_left*."""
72
+ held = self.squares(top_left)
73
+ return self.crossable(top_left) and not any(held & o.squares for o in self.others)
74
+
75
+ def step_feet(self, top_left: Square) -> int:
76
+ """What entering *top_left* costs."""
77
+ held = self.squares(top_left)
78
+ crowded = any(held & o.squares for o in self.others)
79
+ return 2 * grid.FEET if crowded or held & self.difficult else grid.FEET
80
+
81
+ def provoked(self, here: Square, there: Square) -> list[Combatant]:
82
+ """The foes whose reach the step from *here* to *there* leaves."""
83
+ before, after = list(self.squares(here)), list(self.squares(there))
84
+ return [
85
+ o.combatant
86
+ for o in self.others
87
+ if o.threat_reach is not None
88
+ and grid.feet_between(before, list(o.squares)) <= o.threat_reach
89
+ and grid.feet_between(after, list(o.squares)) > o.threat_reach
90
+ ]
91
+
92
+ def cuts_corner(self, here: Square, there: Square) -> bool:
93
+ """A diagonal step between two blocked squares."""
94
+ (x1, y1), (x2, y2) = here, there
95
+ if x1 == x2 or y1 == y2:
96
+ return False
97
+ return not self.crossable((x2, y1)) and not self.crossable((x1, y2))
98
+
99
+
100
+ def routes(ground: Ground, start: Square) -> dict[Square, Route]:
101
+ """The cheapest route to every square the mover can reach (Dijkstra)."""
102
+ best = {start: Route(0, 0, None)}
103
+ queue = [(0, 0, start)]
104
+ while queue:
105
+ feet, provocations, here = heapq.heappop(queue)
106
+ if (feet, provocations) > (best[here].feet, best[here].provocations):
107
+ continue
108
+ for dx, dy in DIRECTIONS:
109
+ there = (here[0] + dx, here[1] + dy)
110
+ if not ground.crossable(there) or ground.cuts_corner(here, there):
111
+ continue
112
+ cost = (
113
+ feet + ground.step_feet(there),
114
+ provocations + len(ground.provoked(here, there)),
115
+ )
116
+ known = best.get(there)
117
+ if known is None or cost < (known.feet, known.provocations):
118
+ best[there] = Route(cost[0], cost[1], here)
119
+ heapq.heappush(queue, (cost[0], cost[1], there))
120
+ return best
121
+
122
+
123
+ def path(found: dict[Square, Route], end: Square) -> list[Square]:
124
+ """The squares from the start to *end*, both included."""
125
+ squares = [end]
126
+ while (previous := found[squares[-1]].previous) is not None:
127
+ squares.append(previous)
128
+ return squares[::-1]
129
+
130
+
131
+ def provoked_along(ground: Ground, squares: list[Square]) -> list[str]:
132
+ """Who gets an opportunity attack along *squares*, each named once."""
133
+ names: list[str] = []
134
+ for here, there in zip(squares, squares[1:], strict=False):
135
+ names += [c.name for c in ground.provoked(here, there) if c.name not in names]
136
+ return names
137
+
138
+
139
+ def _meet(
140
+ campaign: Campaign, mover: Combatant, other: Combatant, held: list[Square], provokes: bool
141
+ ) -> Other:
142
+ hostile = battlefield.is_party(other) != battlefield.is_party(mover)
143
+ apart = abs(grid.SIZE_RANK[other.size] - grid.SIZE_RANK[mover.size]) >= 2
144
+ reacts = provokes and hostile and not other.reaction_used and can_react(campaign, other)
145
+ return Other(other, frozenset(held), not hostile or apart, other.reach if reacts else None)
146
+
147
+
148
+ def can_react(campaign: Campaign, combatant: Combatant) -> bool:
149
+ """Whether *combatant* can take a reaction: conscious, not incapacitated."""
150
+ character = campaign.characters.get(combatant.character_id or "")
151
+ conditions = set(character.conditions if character else combatant.conditions)
152
+ hit_points = character.hit_points_current if character else combatant.hit_points
153
+ return not conditions & NO_REACTION and hit_points != 0
@@ -0,0 +1,104 @@
1
+ """Skill and saving throw proficiencies (PHB ch. 7): the names players say, in
2
+ English or French, and the bonus each gives (ability modifier + proficiency,
3
+ twice the proficiency with Expertise)."""
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+
8
+ from dndkit.errors import InvalidChangeError
9
+ from dndkit.game import rules
10
+ from dndkit.game.tables import SKILL_ALIASES, SKILLS
11
+ from dndkit.models import Character
12
+ from dndkit.text import normalize
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Proficiency:
17
+ """A proficient skill or saving throw and its bonus; None for a skill the rules
18
+ do not know (a sheet from elsewhere)."""
19
+
20
+ name: str
21
+ bonus: int | None
22
+
23
+
24
+ def skill_name(text: str) -> str | None:
25
+ """ "stealth", "Discrétion" -> "Stealth"; None for anything else."""
26
+ wanted = normalize(text)
27
+ found = next((skill for skill in SKILLS if normalize(skill) == wanted), None)
28
+ return found or SKILL_ALIASES.get(wanted)
29
+
30
+
31
+ def skills(names: Sequence[str]) -> list[str]:
32
+ """The skills *names* say, by their English names; one error lists the unknown."""
33
+ found = {name: skill_name(name) for name in names}
34
+ unknown = [name for name, skill in found.items() if skill is None]
35
+ if unknown:
36
+ raise InvalidChangeError(
37
+ f"Unknown skills: {', '.join(unknown)}. Skills (English or French names): "
38
+ f"{', '.join(SKILLS)}."
39
+ )
40
+ return list(dict.fromkeys(skill for skill in found.values() if skill))
41
+
42
+
43
+ def saves(names: Sequence[str]) -> list[str]:
44
+ """The abilities *names* say ("DEX", "Sagesse"); one error lists the unknown."""
45
+ found = {name: rules.ability_name(name) for name in names}
46
+ unknown = [name for name, ability in found.items() if ability is None]
47
+ if unknown:
48
+ raise InvalidChangeError(f"Unknown saving throws: {', '.join(unknown)}; use STR to CHA.")
49
+ return list(dict.fromkeys(ability for ability in found.values() if ability))
50
+
51
+
52
+ def expertise(names: Sequence[str], proficient: Sequence[str]) -> list[str]:
53
+ """The skills *names* say, each one among *proficient*: Expertise (a rogue's, a
54
+ bard's) doubles a proficiency the character has."""
55
+ chosen = skills(names)
56
+ have = {skill_name(name) for name in proficient}
57
+ missing = [skill for skill in chosen if skill not in have]
58
+ if missing:
59
+ raise InvalidChangeError(
60
+ f"Expertise doubles a proficiency, and {', '.join(missing)} is not among the "
61
+ "proficient skills: give it in skills too."
62
+ )
63
+ return chosen
64
+
65
+
66
+ def jack_of_all_trades(character: Character) -> int:
67
+ """A bard's half proficiency, rounded down, on the checks it is not proficient in
68
+ (initiative included), from level 2."""
69
+ cls = character.character_class
70
+ if rules.class_key(cls.name) == "bard" and cls.level >= 2:
71
+ return character.proficiency_bonus // 2
72
+ return 0
73
+
74
+
75
+ def skill_bonuses(character: Character) -> list[Proficiency]:
76
+ """Each proficient skill with its bonus, as the sheet lists them."""
77
+ experts = {skill_name(name) for name in character.skill_expertise}
78
+ shown: list[Proficiency] = []
79
+ for name in character.skill_proficiencies:
80
+ skill = skill_name(name)
81
+ ability = SKILLS[skill] if skill else None
82
+ times = 2 if skill and skill in experts else 1
83
+ shown.append(Proficiency(skill or name, _bonus(character, ability, times)))
84
+ return shown
85
+
86
+
87
+ def save_bonuses(character: Character) -> list[Proficiency]:
88
+ """Each proficient saving throw with its bonus."""
89
+ abilities = [rules.ability_name(name) for name in character.saving_throw_proficiencies]
90
+ return [Proficiency(a, _bonus(character, a)) for a in abilities if a]
91
+
92
+
93
+ def save_bonus(character: Character, ability: str) -> int:
94
+ """A saving throw's bonus: the modifier, plus proficiency where the sheet has it."""
95
+ score = character.abilities.get(ability)
96
+ proficient = ability in {rules.ability_name(n) for n in character.saving_throw_proficiencies}
97
+ return (score.mod if score else 0) + (character.proficiency_bonus if proficient else 0)
98
+
99
+
100
+ def _bonus(character: Character, ability: str | None, times: int = 1) -> int | None:
101
+ if ability is None:
102
+ return None
103
+ score = character.abilities.get(ability)
104
+ return (score.mod if score else 0) + times * character.proficiency_bonus
@@ -0,0 +1,77 @@
1
+ """Experience and levels: awarding XP and levelling a character up (PHB ch. 1)."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from dndkit.errors import InvalidChangeError
6
+ from dndkit.game import characters, registry, rules, seating
7
+ from dndkit.game.spellbook import describe_slots
8
+ from dndkit.models import Campaign, Character, now
9
+
10
+
11
+ def award_experience(campaign: Campaign, refs: Sequence[str], total_xp: int) -> list[str]:
12
+ """Split *total_xp* evenly between the named characters, else those at the table.
13
+
14
+ Without a roll call, the whole party is at the table (``seating``).
15
+ """
16
+ targets = (
17
+ registry.find_many(campaign.characters, refs, characters.KIND)
18
+ if refs
19
+ else seating.present_characters(campaign)
20
+ )
21
+ if not targets:
22
+ raise InvalidChangeError("There is no character to award experience to.")
23
+ share = rules.experience_per_character(total_xp, len(targets))
24
+ lines = [f"{total_xp} XP split between {len(targets)}: {share} each."]
25
+ for character in targets:
26
+ character.experience_points += share
27
+ character.updated_at = now()
28
+ line = f"{character.name}: {character.experience_points} XP"
29
+ reachable = rules.level_for_xp(character.experience_points)
30
+ if reachable > character.character_class.level:
31
+ line += f", enough for level {reachable} (use level_up)"
32
+ lines.append(line)
33
+ return lines
34
+
35
+
36
+ def level_up(campaign: Campaign, ref: str, hit_die_roll: int | None) -> str:
37
+ """Raise a character one level: hit points, proficiency, hit dice, spell slots.
38
+
39
+ The hit point gain is the hit die roll (or the fixed average, half the die
40
+ plus one) plus the Constitution modifier, at least 1.
41
+ """
42
+ character = characters.find(campaign, ref)
43
+ cls = character.character_class
44
+ if cls.level >= 20:
45
+ raise InvalidChangeError(f"{character.name} is already level 20.")
46
+ die = rules.hit_die(cls.name)
47
+ if hit_die_roll is not None and not 1 <= hit_die_roll <= die:
48
+ raise InvalidChangeError(f"A d{die} roll is between 1 and {die}.")
49
+ roll = hit_die_roll if hit_die_roll is not None else die // 2 + 1
50
+ con = character.abilities["constitution"].mod if "constitution" in character.abilities else 0
51
+ gain = max(1, roll + con)
52
+ level = cls.level + 1
53
+ character.character_class = cls.model_copy(update={"level": level, "hit_dice": f"1d{die}"})
54
+ character.hit_points_max += gain
55
+ character.hit_points_current += gain
56
+ character.proficiency_bonus = rules.proficiency_bonus(level)
57
+ character.hit_dice_remaining = f"{min(level, characters.hit_dice_left(character) + 1)}d{die}"
58
+ slots_note = _update_slots(character, level)
59
+ character.updated_at = now()
60
+ source = "rolled" if hit_die_roll is not None else "average"
61
+ return (
62
+ f"{character.name} reaches level {level}: +{gain} HP ({source} {roll}, CON {con:+d}), "
63
+ f"HP {character.hit_points_current}/{character.hp_maximum}, "
64
+ f"proficiency +{character.proficiency_bonus}{slots_note}. "
65
+ f"Check the {cls.name} features gained at level {level}."
66
+ )
67
+
68
+
69
+ def _update_slots(character: Character, level: int) -> str:
70
+ slots = rules.spell_slots_for(character.character_class.name, level)
71
+ if slots is None:
72
+ if rules.class_key(character.character_class.name) is None:
73
+ return "; unknown class, so spell slots are left as they were"
74
+ return ""
75
+ character.spell_slots_used = characters.slots_still_used(character, slots)
76
+ character.spell_slots = dict(slots)
77
+ return f", spell slots {describe_slots(character)}" if slots else ""