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/web/server.py ADDED
@@ -0,0 +1,131 @@
1
+ """The web server, run inside the MCP server's process for as long as it runs.
2
+
3
+ roomkit-ui starts the MCP server and stops it; the pages come and go with it
4
+ (browsers reconnect by themselves). The web pages must never stop the game:
5
+ a taken port only logs a warning, and uvicorn leaves signals and logging to
6
+ the MCP server.
7
+ """
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import ipaddress
12
+ import logging
13
+ import socket
14
+ from collections.abc import AsyncIterator, Iterator
15
+
16
+ import uvicorn
17
+ from starlette.types import ASGIApp
18
+
19
+ from dndkit.activity import ActivityLog
20
+ from dndkit.config import WebSettings
21
+ from dndkit.game.dice import DiceLog
22
+ from dndkit.storage import CampaignStore
23
+ from dndkit.web.access import PlayerLinks, lan_address
24
+ from dndkit.web.audiences import Sources
25
+ from dndkit.web.changes import Changes
26
+ from dndkit.web.routes import create_app
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ STOP_SECONDS = 5.0
31
+ MAX_CONNECTIONS = 64 # pages and their feeds at once; a table needs a dozen
32
+
33
+
34
+ @contextlib.asynccontextmanager
35
+ async def web_pages(
36
+ settings: WebSettings | None,
37
+ store: CampaignStore,
38
+ dice: DiceLog,
39
+ activity: ActivityLog,
40
+ changes: Changes,
41
+ ) -> AsyncIterator[None]:
42
+ """Serve the pages while the context is open; nothing when *settings* is None."""
43
+ if settings is None:
44
+ yield
45
+ return
46
+ links = PlayerLinks.open(store.data_dir)
47
+ app = create_app(Sources(store, dice, changes, links, settings, activity))
48
+ async with serving(app, settings):
49
+ try:
50
+ yield
51
+ finally:
52
+ changes.close() # ends the open feeds, so the server stops at once
53
+
54
+
55
+ @contextlib.asynccontextmanager
56
+ async def serving(app: ASGIApp, settings: WebSettings) -> AsyncIterator[str | None]:
57
+ """Run *app* on the configured port; yields its address, or None if it is taken."""
58
+ try:
59
+ sock = _listen(settings.host, settings.port)
60
+ except OSError as exc:
61
+ logger.warning(
62
+ "Web pages are off: cannot listen on %s port %s (%s).",
63
+ settings.host,
64
+ settings.port,
65
+ exc.strerror or exc,
66
+ )
67
+ yield None
68
+ return
69
+ config = uvicorn.Config(
70
+ app,
71
+ log_config=None, # the MCP server's logging (stderr) stays as it is
72
+ access_log=False,
73
+ lifespan="off",
74
+ ws="none",
75
+ proxy_headers=False, # the backstage trusts the peer address only
76
+ server_header=False,
77
+ limit_concurrency=MAX_CONNECTIONS, # past it, 503: a flood cannot stall the game
78
+ timeout_graceful_shutdown=1,
79
+ )
80
+ server = _EmbeddedServer(config)
81
+ task = asyncio.create_task(server.serve(sockets=[sock]))
82
+ url = _announce(sock, settings)
83
+ try:
84
+ yield url
85
+ finally:
86
+ server.should_exit = True
87
+ try:
88
+ await asyncio.wait_for(task, STOP_SECONDS)
89
+ except TimeoutError:
90
+ task.cancel()
91
+
92
+
93
+ class _EmbeddedServer(uvicorn.Server):
94
+ """A uvicorn server that leaves Ctrl-C and SIGTERM to the MCP server around it."""
95
+
96
+ @contextlib.contextmanager
97
+ def capture_signals(self) -> Iterator[None]:
98
+ yield
99
+
100
+
101
+ def _listen(host: str, port: int) -> socket.socket:
102
+ # Bound here rather than by uvicorn, which exits the process on failure.
103
+ family, kind, proto, _, address = socket.getaddrinfo(
104
+ host or None, port, type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE
105
+ )[0]
106
+ sock = socket.socket(family, kind, proto)
107
+ try:
108
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
109
+ sock.bind(address)
110
+ sock.listen(64)
111
+ sock.setblocking(False)
112
+ except OSError:
113
+ sock.close()
114
+ raise
115
+ return sock
116
+
117
+
118
+ def _announce(sock: socket.socket, settings: WebSettings) -> str:
119
+ host, port = sock.getsockname()[:2]
120
+ everywhere = ipaddress.ip_address(host).is_unspecified
121
+ local = f"http://127.0.0.1:{port}" if everywhere else _url(host, port)
122
+ logger.info("Table screen: %s/table/ Backstage: %s/gm/", local, local)
123
+ lan = lan_address() if everywhere else None
124
+ phones = settings.public_url or (_url(lan, port) if lan else None)
125
+ if phones:
126
+ logger.info("Phones on this network: %s/table/", phones)
127
+ return local
128
+
129
+
130
+ def _url(host: str, port: int) -> str:
131
+ return f"http://[{host}]:{port}" if ":" in host else f"http://{host}:{port}"