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/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
|
dndkit/game/movement.py
ADDED
|
@@ -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 ""
|