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/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
|
dndkit/web/audiences.py
ADDED
|
@@ -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)
|