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/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}"
|