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/views/battle.py ADDED
@@ -0,0 +1,92 @@
1
+ """The battle map as the screens draw it: terrain, tokens, where the actor can go.
2
+
3
+ The game master's screens get exact hit points; the players' get how hurt a
4
+ foe looks, and see where a creature can move only on their own side's turns.
5
+ """
6
+
7
+ from dndkit.game import battlefield, grid, movement, pathing
8
+ from dndkit.models import Campaign, Combatant
9
+
10
+ Json = dict[str, object]
11
+
12
+
13
+ def health(hit_points: int | None, maximum: int | None) -> str | None:
14
+ """How hurt a creature looks: unhurt, hurt, bloodied (half or less) or down."""
15
+ if hit_points is None or not maximum:
16
+ return None
17
+ if hit_points <= 0:
18
+ return "down"
19
+ if hit_points >= maximum:
20
+ return "unhurt"
21
+ return "bloodied" if hit_points * 2 <= maximum else "hurt"
22
+
23
+
24
+ def battle_map(campaign: Campaign, public: bool) -> Json | None:
25
+ """The map for drawing, or None when the fight has none."""
26
+ state = campaign.game_state
27
+ field = state.battlefield
28
+ if field is None:
29
+ return None
30
+ current = state.current_combatant
31
+ tokens = [
32
+ _token(campaign, c, c is current, public)
33
+ for c in state.initiative_order
34
+ if c.position is not None and not (public and unseen(c))
35
+ ]
36
+ shows_moves = current is not None and (not public or battlefield.is_party(current))
37
+ return {
38
+ "width": field.width,
39
+ "height": field.height,
40
+ "walls": [list(s) for s in sorted(battlefield.wall_squares(field))],
41
+ "difficult": [list(s) for s in sorted(battlefield.difficult_squares(field))],
42
+ "area": [list(grid.parse(s, field)) for s in field.area],
43
+ "area_label": field.area_label,
44
+ "tokens": tokens,
45
+ "moves": _moves(campaign, current, public) if shows_moves and current else [],
46
+ }
47
+
48
+
49
+ def unseen(combatant: Combatant) -> bool:
50
+ """A foe hidden from the players: kept off their screens altogether."""
51
+ return combatant.hidden and not battlefield.is_party(combatant)
52
+
53
+
54
+ def _token(campaign: Campaign, combatant: Combatant, current: bool, public: bool) -> Json:
55
+ x, y = battlefield.square_of(campaign.game_state, combatant) or (0, 0)
56
+ character = campaign.characters.get(combatant.character_id or "")
57
+ hp: int | None
58
+ hp_max: int | None
59
+ if character is not None:
60
+ hp, hp_max = character.hit_points_current, character.hp_maximum
61
+ else:
62
+ hp, hp_max = combatant.hit_points, combatant.hit_points_max
63
+ token: Json = {
64
+ "name": combatant.name,
65
+ "x": x,
66
+ "y": y,
67
+ "size": grid.SIZE_SQUARES[combatant.size],
68
+ "party": battlefield.is_party(combatant),
69
+ "current": current,
70
+ "down": hp == 0,
71
+ "hidden": combatant.hidden,
72
+ "boss": combatant.boss,
73
+ }
74
+ if public and character is None:
75
+ token["health"] = health(hp, hp_max)
76
+ else:
77
+ token.update(hp=hp, hp_max=hp_max)
78
+ return token
79
+
80
+
81
+ def _moves(campaign: Campaign, mover: Combatant, public: bool) -> list[list[int]]:
82
+ """The squares *mover* can still reach this turn."""
83
+ start = battlefield.square_of(campaign.game_state, mover)
84
+ left = movement.movement_left(campaign, mover)
85
+ if start is None or left == 0:
86
+ return []
87
+ ground = pathing.Ground(campaign, mover, provokes=False, as_players_see=public)
88
+ return [
89
+ list(square)
90
+ for square, route in pathing.routes(ground, start).items()
91
+ if 0 < route.feet <= left and ground.standable(square)
92
+ ]
dndkit/views/desk.py ADDED
@@ -0,0 +1,76 @@
1
+ """The game master's desk, to follow and debug the model: what it called,
2
+ the campaign's notebook, the full sheets, and the server's own facts."""
3
+
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from dndkit import __version__
8
+ from dndkit.activity import ActivityLog
9
+ from dndkit.models import Campaign
10
+ from dndkit.skills import load_catalog
11
+ from dndkit.views import everyone
12
+ from dndkit.views.everyone import Json
13
+
14
+ SHOWN_CALLS = 60
15
+ SHOWN_SESSIONS = 6
16
+
17
+
18
+ def activity(log: ActivityLog) -> Json:
19
+ """The model's latest calls, newest first."""
20
+ calls = log.recent()[-SHOWN_CALLS:]
21
+ return {
22
+ "calls": [
23
+ {
24
+ "n": c.number,
25
+ "time": time.strftime("%H:%M:%S", time.localtime(c.at)),
26
+ "kind": c.kind,
27
+ "name": c.name,
28
+ "arguments": c.arguments,
29
+ "outcome": c.outcome,
30
+ "failed": c.failed,
31
+ "ms": c.milliseconds,
32
+ }
33
+ for c in reversed(calls)
34
+ ],
35
+ "failures": sum(c.failed for c in calls),
36
+ }
37
+
38
+
39
+ def notebook(campaign: Campaign, saved_in: Path | None, data_dir: Path) -> Json:
40
+ """The campaign as the model's tools hold it, beyond the board: notes, the
41
+ setting, past sessions, and where it all lives."""
42
+ state = campaign.game_state
43
+ return {
44
+ "situation": state.notes,
45
+ "world_notes": campaign.world_notes,
46
+ "setting": campaign.setting,
47
+ "dm": campaign.dm_name,
48
+ "sessions": [
49
+ {
50
+ "number": s.session_number,
51
+ "title": s.title,
52
+ "summary": s.summary,
53
+ "moments": s.events,
54
+ "present": s.characters_present,
55
+ "xp": s.experience_gained,
56
+ "treasure": s.treasure_found,
57
+ "notes": s.notes,
58
+ }
59
+ for s in sorted(campaign.sessions, key=lambda s: s.session_number)[-SHOWN_SESSIONS:]
60
+ ],
61
+ "server": {
62
+ "version": __version__,
63
+ "data_dir": str(data_dir),
64
+ "file": saved_in.name if saved_in else None,
65
+ "saved": campaign.updated_at.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
66
+ "skills": [s.name for s in load_catalog()],
67
+ },
68
+ }
69
+
70
+
71
+ def sheets(campaign: Campaign) -> Json:
72
+ """Every character's full sheet, with the game master's notes on it."""
73
+ party = sorted(campaign.characters.values(), key=lambda c: c.name)
74
+ return {
75
+ "sheets": [{**everyone.character(campaign, c.id), "notes": c.notes} for c in party],
76
+ }
@@ -0,0 +1,194 @@
1
+ """What everyone at the table may see: the dice, the party, the seats, a sheet.
2
+
3
+ Each view lists the fields it sends. The MCP widgets, the table screen and the
4
+ players' phones all show these; the game master's views (``widgets``, ``desk``)
5
+ add their own on top, so nothing here may hold a secret: a foe's numbers, a
6
+ plan, the game master's notes.
7
+ """
8
+
9
+ from typing import Any
10
+
11
+ from dndkit.game import characters, proficiencies, seating
12
+ from dndkit.game.dice import DiceLog, LoggedRoll
13
+ from dndkit.game.tables import XP_FOR_LEVEL
14
+ from dndkit.models import ABILITIES, Campaign, Character, Item, Spell
15
+ from dndkit.views.battle import unseen
16
+
17
+ Json = dict[str, Any]
18
+
19
+
20
+ def dice(log: DiceLog, limit: int = 6, secrets: bool = False) -> Json:
21
+ """The latest rolls, newest last; the secret ones only for the game master."""
22
+ shown = [roll for roll in log.recent() if secrets or not roll.secret]
23
+ return {"rolls": [_roll(roll) for roll in shown[-limit:]]}
24
+
25
+
26
+ def _roll(roll: LoggedRoll) -> Json:
27
+ result = roll.result
28
+ return {
29
+ "n": roll.number,
30
+ "t": roll.at,
31
+ "secret": roll.secret,
32
+ "notation": result.notation,
33
+ "total": result.total,
34
+ "mode": result.mode,
35
+ "terms": [
36
+ {"sign": t.sign, "sides": t.sides, "faces": list(t.faces), "kept": t.kept}
37
+ for t in result.terms
38
+ ],
39
+ }
40
+
41
+
42
+ def party(campaign: Campaign) -> Json:
43
+ """Where the party stands, every character's vitals and the quests' progress."""
44
+ state = campaign.game_state
45
+ current = state.current_combatant
46
+ return {
47
+ "campaign": campaign.name,
48
+ "description": campaign.description,
49
+ "session": state.current_session,
50
+ "location": state.current_location,
51
+ "date": state.current_date_in_game,
52
+ "funds": state.party_funds,
53
+ "in_combat": state.in_combat,
54
+ "round": state.combat_round,
55
+ # The players' pages show this too: a hidden foe's turn names no one.
56
+ "current": current.name if current and not unseen(current) else None,
57
+ "characters": [
58
+ {
59
+ "name": c.name,
60
+ "summary": _summary(c),
61
+ "player": c.player_name,
62
+ "present": seating.is_present(campaign, c),
63
+ **vitals(c),
64
+ }
65
+ for c in sorted(campaign.characters.values(), key=lambda c: c.name)
66
+ ],
67
+ "quests": [
68
+ {"title": q.title, "done": len(q.completed_objectives), "total": len(q.objectives)}
69
+ for q in campaign.quests.values()
70
+ if q.status == "active"
71
+ ],
72
+ }
73
+
74
+
75
+ def seating_chart(campaign: Campaign) -> Json:
76
+ """Who sits at the table in seat order, with their characters, and who is missing."""
77
+ state = campaign.game_state
78
+ party = sorted(campaign.characters.values(), key=lambda c: c.name)
79
+ return {
80
+ "campaign": campaign.name,
81
+ "session": state.current_session,
82
+ "called": bool(state.players_present),
83
+ "seats": [
84
+ {
85
+ "player": player,
86
+ "characters": [
87
+ {"name": c.name, "summary": _summary(c)}
88
+ for c in seating.characters_of(campaign, player)
89
+ ],
90
+ }
91
+ for player in state.players_present
92
+ ],
93
+ # Before the roll call: the players known so far.
94
+ "absent": [
95
+ {"name": c.name, "player": c.player_name}
96
+ for c in party
97
+ if c.player_name and (not state.players_present or not seating.is_present(campaign, c))
98
+ ],
99
+ "unplayed": [c.name for c in party if not c.player_name],
100
+ }
101
+
102
+
103
+ def character(campaign: Campaign, ref: str) -> Json:
104
+ """One character's sheet, as its player sees it (the game master's notes left out)."""
105
+ c = characters.find(campaign, ref)
106
+ level = c.character_class.level
107
+ casting = characters.spellcasting(c)
108
+ return {
109
+ "name": c.name,
110
+ "summary": _summary(c),
111
+ "player": c.player_name,
112
+ "background": c.background,
113
+ "alignment": c.alignment,
114
+ "description": c.description,
115
+ "bio": c.bio,
116
+ "abilities": [
117
+ {"key": key[:3], "score": c.abilities[key].score, "mod": c.abilities[key].mod}
118
+ for key in ABILITIES
119
+ if key in c.abilities
120
+ ],
121
+ "proficiency": c.proficiency_bonus,
122
+ "speed": c.speed,
123
+ "initiative": characters.initiative(c),
124
+ "hit_dice": c.hit_dice_remaining,
125
+ "casting": (
126
+ {"ability": casting.ability, "attack": casting.attack, "dc": casting.save_dc}
127
+ if casting
128
+ else None
129
+ ),
130
+ "xp": c.experience_points,
131
+ "next_level_xp": XP_FOR_LEVEL.get(level + 1),
132
+ "inspiration": c.inspiration,
133
+ "race": {"name": c.race.name, "subrace": c.race.subrace, "traits": c.race.traits},
134
+ "inventory": [_item(i) for i in c.inventory],
135
+ "equipment": [
136
+ {"slot": slot, **_item(item)} for slot, item in c.equipment.items() if item is not None
137
+ ],
138
+ "spells": [_spell(s) for s in sorted(c.spells_known, key=lambda s: (s.level, s.name))],
139
+ "features": c.features_and_traits,
140
+ "skills": [{"name": p.name, "bonus": p.bonus} for p in proficiencies.skill_bonuses(c)],
141
+ "saves": [{"name": p.name[:3], "bonus": p.bonus} for p in proficiencies.save_bonuses(c)],
142
+ "languages": c.languages,
143
+ **vitals(c),
144
+ }
145
+
146
+
147
+ def _item(i: Item) -> Json:
148
+ details = [i.item_type if i.item_type != "misc" else "", i.value or ""]
149
+ if i.weight:
150
+ details.append(f"{i.weight:g} lb")
151
+ return {
152
+ "name": i.name,
153
+ "qty": i.quantity,
154
+ "description": i.description,
155
+ "details": " · ".join(d for d in details if d),
156
+ }
157
+
158
+
159
+ def _spell(s: Spell) -> Json:
160
+ components = ", ".join(s.components)
161
+ if s.material_components:
162
+ components += f" ({s.material_components})"
163
+ details = [s.school, s.casting_time, str(s.range) if s.range else "", s.duration, components]
164
+ return {
165
+ "name": s.name,
166
+ "level": s.level,
167
+ "prepared": s.prepared,
168
+ "details": " · ".join(d for d in details if d),
169
+ "description": s.description,
170
+ }
171
+
172
+
173
+ def _summary(c: Character) -> str:
174
+ return f"{c.race.name} {c.character_class.name} {c.character_class.level}"
175
+
176
+
177
+ def vitals(c: Character) -> Json:
178
+ """Hit points, defenses and resources: what the whole table may see of a character."""
179
+ return {
180
+ "hp": c.hit_points_current,
181
+ "hp_max": c.hp_maximum,
182
+ "temp": c.temporary_hit_points,
183
+ "ac": c.armor_class,
184
+ "conditions": c.conditions,
185
+ "rounds": c.condition_rounds,
186
+ "concentration": c.concentration,
187
+ "exhaustion": c.exhaustion,
188
+ "death_saves": [c.death_saves_success, c.death_saves_failure],
189
+ "stable": c.stable,
190
+ "slots": [
191
+ [level, maximum - c.spell_slots_used.get(level, 0), maximum]
192
+ for level, maximum in sorted(c.spell_slots.items())
193
+ ],
194
+ }
dndkit/views/public.py ADDED
@@ -0,0 +1,89 @@
1
+ """What the players may see: the table screen and each player's phone.
2
+
3
+ Built field by field from what is allowed, never by trimming the game
4
+ master's views, so a field added to a model stays hidden until it is added
5
+ here. Players see their party in full, but never an NPC's biography or notes,
6
+ a planned encounter, a monster's statistics, or a foe hidden from them: a
7
+ monster's hit points show as how hurt it looks.
8
+ """
9
+
10
+ from dndkit.models import Campaign, Combatant
11
+ from dndkit.views.battle import battle_map, health, unseen
12
+ from dndkit.views.everyone import Json, vitals
13
+
14
+ JOURNAL_EVENTS = 8
15
+
16
+
17
+ def combat(campaign: Campaign) -> Json:
18
+ """The initiative order; monsters without their numbers."""
19
+ state = campaign.game_state
20
+ current = state.current_combatant
21
+ return {
22
+ "in_combat": state.in_combat,
23
+ "round": state.combat_round,
24
+ "order": [
25
+ _fighter(campaign, c, c is current) for c in state.initiative_order if not unseen(c)
26
+ ],
27
+ "defeated": [c.name for c in state.defeated if not unseen(c)],
28
+ "xp": sum(c.xp for c in state.defeated if not unseen(c)),
29
+ "map": battle_map(campaign, public=True),
30
+ }
31
+
32
+
33
+ def _fighter(campaign: Campaign, combatant: Combatant, current: bool) -> Json:
34
+ seen = {
35
+ "name": combatant.name,
36
+ "initiative": combatant.initiative,
37
+ "current": current,
38
+ "position": combatant.position,
39
+ "boss": combatant.boss,
40
+ }
41
+ character = campaign.characters.get(combatant.character_id or "")
42
+ if character is not None:
43
+ return {**seen, "pc": True, **vitals(character)}
44
+ return {
45
+ **seen,
46
+ "pc": False,
47
+ "health": health(combatant.hit_points, combatant.hit_points_max),
48
+ "conditions": list(combatant.conditions),
49
+ "rounds": dict(combatant.condition_rounds),
50
+ }
51
+
52
+
53
+ def journal(campaign: Campaign, limit: int = JOURNAL_EVENTS, full: bool = False) -> Json:
54
+ """Active quests with their objectives, and the latest moments of the adventure log."""
55
+ return {
56
+ "quests": [
57
+ {
58
+ "title": q.title,
59
+ "giver": q.giver,
60
+ "description": q.description,
61
+ "objectives": [
62
+ {"text": text, "done": text in q.completed_objectives} for text in q.objectives
63
+ ],
64
+ }
65
+ for q in campaign.quests.values()
66
+ if q.status == "active"
67
+ ],
68
+ "events": [
69
+ {
70
+ "title": e.title,
71
+ "description": e.description,
72
+ "type": e.event_type.value,
73
+ "location": e.location,
74
+ # The game master's own log adds what the players do not need.
75
+ **(
76
+ {
77
+ "session": e.session_number,
78
+ "importance": e.importance,
79
+ "characters": e.characters_involved,
80
+ "tags": e.tags,
81
+ }
82
+ if full
83
+ else {}
84
+ ),
85
+ }
86
+ for e in reversed(campaign.events[-limit:])
87
+ ],
88
+ "total": len(campaign.events),
89
+ }
@@ -0,0 +1,58 @@
1
+ """The fight as the game master sees it, in the MCP widget and the web backstage.
2
+
3
+ Monsters' exact hit points, hidden foes and legendary uses show here; what
4
+ the players may see is built separately, field by field, in ``views.public``
5
+ and ``views.everyone``.
6
+ """
7
+
8
+ from dndkit.models import Campaign, Combatant
9
+ from dndkit.views.battle import battle_map
10
+ from dndkit.views.everyone import Json, vitals
11
+
12
+
13
+ def combat(campaign: Campaign) -> Json:
14
+ """The initiative order with live hit points, and who has fallen."""
15
+ state = campaign.game_state
16
+ current = state.current_combatant
17
+ return {
18
+ "in_combat": state.in_combat,
19
+ "round": state.combat_round,
20
+ "order": [_combatant(campaign, c, c is current) for c in state.initiative_order],
21
+ "defeated": [c.name for c in state.defeated],
22
+ "xp": sum(c.xp for c in state.defeated),
23
+ "map": battle_map(campaign, public=False),
24
+ "last": (
25
+ state.last_combat.model_dump(mode="json")
26
+ if state.last_combat and not state.in_combat
27
+ else None
28
+ ),
29
+ }
30
+
31
+
32
+ def _combatant(campaign: Campaign, combatant: Combatant, current: bool) -> Json:
33
+ character = campaign.characters.get(combatant.character_id or "")
34
+ if character is not None:
35
+ stats = vitals(character)
36
+ else:
37
+ stats = {
38
+ "hp": combatant.hit_points,
39
+ "hp_max": combatant.hit_points_max,
40
+ "temp": 0,
41
+ "ac": combatant.armor_class,
42
+ "conditions": combatant.conditions,
43
+ "rounds": combatant.condition_rounds,
44
+ "concentration": combatant.concentration,
45
+ }
46
+ return {
47
+ "name": combatant.name,
48
+ "initiative": combatant.initiative,
49
+ "pc": character is not None,
50
+ "current": current,
51
+ "position": combatant.position,
52
+ "hidden": combatant.hidden,
53
+ "reaction_used": combatant.reaction_used,
54
+ "boss": combatant.boss,
55
+ "legendary": [combatant.legendary_actions_left, combatant.legendary_actions],
56
+ "resistances": combatant.legendary_resistances_left,
57
+ **stats,
58
+ }
dndkit/views/world.py ADDED
@@ -0,0 +1,70 @@
1
+ """The world as the game master knows it: NPCs, places and the bestiary, secrets included."""
2
+
3
+ from dndkit.models import NPC, Campaign, Location, Monster
4
+ from dndkit.text import normalize
5
+ from dndkit.views.everyone import Json
6
+
7
+
8
+ def world(campaign: Campaign) -> Json:
9
+ """Every NPC, location and stat block, by name."""
10
+ here = normalize(campaign.game_state.current_location or "")
11
+ fighting = [c.monster for c in campaign.game_state.initiative_order if c.monster]
12
+ return {
13
+ "npcs": [_npc(n) for n in sorted(campaign.npcs.values(), key=lambda n: n.name)],
14
+ "locations": [
15
+ _location(place, normalize(place.name) == here)
16
+ for place in sorted(campaign.locations.values(), key=lambda p: p.name)
17
+ ],
18
+ "monsters": [
19
+ _monster(m, fighting.count(m.name))
20
+ for m in sorted(campaign.monsters.values(), key=lambda m: m.name)
21
+ ],
22
+ }
23
+
24
+
25
+ def _npc(npc: NPC) -> Json:
26
+ return {
27
+ "name": npc.name,
28
+ "race": npc.race,
29
+ "occupation": npc.occupation,
30
+ "location": npc.location,
31
+ "attitude": npc.attitude,
32
+ "description": npc.description,
33
+ "bio": npc.bio,
34
+ "notes": npc.notes,
35
+ "ties": [f"{who}: {what}" for who, what in npc.relationships.items()],
36
+ "stats": [f"{key}: {value}" for key, value in (npc.stats or {}).items()],
37
+ }
38
+
39
+
40
+ def _location(place: Location, current: bool) -> Json:
41
+ return {
42
+ "name": place.name,
43
+ "type": place.location_type,
44
+ "current": current,
45
+ "description": place.description,
46
+ "population": place.population,
47
+ "government": place.government,
48
+ "features": place.notable_features,
49
+ "npcs": place.npcs,
50
+ "connections": place.connections,
51
+ "notes": place.notes,
52
+ }
53
+
54
+
55
+ def _monster(monster: Monster, fighting: int) -> Json:
56
+ return {
57
+ "name": monster.name,
58
+ "size": monster.size,
59
+ "ac": monster.armor_class,
60
+ "hp": monster.hit_points,
61
+ "speed": monster.speed,
62
+ "cr": monster.challenge_rating,
63
+ "xp": monster.xp,
64
+ "initiative": monster.initiative_bonus,
65
+ "boss": monster.boss,
66
+ "legendary": [monster.legendary_actions, monster.legendary_resistances],
67
+ "actions": monster.actions,
68
+ "notes": monster.notes,
69
+ "fighting": fighting,
70
+ }
dndkit/web/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Web pages that follow the game live: the table screen, players' phones, the backstage.
2
+
3
+ They run inside the MCP server's process (``server.web_pages``) and read the
4
+ same campaign store and dice log as the tools, so a change shows at once.
5
+ """
6
+
7
+ from dndkit.web.changes import Changes
8
+ from dndkit.web.server import web_pages
9
+
10
+ __all__ = ["Changes", "web_pages"]