mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,4 +6,4 @@ import understone
|
||||
|
||||
|
||||
def test_version_present() -> None:
|
||||
assert understone.__version__ == "0.2.0"
|
||||
assert understone.__version__ == "0.3.0"
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""Understone — a BBS-style ANSI door game served over MCP."""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user