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/tools/render.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Plain-text views of game data, written for a model that will speak them.
|
|
2
|
+
|
|
3
|
+
No emoji and little markup: a voice model reads these, then says what
|
|
4
|
+
matters in its own words.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from dndkit.game import characters, effects, proficiencies, seating
|
|
10
|
+
from dndkit.game.spellbook import describe_slots
|
|
11
|
+
from dndkit.models import (
|
|
12
|
+
ABILITIES,
|
|
13
|
+
NPC,
|
|
14
|
+
AdventureEvent,
|
|
15
|
+
Campaign,
|
|
16
|
+
Character,
|
|
17
|
+
Location,
|
|
18
|
+
Quest,
|
|
19
|
+
SessionNote,
|
|
20
|
+
)
|
|
21
|
+
from dndkit.storage import CampaignEntry
|
|
22
|
+
from dndkit.tools import render_combat, render_map
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def character_line(c: Character) -> str:
|
|
26
|
+
"""One-line summary: name, ID, class and hit points."""
|
|
27
|
+
cls = c.character_class
|
|
28
|
+
return (
|
|
29
|
+
f"{c.name} (id {c.id}): level {cls.level} {c.race.name} {cls.name}, "
|
|
30
|
+
f"HP {c.hit_points_current}/{c.hp_maximum}{_down(c)}, AC {c.armor_class}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _down(c: Character) -> str:
|
|
35
|
+
"""How a character at 0 HP fares: dying, stable or dead."""
|
|
36
|
+
if c.hit_points_current > 0:
|
|
37
|
+
return ""
|
|
38
|
+
if c.death_saves_failure >= 3:
|
|
39
|
+
return " (dead)"
|
|
40
|
+
return " (stable)" if c.stable else " (dying)"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def character_sheet(c: Character) -> str:
|
|
44
|
+
"""The full sheet, one fact per line, empty sections left out."""
|
|
45
|
+
scores = ", ".join(
|
|
46
|
+
f"{name[:3].upper()} {c.abilities[name].score} ({c.abilities[name].mod:+d})"
|
|
47
|
+
for name in ABILITIES
|
|
48
|
+
if name in c.abilities
|
|
49
|
+
)
|
|
50
|
+
lines = [
|
|
51
|
+
character_line(c),
|
|
52
|
+
f"Player: {c.player_name or 'unknown'}. Background: {c.background or 'none'}. "
|
|
53
|
+
f"Alignment: {c.alignment or 'none'}.",
|
|
54
|
+
f"Abilities: {scores}.",
|
|
55
|
+
f"Speed {c.speed} ft. Initiative {characters.initiative(c):+d}. "
|
|
56
|
+
f"Temporary HP {c.temporary_hit_points}. Proficiency +{c.proficiency_bonus}. "
|
|
57
|
+
f"Inspiration: {'yes' if c.inspiration else 'no'}. XP {c.experience_points}.",
|
|
58
|
+
]
|
|
59
|
+
if c.concentration:
|
|
60
|
+
lines.append(f"Concentrating on {c.concentration}.")
|
|
61
|
+
casting = spellcasting_line(c)
|
|
62
|
+
if casting:
|
|
63
|
+
lines.append(casting)
|
|
64
|
+
if c.conditions or c.exhaustion:
|
|
65
|
+
exhaustion = [f"exhaustion {c.exhaustion}"] if c.exhaustion else []
|
|
66
|
+
lines.append(f"Conditions: {', '.join(filter(None, [effects.timed(c), *exhaustion]))}.")
|
|
67
|
+
if c.hit_points_current == 0:
|
|
68
|
+
lines.append(
|
|
69
|
+
f"Death saves: {c.death_saves_success} successes, {c.death_saves_failure} failures."
|
|
70
|
+
)
|
|
71
|
+
lines += [f"{label}: {value}" for label, value in _sheet_extras(c).items() if value]
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _sheet_extras(c: Character) -> dict[str, str | None]:
|
|
76
|
+
return {
|
|
77
|
+
"Description": c.description,
|
|
78
|
+
"Bio": c.bio,
|
|
79
|
+
"Inventory": ", ".join(f"{i.name} x{i.quantity}" for i in c.inventory),
|
|
80
|
+
"Spell slots": describe_slots(c) if c.spell_slots else "",
|
|
81
|
+
"Spells": ", ".join(
|
|
82
|
+
f"{s.name} ({'cantrip' if s.level == 0 else f'level {s.level}'}"
|
|
83
|
+
f"{', prepared' if s.prepared else ''})"
|
|
84
|
+
for s in sorted(c.spells_known, key=lambda s: (s.level, s.name))
|
|
85
|
+
),
|
|
86
|
+
"Skills": _listed(proficiencies.skill_bonuses(c)),
|
|
87
|
+
"Saving throws": _listed(proficiencies.save_bonuses(c), short=True),
|
|
88
|
+
"Other checks": (
|
|
89
|
+
f"+{bonus} (Jack of All Trades)"
|
|
90
|
+
if (bonus := proficiencies.jack_of_all_trades(c))
|
|
91
|
+
else ""
|
|
92
|
+
),
|
|
93
|
+
"Features": ", ".join(c.features_and_traits),
|
|
94
|
+
"Languages": ", ".join(c.languages),
|
|
95
|
+
"Notes": c.notes,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _listed(shown: list[proficiencies.Proficiency], short: bool = False) -> str:
|
|
100
|
+
""" "Stealth +5, Arcana +4": a bonus where the rules know it."""
|
|
101
|
+
return ", ".join(
|
|
102
|
+
(p.name[:3].upper() if short else p.name)
|
|
103
|
+
+ (f" {p.bonus:+d}" if p.bonus is not None else "")
|
|
104
|
+
for p in shown
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def npc(n: NPC) -> str:
|
|
109
|
+
"""An NPC, private bio included: the game master's eyes only."""
|
|
110
|
+
lines = [
|
|
111
|
+
f"{n.name} (id {n.id}): {n.race or 'unknown race'}, {n.occupation or 'no occupation'}, "
|
|
112
|
+
f"at {n.location or 'unknown location'}, attitude {n.attitude or 'unknown'}."
|
|
113
|
+
]
|
|
114
|
+
lines += [
|
|
115
|
+
f"{label}: {value}"
|
|
116
|
+
for label, value in (("Description", n.description), ("Bio", n.bio), ("Notes", n.notes))
|
|
117
|
+
if value
|
|
118
|
+
]
|
|
119
|
+
if n.relationships:
|
|
120
|
+
lines.append(
|
|
121
|
+
"Relationships: " + ", ".join(f"{k}: {v}" for k, v in n.relationships.items())
|
|
122
|
+
)
|
|
123
|
+
return "\n".join(lines)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def location(loc: Location) -> str:
|
|
127
|
+
"""A location and what is known about it."""
|
|
128
|
+
lines = [f"{loc.name} (id {loc.id}), {loc.location_type}: {loc.description}"]
|
|
129
|
+
if loc.population is not None:
|
|
130
|
+
lines.append(f"Population: {loc.population}.")
|
|
131
|
+
optional = {
|
|
132
|
+
"Government": loc.government,
|
|
133
|
+
"Notable features": "; ".join(loc.notable_features),
|
|
134
|
+
"NPCs": ", ".join(loc.npcs),
|
|
135
|
+
"Connections": ", ".join(loc.connections),
|
|
136
|
+
"Notes": loc.notes,
|
|
137
|
+
}
|
|
138
|
+
lines += [f"{label}: {value}" for label, value in optional.items() if value]
|
|
139
|
+
return "\n".join(lines)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def quest(q: Quest) -> str:
|
|
143
|
+
"""A quest with its objectives numbered and marked done or open."""
|
|
144
|
+
lines = [f"{q.title} (id {q.id}), {q.status}: {q.description}"]
|
|
145
|
+
if q.giver:
|
|
146
|
+
lines.append(f"Given by {q.giver}.")
|
|
147
|
+
for number, objective in enumerate(q.objectives, 1):
|
|
148
|
+
mark = "done" if objective in q.completed_objectives else "open"
|
|
149
|
+
lines.append(f"{number}. [{mark}] {objective}")
|
|
150
|
+
lines += [
|
|
151
|
+
f"{label}: {value}" for label, value in (("Reward", q.reward), ("Notes", q.notes)) if value
|
|
152
|
+
]
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
ROLL_CALL = (
|
|
157
|
+
"{what}. As soon as a player says who they are, call seat_players with them and "
|
|
158
|
+
"their character (start_over the first time tonight); the others join as they speak."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def game_state(campaign: Campaign, session_start: datetime | None = None) -> str:
|
|
163
|
+
"""Where the party stands right now."""
|
|
164
|
+
state = campaign.game_state
|
|
165
|
+
active = [q.title for q in campaign.quests.values() if q.status == "active"]
|
|
166
|
+
party = sorted(campaign.characters.values(), key=lambda c: c.name)
|
|
167
|
+
lines = [
|
|
168
|
+
f"Campaign {campaign.name}, session {state.current_session}.",
|
|
169
|
+
f"Location: {state.current_location or 'unknown'}. "
|
|
170
|
+
f"In-game date: {state.current_date_in_game or 'unknown'}.",
|
|
171
|
+
f"Funds {state.party_funds}.",
|
|
172
|
+
"Party: "
|
|
173
|
+
+ (
|
|
174
|
+
", ".join(
|
|
175
|
+
f"{c.name} (level {c.character_class.level}) "
|
|
176
|
+
f"HP {c.hit_points_current}/{c.hp_maximum}{_down(c)}"
|
|
177
|
+
for c in party
|
|
178
|
+
)
|
|
179
|
+
or "none"
|
|
180
|
+
),
|
|
181
|
+
f"Active quests: {', '.join(active) or 'none'}.",
|
|
182
|
+
table(campaign, session_start),
|
|
183
|
+
]
|
|
184
|
+
if state.in_combat:
|
|
185
|
+
lines.append("In combat. " + render_combat.initiative(campaign))
|
|
186
|
+
where = render_map.positions(campaign)
|
|
187
|
+
if where:
|
|
188
|
+
lines.append(where)
|
|
189
|
+
if state.notes:
|
|
190
|
+
lines.append(f"Notes: {state.notes}")
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def spellcasting_line(c: Character) -> str:
|
|
195
|
+
""" "Casts with intelligence: spell attack +5, save DC 13." or "" for a non-caster."""
|
|
196
|
+
casting = characters.spellcasting(c)
|
|
197
|
+
if casting is None:
|
|
198
|
+
return ""
|
|
199
|
+
return (
|
|
200
|
+
f"Casts with {casting.ability}: spell attack {casting.attack:+d}, "
|
|
201
|
+
f"save DC {casting.save_dc}."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def table(campaign: Campaign, session_start: datetime | None = None) -> str:
|
|
206
|
+
"""Who plays which character tonight, so the model knows whom it speaks to.
|
|
207
|
+
|
|
208
|
+
Until seat_players has run since *session_start* (this server's start), it
|
|
209
|
+
says what to call: a small model reads a tool's answer, not always its prompt.
|
|
210
|
+
"""
|
|
211
|
+
party = sorted(campaign.characters.values(), key=lambda c: c.name)
|
|
212
|
+
state = campaign.game_state
|
|
213
|
+
present = state.players_present
|
|
214
|
+
if not present:
|
|
215
|
+
known = [f"{c.player_name} plays {c.name}" for c in party if c.player_name]
|
|
216
|
+
return ROLL_CALL.format(what="Nobody is seated yet") + (
|
|
217
|
+
f" Players seen before: {', '.join(known)}." if known else ""
|
|
218
|
+
)
|
|
219
|
+
stale = session_start is not None and (
|
|
220
|
+
state.roll_call_at is None or state.roll_call_at < session_start
|
|
221
|
+
)
|
|
222
|
+
seats = []
|
|
223
|
+
for player in present:
|
|
224
|
+
played = " and ".join(c.name for c in seating.characters_of(campaign, player))
|
|
225
|
+
seats.append(f"{player} plays {played}" if played else f"{player} has no character yet")
|
|
226
|
+
lines = [f"At the table: {'; '.join(seats)}."]
|
|
227
|
+
if stale:
|
|
228
|
+
lines.insert(0, ROLL_CALL.format(what="This was last session's table"))
|
|
229
|
+
away = [
|
|
230
|
+
f"{c.name} ({c.player_name})"
|
|
231
|
+
for c in party
|
|
232
|
+
if c.player_name and not seating.is_present(campaign, c)
|
|
233
|
+
]
|
|
234
|
+
if away:
|
|
235
|
+
lines.append(f"Absent: {', '.join(away)}.")
|
|
236
|
+
unplayed = [c.name for c in party if not c.player_name]
|
|
237
|
+
if unplayed:
|
|
238
|
+
lines.append(f"Nobody plays {', '.join(unplayed)}.")
|
|
239
|
+
return " ".join(lines)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def campaign_contents(entry: CampaignEntry) -> str:
|
|
243
|
+
"""What a campaign holds, e.g. "3 characters, 5 NPCs, ... and 12 log events"."""
|
|
244
|
+
return (
|
|
245
|
+
f"{entry.characters} characters, {entry.npcs} NPCs, {entry.locations} locations, "
|
|
246
|
+
f"{entry.quests} quests, {entry.session_notes} session notes "
|
|
247
|
+
f"and {entry.events} log events"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def campaign_entry(entry: CampaignEntry, active: bool) -> str:
|
|
252
|
+
"""One saved campaign: pitch, size, session and when it was last used."""
|
|
253
|
+
marker = " (active)" if active else ""
|
|
254
|
+
if not entry.readable:
|
|
255
|
+
return f"{entry.name}{marker}: the file cannot be read."
|
|
256
|
+
pitch = f" {_short(entry.description, 100)}." if entry.description else ""
|
|
257
|
+
return (
|
|
258
|
+
f"{entry.name}{marker}:{pitch} {entry.characters} characters, "
|
|
259
|
+
f"{entry.quests} quests, at session {entry.current_session}, "
|
|
260
|
+
f"last used {entry.modified:%Y-%m-%d %H:%M}."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def session_line(note: SessionNote) -> str:
|
|
265
|
+
"""One session note, summary shortened."""
|
|
266
|
+
title = f" '{note.title}'" if note.title else ""
|
|
267
|
+
return f"Session {note.session_number}{title} ({note.date:%Y-%m-%d}): {_short(note.summary)}"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def event_line(event: AdventureEvent) -> str:
|
|
271
|
+
"""One adventure log entry."""
|
|
272
|
+
where = f" at {event.location}" if event.location else ""
|
|
273
|
+
session = f", session {event.session_number}" if event.session_number else ""
|
|
274
|
+
return (
|
|
275
|
+
f"[{event.event_type}, importance {event.importance}{session}] "
|
|
276
|
+
f"{event.title}{where}: {_short(event.description, 200)}"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _short(text: str, limit: int = 150) -> str:
|
|
281
|
+
return text if len(text) <= limit else text[: limit - 3].rstrip() + "..."
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Plain-text views of fights, monsters and encounters."""
|
|
2
|
+
|
|
3
|
+
from dndkit.game import bestiary, effects, rules
|
|
4
|
+
from dndkit.models import (
|
|
5
|
+
Campaign,
|
|
6
|
+
Character,
|
|
7
|
+
Combatant,
|
|
8
|
+
Encounter,
|
|
9
|
+
EncounterGroup,
|
|
10
|
+
Monster,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def combatant_line(campaign: Campaign, combatant: Combatant) -> str:
|
|
15
|
+
"""Initiative, hit points, AC and conditions, from the sheet for a character."""
|
|
16
|
+
character = campaign.characters.get(combatant.character_id or "")
|
|
17
|
+
parts = [f"initiative {combatant.initiative}"]
|
|
18
|
+
if character is not None:
|
|
19
|
+
parts.append(f"HP {character.hit_points_current}/{character.hp_maximum}")
|
|
20
|
+
parts.append(f"AC {character.armor_class}")
|
|
21
|
+
holder: Character | Combatant = character
|
|
22
|
+
else:
|
|
23
|
+
if combatant.hit_points is not None:
|
|
24
|
+
parts.append(f"HP {combatant.hit_points}/{combatant.hit_points_max}")
|
|
25
|
+
if combatant.armor_class is not None:
|
|
26
|
+
parts.append(f"AC {combatant.armor_class}")
|
|
27
|
+
holder = combatant
|
|
28
|
+
if holder.conditions:
|
|
29
|
+
parts.append(effects.timed(holder))
|
|
30
|
+
held = character.concentration if character is not None else combatant.concentration
|
|
31
|
+
if held:
|
|
32
|
+
parts.append(f"concentrating on {held}")
|
|
33
|
+
if combatant.position:
|
|
34
|
+
parts.append(f"at {combatant.position}")
|
|
35
|
+
if combatant.reaction_used:
|
|
36
|
+
parts.append("reaction used")
|
|
37
|
+
if combatant.hidden:
|
|
38
|
+
parts.append("hidden from the players")
|
|
39
|
+
if combatant.boss:
|
|
40
|
+
parts.append("boss")
|
|
41
|
+
if combatant.legendary_actions or combatant.legendary_resistances_left:
|
|
42
|
+
parts.append(
|
|
43
|
+
f"legendary actions {combatant.legendary_actions_left}/"
|
|
44
|
+
f"{combatant.legendary_actions}, resistances {combatant.legendary_resistances_left}"
|
|
45
|
+
)
|
|
46
|
+
return "; ".join(parts)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def turn_hint(campaign: Campaign, combatant: Combatant) -> str | None:
|
|
50
|
+
"""What a character down at 0 HP does on their turn."""
|
|
51
|
+
character = campaign.characters.get(combatant.character_id or "")
|
|
52
|
+
if character is None or character.hit_points_current > 0:
|
|
53
|
+
return None
|
|
54
|
+
if character.death_saves_failure >= 3:
|
|
55
|
+
return f"{character.name} is dead: call next_turn."
|
|
56
|
+
if character.stable:
|
|
57
|
+
return f"{character.name} is stable at 0 HP: call next_turn."
|
|
58
|
+
return f"{character.name} is down: roll a death save (d20) and call record_death_save."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def initiative(campaign: Campaign) -> str:
|
|
62
|
+
"""The initiative order, the current combatant marked."""
|
|
63
|
+
state = campaign.game_state
|
|
64
|
+
current = state.current_combatant
|
|
65
|
+
rows = [
|
|
66
|
+
f"{n}. {c.name} ({combatant_line(campaign, c)}){' <- now' if c is current else ''}"
|
|
67
|
+
for n, c in enumerate(state.initiative_order, 1)
|
|
68
|
+
]
|
|
69
|
+
return f"Round {state.combat_round}.\n" + "\n".join(rows)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def monster(m: Monster) -> str:
|
|
73
|
+
"""A saved stat block."""
|
|
74
|
+
challenge = f", CR {m.challenge_rating}" if m.challenge_rating else ""
|
|
75
|
+
speed = f", speed {m.speed.rstrip('. ')}" if m.speed else ""
|
|
76
|
+
size = f", {m.size}" if m.size != "medium" else ""
|
|
77
|
+
lines = [
|
|
78
|
+
f"{m.name}{' (boss)' if m.boss else ''}: AC {m.armor_class}, HP {m.hit_points}"
|
|
79
|
+
f"{challenge}, {m.xp} XP, initiative {m.initiative_bonus:+d}{speed}{size}."
|
|
80
|
+
]
|
|
81
|
+
if m.legendary_actions or m.legendary_resistances:
|
|
82
|
+
lines.append(
|
|
83
|
+
f"Legendary: {m.legendary_actions} actions a round, "
|
|
84
|
+
f"{m.legendary_resistances} resistances a day."
|
|
85
|
+
)
|
|
86
|
+
lines += [
|
|
87
|
+
f"{label}: {value}"
|
|
88
|
+
for label, value in (("Actions", m.actions), ("Notes", m.notes))
|
|
89
|
+
if value
|
|
90
|
+
]
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def encounter(campaign: Campaign, e: Encounter) -> str:
|
|
95
|
+
"""An encounter with its monsters and its difficulty for the current party."""
|
|
96
|
+
groups = ", ".join(_group(g) for g in e.monsters) or "no monster"
|
|
97
|
+
where = f" at {e.location}" if e.location else ""
|
|
98
|
+
line = f"{e.name} [{e.status}]{where}: {groups}."
|
|
99
|
+
if e.battlefield is not None:
|
|
100
|
+
line += f" On a {e.battlefield.width}x{e.battlefield.height} battle map."
|
|
101
|
+
rated = bestiary.difficulty(campaign, e)
|
|
102
|
+
if rated is not None:
|
|
103
|
+
line += f" {difficulty(rated)}"
|
|
104
|
+
if e.description:
|
|
105
|
+
line += f" {e.description}"
|
|
106
|
+
return line
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _group(g: EncounterGroup) -> str:
|
|
110
|
+
details = [*(["hidden"] if g.hidden else []), *([f"at {', '.join(g.at)}"] if g.at else [])]
|
|
111
|
+
return f"{g.count} {g.monster}" + (f" ({'; '.join(details)})" if details else "")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def difficulty(rated: rules.Difficulty) -> str:
|
|
115
|
+
"""A difficulty rating with its numbers."""
|
|
116
|
+
easy, medium, hard, deadly = rated.thresholds
|
|
117
|
+
return (
|
|
118
|
+
f"Difficulty {rated.rating}: {rated.total_xp} XP, adjusted {rated.adjusted_xp} "
|
|
119
|
+
f"(x{rated.multiplier:g}); party thresholds easy {easy}, medium {medium}, "
|
|
120
|
+
f"hard {hard}, deadly {deadly}."
|
|
121
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The battle map in words: a text grid, who stands where, what a move did.
|
|
2
|
+
|
|
3
|
+
The model never sees the drawn map, so these say what it needs to describe
|
|
4
|
+
the scene and rule on the next action: squares, distances, who is in reach.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dndkit.game import battlefield, grid
|
|
8
|
+
from dndkit.game.areas import Area
|
|
9
|
+
from dndkit.game.movement import Move
|
|
10
|
+
from dndkit.game.sight import COVER_BONUS, Sightline
|
|
11
|
+
from dndkit.models import Campaign, Combatant, GameState
|
|
12
|
+
|
|
13
|
+
KEYS = "abcdefghijklmnopqrstuvwxyz"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def battle_map(campaign: Campaign) -> str:
|
|
17
|
+
"""The grid (# wall, ~ difficult, a letter per combatant) and its legend."""
|
|
18
|
+
state = campaign.game_state
|
|
19
|
+
field = battlefield.require(state)
|
|
20
|
+
walls, rough = battlefield.wall_squares(field), battlefield.difficult_squares(field)
|
|
21
|
+
marks: dict[grid.Square, str] = {}
|
|
22
|
+
legend = []
|
|
23
|
+
for key, combatant in zip(KEYS, _placed(state), strict=False):
|
|
24
|
+
for square in battlefield.footprint_of(state, combatant) or []:
|
|
25
|
+
marks[square] = key
|
|
26
|
+
side = "party" if battlefield.is_party(combatant) else "foe"
|
|
27
|
+
size = "" if combatant.size == "medium" else f", {combatant.size}"
|
|
28
|
+
hidden = ", hidden" if combatant.hidden else ""
|
|
29
|
+
legend.append(f"{key} {combatant.name} ({side}{size}{hidden}) {combatant.position}")
|
|
30
|
+
columns = [grid.name((x, 0))[0] for x in range(field.width)]
|
|
31
|
+
rows = [
|
|
32
|
+
f"{y + 1:>2} "
|
|
33
|
+
+ " ".join(
|
|
34
|
+
marks.get((x, y), "#" if (x, y) in walls else "~" if (x, y) in rough else ".")
|
|
35
|
+
for x in range(field.width)
|
|
36
|
+
)
|
|
37
|
+
for y in range(field.height)
|
|
38
|
+
]
|
|
39
|
+
last = grid.name((field.width - 1, field.height - 1))
|
|
40
|
+
lines = [
|
|
41
|
+
f"Battle map A1-{last}, 5 ft squares. # wall, ~ difficult terrain.",
|
|
42
|
+
" " + " ".join(columns),
|
|
43
|
+
*rows,
|
|
44
|
+
"; ".join(legend) or "Nobody on the map yet: the fight's start places everyone.",
|
|
45
|
+
]
|
|
46
|
+
away = [c.name for c in state.initiative_order if c.position is None]
|
|
47
|
+
if away:
|
|
48
|
+
lines.append(f"Not on the map: {', '.join(away)} (place_tokens).")
|
|
49
|
+
return "\n".join(lines)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def positions(campaign: Campaign) -> str | None:
|
|
53
|
+
"""Who stands where, and how far the acting combatant is from the others."""
|
|
54
|
+
state = campaign.game_state
|
|
55
|
+
placed = _placed(state)
|
|
56
|
+
if state.battlefield is None or not placed:
|
|
57
|
+
return None
|
|
58
|
+
line = "Positions: " + ", ".join(f"{c.name} {c.position}" for c in placed) + "."
|
|
59
|
+
current = state.current_combatant
|
|
60
|
+
if current is not None and current.position is not None:
|
|
61
|
+
line += f" From {current.name}: {nearby(campaign, current)}."
|
|
62
|
+
return line
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def nearby(campaign: Campaign, combatant: Combatant) -> str:
|
|
66
|
+
"""The others by distance from *combatant*: "Goblin 1 adjacent, Ogre 30 ft"."""
|
|
67
|
+
state = campaign.game_state
|
|
68
|
+
mine = battlefield.footprint_of(state, combatant) or []
|
|
69
|
+
distances = sorted(
|
|
70
|
+
(grid.feet_between(mine, held), other.name)
|
|
71
|
+
for other, held in battlefield.others(state, combatant)
|
|
72
|
+
)
|
|
73
|
+
return ", ".join(
|
|
74
|
+
f"{name} {'adjacent' if feet == grid.FEET else f'{feet} ft'}" for feet, name in distances
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def move(campaign: Campaign, done: Move) -> str:
|
|
79
|
+
"""What a move did: where to, what it cost, who may strike, who is near now."""
|
|
80
|
+
who = done.mover
|
|
81
|
+
if done.forced:
|
|
82
|
+
head = f"{who.name} is moved {done.feet} ft, {done.start} to {who.position}"
|
|
83
|
+
return f"{head} (no movement spent, no opportunity attack). Now: {nearby(campaign, who)}."
|
|
84
|
+
stood = " stands up and" if done.stood_up else ""
|
|
85
|
+
text = (
|
|
86
|
+
f"{who.name}{stood} moves {done.feet} ft, {done.start} to {who.position}; "
|
|
87
|
+
f"{done.left} ft left this turn."
|
|
88
|
+
)
|
|
89
|
+
if done.provoked:
|
|
90
|
+
text += (
|
|
91
|
+
f" Leaving their reach provokes opportunity attacks from {', '.join(done.provoked)}."
|
|
92
|
+
)
|
|
93
|
+
if done.short_of is not None:
|
|
94
|
+
target, feet = done.short_of
|
|
95
|
+
text += f" {target} is still {feet} ft away."
|
|
96
|
+
return f"{text} Now: {nearby(campaign, who)}."
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def area(done: Area) -> str:
|
|
100
|
+
"""Who an area of effect catches, the party first."""
|
|
101
|
+
party = [_named(c) for c in done.caught if battlefield.is_party(c)]
|
|
102
|
+
foes = [_named(c) for c in done.caught if not battlefield.is_party(c)]
|
|
103
|
+
if not done.caught:
|
|
104
|
+
return f"The {done.label} catches no one ({len(done.squares)} squares)."
|
|
105
|
+
caught = "; ".join(
|
|
106
|
+
part
|
|
107
|
+
for part in (
|
|
108
|
+
f"party: {', '.join(party)}" if party else "",
|
|
109
|
+
f"foes: {', '.join(foes)}" if foes else "",
|
|
110
|
+
)
|
|
111
|
+
if part
|
|
112
|
+
)
|
|
113
|
+
return f"The {done.label} catches {caught} ({len(done.squares)} squares)."
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def sightline(seen: Sightline) -> str:
|
|
117
|
+
"""The facts bearing on an attack, in the order a ruling needs them."""
|
|
118
|
+
who, at = seen.attacker.name, seen.target.name
|
|
119
|
+
parts = [f"{who} to {at}: {seen.feet} ft{', within reach' if seen.in_reach else ''}."]
|
|
120
|
+
if seen.cover == "total":
|
|
121
|
+
parts.append(f"{at} has total cover: it cannot be targeted directly.")
|
|
122
|
+
elif seen.cover != "none":
|
|
123
|
+
bonus = COVER_BONUS[seen.cover]
|
|
124
|
+
parts.append(f"{at} has {seen.cover} cover: +{bonus} AC and Dexterity saves.")
|
|
125
|
+
if seen.pressed_by:
|
|
126
|
+
parts.append(
|
|
127
|
+
f"{', '.join(seen.pressed_by)} adjacent to {who}: ranged attacks have disadvantage."
|
|
128
|
+
)
|
|
129
|
+
if seen.target.hidden:
|
|
130
|
+
parts.append(f"{at} is hidden: attacks at disadvantage, if {who} guesses the square.")
|
|
131
|
+
if seen.attacker.hidden:
|
|
132
|
+
parts.append(f"{who} attacks from hiding: advantage; the attack reveals them.")
|
|
133
|
+
return " ".join(parts)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _named(combatant: Combatant) -> str:
|
|
137
|
+
return f"{combatant.name} (hidden)" if combatant.hidden else combatant.name
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _placed(state: GameState) -> list[Combatant]:
|
|
141
|
+
return [c for c in state.initiative_order if c.position is not None]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Character resource tools: inventory, death saves and rests."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Annotated, Literal
|
|
5
|
+
|
|
6
|
+
from fastmcp.dependencies import Depends
|
|
7
|
+
from fastmcp.tools import tool
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from dndkit.game import resources, rests
|
|
11
|
+
from dndkit.game.dice import DiceLog
|
|
12
|
+
from dndkit.game.resources import DeathSave
|
|
13
|
+
from dndkit.game.rests import RestKind
|
|
14
|
+
from dndkit.models import Item
|
|
15
|
+
from dndkit.storage import CampaignStore
|
|
16
|
+
from dndkit.tools.deps import NameOrId, get_dice_log, get_store
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@tool
|
|
20
|
+
def add_item_to_character(
|
|
21
|
+
character_name_or_id: NameOrId,
|
|
22
|
+
item_name: Annotated[str, Field(min_length=1, description="Item name")],
|
|
23
|
+
quantity: Annotated[int, Field(ge=1, description="How many")] = 1,
|
|
24
|
+
item_type: Annotated[
|
|
25
|
+
Literal["weapon", "armor", "consumable", "misc"], Field(description="Item type")
|
|
26
|
+
] = "misc",
|
|
27
|
+
description: Annotated[str | None, Field(description="Item description")] = None,
|
|
28
|
+
weight: Annotated[float | None, Field(ge=0, description="Weight in pounds")] = None,
|
|
29
|
+
value: Annotated[str | None, Field(description="Value, e.g. '50 gp'")] = None,
|
|
30
|
+
store: CampaignStore = Depends(get_store),
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Give items to a character. Items already carried stack."""
|
|
33
|
+
item = Item(
|
|
34
|
+
name=item_name.strip(),
|
|
35
|
+
quantity=quantity,
|
|
36
|
+
item_type=item_type,
|
|
37
|
+
description=description,
|
|
38
|
+
weight=weight,
|
|
39
|
+
value=value,
|
|
40
|
+
)
|
|
41
|
+
with store.transaction() as campaign:
|
|
42
|
+
character, held = resources.add_item(campaign, character_name_or_id, item)
|
|
43
|
+
return f"{character.name} now carries {held.quantity} x {held.name}."
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@tool
|
|
47
|
+
def remove_item_from_character(
|
|
48
|
+
character_name_or_id: NameOrId,
|
|
49
|
+
item_name: Annotated[str, Field(min_length=1, description="Item name")],
|
|
50
|
+
quantity: Annotated[int, Field(ge=1, description="How many to remove")] = 1,
|
|
51
|
+
store: CampaignStore = Depends(get_store),
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Take items away from a character: used, sold, lost or given away."""
|
|
54
|
+
with store.transaction() as campaign:
|
|
55
|
+
character, item, left = resources.remove_item(
|
|
56
|
+
campaign, character_name_or_id, item_name, quantity
|
|
57
|
+
)
|
|
58
|
+
return f"{character.name} has {left} x {item.name} left."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@tool
|
|
62
|
+
def record_death_save(
|
|
63
|
+
character_name_or_id: NameOrId,
|
|
64
|
+
outcome: Annotated[
|
|
65
|
+
DeathSave,
|
|
66
|
+
Field(
|
|
67
|
+
description="success (10+), failure (2-9), nat20 (regain 1 HP), nat1 (two "
|
|
68
|
+
"failures), stabilized (Spare the Dying, Medicine DC 10)"
|
|
69
|
+
),
|
|
70
|
+
],
|
|
71
|
+
store: CampaignStore = Depends(get_store),
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Record a death saving throw for a character at 0 HP."""
|
|
74
|
+
with store.transaction() as campaign:
|
|
75
|
+
return resources.record_death_save(campaign, character_name_or_id, outcome)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@tool
|
|
79
|
+
def rest(
|
|
80
|
+
kind: Annotated[RestKind, Field(description="short (1 hour) or long (8 hours)")] = "long",
|
|
81
|
+
names_or_ids: Annotated[
|
|
82
|
+
list[str] | None, Field(description="Who rests; omit for the whole party")
|
|
83
|
+
] = None,
|
|
84
|
+
hit_dice: Annotated[
|
|
85
|
+
int, Field(ge=0, le=20, description="Short rest: Hit Dice each one spends to heal")
|
|
86
|
+
] = 0,
|
|
87
|
+
store: CampaignStore = Depends(get_store),
|
|
88
|
+
log: DiceLog = Depends(get_dice_log),
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Rest. Long: full HP and slots, half the Hit Dice back, one exhaustion level less.
|
|
91
|
+
Short: pact slots, and Hit Dice spent heal (rolled here)."""
|
|
92
|
+
with store.transaction() as campaign:
|
|
93
|
+
rested = rests.rest(campaign, names_or_ids or [], kind, hit_dice)
|
|
94
|
+
for roll in rested.rolls:
|
|
95
|
+
log.record(roll)
|
|
96
|
+
return "\n".join(rested.lines) or "No characters to rest."
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
TOOLS: list[Callable[..., str]] = [
|
|
100
|
+
add_item_to_character,
|
|
101
|
+
remove_item_from_character,
|
|
102
|
+
record_death_save,
|
|
103
|
+
rest,
|
|
104
|
+
]
|