Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index b3cda00..651d8de 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -139,6 +139,16 @@ set(NETHACK_MCP_SOURCES
src/observation_store.cpp
src/protocol.cpp
src/spectator_server.cpp)
+
+set(STATIC_FILES
+ "${CMAKE_CURRENT_SOURCE_DIR}/web/index.html"
+ "${CMAKE_CURRENT_SOURCE_DIR}/web/viewer.css"
+ "${CMAKE_CURRENT_SOURCE_DIR}/web/viewer.js"
+ "${CMAKE_CURRENT_SOURCE_DIR}/web/kreative_square.ttf")
+include(cmake/embed_assets.cmake)
+list(APPEND NETHACK_MCP_SOURCES
+ "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp")
+
if(NETHACK_BUILD_ENGINE)
list(APPEND NETHACK_MCP_SOURCES
src/engine_worker.cpp
diff --git a/README.md b/README.md
index fac9447..28ddb91 100644
--- a/README.md
+++ b/README.md
@@ -30,4 +30,7 @@ Run the server with:
The MCP server reads newline-delimited JSON-RPC from standard input. The
browser viewer is available at `http://127.0.0.1:8765/` and serves only
-read-only state from the active session.
+read-only state from the active session. Its map uses the bundled Kreative
+Square font, whose character cells are designed for square text graphics.
+The viewer's HTML, CSS, JavaScript, and font files live under `web/` and are
+embedded into the executable during the CMake configure step.
diff --git a/cmake/embed_assets.cmake b/cmake/embed_assets.cmake
new file mode 100644
index 0000000..b930a6e
--- /dev/null
+++ b/cmake/embed_assets.cmake
@@ -0,0 +1,53 @@
+# Generate byte arrays without depending on external embedding tools.
+set(EMBEDDED_SOURCE
+ "#include \"embedded_assets.hpp\"\n\n"
+ "namespace nethack_mcp\n{\n\nnamespace\n{\n")
+set(ASSET_ENTRIES "")
+set(ASSET_INDEX 0)
+foreach(ASSET IN LISTS STATIC_FILES)
+ set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${ASSET}")
+ file(READ "${ASSET}" ASSET_HEX HEX)
+ string(LENGTH "${ASSET_HEX}" ASSET_SIZE)
+ math(EXPR ASSET_SIZE "${ASSET_SIZE} / 2")
+ string(REGEX REPLACE "([0-9A-Fa-f][0-9A-Fa-f])" "0x\\1,"
+ ASSET_BYTES "${ASSET_HEX}")
+ string(APPEND EMBEDDED_SOURCE
+ "const unsigned char ASSET_${ASSET_INDEX}[] = "
+ "{${ASSET_BYTES}0};\n")
+ file(RELATIVE_PATH ASSET_NAME "${CMAKE_CURRENT_SOURCE_DIR}/web"
+ "${ASSET}")
+ get_filename_component(ASSET_EXTENSION "${ASSET}" LAST_EXT)
+ if(ASSET_EXTENSION STREQUAL ".html")
+ set(ASSET_TYPE "text/html; charset=utf-8")
+ elseif(ASSET_EXTENSION STREQUAL ".css")
+ set(ASSET_TYPE "text/css; charset=utf-8")
+ elseif(ASSET_EXTENSION STREQUAL ".js")
+ set(ASSET_TYPE "application/javascript; charset=utf-8")
+ elseif(ASSET_EXTENSION STREQUAL ".ttf")
+ set(ASSET_TYPE "font/ttf")
+ else()
+ set(ASSET_TYPE "application/octet-stream")
+ endif()
+ if(ASSET_NAME STREQUAL "index.html")
+ set(ASSET_PATH "/")
+ elseif(ASSET_NAME STREQUAL "kreative_square.ttf")
+ set(ASSET_PATH "/viewer-font.ttf")
+ else()
+ set(ASSET_PATH "/${ASSET_NAME}")
+ endif()
+ string(APPEND ASSET_ENTRIES
+ " {\"${ASSET_PATH}\", \"${ASSET_TYPE}\", "
+ "{reinterpret_cast<const char*>(ASSET_${ASSET_INDEX}), "
+ "${ASSET_SIZE}}},\n")
+ math(EXPR ASSET_INDEX "${ASSET_INDEX} + 1")
+endforeach()
+string(APPEND EMBEDDED_SOURCE
+ "const EmbeddedAsset ASSETS[] = {\n${ASSET_ENTRIES}};\n}\n\n"
+ "std::span<const EmbeddedAsset> embeddedAssets()\n{\n"
+ " return ASSETS;\n}\n\n}\n")
+file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated")
+file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
+ "${EMBEDDED_SOURCE}")
+configure_file(
+ "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
+ "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp" COPYONLY)
diff --git a/include/embedded_assets.hpp b/include/embedded_assets.hpp
new file mode 100644
index 0000000..106a3d6
--- /dev/null
+++ b/include/embedded_assets.hpp
@@ -0,0 +1,23 @@
+#pragma once
+
+#include <span>
+#include <string_view>
+
+namespace nethack_mcp
+{
+
+/// One HTTP resource stored in the executable.
+struct EmbeddedAsset
+{
+ /// Exact request path for this resource.
+ std::string_view path;
+ /// HTTP content type, including charset for text resources.
+ std::string_view content_type;
+ /// Resource bytes, which may contain null bytes.
+ std::string_view content;
+};
+
+/// Access static resources with storage lasting for the process lifetime.
+std::span<const EmbeddedAsset> embeddedAssets();
+
+} // namespace nethack_mcp
diff --git a/include/spectator_server.hpp b/include/spectator_server.hpp
index e3b5d64..f0a933f 100644
--- a/include/spectator_server.hpp
+++ b/include/spectator_server.hpp
@@ -2,6 +2,7 @@
#include <atomic>
#include <string>
+#include <string_view>
#include <thread>
#include <mw/http_server.hpp>
@@ -36,9 +37,12 @@ protected:
private:
bool validHost(const Request& request) const;
+ void serveStatic(std::string_view path, const Request& request,
+ Response& response);
void servePage(const Request& request, Response& response);
void serveScript(const Request& request, Response& response);
void serveStyle(const Request& request, Response& response);
+ void serveFont(const Request& request, Response& response);
void serveState(const Request& request, Response& response);
void serveHealth(const Request& request, Response& response);
diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp
index 42445c5..08bcf46 100644
--- a/src/mcp_server.cpp
+++ b/src/mcp_server.cpp
@@ -154,6 +154,22 @@ Json McpServer::dispatch(const Json& request, bool& should_respond)
{
return {{"jsonrpc", "2.0"}, {"id", id}, {"result", {}}};
}
+ if(method == "resources/list")
+ {
+ return {
+ {"jsonrpc", "2.0"},
+ {"id", id},
+ {"result", {{"resources", Json::array()}}},
+ };
+ }
+ if(method == "resources/templates/list")
+ {
+ return {
+ {"jsonrpc", "2.0"},
+ {"id", id},
+ {"result", {{"resourceTemplates", Json::array()}}},
+ };
+ }
if(method == "tools/list")
{
return {
diff --git a/src/spectator_server.cpp b/src/spectator_server.cpp
index 59942f3..df3cd91 100644
--- a/src/spectator_server.cpp
+++ b/src/spectator_server.cpp
@@ -1,8 +1,10 @@
#include "spectator_server.hpp"
+#include "embedded_assets.hpp"
#include "game_session.hpp"
#include <string>
+#include <string_view>
namespace nethack_mcp
{
@@ -10,104 +12,18 @@ namespace nethack_mcp
namespace
{
-constexpr char VIEWER_HTML[] = R"HTML(<!doctype html>
-<html lang="en">
-<head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>NetHack spectator</title>
- <link rel="stylesheet" href="/viewer.css">
-</head>
-<body>
- <main>
- <header>
- <h1>NetHack spectator</h1>
- <p id="lifecycle">Connecting…</p>
- </header>
- <pre id="map" aria-label="NetHack map"></pre>
- <section>
- <h2>Status</h2>
- <pre id="status"></pre>
- </section>
- <section>
- <h2>Pending input</h2>
- <pre id="pending">None</pre>
- </section>
- <section>
- <h2>Messages</h2>
- <ol id="messages"></ol>
- </section>
- </main>
- <script src="/viewer.js"></script>
-</body>
-</html>
-)HTML";
-
-constexpr char VIEWER_SCRIPT[] = R"JS((() => {
- let etag = "";
- const lifecycle = document.querySelector("#lifecycle");
- const map = document.querySelector("#map");
- const status = document.querySelector("#status");
- const pending = document.querySelector("#pending");
- const messages = document.querySelector("#messages");
-
- function show(value) {
- lifecycle.textContent = value.lifecycle || "unknown";
- map.textContent = (value.map && value.map.rows || []).join("\n");
- status.textContent = JSON.stringify(value.status || {}, null, 2);
- pending.textContent = value.pending
- ? JSON.stringify(value.pending, null, 2) : "None";
- messages.replaceChildren();
- for (const message of value.messages || []) {
- const item = document.createElement("li");
- item.textContent = message.text || "";
- messages.append(item);
- }
- }
-
- async function poll() {
- try {
- const headers = etag ? {"If-None-Match": etag} : {};
- const response = await fetch("/api/state", {headers, cache: "no-store"});
- if (response.status === 304) return;
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
- etag = response.headers.get("ETag") || "";
- show(await response.json());
- } catch (error) {
- lifecycle.textContent = `Disconnected: ${error.message}`;
+const EmbeddedAsset* findAsset(std::string_view path)
+{
+ for(const auto& asset : embeddedAssets())
+ {
+ if(asset.path == path)
+ {
+ return &asset;
+ }
}
- }
-
- poll();
- setInterval(poll, 250);
-})();
-)JS";
-
-constexpr char VIEWER_STYLE[] = R"CSS(:root {
- color-scheme: dark;
- font-family: system-ui, sans-serif;
- background: #171717;
- color: #eeeeee;
+ return nullptr;
}
-body { margin: 0; }
-main { max-width: 1000px; margin: 0 auto; padding: 1rem; }
-h1, h2 { font-weight: 600; }
-h1 { margin-bottom: 0.25rem; }
-h2 { font-size: 1rem; margin-bottom: 0.35rem; }
-pre { overflow: auto; }
-#map {
- border: 1px solid #555;
- padding: 0.75rem;
- line-height: 1.1;
- font: 16px/1.1 monospace;
- white-space: pre;
- min-height: 21em;
-}
-section { margin-top: 1rem; }
-#messages { max-height: 16rem; overflow: auto; }
-)CSS";
-
} // namespace
SpectatorServer::SpectatorServer(GameSession& session, int port)
@@ -160,6 +76,10 @@ void SpectatorServer::setup()
Response& response) {
serveStyle(request, response);
});
+ server.Get("/viewer-font.ttf", [this](const Request& request,
+ Response& response) {
+ serveFont(request, response);
+ });
server.Get("/api/state", [this](const Request& request,
Response& response) {
serveState(request, response);
@@ -178,36 +98,44 @@ bool SpectatorServer::validHost(const Request& request) const
host == "localhost:" + std::to_string(port_);
}
-void SpectatorServer::servePage(const Request& request, Response& response)
+void SpectatorServer::serveStatic(std::string_view path,
+ const Request& request,
+ Response& response)
{
if(!validHost(request))
{
response.status = 403;
return;
}
- response.set_content(VIEWER_HTML, "text/html; charset=utf-8");
+ const EmbeddedAsset* asset = findAsset(path);
+ if(asset == nullptr)
+ {
+ response.status = 404;
+ return;
+ }
+ response.set_content(asset->content.data(), asset->content.size(),
+ std::string(asset->content_type));
+}
+
+void SpectatorServer::servePage(const Request& request, Response& response)
+{
+ serveStatic("/", request, response);
}
void SpectatorServer::serveScript(const Request& request,
Response& response)
{
- if(!validHost(request))
- {
- response.status = 403;
- return;
- }
- response.set_content(VIEWER_SCRIPT,
- "application/javascript; charset=utf-8");
+ serveStatic("/viewer.js", request, response);
}
void SpectatorServer::serveStyle(const Request& request, Response& response)
{
- if(!validHost(request))
- {
- response.status = 403;
- return;
- }
- response.set_content(VIEWER_STYLE, "text/css; charset=utf-8");
+ serveStatic("/viewer.css", request, response);
+}
+
+void SpectatorServer::serveFont(const Request& request, Response& response)
+{
+ serveStatic("/viewer-font.ttf", request, response);
}
void SpectatorServer::serveState(const Request& request, Response& response)
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..4cb00d2
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>NetHack spectator</title>
+ <link rel="stylesheet" href="/viewer.css">
+</head>
+<body>
+ <main>
+ <header>
+ <h1>NetHack spectator</h1>
+ <p id="lifecycle">Connecting…</p>
+ </header>
+ <pre id="map" aria-label="NetHack map"></pre>
+ <section>
+ <h2>Status</h2>
+ <pre id="status"></pre>
+ </section>
+ <section>
+ <h2>Pending input</h2>
+ <pre id="pending">None</pre>
+ </section>
+ <section>
+ <h2>Messages</h2>
+ <ol id="messages"></ol>
+ </section>
+ </main>
+ <script src="/viewer.js"></script>
+</body>
+</html>
diff --git a/web/kreative_square.ttf b/web/kreative_square.ttf
new file mode 100644
index 0000000..41b5286
Binary files /dev/null and b/web/kreative_square.ttf differ
diff --git a/web/kreative_square_ofl.txt b/web/kreative_square_ofl.txt
new file mode 100644
index 0000000..a694297
--- /dev/null
+++ b/web/kreative_square_ofl.txt
@@ -0,0 +1,93 @@
+Copyright (c) 2017-2020 Kreative Software.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/web/viewer.css b/web/viewer.css
new file mode 100644
index 0000000..9fc6e91
--- /dev/null
+++ b/web/viewer.css
@@ -0,0 +1,32 @@
+@font-face {
+ font-family: "Kreative Square";
+ src: url("/viewer-font.ttf") format("truetype");
+ font-display: block;
+}
+
+:root {
+ color-scheme: dark;
+ font-family: system-ui, sans-serif;
+ background: #171717;
+ color: #eeeeee;
+}
+
+body { margin: 0; }
+main { width: 100vh; margin: 0 auto; padding: 1rem; }
+h1, h2 { font-weight: 600; }
+h1 { margin-bottom: 0.25rem; }
+h2 { font-size: 1rem; margin-bottom: 0.35rem; }
+pre { overflow: auto; }
+#map {
+ border: 1px solid #555;
+ padding: 0.75rem;
+ font-family: "Kreative Square", monospace;
+ font-size: 16px;
+ line-height: 1;
+ font-variant-ligatures: none;
+ white-space: pre;
+ height: 21em;
+ width: 79em;
+}
+section { margin-top: 1rem; }
+#messages { max-height: 16rem; overflow: auto; }
diff --git a/web/viewer.js b/web/viewer.js
new file mode 100644
index 0000000..ccd8453
--- /dev/null
+++ b/web/viewer.js
@@ -0,0 +1,38 @@
+(() => {
+ let etag = "";
+ const lifecycle = document.querySelector("#lifecycle");
+ const map = document.querySelector("#map");
+ const status = document.querySelector("#status");
+ const pending = document.querySelector("#pending");
+ const messages = document.querySelector("#messages");
+
+ function show(value) {
+ lifecycle.textContent = value.lifecycle || "unknown";
+ map.textContent = (value.map && value.map.rows || []).join("\n");
+ status.textContent = JSON.stringify(value.status || {}, null, 2);
+ pending.textContent = value.pending
+ ? JSON.stringify(value.pending, null, 2) : "None";
+ messages.replaceChildren();
+ for (const message of value.messages || []) {
+ const item = document.createElement("li");
+ item.textContent = message.text || "";
+ messages.append(item);
+ }
+ }
+
+ async function poll() {
+ try {
+ const headers = etag ? {"If-None-Match": etag} : {};
+ const response = await fetch("/api/state", {headers, cache: "no-store"});
+ if (response.status === 304) return;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ etag = response.headers.get("ETag") || "";
+ show(await response.json());
+ } catch (error) {
+ lifecycle.textContent = `Disconnected: ${error.message}`;
+ }
+ }
+
+ poll();
+ setInterval(poll, 250);
+})();