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/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
+ ]