From d54110ffcbd7aa363a4facb9e798e1933e23bd22 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 12 Jun 2026 17:23:29 -0700 Subject: [PATCH] =?UTF-8?q?feat(examples):=20Understone=20v0.3=20=E2=80=94?= =?UTF-8?q?=20the=20Watch=20(lobby=20TV)=20+=20a=20livelier=20Vale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A read-only CRT spectator page served by the game process itself, plus content depth. Input never flows through the Watch — it is the wall-mounted terminal in the BBS room; chat remains the only actuator, so there is no input channel to deadlock and no cross-origin surface (the page polls the same origin that served it). - /watch: one self-contained page (inline CSS/JS, no external assets), phosphor CRT styling. The base map paints once from /watch/world.json (terrain glyph rows + a glyph->color legend — the palette the text renderer has deliberately ignored since v0.1 finally gets its first renderer); players overlay as positioned glyphs repainted from /watch/state.json every 2s; the sidebar carries the roster with win stars, the Hall of Legends, and the Herald. SIGNAL LOST on poll failure; the bootstrap retries so a spectator arriving during a server blip recovers without a reload. - Routes ride FastMCP custom_route on the existing process — read-only handlers with no awaits between reads (handlers and sync tools interleave on one event loop, so every response is a consistent snapshot). - door_join/door_help advertise the Watch URL in http mode (stdio: none). - Content: +5 monsters (one per tier; the gauntlet's first-in-tier foes preserved), +3 items smoothing the gear curve, +6 events; fight weight retuned to hold ~55% of encounter rolls. Zero geography churn. - Review round: the Herald window is a plain list tail (id arithmetic under-reported the feed when AUTOINCREMENT ids gap — regression-pinned with sparse ids), and the bootstrap-retry fix above. Tests 149 -> 166. --- examples/door-game/README.md | 32 + examples/door-game/pyproject.toml | 2 +- .../door-game/tests/test_mcp_integration.py | 78 +++ examples/door-game/tests/test_package.py | 2 +- examples/door-game/tests/test_watch.py | 257 +++++++++ examples/door-game/understone/__init__.py | 2 +- examples/door-game/understone/game.py | 21 +- examples/door-game/understone/server.py | 70 ++- examples/door-game/understone/watch.py | 545 ++++++++++++++++++ .../understone/world/data/events.json | 38 +- .../understone/world/data/items.json | 21 + .../understone/world/data/monsters.json | 45 ++ 12 files changed, 1096 insertions(+), 17 deletions(-) create mode 100644 examples/door-game/tests/test_watch.py create mode 100644 examples/door-game/understone/watch.py diff --git a/examples/door-game/README.md b/examples/door-game/README.md index 9ceb9d5a..3808df09 100644 --- a/examples/door-game/README.md +++ b/examples/door-game/README.md @@ -99,6 +99,38 @@ UNDERSTONE_TRANSPORT=streamable-http understone | `UNDERSTONE_PORT` | `8077` | Bind port (streamable-http only). | | `UNDERSTONE_PATH` | `/mcp` | HTTP path for the MCP endpoint (streamable-http only). | +## The Watch — a live spectator view + +When the server runs under the **streamable-http** transport, it also serves a +read-only **Watch** page: the lobby TV of the Vale. Point a browser at + +``` +http://127.0.0.1:8077/watch +``` + +(the host and port follow `UNDERSTONE_HOST` / `UNDERSTONE_PORT`). It is a +period **CRT spectator console** — a green-and-amber phosphor map of the whole +world with every adventurer's `@` marker, a live **Understone Herald** feed, the +**Hall of Legends**, and a roster of who is currently abroad. It refreshes every +couple of seconds; if it loses contact it dims and reads `SIGNAL LOST` until the +server returns. + +The Watch is **strictly read-only**. Input never flows through it — there are no +controls, no forms, nothing that can change the world. It reads the same shared +state the tools do and paints it; that is all. There is no authentication, in +keeping with the rest of this easter-egg server (see the safety note below), so +treat the page as you would the MCP endpoint itself. + +> _Screenshot: the Watch console — a phosphor-green overworld map with amber +> `@` markers, the Herald feed and Hall of Legends down the right-hand rail. +> (Image placeholder; run the server and open the URL to see it live.)_ + +When the Watch is up, the `door_join` welcome and the `door_help` manual both +print its URL so players (and the assistant narrating for them) know it exists. +If you bind to `0.0.0.0` to share the world across a network, advertise a host +that browsers can actually reach (your machine's LAN address or hostname) rather +than `0.0.0.0` itself — the link is composed from `UNDERSTONE_HOST`. + ## Registering with Turnstone Understone is an ordinary MCP server, so it plugs into Turnstone's MCP client diff --git a/examples/door-game/pyproject.toml b/examples/door-game/pyproject.toml index 5648ed94..79621368 100644 --- a/examples/door-game/pyproject.toml +++ b/examples/door-game/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "understone" -version = "0.2.0" +version = "0.3.0" description = "Understone — a BBS-style ANSI door game served over MCP." requires-python = ">=3.11" license = "Apache-2.0" diff --git a/examples/door-game/tests/test_mcp_integration.py b/examples/door-game/tests/test_mcp_integration.py index 205bc852..35a71252 100644 --- a/examples/door-game/tests/test_mcp_integration.py +++ b/examples/door-game/tests/test_mcp_integration.py @@ -6,6 +6,11 @@ client: initialize, list_tools (all nine door_* names), join, look. A second client session joins a second adventurer in the SAME process and world, and the first player's view then shows the '&' other-player marker — proving the shared-world, single-process contract over a real wire. + +A second test drives the read-only Watch routes that ride inside the same app: +GET /watch (the HTML page), /watch/world.json (the static map), and +/watch/state.json (the live snapshot) — confirming the spectator endpoints +serve real world data alongside a working /mcp without breaking either. """ from __future__ import annotations @@ -16,6 +21,7 @@ import threading import time from typing import TYPE_CHECKING, Any +import httpx import pytest import uvicorn from mcp import ClientSession @@ -80,6 +86,12 @@ def live_server(tmp_path: Path) -> Any: if understone_server._GAME is not None: understone_server._GAME.store.close() understone_server._GAME = None + # FastMCP caches a StreamableHTTPSessionManager on the module-level mcp + # singleton and refuses a second lifespan .run() on the same instance. + # Reset it so each fixture instance boots a fresh session manager (the + # production server only ever runs one). Without this, a second + # fixture-using test fails on "run() can only be called once". + understone_server.mcp._session_manager = None async def _call_text(session: ClientSession, name: str, arguments: dict[str, Any]) -> str: @@ -159,3 +171,69 @@ def test_mcp_end_to_end(live_server: str) -> None: # And the leaderboard lists both adventurers (one process, one world). assert "Brandr" in obs["rank"] assert "Sigrun" in obs["rank"] + + +def _watch_base(mcp_url: str) -> str: + """Derive the app root (where /watch lives) from the /mcp endpoint URL.""" + return mcp_url[: -len("/mcp")] if mcp_url.endswith("/mcp") else mcp_url + + +async def _join_over_mcp(mcp_url: str, name: str) -> None: + """Sign one adventurer in over the real MCP wire (so state.json sees them).""" + async with ( + streamable_http_client(mcp_url) as (read, write, _get_session_id), + ClientSession(read, write) as session, + ): + await session.initialize() + await _call_text(session, "door_join", {"player": name}) + + +def test_watch_routes_serve_world_state(live_server: str) -> None: + base = _watch_base(live_server) + + # The MCP join writes the player into the shared world the routes read. + asyncio.run(_join_over_mcp(live_server, "Watcher")) + + with httpx.Client(timeout=5.0) as client: + page = client.get(f"{base}/watch") + world = client.get(f"{base}/watch/world.json") + state = client.get(f"{base}/watch/state.json") + + # The page is real HTML carrying the static masthead. + assert page.status_code == 200 + assert page.headers["content-type"].startswith("text/html") + assert "Understone — Live Watch" in page.text + + # The static world payload matches the loaded world. + assert world.status_code == 200 + world_body = world.json() + assert world_body["width"] == 96 + assert world_body["height"] == 48 + assert len(world_body["glyph_rows"]) == world_body["height"] + assert all(len(row) == world_body["width"] for row in world_body["glyph_rows"]) + + # The live snapshot lists the adventurer who joined over MCP. + assert state.status_code == 200 + state_body = state.json() + names = {p["name"] for p in state_body["players"]} + assert "Watcher" in names + + +def test_watch_routes_coexist_with_mcp(live_server: str) -> None: + """The custom routes don't shadow /mcp: tool calls still work alongside them.""" + base = _watch_base(live_server) + + async def _drive_both() -> tuple[str, int]: + async with ( + streamable_http_client(live_server) as (read, write, _get_session_id), + ClientSession(read, write) as session, + ): + await session.initialize() + joined = await _call_text(session, "door_join", {"player": "Coexist"}) + with httpx.Client(timeout=5.0) as client: + status = client.get(f"{base}/watch/state.json").status_code + return joined, status + + joined, watch_status = asyncio.run(_drive_both()) + assert "@" in joined # the MCP tool still returns a real frame + assert watch_status == 200 # and the watch route still answers diff --git a/examples/door-game/tests/test_package.py b/examples/door-game/tests/test_package.py index 3565de2a..c1307685 100644 --- a/examples/door-game/tests/test_package.py +++ b/examples/door-game/tests/test_package.py @@ -6,4 +6,4 @@ import understone def test_version_present() -> None: - assert understone.__version__ == "0.2.0" + assert understone.__version__ == "0.3.0" diff --git a/examples/door-game/tests/test_watch.py b/examples/door-game/tests/test_watch.py new file mode 100644 index 00000000..6aa9ba31 --- /dev/null +++ b/examples/door-game/tests/test_watch.py @@ -0,0 +1,257 @@ +"""Watch-page payload builders and the watch-URL advertisement. + +These are pure-unit tests of :mod:`understone.watch` (no network): the static +world payload's shape and legend completeness, the dynamic state payload's +player/herald/hall content under a frozen clock, and the join/help "Watch the +Vale live" line that appears only when a Game carries a watch URL. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from tests.conftest import fixed_clock, utc +from understone import server as understone_server +from understone import watch +from understone.engine.log import Event +from understone.engine.rng import GameRNG +from understone.game import Game +from understone.persistence import Store +from understone.world.loader import load_world + +if TYPE_CHECKING: + from understone.engine.world import World + +PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data" + + +@pytest.fixture +def world() -> World: + return load_world(PACK) + + +@pytest.fixture +def clock() -> object: + return fixed_clock(utc(2026, 6, 12, 10, 30)) + + +def _game(tmp_path: Path, clock: object, watch_url: str | None = None) -> Game: + world = load_world(PACK) + store = Store(tmp_path / "watch.db") + return Game( # type: ignore[arg-type] + world, store, clock=clock, rng=GameRNG(seed=7), watch_url=watch_url + ) + + +# --------------------------------------------------------------------------- +# World payload (static) +# --------------------------------------------------------------------------- + + +def test_world_payload_shape(world: World) -> None: + payload = watch.build_world_payload(world) + assert payload["name"] == world.name + assert payload["width"] == world.width + assert payload["height"] == world.height + rows = payload["glyph_rows"] + assert isinstance(rows, list) + assert len(rows) == world.height + assert all(isinstance(r, str) and len(r) == world.width for r in rows) + + +def test_world_payload_legend_is_complete(world: World) -> None: + payload = watch.build_world_payload(world) + rows = payload["glyph_rows"] + legend = payload["legend"] + assert isinstance(rows, list) + assert isinstance(legend, dict) + # Contract: every glyph that appears in the rows has a colour in the legend. + glyphs = {ch for row in rows for ch in row} + assert glyphs <= set(legend) + # And every legend colour is a real palette colour name (no stray roles). + from understone.screen.palette import Color + + valid = {c.value for c in Color} + assert set(legend.values()) <= valid + + +def test_world_payload_locations_present(world: World) -> None: + payload = watch.build_world_payload(world) + locations = payload["locations"] + assert isinstance(locations, list) + assert len(locations) == len(world.locations) + by_name = {loc["name"]: loc for loc in locations} + # The dungeon mouth rides in the locations overlay with its glyph + colour. + deep = by_name["The Understone Deep"] + assert deep["glyph"] == ">" + assert deep["color"] == "dungeon" + assert (deep["x"], deep["y"]) == (70, 12) + + +# --------------------------------------------------------------------------- +# State payload (dynamic) +# --------------------------------------------------------------------------- + + +def test_state_payload_includes_joined_player(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + game.join("Brandr") + payload = watch.build_state_payload(game) + players = payload["players"] + assert isinstance(players, list) + brandr = next(p for p in players if p["name"] == "Brandr") + assert brandr["level"] == 1 + assert brandr["wins"] == 0 + assert brandr["hp"] == brandr["max_hp"] + assert brandr["mode"] == "tile" + assert (brandr["x"], brandr["y"]) == game.world.spawn + + +def test_state_payload_reports_all_players_including_menu(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + game.join("Brandr") + game.join("Sigrun") + # Put Sigrun in a MENU surface; the Watch still shows her on the board. + sigrun = game.players["Sigrun"] + from understone.engine.models import Mode + + sigrun.mode = Mode.MENU + sigrun.at_location = "inn" + payload = watch.build_state_payload(game) + names = {p["name"] for p in payload["players"]} # type: ignore[union-attr] + assert names == {"Brandr", "Sigrun"} + menu = next(p for p in payload["players"] if p["name"] == "Sigrun") # type: ignore[union-attr] + assert menu["mode"] == "menu" + + +def test_state_payload_ts_comes_from_clock(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + payload = watch.build_state_payload(game) + assert payload["ts"] == "2026-06-12T10:30:00+00:00" + + +def test_state_payload_herald_is_last_15_oldest_first(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + # Replace the resident feed with 20 synthetic events in ascending id order. + game.events = [ + Event( + event_id=i, + ts=f"2026-06-12T10:{i:02d}:00+00:00", + kind="join", + actor=f"Hero{i}", + text=f"event {i}", + ) + for i in range(1, 21) + ] + payload = watch.build_state_payload(game) + herald = payload["herald"] + assert isinstance(herald, list) + assert len(herald) == 15 + # Oldest-first: the window is events 6..20, in ascending order. + assert herald[0]["text"] == "event 6" + assert herald[-1]["text"] == "event 20" + + +def test_state_payload_herald_full_window_despite_sparse_ids(tmp_path: Path, clock: object) -> None: + """Id gaps must not shrink the feed (regression: the window is a list + tail, not id arithmetic — AUTOINCREMENT ids may be non-contiguous).""" + game = _game(tmp_path, clock) + game.events = [ + Event( + event_id=i * 7, # sparse, non-contiguous ids + ts=f"2026-06-12T10:{i:02d}:00+00:00", + kind="join", + actor=f"Hero{i}", + text=f"event {i}", + ) + for i in range(1, 21) + ] + herald = watch.build_state_payload(game)["herald"] + assert len(herald) == 15 + assert herald[0]["text"] == "event 6" + assert herald[-1]["text"] == "event 20" + + +def test_state_payload_herald_handles_short_feed(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + game.events = [ + Event( + event_id=1, + ts="2026-06-12T10:00:00+00:00", + kind="join", + actor="Solo", + text="only one", + ) + ] + payload = watch.build_state_payload(game) + herald = payload["herald"] + assert isinstance(herald, list) + assert [e["text"] for e in herald] == ["only one"] + + +def test_state_payload_hall_capped_at_five(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + # Seven immortalised runs; the Watch shows only the five most recent. + for i in range(7): + game.store.insert_hall_row(f"Hero{i}", f"2026-06-{10 + i:02d}T12:00:00+00:00", i, 6 + i) + game.store.commit() + payload = watch.build_state_payload(game) + hall = payload["hall"] + assert isinstance(hall, list) + assert len(hall) == 5 + # Newest first (store ordering): Hero6 leads. + assert hall[0]["name"] == "Hero6" + assert hall[0]["level_at_win"] == 12 + + +# --------------------------------------------------------------------------- +# Watch-URL advertisement (join banner + help manual) +# --------------------------------------------------------------------------- + + +def test_join_advertises_watch_url_when_set(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock, watch_url="http://127.0.0.1:8077/watch") + out = game.join("Brandr") + assert "Watch the Vale live: http://127.0.0.1:8077/watch" in out + + +def test_join_omits_watch_line_when_unset(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + out = game.join("Brandr") + assert "Watch the Vale live" not in out + + +def test_resume_advertises_watch_url_when_set(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock, watch_url="http://127.0.0.1:8077/watch") + game.join("Brandr") + again = game.join("Brandr") + assert "Welcome back" in again + assert "Watch the Vale live: http://127.0.0.1:8077/watch" in again + + +def test_help_advertises_watch_url_when_set(tmp_path: Path) -> None: + # door_help reads the module game; install one carrying a watch URL. + world = load_world(PACK) + store = Store(tmp_path / "help.db") + understone_server._set_game(Game(world, store, watch_url="http://127.0.0.1:8077/watch")) + try: + manual = understone_server.door_help() + assert "Watch the Vale live: http://127.0.0.1:8077/watch" in manual + finally: + understone_server._GAME.store.close() # type: ignore[union-attr] + understone_server._GAME = None + + +def test_help_omits_watch_line_when_unset(tmp_path: Path) -> None: + world = load_world(PACK) + store = Store(tmp_path / "help.db") + understone_server._set_game(Game(world, store)) + try: + manual = understone_server.door_help() + assert "Watch the Vale live" not in manual + finally: + understone_server._GAME.store.close() # type: ignore[union-attr] + understone_server._GAME = None diff --git a/examples/door-game/understone/__init__.py b/examples/door-game/understone/__init__.py index bdeceacb..0e198e07 100644 --- a/examples/door-game/understone/__init__.py +++ b/examples/door-game/understone/__init__.py @@ -1,3 +1,3 @@ """Understone — a BBS-style ANSI door game served over MCP.""" -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/examples/door-game/understone/game.py b/examples/door-game/understone/game.py index b54f36ab..c6140707 100644 --- a/examples/door-game/understone/game.py +++ b/examples/door-game/understone/game.py @@ -98,11 +98,15 @@ class Game: *, clock: Callable[[], datetime] | None = None, rng: GameRNG | None = None, + watch_url: str | None = None, ) -> None: self.world = world self.store = store self.clock = clock or (lambda: datetime.now(UTC)) self.rng = rng or GameRNG() + # When the http transport is up, this is the spectator page URL; the + # join banner and the help manual advertise it. None under stdio. + self.watch_url = watch_url players, events = store.load_all() self.players: dict[str, Player] = players self.events: list[Event] = events @@ -117,6 +121,14 @@ class Game: def _now_iso(self) -> str: return self.clock().isoformat() + def watch_line(self) -> str: + """Return the 'Watch the Vale live' advertisement, or '' when no URL. + + Surfaced by ``join`` (appended to its banner) and by the server's + ``door_help`` manual. Empty under stdio, where there is no page to view. + """ + return f"Watch the Vale live: {self.watch_url}" if self.watch_url else "" + def _get(self, name: str) -> Player | None: return self.players.get(name.strip()) @@ -254,7 +266,7 @@ class Game: self.store.upsert_player(existing) self.store.commit() banner = f"Welcome back to {self.world.name}, {existing.name}." - return self._overworld_frame(existing, lines=[banner]) + return self._overworld_frame(existing, lines=self._with_watch(banner)) settings = self.world.settings atk, def_, max_hp = self._fresh_combat_stats() @@ -290,7 +302,12 @@ class Game: f"You arrive in {self.world.name}, {clean}. A road runs east from the town.\n" "New here? Call door_help to learn how the world is run." ) - return self._overworld_frame(player, lines=[banner]) + return self._overworld_frame(player, lines=self._with_watch(banner)) + + def _with_watch(self, banner: str) -> list[str]: + """Return the join banner as frame lines, plus the watch line if set.""" + line = self.watch_line() + return [banner, line] if line else [banner] def _fresh_combat_stats(self) -> tuple[int, int, int]: """Return the fresh ``(atk, def_, max_hp)`` for the starting kit. diff --git a/examples/door-game/understone/server.py b/examples/door-game/understone/server.py index 30da1d52..c8424383 100644 --- a/examples/door-game/understone/server.py +++ b/examples/door-game/understone/server.py @@ -1,4 +1,4 @@ -"""MCP server for Understone — the only module that imports ``mcp``. +"""MCP server for Understone — the only module that imports ``mcp`` (or ``starlette``). Nine ``door_*`` tools form the entire player interface. Every handler is a synchronous ``def`` that takes and returns ``str``; no exception is allowed @@ -7,6 +7,13 @@ returns an in-fiction line). The handlers are thin wrappers over a single module-level :class:`~understone.game.Game`; all rules live behind that façade. +Three extra HTTP routes (``/watch`` and its two JSON feeds) serve the +read-only spectator page from :mod:`understone.watch`. They are registered via +FastMCP's ``custom_route`` and ride inside the streamable-http app; the +``starlette`` request/response types appear ONLY here, mirroring the MCP SDK's +own ``custom_route`` examples. The routes are unauthenticated by design and +strictly read-only — they never mutate or persist world state. + Usage:: understone # via entry point (stdio transport) @@ -30,7 +37,9 @@ from pathlib import Path from typing import TYPE_CHECKING from mcp.server.fastmcp import FastMCP +from starlette.responses import HTMLResponse, JSONResponse, Response +from understone import watch from understone.errors import WorldLoadError from understone.game import Game from understone.persistence import Store @@ -38,6 +47,7 @@ from understone.world.loader import load_world if TYPE_CHECKING: from starlette.applications import Starlette + from starlette.requests import Request log = logging.getLogger(__name__) @@ -151,13 +161,13 @@ _BLANK_NAME = 'The gatekeeper squints. "I didn\'t catch your name, traveller."' _GAME: Game | None = None -def _build_game() -> Game: +def _build_game(watch_url: str | None = None) -> Game: """Construct the module Game from environment configuration.""" db_path = os.environ.get("UNDERSTONE_DB", "understone.db") world_dir = os.environ.get("UNDERSTONE_WORLD") or str(_PACKAGED_WORLD) world = load_world(world_dir) store = Store(db_path) - return Game(world, store) + return Game(world, store, watch_url=watch_url) def _game() -> Game: @@ -195,6 +205,9 @@ def door_help() -> str: cheat-sheet. Call door_help before your first session to learn how to run the game, then call door_join to begin. """ + watch_line = _game().watch_line() + if watch_line: + return f"{_DM_MANUAL}\nTHE LOBBY TV\n {watch_line}\n" return _DM_MANUAL @@ -388,6 +401,30 @@ def door_bestow(player: str, reason: str, gold: int = 0, heal: int = 0) -> str: return _unexpected() +# FastMCP.custom_route has no return annotation upstream (mcp 1.27.2), so mypy +# reads the decorator as untyped; the ignore is scoped to that single gap. +@mcp.custom_route("/watch", methods=["GET"]) # type: ignore[untyped-decorator] +async def watch_page(_request: Request) -> Response: + """Serve the read-only CRT spectator page (static HTML, no world reads).""" + return HTMLResponse(watch.WATCH_HTML) + + +@mcp.custom_route("/watch/world.json", methods=["GET"]) # type: ignore[untyped-decorator] +async def watch_world(_request: Request) -> Response: + """Serve the STATIC map payload (dimensions, coloured rows, locations).""" + return JSONResponse(watch.build_world_payload(_game().world)) + + +@mcp.custom_route("/watch/state.json", methods=["GET"]) # type: ignore[untyped-decorator] +async def watch_state(_request: Request) -> Response: + """Serve the DYNAMIC snapshot (players, Herald, Hall) — read-only. + + The builder reads the module Game with no ``await`` in between, so each + response is a consistent point-in-time snapshot of the shared world. + """ + return JSONResponse(watch.build_state_payload(_game())) + + def _unexpected() -> str: """In-fiction line for an unexpected server-side error.""" return ( @@ -396,15 +433,19 @@ def _unexpected() -> str: ) -def create_app(db_path: str, world_dir: str | None = None) -> Starlette: +def create_app( + db_path: str, world_dir: str | None = None, watch_url: str | None = None +) -> Starlette: """Build the streamable-HTTP ASGI app backed by a fresh game. Used by both ``main`` (for the http transport) and the integration tests, - so tests can point at a temp DB without environment juggling. + so tests can point at a temp DB without environment juggling. ``watch_url``, + when given, is the spectator page URL the join banner and help manual + advertise; ``main`` derives it from the bind host/port. """ world = load_world(world_dir or str(_PACKAGED_WORLD)) store = Store(db_path) - _set_game(Game(world, store)) + _set_game(Game(world, store, watch_url=watch_url)) return mcp.streamable_http_app() @@ -419,16 +460,23 @@ def main() -> None: transport = os.environ.get("UNDERSTONE_TRANSPORT", "stdio") if transport == "streamable-http": - mcp.settings.host = os.environ.get("UNDERSTONE_HOST", "127.0.0.1") - mcp.settings.port = int(os.environ.get("UNDERSTONE_PORT", "8077")) + host = os.environ.get("UNDERSTONE_HOST", "127.0.0.1") + port = int(os.environ.get("UNDERSTONE_PORT", "8077")) + mcp.settings.host = host + mcp.settings.port = port mcp.settings.streamable_http_path = os.environ.get("UNDERSTONE_PATH", "/mcp") mcp.settings.stateless_http = False - # Build the game eagerly so a config error surfaces before serving. - _game() + # The spectator page is only reachable over http, so its URL is composed + # here from the bind address. A 0.0.0.0 bind should advertise a host a + # browser can actually reach (see the README Watch section). + watch_url = f"http://{host}:{port}/watch" + # Build the game eagerly (with the watch URL) so a config error surfaces + # before serving and the join/help advertisements carry the page link. try: - mcp.run(transport="streamable-http") + _set_game(_build_game(watch_url)) except WorldLoadError as exc: raise SystemExit(f"failed to load world: {exc}") from exc + mcp.run(transport="streamable-http") return try: diff --git a/examples/door-game/understone/watch.py b/examples/door-game/understone/watch.py new file mode 100644 index 00000000..6c63ce9c --- /dev/null +++ b/examples/door-game/understone/watch.py @@ -0,0 +1,545 @@ +"""The Watch page — a read-only CRT spectator view of the shared world. + +This module is PURE: it imports nothing from ``mcp`` or ``starlette``. It owns +two payload builders and one self-contained HTML page; the server wires them to +HTTP routes. Input never flows through here — the Watch is the lobby TV, not a +controller. + +* :func:`build_world_payload` — the STATIC map: dimensions, the legend-coloured + terrain rows, and the placed locations. Fetched once by the page. +* :func:`build_state_payload` — the DYNAMIC snapshot: every player's position + and vitals, the recent Herald feed, and the Hall of Legends. Polled. +* :data:`WATCH_HTML` — one inline-everything page (vanilla JS, phosphor CRT + styling) that paints the base map once and overlays the players each poll. + +A correspondence game leaves every adventurer on the board between their turns, +so the state payload reports *all* players, not just the active ones. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from understone.engine.models import Mode + +if TYPE_CHECKING: + from understone.engine.log import Event + from understone.engine.world import World + from understone.game import Game + +# How many of the newest Herald events the Watch shows, oldest-first. +_HERALD_LIMIT = 15 +# How many Hall-of-Legends runs the Watch shows. +_HALL_LIMIT = 5 + + +def build_world_payload(world: World) -> dict[str, object]: + """Return the STATIC map payload the Watch page fetches once. + + ``glyph_rows`` is the base terrain rendered glyph-for-glyph (locations are + NOT burned in here — they ride in ``locations`` so the client can colour + them as an overlay). ``legend`` maps every terrain glyph that appears to a + palette colour *name*; the client owns the name→hex mapping. Completeness is + a contract: every glyph in ``glyph_rows`` has a ``legend`` entry. + """ + glyph_rows: list[str] = [] + legend: dict[str, str] = {} + for y in range(world.height): + chars: list[str] = [] + for x in range(world.width): + terrain = world.terrain_at(x, y) + chars.append(terrain.glyph) + legend.setdefault(terrain.glyph, terrain.color) + glyph_rows.append("".join(chars)) + + locations = [ + { + "x": loc.x, + "y": loc.y, + "glyph": loc.glyph, + "name": loc.name, + "color": loc.color, + } + for loc in world.locations + ] + return { + "name": world.name, + "width": world.width, + "height": world.height, + "glyph_rows": glyph_rows, + "legend": legend, + "locations": locations, + } + + +def build_state_payload(game: Game) -> dict[str, object]: + """Return the DYNAMIC snapshot payload the Watch page polls. + + Reports every player (a correspondence game keeps idle pieces on the + board), the last :data:`_HERALD_LIMIT` events oldest-first, and the top + :data:`_HALL_LIMIT` completed runs. ``ts`` is the game clock, so a seeded + test clock drives a deterministic payload. + """ + players = [ + { + "name": p.name, + "x": p.x, + "y": p.y, + "level": p.level, + "wins": p.wins, + "hp": p.hp, + "max_hp": p.max_hp, + "mode": p.mode.value if isinstance(p.mode, Mode) else str(p.mode), + } + for p in game.players.values() + ] + herald = [ + {"ts": event.ts, "kind": event.kind, "text": event.text} for event in _recent_events(game) + ] + hall = [ + { + "name": entry.name, + "level_at_win": entry.level_at_win, + "run_days": entry.run_days, + "win_ts": entry.win_ts, + } + for entry in game.store.top_hall(_HALL_LIMIT) + ] + return { + "ts": game.clock().isoformat(), + "players": players, + "herald": herald, + "hall": hall, + } + + +def _recent_events(game: Game) -> list[Event]: + """Return the last :data:`_HERALD_LIMIT` resident events, oldest-first. + + The façade keeps events in ascending id order, so the list tail IS the + newest window — a plain slice is correct even when event ids are sparse + (AUTOINCREMENT gaps must not shrink the feed). + """ + return game.events[-_HERALD_LIMIT:] + + +# The Watch page. One self-contained document: inline CSS + vanilla JS, no +# external assets, no innerHTML-with-data (every dynamic node is built with +# createElement / textContent). The base map is painted once from world.json; +# players are an absolutely-positioned overlay repainted from state.json every +# two seconds. On a fetch failure the page dims and shows "SIGNAL LOST". +WATCH_HTML = """\ + + + + + +Understone — Live Watch + + + +
+

The Understone Watch

+
CONNECTING…
+
+
+
+
+
+ +
+ + + +""" diff --git a/examples/door-game/understone/world/data/events.json b/examples/door-game/understone/world/data/events.json index 26e4eafb..b4b91de7 100644 --- a/examples/door-game/understone/world/data/events.json +++ b/examples/door-game/understone/world/data/events.json @@ -2,7 +2,7 @@ "events": [ { "kind": "fight", - "weight": 55, + "weight": 82, "text": "Something snarls out of the brush." }, { @@ -19,6 +19,13 @@ "min": 2, "max": 9 }, + { + "kind": "gold", + "weight": 2, + "text": "a hoard-cache prised from beneath a toppled menhir", + "min": 40, + "max": 80 + }, { "kind": "heal", "weight": 5, @@ -33,6 +40,13 @@ "min": 4, "max": 10 }, + { + "kind": "heal", + "weight": 5, + "text": "a moss-bed where weary travellers mend", + "min": 6, + "max": 14 + }, { "kind": "trap", "weight": 5, @@ -47,6 +61,13 @@ "min": 2, "max": 8 }, + { + "kind": "trap", + "weight": 5, + "text": "a hidden pit-deadfall, its cover long rotted through", + "min": 4, + "max": 11 + }, { "kind": "lore", "weight": 3, @@ -61,6 +82,21 @@ "kind": "lore", "weight": 4, "text": "a charcoal sketch nailed to a tree: a stair of stone descending into a great open mouth." + }, + { + "kind": "lore", + "weight": 3, + "text": "a scorched ring in the grass where no plant grows, the soil still faintly warm." + }, + { + "kind": "lore", + "weight": 3, + "text": "a ranger's cairn marking the dungeon road, three skulls set facing the deep as warning." + }, + { + "kind": "lore", + "weight": 4, + "text": "fishermen swear the vale lake has no bottom, and that on still nights it breathes." } ] } diff --git a/examples/door-game/understone/world/data/items.json b/examples/door-game/understone/world/data/items.json index 98a2f48f..856ba2f7 100644 --- a/examples/door-game/understone/world/data/items.json +++ b/examples/door-game/understone/world/data/items.json @@ -13,6 +13,13 @@ "atk": 5, "price": 40 }, + { + "id": "iron_sword", + "name": "Iron Sword", + "slot": "weapon", + "atk": 7, + "price": 80 + }, { "id": "war_axe", "name": "War Axe", @@ -27,6 +34,13 @@ "def": 1, "price": 0 }, + { + "id": "padded_jerkin", + "name": "Padded Jerkin", + "slot": "armor", + "def": 2, + "price": 25 + }, { "id": "leather_armor", "name": "Leather Armor", @@ -54,5 +68,12 @@ "slot": "consumable", "heal": 40, "price": 35 + }, + { + "id": "elixir_of_the_vale", + "name": "Elixir of the Vale", + "slot": "consumable", + "heal": 70, + "price": 60 } ] diff --git a/examples/door-game/understone/world/data/monsters.json b/examples/door-game/understone/world/data/monsters.json index 6f8cfe20..7ec6a64e 100644 --- a/examples/door-game/understone/world/data/monsters.json +++ b/examples/door-game/understone/world/data/monsters.json @@ -8,6 +8,15 @@ "xp": 8, "gold": 3 }, + { + "tier": 1, + "name": "Mud Hare", + "hp": 7, + "atk": 5, + "def": 0, + "xp": 9, + "gold": 2 + }, { "tier": 2, "name": "Goblin", @@ -17,6 +26,15 @@ "xp": 18, "gold": 7 }, + { + "tier": 2, + "name": "Bandit Scout", + "hp": 14, + "atk": 6, + "def": 1, + "xp": 20, + "gold": 9 + }, { "tier": 3, "name": "Forest Wolf", @@ -26,6 +44,15 @@ "xp": 35, "gold": 14 }, + { + "tier": 3, + "name": "Bog Stalker", + "hp": 22, + "atk": 9, + "def": 2, + "xp": 38, + "gold": 16 + }, { "tier": 4, "name": "Cave Troll", @@ -35,6 +62,15 @@ "xp": 70, "gold": 30 }, + { + "tier": 4, + "name": "Barrow Wight", + "hp": 35, + "atk": 13, + "def": 4, + "xp": 65, + "gold": 28 + }, { "tier": 5, "name": "Stone Wyrm", @@ -44,6 +80,15 @@ "xp": 140, "gold": 60 }, + { + "tier": 5, + "name": "Vale Reaver", + "hp": 55, + "atk": 17, + "def": 6, + "xp": 130, + "gold": 55 + }, { "tier": 6, "name": "the Wyrm Below",