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/access.py ADDED
@@ -0,0 +1,136 @@
1
+ """Who opens what: each character's private link, and where phones find the server.
2
+
3
+ A player's link carries a token derived from the campaign, the character and
4
+ a secret kept in the data directory: links survive restarts, cannot be
5
+ guessed, and all change if that file is deleted.
6
+ """
7
+
8
+ import base64
9
+ import functools
10
+ import hashlib
11
+ import hmac
12
+ import ipaddress
13
+ import os
14
+ import re
15
+ import secrets
16
+ import socket
17
+ from pathlib import Path
18
+ from urllib.parse import urlsplit
19
+
20
+ import segno
21
+
22
+ from dndkit.config import WebSettings
23
+ from dndkit.models import Campaign, Character
24
+ from dndkit.views.everyone import Json
25
+
26
+ KEY_FILE = "player-links.key"
27
+ SECRET_BYTES = 32
28
+ TOKEN_BYTES = 10 # 80 bits, 16 base32 characters
29
+ TOKEN_PATTERN = re.compile(r"[a-z2-7]{16}")
30
+
31
+
32
+ class PlayerLinks:
33
+ """The token in each character's link."""
34
+
35
+ def __init__(self, secret: bytes) -> None:
36
+ self._secret = secret
37
+
38
+ @classmethod
39
+ def open(cls, data_dir: Path) -> "PlayerLinks":
40
+ """Links signed with the data directory's secret, created on first use."""
41
+ return cls(_load_secret(data_dir / KEY_FILE))
42
+
43
+ def token(self, campaign: Campaign, character: Character) -> str:
44
+ """The token for *character* of *campaign*."""
45
+ message = f"{campaign.id}/{character.id}".encode()
46
+ digest = hmac.new(self._secret, message, hashlib.sha256).digest()
47
+ return base64.b32encode(digest[:TOKEN_BYTES]).decode().lower()
48
+
49
+ def character_for(self, campaign: Campaign, token: str) -> Character | None:
50
+ """The character of *campaign* whose link carries *token*, if any."""
51
+ if not TOKEN_PATTERN.fullmatch(token):
52
+ return None
53
+ for character in campaign.characters.values():
54
+ if hmac.compare_digest(self.token(campaign, character), token):
55
+ return character
56
+ return None
57
+
58
+
59
+ def join(campaign: Campaign, links: PlayerLinks, base_url: str, reachable: bool) -> Json:
60
+ """Each character's link and its QR code, for the table screen."""
61
+ players = []
62
+ for character in sorted(campaign.characters.values(), key=lambda c: c.name):
63
+ url = f"{base_url}/p/{links.token(campaign, character)}/"
64
+ players.append(
65
+ {"name": character.name, "player": character.player_name, "url": url, "qr": _qr(url)}
66
+ )
67
+ return {"reachable": reachable, "players": players}
68
+
69
+
70
+ @functools.lru_cache(maxsize=64)
71
+ def _qr(url: str) -> str:
72
+ """A link's QR code as an image URI; the same links come back at every change."""
73
+ return segno.make(url, error="m").svg_data_uri(scale=4, dark="#1d1a22", light="#ffffff")
74
+
75
+
76
+ def phone_base_url(settings: WebSettings, base_url: str) -> tuple[str, bool]:
77
+ """The address phones should use, and whether they can reach it at all.
78
+
79
+ *base_url* is the one the table screen used. A loopback one (the screen
80
+ runs on this machine) becomes the LAN address when the server listens on
81
+ every interface; otherwise phones cannot reach a loopback server.
82
+ """
83
+ if settings.public_url:
84
+ return settings.public_url, True
85
+ parts = urlsplit(base_url)
86
+ own = base_url.rstrip("/")
87
+ if not is_loopback(parts.hostname or ""):
88
+ return own, True
89
+ lan = lan_address() if _is_unspecified(settings.host) else None
90
+ if lan is None:
91
+ return own, False
92
+ port = f":{parts.port}" if parts.port else ""
93
+ return f"{parts.scheme}://{lan}{port}", True
94
+
95
+
96
+ def lan_address() -> str | None:
97
+ """This machine's IPv4 address on the local network; nothing is sent."""
98
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
99
+ try:
100
+ probe.connect(("192.0.2.1", 9)) # TEST-NET-1: only picks the outgoing interface
101
+ address: str = probe.getsockname()[0]
102
+ except OSError:
103
+ return None
104
+ return None if is_loopback(address) else address
105
+
106
+
107
+ def is_loopback(host: str) -> bool:
108
+ """True for this machine's own addresses: a request from it, or a URL to it."""
109
+ if host == "localhost":
110
+ return True
111
+ try:
112
+ return ipaddress.ip_address(host).is_loopback
113
+ except ValueError:
114
+ return False
115
+
116
+
117
+ def _is_unspecified(host: str) -> bool:
118
+ try:
119
+ return ipaddress.ip_address(host).is_unspecified
120
+ except ValueError:
121
+ return host == ""
122
+
123
+
124
+ def _load_secret(path: Path) -> bytes:
125
+ try:
126
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
127
+ with os.fdopen(os.open(path, flags, 0o600), "wb") as file:
128
+ secret = secrets.token_bytes(SECRET_BYTES)
129
+ file.write(secret)
130
+ return secret
131
+ except FileExistsError:
132
+ secret = path.read_bytes()
133
+ if len(secret) < SECRET_BYTES: # a write cut short: start again, links change
134
+ path.unlink()
135
+ return _load_secret(path)
136
+ return secret
@@ -0,0 +1,111 @@
1
+ """The views each audience may stream: the table, one player, the game master.
2
+
3
+ A feed asks for some views by name and gets only those its audience lists.
4
+ """
5
+
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+
9
+ from dndkit.activity import ActivityLog
10
+ from dndkit.config import WebSettings
11
+ from dndkit.errors import GameError, NotFoundError
12
+ from dndkit.game.dice import DiceLog
13
+ from dndkit.models import Campaign
14
+ from dndkit.storage import CampaignStore
15
+ from dndkit.views import desk, everyone, public, widgets
16
+ from dndkit.views.backstage import backstage
17
+ from dndkit.views.everyone import Json
18
+ from dndkit.views.world import world
19
+ from dndkit.web import access
20
+ from dndkit.web.access import PlayerLinks
21
+ from dndkit.web.changes import ACTIVITY, Changes
22
+ from dndkit.web.feed import Render, Renders, View
23
+
24
+ Views = dict[str, View]
25
+ GM_EVENTS = 40 # of the adventure log, on the backstage
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Sources:
30
+ """What the web pages read, all shared with the MCP tools."""
31
+
32
+ store: CampaignStore
33
+ dice: DiceLog
34
+ changes: Changes
35
+ links: PlayerLinks
36
+ settings: WebSettings
37
+ activity: ActivityLog
38
+ renders: Renders = field(default_factory=Renders)
39
+
40
+ def reading(self, build: Callable[[Campaign], Json]) -> Render:
41
+ """A render of the active campaign through *build*."""
42
+
43
+ def render() -> Json:
44
+ with self.store.reading() as campaign:
45
+ return build(campaign)
46
+
47
+ return render
48
+
49
+ def player(self, token: str) -> str | None:
50
+ """The name of the character whose link carries *token*, if any."""
51
+ try:
52
+ with self.store.reading() as campaign:
53
+ character = self.links.character_for(campaign, token)
54
+ except GameError: # no active campaign
55
+ return None
56
+ return character.name if character else None
57
+
58
+
59
+ def table(sources: Sources, base_url: str) -> Views:
60
+ """The shared screen: what everyone at the table may see, and the join codes."""
61
+ phones, reachable = access.phone_base_url(sources.settings, base_url)
62
+ join = sources.reading(lambda c: access.join(c, sources.links, phones, reachable))
63
+ return {
64
+ **_shared(sources),
65
+ "join": View(join, f"join@{phones}"), # the links follow the address asked
66
+ }
67
+
68
+
69
+ def player(sources: Sources, token: str) -> Views:
70
+ """One player's phone: the shared screen's views and that character's sheet."""
71
+
72
+ def sheet(campaign: Campaign) -> Json:
73
+ character = sources.links.character_for(campaign, token)
74
+ if character is None:
75
+ raise NotFoundError("unknownLink") # a word the page translates
76
+ return everyone.character(campaign, character.id)
77
+
78
+ return {**_shared(sources), "me": View(sources.reading(sheet), f"me@{token}")}
79
+
80
+
81
+ def game_master(sources: Sources, base_url: str) -> Views:
82
+ """The backstage: exact numbers, plans, secrets, and what the model does."""
83
+ store = sources.store
84
+ renders: dict[str, Render] = {
85
+ "combat": sources.reading(widgets.combat),
86
+ "dice": lambda: everyone.dice(sources.dice, secrets=True),
87
+ "backstage": sources.reading(backstage),
88
+ "world": sources.reading(world),
89
+ "sheets": sources.reading(desk.sheets),
90
+ "notebook": sources.reading(
91
+ lambda c: desk.notebook(c, store.current_file, store.data_dir)
92
+ ),
93
+ "journal": sources.reading(lambda c: public.journal(c, limit=GM_EVENTS, full=True)),
94
+ }
95
+ return {
96
+ **table(sources, base_url), # the join codes too
97
+ **{name: View(render, f"gm/{name}") for name, render in renders.items()},
98
+ "activity": View(lambda: desk.activity(sources.activity), "gm/activity", ACTIVITY),
99
+ }
100
+
101
+
102
+ def _shared(sources: Sources) -> Views:
103
+ """What the table and every phone show alike: rendered once for all of them."""
104
+ renders: dict[str, Render] = {
105
+ "party": sources.reading(everyone.party),
106
+ "seating": sources.reading(everyone.seating_chart),
107
+ "journal": sources.reading(public.journal),
108
+ "dice": lambda: everyone.dice(sources.dice),
109
+ "combat": sources.reading(public.combat),
110
+ }
111
+ return {name: View(render, f"public/{name}") for name, render in renders.items()}
dndkit/web/changes.py ADDED
@@ -0,0 +1,84 @@
1
+ """Change notices: tools change the game in worker threads, web feeds wait on a loop."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import threading
6
+
7
+ Waiter = tuple[asyncio.AbstractEventLoop, asyncio.Event]
8
+
9
+ GAME = "game" # the campaign and the dice: what every page shows
10
+ ACTIVITY = "activity" # the model's calls, read ones included: the backstage's log only
11
+
12
+
13
+ class Changes:
14
+ """Versions that go up at every change, one per topic, and wake whoever waits.
15
+
16
+ The campaign store and the dice log call ``notify`` from FastMCP's worker
17
+ threads, the activity log ``notify_activity``; web feeds ``await wait(...)``
18
+ on an event loop. A view follows one topic, so a read-only call refreshes
19
+ the backstage's log and nothing else. ``close`` wakes everyone for good, so
20
+ open feeds end when the server stops.
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ self._lock = threading.Lock()
25
+ self._versions = {GAME: 0, ACTIVITY: 0}
26
+ self._closed = False
27
+ self._waiters: set[Waiter] = set()
28
+
29
+ @property
30
+ def version(self) -> int:
31
+ """How many changes so far, all topics."""
32
+ with self._lock:
33
+ return sum(self._versions.values())
34
+
35
+ def versions(self) -> dict[str, int]:
36
+ """How many changes so far, by topic."""
37
+ with self._lock:
38
+ return dict(self._versions)
39
+
40
+ @property
41
+ def closed(self) -> bool:
42
+ """True once the server stops: feeds should end."""
43
+ with self._lock:
44
+ return self._closed
45
+
46
+ def notify(self, topic: str = GAME) -> None:
47
+ """Something changed: wake every waiting feed. Safe from any thread."""
48
+ with self._lock:
49
+ self._versions[topic] += 1
50
+ waiters = list(self._waiters)
51
+ _wake(waiters)
52
+
53
+ def notify_activity(self) -> None:
54
+ """The model called something: only the activity log changed."""
55
+ self.notify(ACTIVITY)
56
+
57
+ def close(self) -> None:
58
+ """Wake every feed, which then ends."""
59
+ with self._lock:
60
+ self._closed = True
61
+ waiters = list(self._waiters)
62
+ _wake(waiters)
63
+
64
+ async def wait(self, seen: int, timeout: float) -> int:
65
+ """The version once it is no longer *seen*, or after *timeout* seconds anyway."""
66
+ waiter = (asyncio.get_running_loop(), asyncio.Event())
67
+ with self._lock:
68
+ if sum(self._versions.values()) != seen or self._closed:
69
+ return sum(self._versions.values())
70
+ self._waiters.add(waiter)
71
+ try:
72
+ with contextlib.suppress(TimeoutError):
73
+ await asyncio.wait_for(waiter[1].wait(), timeout)
74
+ finally:
75
+ with self._lock:
76
+ self._waiters.discard(waiter)
77
+ return self.version
78
+
79
+
80
+ def _wake(waiters: list[Waiter]) -> None:
81
+ for loop, event in waiters:
82
+ # A loop already closed has nobody left to wake.
83
+ with contextlib.suppress(RuntimeError):
84
+ loop.call_soon_threadsafe(event.set)
dndkit/web/feed.py ADDED
@@ -0,0 +1,112 @@
1
+ """Server-sent events: each view's JSON at once, then again whenever it changes.
2
+
3
+ Every open page streams its own views, but they render once per change for
4
+ all of them (``Renders``): ten phones showing the party cost one render, in a
5
+ small pool of threads of their own, so pages never keep the tools waiting.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import threading
11
+ from collections.abc import AsyncIterator, Callable, Mapping
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import anyio
16
+ import anyio.to_thread
17
+
18
+ from dndkit.errors import GameError, NoActiveCampaignError
19
+ from dndkit.web.changes import GAME, Changes
20
+
21
+ Render = Callable[[], Any]
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ HEARTBEAT_SECONDS = 15.0
26
+ RETRY_MILLISECONDS = 2000
27
+ RENDER_THREADS = 2 # the tools have FastMCP's pool; the pages share these
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class View:
32
+ """A view to stream: how to render it, the key every feed showing the same
33
+ thing shares (the table's party, one player's sheet), and the topic of
34
+ changes it follows (``changes.GAME`` or ``changes.ACTIVITY``)."""
35
+
36
+ render: Render
37
+ key: str
38
+ topic: str = GAME
39
+
40
+
41
+ class Renders:
42
+ """Each view's latest text, rendered once per change for every feed."""
43
+
44
+ def __init__(self) -> None:
45
+ self._lock = threading.Lock()
46
+ self._texts: dict[str, tuple[int, str]] = {}
47
+ self._key_locks: dict[str, threading.Lock] = {}
48
+ self._limiter: anyio.CapacityLimiter | None = None
49
+
50
+ async def all(self, views: Mapping[str, View], versions: Mapping[str, int]) -> dict[str, str]:
51
+ """The text of every view at *versions*, in the pages' own threads."""
52
+ if self._limiter is None:
53
+ self._limiter = anyio.CapacityLimiter(RENDER_THREADS)
54
+ return await anyio.to_thread.run_sync(self._all, views, versions, limiter=self._limiter)
55
+
56
+ def _all(self, views: Mapping[str, View], versions: Mapping[str, int]) -> dict[str, str]:
57
+ return {name: self.text(view, versions[view.topic]) for name, view in views.items()}
58
+
59
+ def text(self, view: View, version: int) -> str:
60
+ """*view*'s text at *version* of its topic: kept, or rendered now."""
61
+ with self._lock:
62
+ key_lock = self._key_locks.setdefault(view.key, threading.Lock())
63
+ with key_lock: # a second feed asking at once waits for the first render
64
+ kept = self._texts.get(view.key)
65
+ if kept is not None and kept[0] == version:
66
+ return kept[1]
67
+ text = render(view)
68
+ self._texts[view.key] = (version, text)
69
+ return text
70
+
71
+
72
+ async def stream(
73
+ changes: Changes, views: Mapping[str, View], renders: Renders
74
+ ) -> AsyncIterator[str]:
75
+ """SSE text for *views*: all of them, then those that changed after each change.
76
+
77
+ A heartbeat comment keeps idle connections open; the stream ends when the
78
+ server stops (``Changes.close``).
79
+ """
80
+ yield f"retry: {RETRY_MILLISECONDS}\n\n"
81
+ sent: dict[str, str] = {}
82
+ seen: dict[str, int] | None = None
83
+ while not changes.closed:
84
+ versions = changes.versions()
85
+ if versions == seen:
86
+ yield ": still here\n\n"
87
+ else:
88
+ seen = versions
89
+ for name, text in (await renders.all(views, versions)).items():
90
+ if sent.get(name) != text:
91
+ sent[name] = text
92
+ yield f"event: {name}\ndata: {text}\n\n"
93
+ await changes.wait(sum(versions.values()), HEARTBEAT_SECONDS)
94
+
95
+
96
+ def render_once(views: Mapping[str, View]) -> dict[str, str]:
97
+ """Every view's text now, without keeping it (tests, a one-off look)."""
98
+ return {name: render(view) for name, view in views.items()}
99
+
100
+
101
+ def render(view: View) -> str:
102
+ """One view's JSON; a view that fails sends its error alone, the others go on."""
103
+ try:
104
+ data = view.render()
105
+ except NoActiveCampaignError:
106
+ data = {"error": "noCampaign"} # a word the pages translate
107
+ except GameError as exc:
108
+ data = {"error": str(exc)}
109
+ except Exception:
110
+ logger.exception("A web view failed")
111
+ data = {"error": "viewFailed"}
112
+ return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
dndkit/web/routes.py ADDED
@@ -0,0 +1,134 @@
1
+ """The web site: pages and their live feeds, for three audiences.
2
+
3
+ /table/ the shared screen: public views and the players' join codes
4
+ /p/<token>/ one player's phone: public views and that character's sheet
5
+ /gm/ the game master's backstage, only from this machine
6
+
7
+ Each page is self-contained HTML (``ui.page``) that opens one event stream,
8
+ ``feed?views=a,b``, relative to its own address. Every page is read-only:
9
+ the game changes only through the MCP tools, so the model always knows.
10
+ """
11
+
12
+ from urllib.parse import urlsplit
13
+
14
+ import anyio.to_thread
15
+ from starlette.applications import Starlette
16
+ from starlette.requests import Request
17
+ from starlette.responses import (
18
+ HTMLResponse,
19
+ PlainTextResponse,
20
+ RedirectResponse,
21
+ Response,
22
+ StreamingResponse,
23
+ )
24
+ from starlette.routing import Route
25
+
26
+ from dndkit import ui
27
+ from dndkit.config import WebSettings
28
+ from dndkit.web import audiences, feed
29
+ from dndkit.web.access import is_loopback
30
+ from dndkit.web.audiences import Sources, Views
31
+
32
+ PAGE_HEADERS = {
33
+ "Cache-Control": "no-store",
34
+ "Referrer-Policy": "no-referrer", # a player's address holds their token
35
+ "X-Content-Type-Options": "nosniff",
36
+ "Content-Security-Policy": (
37
+ "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
38
+ "img-src data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'"
39
+ ),
40
+ }
41
+ PROXY_HEADERS = ("forwarded", "x-forwarded-for", "x-forwarded-host", "via")
42
+ FEED_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
43
+
44
+
45
+ def create_app(sources: Sources) -> Starlette:
46
+ """The site over *sources*, shared with the running MCP server."""
47
+
48
+ async def table_feed(request: Request) -> Response:
49
+ return _feed(request, sources, audiences.table(sources, str(request.base_url)))
50
+
51
+ async def player_page(request: Request) -> Response:
52
+ name = await _player(sources, request)
53
+ return _page("player") if name else _refused(request, 404, "unknownLink")
54
+
55
+ async def player_feed(request: Request) -> Response:
56
+ token = request.path_params["token"]
57
+ if not await _player(sources, request):
58
+ return _refused(request, 404, "unknownLink")
59
+ return _feed(request, sources, audiences.player(sources, token))
60
+
61
+ async def gm_page(request: Request) -> Response:
62
+ if not _local(request, sources.settings):
63
+ return _refused(request, 403, "localOnly")
64
+ return _page("gm")
65
+
66
+ async def gm_feed(request: Request) -> Response:
67
+ if not _local(request, sources.settings):
68
+ return _refused(request, 403, "localOnly")
69
+ return _feed(request, sources, audiences.game_master(sources, str(request.base_url)))
70
+
71
+ return Starlette(
72
+ routes=[
73
+ Route("/", lambda _: RedirectResponse("/table/")),
74
+ Route("/table/", lambda _: _page("table")),
75
+ Route("/table/feed", table_feed),
76
+ Route("/p/{token}/", player_page),
77
+ Route("/p/{token}/feed", player_feed),
78
+ Route("/gm/", gm_page),
79
+ Route("/gm/feed", gm_feed),
80
+ ]
81
+ )
82
+
83
+
84
+ def _page(name: str) -> Response:
85
+ return HTMLResponse(ui.page(name, web=True), headers=PAGE_HEADERS)
86
+
87
+
88
+ def _feed(request: Request, sources: Sources, views: Views) -> Response:
89
+ wanted = [name for name in request.query_params.get("views", "").split(",") if name]
90
+ unknown = [name for name in wanted if name not in views]
91
+ if not wanted or unknown:
92
+ known = ", ".join(sorted(views))
93
+ return PlainTextResponse(f"Ask for views among: {known}.", status_code=400)
94
+ stream = feed.stream(sources.changes, {name: views[name] for name in wanted}, sources.renders)
95
+ return StreamingResponse(stream, media_type="text/event-stream", headers=FEED_HEADERS)
96
+
97
+
98
+ async def _player(sources: Sources, request: Request) -> str | None:
99
+ token = request.path_params["token"]
100
+ return await anyio.to_thread.run_sync(sources.player, token)
101
+
102
+
103
+ def _local(request: Request, settings: WebSettings) -> bool:
104
+ """A request from this machine, for this server.
105
+
106
+ The peer is a loopback address or our own. The Host must name this server
107
+ too: a hostile page whose domain resolves to 127.0.0.1 (DNS rebinding)
108
+ would send its own domain. Anything that went through a proxy is refused.
109
+ """
110
+ if request.client is None or any(h in request.headers for h in PROXY_HEADERS):
111
+ return False
112
+ server = (request.scope.get("server") or ("", 0))[0]
113
+ peer = request.client.host
114
+ host = urlsplit(f"//{request.headers.get('host', '')}").hostname or ""
115
+ ours = {server, urlsplit(settings.public_url or "").hostname}
116
+ return (is_loopback(peer) or peer == server) and (is_loopback(host) or host in ours)
117
+
118
+
119
+ REFUSALS = { # English, French
120
+ "unknownLink": (
121
+ "This link matches no character of the active campaign.",
122
+ "Ce lien ne correspond à aucun personnage de la campagne active.",
123
+ ),
124
+ "localOnly": (
125
+ "The backstage opens only on the computer running the game.",
126
+ "La régie ne s'ouvre que sur l'ordinateur qui fait tourner la partie.",
127
+ ),
128
+ }
129
+
130
+
131
+ def _refused(request: Request, status: int, reason: str) -> Response:
132
+ french = request.headers.get("accept-language", "").lower().startswith("fr")
133
+ text = REFUSALS[reason][1 if french else 0]
134
+ return PlainTextResponse(text, status_code=status, headers=PAGE_HEADERS)