feat(examples): Understone v0.3 — the Watch (lobby TV) + a livelier Vale

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.
This commit is contained in:
Patrick Buckley
2026-06-12 17:23:29 -07:00
parent 4b8681db8a
commit d54110ffcb
12 changed files with 1096 additions and 17 deletions
+32
View File
@@ -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
+1 -1
View File
@@ -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"
@@ -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
+1 -1
View File
@@ -6,4 +6,4 @@ import understone
def test_version_present() -> None:
assert understone.__version__ == "0.2.0"
assert understone.__version__ == "0.3.0"
+257
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Understone — a BBS-style ANSI door game served over MCP."""
__version__ = "0.2.0"
__version__ = "0.3.0"
+19 -2
View File
@@ -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.
+59 -11
View File
@@ -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:
+545
View File
@@ -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 = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Understone — Live Watch</title>
<style>
:root {
--phosphor: #7dffa0;
--phosphor-dim: #2f7a46;
--amber: #ffb44d;
--bg: #050a06;
--panel: #0a140d;
--edge: #163a22;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--bg);
color: var(--phosphor);
font-family: "DejaVu Sans Mono", "Liberation Mono", "Courier New", monospace;
font-size: 14px;
line-height: 1.2;
}
body::after {
/* Scanline overlay — faint, non-interactive. */
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0px,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0.22) 3px,
rgba(0, 0, 0, 0) 4px
);
z-index: 50;
}
body.lost { filter: grayscale(0.7) brightness(0.55); }
header {
padding: 10px 16px;
border-bottom: 1px solid var(--edge);
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
text-shadow: 0 0 6px rgba(125, 255, 160, 0.5);
}
header h1 {
margin: 0;
font-size: 18px;
letter-spacing: 2px;
text-transform: uppercase;
}
.live {
color: var(--amber);
font-size: 13px;
letter-spacing: 1px;
text-shadow: 0 0 6px rgba(255, 180, 77, 0.5);
}
.live .dot {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 6px;
border-radius: 50%;
background: var(--amber);
box-shadow: 0 0 8px var(--amber);
animation: pulse 2s ease-in-out infinite;
}
body.lost .live .dot { animation: none; background: var(--phosphor-dim); box-shadow: none; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
main {
display: flex;
gap: 16px;
padding: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
.map-frame {
position: relative;
border: 1px solid var(--edge);
background: var(--panel);
padding: 8px;
overflow: auto;
max-width: 100%;
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.6);
}
#map {
position: relative;
white-space: pre;
text-shadow: 0 0 4px rgba(125, 255, 160, 0.35);
}
#map .row { display: block; }
#overlay {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
#overlay .pc {
position: absolute;
color: var(--amber);
text-shadow: 0 0 6px rgba(255, 180, 77, 0.8);
}
aside {
flex: 1 1 280px;
min-width: 260px;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
border: 1px solid var(--edge);
background: var(--panel);
padding: 10px 12px;
}
.card h2 {
margin: 0 0 8px;
font-size: 13px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--phosphor);
border-bottom: 1px solid var(--edge);
padding-bottom: 4px;
}
ul { margin: 0; padding: 0; list-style: none; }
li { padding: 2px 0; }
.muted { color: var(--phosphor-dim); }
.adv-name { color: var(--amber); }
.stars { color: var(--amber); letter-spacing: 1px; }
.feed li { border-bottom: 1px dotted var(--edge); padding: 4px 0; }
.feed li:last-child { border-bottom: none; }
.feed .ts { color: var(--phosphor-dim); margin-right: 6px; }
</style>
</head>
<body>
<header>
<h1 id="world-name">The Understone Watch</h1>
<div class="live"><span class="dot"></span><span id="live-label">CONNECTING…</span></div>
</header>
<main>
<div class="map-frame">
<div id="map"><div id="overlay"></div></div>
</div>
<aside>
<section class="card">
<h2>Adventurers</h2>
<ul id="adventurers"><li class="muted">…</li></ul>
</section>
<section class="card">
<h2>Hall of Legends</h2>
<ul id="hall"><li class="muted">No legends yet.</li></ul>
</section>
<section class="card">
<h2>The Understone Herald</h2>
<ul id="herald" class="feed"><li class="muted">…</li></ul>
</section>
</aside>
</main>
<script>
(function () {
"use strict";
// Palette colour-name -> phosphor-tinted hex. Mirrors understone.screen.palette
// Color values; the base map is coloured from this, never from the server.
var PALETTE = {
default: "#7dffa0",
wall: "#5a6b60",
floor: "#3f7a52",
player: "#ffb44d",
other_player: "#ffd089",
monster: "#ff6b6b",
item: "#ffe07d",
water: "#4aa6c8",
tree: "#3fae6a",
town: "#ffd089",
dungeon: "#c98bff"
};
function colorFor(name) {
return PALETTE[name] || PALETTE.default;
}
var overlay = document.getElementById("overlay");
var mapEl = document.getElementById("map");
var liveLabel = document.getElementById("live-label");
var dims = null; // {width, height} once the map is painted.
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function clockLabel(iso) {
var d = new Date(iso);
if (isNaN(d.getTime())) { return "--:--:--"; }
return pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds());
}
function stars(wins) {
if (wins <= 0) { return ""; }
if (wins <= 5) { return "\\u2605".repeat(wins); }
return "\\u2605x" + wins;
}
// Paint the base map ONCE. Each row is a sequence of <span> runs, a new run
// only where the legend colour changes, so a row is a handful of spans.
function paintMap(world) {
document.getElementById("world-name").textContent = world.name + " — Live Watch";
var legend = world.legend || {};
var rows = world.glyph_rows || [];
for (var y = 0; y < rows.length; y++) {
var row = rows[y];
var rowEl = document.createElement("div");
rowEl.className = "row";
var runText = "";
var runColor = null;
for (var x = 0; x < row.length; x++) {
var ch = row.charAt(x);
var col = colorFor(legend[ch]);
if (runColor === null) { runColor = col; }
if (col !== runColor) {
rowEl.appendChild(makeSpan(runText, runColor));
runText = "";
runColor = col;
}
runText += ch;
}
if (runText.length) { rowEl.appendChild(makeSpan(runText, runColor)); }
mapEl.insertBefore(rowEl, overlay);
}
dims = { width: world.width, height: world.height };
paintLocations(world.locations || []);
}
function makeSpan(text, color) {
var span = document.createElement("span");
span.style.color = color;
span.textContent = text;
return span;
}
// Locations are painted into the overlay layer (above the base terrain) so
// their glyph and colour win over the terrain beneath the door.
function paintLocations(locations) {
for (var i = 0; i < locations.length; i++) {
var loc = locations[i];
var el = document.createElement("span");
el.className = "pc";
el.style.left = "calc(" + loc.x + " * 1ch)";
el.style.top = "calc(" + loc.y + " * 1lh)";
el.style.color = colorFor(loc.color);
el.style.textShadow = "0 0 6px " + colorFor(loc.color);
el.textContent = loc.glyph;
el.title = loc.name;
overlay.appendChild(el);
}
}
// Player markers live in their own layer, cleared and repainted each poll.
var pcLayer = document.createElement("div");
pcLayer.id = "pc-layer";
overlay.appendChild(pcLayer);
function paintPlayers(players) {
while (pcLayer.firstChild) { pcLayer.removeChild(pcLayer.firstChild); }
for (var i = 0; i < players.length; i++) {
var p = players[i];
var el = document.createElement("span");
el.className = "pc";
el.style.left = "calc(" + p.x + " * 1ch)";
el.style.top = "calc(" + p.y + " * 1lh)";
el.textContent = "@";
el.title = p.name;
pcLayer.appendChild(el);
}
}
function renderAdventurers(players) {
var list = document.getElementById("adventurers");
while (list.firstChild) { list.removeChild(list.firstChild); }
if (!players.length) {
list.appendChild(muted("The Vale is empty."));
return;
}
var sorted = players.slice().sort(function (a, b) {
return b.level - a.level || a.name.localeCompare(b.name);
});
for (var i = 0; i < sorted.length; i++) {
var p = sorted[i];
var li = document.createElement("li");
var name = document.createElement("span");
name.className = "adv-name";
name.textContent = p.name;
li.appendChild(name);
var star = stars(p.wins);
if (star) {
var s = document.createElement("span");
s.className = "stars";
s.textContent = " " + star;
li.appendChild(s);
}
var rest = document.createElement("span");
rest.className = "muted";
rest.textContent = " Lv" + p.level + " HP " + p.hp + "/" + p.max_hp;
li.appendChild(rest);
list.appendChild(li);
}
}
function renderHall(hall) {
var list = document.getElementById("hall");
while (list.firstChild) { list.removeChild(list.firstChild); }
if (!hall.length) {
list.appendChild(muted("No legends yet."));
return;
}
for (var i = 0; i < hall.length; i++) {
var h = hall[i];
var li = document.createElement("li");
var star = document.createElement("span");
star.className = "stars";
star.textContent = "\\u2605 ";
li.appendChild(star);
var name = document.createElement("span");
name.className = "adv-name";
name.textContent = h.name;
li.appendChild(name);
var rest = document.createElement("span");
rest.className = "muted";
rest.textContent = " Lv" + h.level_at_win + " " + h.run_days + "d " + (h.win_ts || "").slice(0, 10);
li.appendChild(rest);
list.appendChild(li);
}
}
function renderHerald(herald) {
var list = document.getElementById("herald");
while (list.firstChild) { list.removeChild(list.firstChild); }
if (!herald.length) {
list.appendChild(muted("The Vale is still."));
return;
}
for (var i = 0; i < herald.length; i++) {
var e = herald[i];
var li = document.createElement("li");
var ts = document.createElement("span");
ts.className = "ts";
ts.textContent = clockLabel(e.ts);
li.appendChild(ts);
var text = document.createElement("span");
text.textContent = e.text;
li.appendChild(text);
list.appendChild(li);
}
}
function muted(text) {
var li = document.createElement("li");
li.className = "muted";
li.textContent = text;
return li;
}
function setLive(connected, iso) {
if (connected) {
document.body.classList.remove("lost");
liveLabel.textContent = "LIVE \\u2022 updated " + clockLabel(iso);
} else {
document.body.classList.add("lost");
liveLabel.textContent = "SIGNAL LOST";
}
}
function getJSON(url) {
return fetch(url, { cache: "no-store" }).then(function (r) {
if (!r.ok) { throw new Error("HTTP " + r.status); }
return r.json();
});
}
function poll() {
getJSON("./watch/state.json").then(function (state) {
paintPlayers(state.players || []);
renderAdventurers(state.players || []);
renderHall(state.hall || []);
renderHerald(state.herald || []);
setLive(true, state.ts);
}).catch(function () {
setLive(false, null);
});
}
var POLL_MS = 2000;
// Bootstrap retries until the base map loads, so a spectator who opens the
// page during a server blip recovers without a manual reload. The poll
// interval starts exactly once, on the first successful boot.
function boot() {
getJSON("./watch/world.json").then(function (world) {
paintMap(world);
poll();
setInterval(poll, POLL_MS);
}).catch(function () {
setLive(false, null);
setTimeout(boot, POLL_MS);
});
}
boot();
})();
</script>
</body>
</html>
"""
@@ -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."
}
]
}
@@ -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
}
]
@@ -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",