Changes
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0f69e60
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/build/
+/build-*/
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..b3cda00
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,165 @@
+cmake_minimum_required(VERSION 3.24)
+
+project(nethack_mcp VERSION 0.1.0 LANGUAGES C CXX)
+
+set(CMAKE_CXX_STANDARD 23)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_CXX_EXTENSIONS OFF)
+set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+
+option(NETHACK_BUILD_ENGINE "Build the pinned NetHack library" ON)
+
+include(FetchContent)
+
+# Declare these before loading libmw. Its current CMake files declare the
+# same projects without pins, so the application's declarations must win.
+FetchContent_Declare(
+ httplib
+ GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
+ GIT_TAG 278c2979e8c68468960c3073e28e1c51b098d6a4
+ GIT_SHALLOW FALSE)
+FetchContent_Declare(
+ spdlog
+ GIT_REPOSITORY https://github.com/gabime/spdlog.git
+ GIT_TAG 57cb5fb7a8ff30079751728234623230535a5c92
+ GIT_SHALLOW FALSE)
+FetchContent_Declare(
+ json
+ GIT_REPOSITORY https://github.com/nlohmann/json.git
+ GIT_TAG aa391dc0a56f8409e2e7aca6e7c9a9d766d44ce8
+ GIT_SHALLOW FALSE)
+FetchContent_Declare(
+ libmw
+ GIT_REPOSITORY https://github.com/MetroWind/libmw.git
+ GIT_TAG 00f857d93fda0f0eb84bb8ab540b58063f1c91b2
+ GIT_SHALLOW FALSE)
+
+set(LIBMW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_URL ON CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_HTTP_SERVER ON CACHE BOOL "" FORCE)
+FetchContent_MakeAvailable(libmw)
+find_package(Threads REQUIRED)
+
+if(NETHACK_BUILD_ENGINE)
+ find_path(NETHACK_LUA_INCLUDE_DIR
+ NAMES lua.h
+ PATH_SUFFIXES lua5.4 lua54 lua)
+ find_library(NETHACK_LUA_LIBRARY
+ NAMES lua5.4 lua54 lua)
+ if(NOT NETHACK_LUA_INCLUDE_DIR OR NOT NETHACK_LUA_LIBRARY)
+ message(FATAL_ERROR
+ "NetHack requires Lua 5.4 headers and a linkable library")
+ endif()
+
+ file(STRINGS "${NETHACK_LUA_INCLUDE_DIR}/lua.h"
+ NETHACK_LUA_VERSION_LINE REGEX "^#define LUA_VERSION_NUM")
+ if(NOT NETHACK_LUA_VERSION_LINE MATCHES "504")
+ message(FATAL_ERROR
+ "NetHack requires Lua 5.4 headers; found "
+ "${NETHACK_LUA_INCLUDE_DIR}/lua.h")
+ endif()
+
+ FetchContent_Declare(
+ nethack
+ GIT_REPOSITORY https://github.com/NetHack/NetHack.git
+ GIT_TAG c94fd5225beef48143244bfb7bc42682aad58741
+ GIT_SHALLOW FALSE)
+ FetchContent_MakeAvailable(nethack)
+
+ set(NETHACK_WORK_DIR
+ "${CMAKE_BINARY_DIR}/nethack-work"
+ CACHE PATH "Isolated NetHack build directory")
+ set(NETHACK_LIBRARY "${NETHACK_WORK_DIR}/src/libnh.a")
+ set(NETHACK_COMPAT_LIBRARY
+ "${NETHACK_WORK_DIR}/src/libnethack.a")
+ set(NETHACK_RUNTIME_DIR
+ "${NETHACK_WORK_DIR}/playground")
+ set(NETHACK_BUILD_MARKER
+ "${NETHACK_WORK_DIR}/.nethack-library-built")
+ file(MAKE_DIRECTORY "${NETHACK_WORK_DIR}/include")
+
+ add_custom_command(
+ OUTPUT "${NETHACK_BUILD_MARKER}"
+ BYPRODUCTS
+ "${NETHACK_COMPAT_LIBRARY}"
+ "${NETHACK_RUNTIME_DIR}/nhdat"
+ "${NETHACK_RUNTIME_DIR}/symbols"
+ "${NETHACK_RUNTIME_DIR}/license"
+ "${NETHACK_RUNTIME_DIR}/sysconf"
+ COMMAND "${CMAKE_COMMAND}" -E rm -rf "${NETHACK_WORK_DIR}"
+ COMMAND "${CMAKE_COMMAND}" -E copy_directory
+ "${nethack_SOURCE_DIR}" "${NETHACK_WORK_DIR}"
+ COMMAND "${CMAKE_COMMAND}"
+ "-DNETHACK_SOURCE_DIR=${NETHACK_WORK_DIR}"
+ "-DNETHACK_LUA_INCLUDE_DIR=${NETHACK_LUA_INCLUDE_DIR}"
+ "-DNETHACK_LUA_LIBRARY=${NETHACK_LUA_LIBRARY}"
+ "-DNETHACK_COMPILER=${CMAKE_C_COMPILER}"
+ "-DNETHACK_ARCHIVER=${CMAKE_AR}"
+ -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_nethack.cmake"
+ DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_nethack.cmake"
+ VERBATIM)
+
+ add_custom_target(nethack_engine_build
+ DEPENDS "${NETHACK_BUILD_MARKER}")
+
+ add_library(nethack_lib STATIC IMPORTED GLOBAL)
+ set_target_properties(nethack_lib PROPERTIES
+ IMPORTED_LOCATION "${NETHACK_COMPAT_LIBRARY}"
+ INTERFACE_INCLUDE_DIRECTORIES
+ "${NETHACK_WORK_DIR}/include"
+ INTERFACE_LINK_LIBRARIES
+ "${NETHACK_LUA_LIBRARY};m;dl")
+ add_dependencies(nethack_lib nethack_engine_build)
+
+ add_library(NetHack::libnethack ALIAS nethack_lib)
+
+ add_executable(nethack_engine_probe src/engine_probe.cpp)
+ target_compile_options(nethack_engine_probe PRIVATE
+ -Wall -Wextra -Wpedantic)
+ target_compile_definitions(nethack_engine_probe PRIVATE
+ NETHACK_PROBE_RUNTIME_DIR="${NETHACK_RUNTIME_DIR}")
+ target_include_directories(nethack_engine_probe PRIVATE
+ "${NETHACK_WORK_DIR}/include")
+ target_link_libraries(nethack_engine_probe PRIVATE nethack_lib)
+
+ enable_testing()
+ add_test(NAME nethack_engine_probe COMMAND nethack_engine_probe)
+ set_tests_properties(nethack_engine_probe PROPERTIES
+ WORKING_DIRECTORY "${NETHACK_RUNTIME_DIR}"
+ TIMEOUT 10)
+else()
+ set(NETHACK_RUNTIME_DIR "${CMAKE_CURRENT_BINARY_DIR}")
+endif()
+
+set(NETHACK_MCP_SOURCES
+ src/engine_process.cpp
+ src/game_session.cpp
+ src/main.cpp
+ src/mcp_server.cpp
+ src/observation_store.cpp
+ src/protocol.cpp
+ src/spectator_server.cpp)
+if(NETHACK_BUILD_ENGINE)
+ list(APPEND NETHACK_MCP_SOURCES
+ src/engine_worker.cpp
+ src/window_adapter.cpp)
+else()
+ list(APPEND NETHACK_MCP_SOURCES src/engine_worker_stub.cpp)
+endif()
+
+add_executable(nethack_mcp ${NETHACK_MCP_SOURCES})
+target_compile_features(nethack_mcp PRIVATE cxx_std_23)
+target_compile_options(nethack_mcp PRIVATE -Wall -Wextra -Wpedantic)
+target_include_directories(nethack_mcp PRIVATE include)
+target_compile_definitions(nethack_mcp PRIVATE
+ NETHACK_RUNTIME_DIR="${NETHACK_RUNTIME_DIR}")
+target_link_libraries(nethack_mcp PRIVATE
+ mw::mw
+ mw::http-server
+ nlohmann_json::nlohmann_json
+ Threads::Threads)
+if(NETHACK_BUILD_ENGINE)
+ target_include_directories(nethack_mcp PRIVATE
+ "${NETHACK_WORK_DIR}/include")
+ target_link_libraries(nethack_mcp PRIVATE nethack_lib)
+endif()
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fac9447
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+# nethack-mcp
+
+This project builds the pinned NetHack 5.0 engine as `libnethack.a`, runs it
+in a worker process, and exposes observations through MCP stdio plus a
+read-only loopback viewer.
+
+The default build uses system Lua 5.4 headers and library. CMake checks the
+Lua header version before configuring NetHack; Lua 5.4.8 is used by the
+current pinned NetHack build.
+
+Build and test it with:
+
+```sh
+cmake -S . -B build
+cmake --build build -j24
+ctest --test-dir build --output-on-failure
+```
+
+The produced library is:
+
+```text
+build/nethack-work/src/libnethack.a
+```
+
+Run the server with:
+
+```sh
+./build/nethack_mcp --data-root /tmp/nethack-mcp --port 8765
+```
+
+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.
diff --git a/cmake/build_nethack.cmake b/cmake/build_nethack.cmake
new file mode 100644
index 0000000..3adc2ac
--- /dev/null
+++ b/cmake/build_nethack.cmake
@@ -0,0 +1,157 @@
+cmake_minimum_required(VERSION 3.24)
+
+if(NOT DEFINED NETHACK_SOURCE_DIR
+ OR NOT DEFINED NETHACK_LUA_INCLUDE_DIR
+ OR NOT DEFINED NETHACK_LUA_LIBRARY
+ OR NOT DEFINED NETHACK_COMPILER
+ OR NOT DEFINED NETHACK_ARCHIVER)
+ message(FATAL_ERROR "NetHack build adapter arguments are incomplete")
+endif()
+
+find_program(NETHACK_MAKE make REQUIRED)
+find_program(NETHACK_SH sh REQUIRED)
+
+set(NETHACK_COMMON_ARGS
+ "CC=${NETHACK_COMPILER}"
+ "AR=${NETHACK_ARCHIVER}"
+ "WANT_LIBNH=1"
+ "WANT_SYSTEM_LUA=1"
+ "LINUX_DISTRO=debian"
+ "PKG_EXISTS=yes"
+ "LUA_VERSION=5.4.8"
+ "LUAHEADERS=${NETHACK_LUA_INCLUDE_DIR}"
+ "LUACFLAGS=-I${NETHACK_LUA_INCLUDE_DIR}"
+ "LUALIBS=${NETHACK_LUA_LIBRARY} -lm -ldl")
+
+function(run_nethack_make)
+ execute_process(
+ COMMAND "${NETHACK_MAKE}" ${NETHACK_COMMON_ARGS} ${ARGN}
+ WORKING_DIRECTORY "${NETHACK_SOURCE_DIR}"
+ RESULT_VARIABLE result
+ OUTPUT_VARIABLE output
+ ERROR_VARIABLE error)
+ if(NOT result EQUAL 0)
+ message(FATAL_ERROR
+ "NetHack make failed with ${result}\n${output}\n${error}")
+ endif()
+endfunction()
+
+execute_process(
+ COMMAND "${NETHACK_SH}" setup.sh hints/linux.500
+ WORKING_DIRECTORY "${NETHACK_SOURCE_DIR}/sys/unix"
+ RESULT_VARIABLE setup_result
+ OUTPUT_VARIABLE setup_output
+ ERROR_VARIABLE setup_error)
+if(NOT setup_result EQUAL 0)
+ message(FATAL_ERROR
+ "NetHack setup failed with ${setup_result}\n"
+ "${setup_output}\n${setup_error}")
+endif()
+
+# The upstream libnh rule names this archive even when system Lua is used.
+# It is a dependency marker only; the executable links the system library.
+file(MAKE_DIRECTORY "${NETHACK_SOURCE_DIR}/lib/lua")
+execute_process(
+ COMMAND "${NETHACK_ARCHIVER}" rcs
+ "${NETHACK_SOURCE_DIR}/lib/lua/liblua-5.4.8.a"
+ RESULT_VARIABLE archive_result
+ OUTPUT_VARIABLE archive_output
+ ERROR_VARIABLE archive_error)
+if(NOT archive_result EQUAL 0)
+ message(FATAL_ERROR
+ "Could not create NetHack Lua dependency marker: ${archive_output}\n"
+ "${archive_error}")
+endif()
+
+# Header generation is not safe to run concurrently with the upstream
+# generated-file rules. Once it is complete, source compilation is parallel.
+run_nethack_make(-j1 lua_support)
+run_nethack_make(-C src -j1 pregame)
+run_nethack_make(-C src -j24 libnh.a)
+run_nethack_make(-j1 dlb)
+
+# The upstream rule combines the normal Unix object list with the library
+# object list. Remove the normal terminal entry points before publishing the
+# archive, otherwise linking the library into a host program pulls in a
+# second main() and duplicate Unix support functions.
+execute_process(
+ COMMAND "${NETHACK_ARCHIVER}" d libnh.a
+ unixmain.o getline.o termcap.o topl.o wintty.o
+ WORKING_DIRECTORY "${NETHACK_SOURCE_DIR}/src"
+ RESULT_VARIABLE remove_unix_result
+ OUTPUT_VARIABLE remove_unix_output
+ ERROR_VARIABLE remove_unix_error)
+if(NOT remove_unix_result EQUAL 0)
+ message(FATAL_ERROR
+ "Could not remove normal Unix objects from libnh.a: "
+ "${remove_unix_output}\n${remove_unix_error}")
+endif()
+
+# The Linux libnh rule omits hacklib.o even though the normal executable
+# links hacklib.a separately. Make the published library self-contained.
+execute_process(
+ COMMAND "${NETHACK_ARCHIVER}" rcs libnh.a hacklib.o
+ WORKING_DIRECTORY "${NETHACK_SOURCE_DIR}/src"
+ RESULT_VARIABLE hacklib_result
+ OUTPUT_VARIABLE hacklib_output
+ ERROR_VARIABLE hacklib_error)
+if(NOT hacklib_result EQUAL 0)
+ message(FATAL_ERROR
+ "Could not add NetHack hacklib.o to libnh.a: ${hacklib_output}\n"
+ "${hacklib_error}")
+endif()
+
+execute_process(
+ COMMAND "${CMAKE_COMMAND}" -E copy_if_different
+ "${NETHACK_SOURCE_DIR}/src/libnh.a"
+ "${NETHACK_SOURCE_DIR}/src/libnethack.a"
+ RESULT_VARIABLE copy_result
+ OUTPUT_VARIABLE copy_output
+ ERROR_VARIABLE copy_error)
+if(NOT copy_result EQUAL 0)
+ message(FATAL_ERROR
+ "Could not publish libnethack.a compatibility name: ${copy_output}\n"
+ "${copy_error}")
+endif()
+
+set(NETHACK_PLAYGROUND "${NETHACK_SOURCE_DIR}/playground")
+file(MAKE_DIRECTORY "${NETHACK_PLAYGROUND}/save")
+
+foreach(data_file nhdat symbols license)
+ execute_process(
+ COMMAND "${CMAKE_COMMAND}" -E copy_if_different
+ "${NETHACK_SOURCE_DIR}/dat/${data_file}"
+ "${NETHACK_PLAYGROUND}/${data_file}"
+ RESULT_VARIABLE data_result
+ OUTPUT_VARIABLE data_output
+ ERROR_VARIABLE data_error)
+ if(NOT data_result EQUAL 0)
+ message(FATAL_ERROR
+ "Could not install NetHack data file ${data_file}: "
+ "${data_output}\n${data_error}")
+ endif()
+endforeach()
+
+execute_process(
+ COMMAND "${CMAKE_COMMAND}" -E copy_if_different
+ "${NETHACK_SOURCE_DIR}/sys/libnh/sysconf"
+ "${NETHACK_PLAYGROUND}/sysconf"
+ RESULT_VARIABLE sysconf_result
+ OUTPUT_VARIABLE sysconf_output
+ ERROR_VARIABLE sysconf_error)
+if(NOT sysconf_result EQUAL 0)
+ message(FATAL_ERROR
+ "Could not install NetHack sysconf: ${sysconf_output}\n"
+ "${sysconf_error}")
+endif()
+
+foreach(runtime_file perm record logfile xlogfile livelog)
+ file(WRITE "${NETHACK_PLAYGROUND}/${runtime_file}" "")
+ file(CHMOD "${NETHACK_PLAYGROUND}/${runtime_file}"
+ FILE_PERMISSIONS OWNER_READ OWNER_WRITE)
+endforeach()
+
+file(CHMOD "${NETHACK_PLAYGROUND}" "${NETHACK_PLAYGROUND}/save"
+ DIRECTORY_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE)
+
+file(TOUCH "${NETHACK_SOURCE_DIR}/.nethack-library-built")
diff --git a/include/engine_process.hpp b/include/engine_process.hpp
new file mode 100644
index 0000000..a077819
--- /dev/null
+++ b/include/engine_process.hpp
@@ -0,0 +1,59 @@
+#pragma once
+
+#include <atomic>
+#include <functional>
+#include <memory>
+#include <mutex>
+#include <string>
+#include <thread>
+#include <sys/types.h>
+
+#include "protocol.hpp"
+
+namespace nethack_mcp
+{
+
+/// Spawn and supervise one isolated NetHack worker process.
+class EngineProcess
+{
+public:
+ /// Receive worker events from the reader thread.
+ using EventCallback = std::function<void(const Json&)>;
+
+ /// Construct an idle process supervisor.
+ EngineProcess() = default;
+
+ /// Stop a worker and reap it before destroying the supervisor.
+ ~EngineProcess();
+
+ EngineProcess(const EngineProcess&) = delete;
+ EngineProcess& operator=(const EngineProcess&) = delete;
+
+ /// Spawn a worker and send its validated start message.
+ bool start(const Json& start_message, EventCallback callback,
+ std::string& error);
+
+ /// Send one validated input message to the worker.
+ bool send(const Json& message, std::string& error);
+
+ /// Request termination and wait for the child and reader threads.
+ void terminate();
+
+ /// Report whether the child has not yet been reaped.
+ bool running() const;
+
+private:
+ void readLoop();
+ void diagnosticLoop(int descriptor);
+ std::string executablePath() const;
+
+ pid_t process_id_ = -1;
+ std::unique_ptr<FramedChannel> channel_;
+ EventCallback callback_;
+ std::thread reader_thread_;
+ std::thread diagnostic_thread_;
+ mutable std::mutex state_mutex_;
+ std::atomic<bool> running_ = false;
+};
+
+} // namespace nethack_mcp
diff --git a/include/engine_worker.hpp b/include/engine_worker.hpp
new file mode 100644
index 0000000..3305ee8
--- /dev/null
+++ b/include/engine_worker.hpp
@@ -0,0 +1,9 @@
+#pragma once
+
+namespace nethack_mcp
+{
+
+/// Run the isolated NetHack worker selected by the internal command line.
+int runEngineWorker(int argc, char* argv[]);
+
+} // namespace nethack_mcp
diff --git a/include/game_session.hpp b/include/game_session.hpp
new file mode 100644
index 0000000..3903aa6
--- /dev/null
+++ b/include/game_session.hpp
@@ -0,0 +1,91 @@
+#pragma once
+
+#include <atomic>
+#include <cstdint>
+#include <filesystem>
+#include <mutex>
+#include <string>
+
+#include "engine_process.hpp"
+#include "observation_store.hpp"
+
+namespace nethack_mcp
+{
+
+/// Result returned by one MCP tool implementation.
+struct ToolResult
+{
+ /// Report whether the requested tool operation completed.
+ bool success = false;
+ /// Return the complete state associated with the operation.
+ Json value = Json::object();
+ /// Stable machine-readable error code when `success` is false.
+ std::string code;
+ /// Human-readable error detail when `success` is false.
+ std::string message;
+};
+
+/// Own the single active game and serialize all gameplay input.
+class GameSession
+{
+public:
+ /// Create a session with a private data root and viewer URL.
+ GameSession(std::filesystem::path data_root,
+ std::filesystem::path runtime_dir,
+ std::string viewer_url);
+
+ GameSession(const GameSession&) = delete;
+ GameSession& operator=(const GameSession&) = delete;
+
+ /// Start one new NetHack worker and wait for its first boundary.
+ ToolResult newGame(const Json& arguments);
+
+ /// Return the latest state, optionally waiting for a revision.
+ ToolResult observe(const Json& arguments);
+
+ /// Answer a pending one-key input boundary.
+ ToolResult press(const Json& arguments);
+
+ /// Answer a pending text, choice, command, or acknowledgement boundary.
+ ToolResult respond(const Json& arguments);
+
+ /// Answer a pending menu boundary with complete selections.
+ ToolResult selectMenu(const Json& arguments);
+
+ /// Administratively stop the worker and retain its final state.
+ ToolResult quitGame(const Json& arguments);
+
+ /// Stop any active worker during server shutdown.
+ void shutdown();
+
+ /// Return the configured spectator URL.
+ const std::string& viewerUrl() const;
+
+ /// Return the latest state for the spectator server.
+ Json snapshot() const;
+
+private:
+ ToolResult sendInput(const Json& arguments, Json response,
+ const std::string& expected_kind);
+ ToolResult errorResult(std::string code, std::string message) const;
+ void handleWorkerMessage(const Json& message);
+ bool copyRuntimeFiles(const std::filesystem::path& run_directory,
+ std::string& error) const;
+ static bool validGameId(const Json& arguments,
+ const Json& state,
+ std::string& error);
+ static bool parseKey(const Json& key, int& value, std::string& error);
+ static std::string makeGameId();
+
+ std::filesystem::path data_root_;
+ std::filesystem::path runtime_dir_;
+ std::string viewer_url_;
+ ObservationStore observations_;
+ std::unique_ptr<EngineProcess> process_;
+ mutable std::mutex session_mutex_;
+ mutable std::mutex action_mutex_;
+ std::atomic<bool> stop_requested_ = false;
+ std::uint64_t operation_counter_ = 0;
+};
+
+} // namespace nethack_mcp
diff --git a/include/mcp_server.hpp b/include/mcp_server.hpp
new file mode 100644
index 0000000..0066fba
--- /dev/null
+++ b/include/mcp_server.hpp
@@ -0,0 +1,30 @@
+#pragma once
+
+#include <iosfwd>
+
+#include "game_session.hpp"
+
+namespace nethack_mcp
+{
+
+/// Implement the MCP JSON-RPC stdio transport for one game session.
+class McpServer
+{
+public:
+ /// Bind the protocol dispatcher to a session.
+ explicit McpServer(GameSession& session);
+
+ /// Read requests until stdio reaches EOF.
+ int run(std::istream& input, std::ostream& output);
+
+private:
+ Json dispatch(const Json& request, bool& should_respond);
+ Json tools() const;
+ Json toolResult(const ToolResult& result) const;
+ Json jsonRpcError(const Json& id, int code,
+ const std::string& message) const;
+
+ GameSession& session_;
+};
+
+} // namespace nethack_mcp
diff --git a/include/observation_store.hpp b/include/observation_store.hpp
new file mode 100644
index 0000000..a8b6d5b
--- /dev/null
+++ b/include/observation_store.hpp
@@ -0,0 +1,38 @@
+#pragma once
+
+#include <chrono>
+#include <condition_variable>
+#include <cstdint>
+#include <mutex>
+
+#include "protocol.hpp"
+
+namespace nethack_mcp
+{
+
+/// Own and publish complete snapshots without exposing engine memory.
+class ObservationStore
+{
+public:
+ /// Construct an idle store with a stable initial snapshot.
+ explicit ObservationStore(std::string viewer_url);
+
+ /// Return the latest complete snapshot.
+ Json snapshot() const;
+
+ /// Publish a replacement snapshot and return its new revision.
+ std::uint64_t publish(Json snapshot);
+
+ /// Wait for a revision newer than `revision`, or return the latest state.
+ Json waitForRevision(
+ std::uint64_t revision,
+ std::chrono::milliseconds timeout) const;
+
+private:
+ mutable std::mutex mutex_;
+ mutable std::condition_variable condition_;
+ Json snapshot_;
+ std::uint64_t revision_ = 0;
+};
+
+} // namespace nethack_mcp
diff --git a/include/protocol.hpp b/include/protocol.hpp
new file mode 100644
index 0000000..7131c91
--- /dev/null
+++ b/include/protocol.hpp
@@ -0,0 +1,51 @@
+#pragma once
+
+#include <cstdint>
+#include <mutex>
+#include <string>
+
+#include <nlohmann/json.hpp>
+
+namespace nethack_mcp
+{
+
+using Json = nlohmann::json;
+
+/// The version of the private parent/worker protocol.
+inline constexpr std::uint32_t IPC_VERSION = 1;
+
+/// The maximum encoded payload accepted by the private protocol.
+inline constexpr std::uint32_t MAX_IPC_FRAME_SIZE = 4U * 1024U * 1024U;
+
+/// Exchange length-prefixed JSON messages over one file descriptor.
+class FramedChannel
+{
+public:
+ /// Take ownership of an already connected stream descriptor.
+ explicit FramedChannel(int descriptor);
+
+ FramedChannel(const FramedChannel&) = delete;
+ FramedChannel& operator=(const FramedChannel&) = delete;
+
+ /// Close the descriptor owned by this channel.
+ ~FramedChannel();
+
+ /// Send one JSON message, returning a diagnostic on failure.
+ bool send(const Json& message, std::string& error);
+
+ /// Read one JSON message, returning a diagnostic on failure or EOF.
+ bool receive(Json& message, std::string& error);
+
+ /// Interrupt a blocked read and release the descriptor.
+ void close();
+
+private:
+ bool readExact(void* buffer, std::size_t size, std::string& error);
+ bool writeExact(const void* buffer, std::size_t size,
+ std::string& error);
+
+ int descriptor_;
+ std::mutex write_mutex_;
+};
+
+} // namespace nethack_mcp
diff --git a/include/spectator_server.hpp b/include/spectator_server.hpp
new file mode 100644
index 0000000..e3b5d64
--- /dev/null
+++ b/include/spectator_server.hpp
@@ -0,0 +1,51 @@
+#pragma once
+
+#include <atomic>
+#include <string>
+#include <thread>
+
+#include <mw/http_server.hpp>
+
+namespace nethack_mcp
+{
+
+class GameSession;
+
+/// Serve immutable game observations to a loopback browser viewer.
+class SpectatorServer : public mw::HTTPServer
+{
+public:
+ /// Construct a server bound to the loopback address and requested port.
+ SpectatorServer(GameSession& session, int port);
+
+ /// Stop the listener before destroying the server.
+ ~SpectatorServer();
+
+ SpectatorServer(const SpectatorServer&) = delete;
+ SpectatorServer& operator=(const SpectatorServer&) = delete;
+
+ /// Bind synchronously so an occupied port fails promptly.
+ bool startServer(std::string& error);
+
+ /// Stop the listener and join its serving thread.
+ void stopServer();
+
+protected:
+ /// Register the read-only viewer routes.
+ void setup() override;
+
+private:
+ bool validHost(const Request& request) const;
+ void servePage(const Request& request, Response& response);
+ void serveScript(const Request& request, Response& response);
+ void serveStyle(const Request& request, Response& response);
+ void serveState(const Request& request, Response& response);
+ void serveHealth(const Request& request, Response& response);
+
+ GameSession& session_;
+ int port_;
+ std::thread server_thread_;
+ std::atomic<bool> started_ = false;
+};
+
+} // namespace nethack_mcp
diff --git a/include/window_adapter.hpp b/include/window_adapter.hpp
new file mode 100644
index 0000000..514dc29
--- /dev/null
+++ b/include/window_adapter.hpp
@@ -0,0 +1,77 @@
+#pragma once
+
+#include <cstdarg>
+#include <cstdint>
+#include <map>
+#include <string>
+#include <vector>
+
+#include "protocol.hpp"
+
+namespace nethack_mcp
+{
+
+/// Translate the pinned NetHack shim into owned observations and input.
+class WindowAdapter
+{
+public:
+ /// Bind an adapter to the worker's private IPC channel.
+ WindowAdapter(FramedChannel& channel, std::string game_id);
+
+ WindowAdapter(const WindowAdapter&) = delete;
+ WindowAdapter& operator=(const WindowAdapter&) = delete;
+
+ /// Install this adapter as the callback target for NetHack.
+ void install();
+
+private:
+ struct MenuEntry
+ {
+ int entry_id = 0;
+ std::string text;
+ bool selectable = false;
+ bool selected = false;
+ std::vector<unsigned char> identifier;
+ };
+
+ static void callback(const char* name, void* return_ptr,
+ const char* format, ...);
+ void handleCallback(const char* name, void* return_ptr,
+ const char* format, std::va_list arguments);
+
+ Json makeSnapshot() const;
+ Json waitForInput(Json pending);
+ Json makePending(std::string kind, std::string source) const;
+ void publishSnapshot(Json snapshot);
+ void addMessage(const char* message);
+ void setIntegerReturn(void* return_ptr, int value) const;
+ void setCharacterReturn(void* return_ptr, char value) const;
+ void copyTextReturn(void* return_ptr, const std::string& text) const;
+ int resolveCommand(const std::string& command) const;
+
+ FramedChannel& channel_;
+ std::string game_id_;
+ std::uint64_t sequence_ = 0;
+ std::uint64_t input_id_ = 0;
+ int next_window_id_ = 1;
+ std::map<int, int> window_types_;
+ int active_menu_window_ = -1;
+ int next_menu_entry_id_ = 1;
+ int cursor_x_ = 1;
+ int cursor_y_ = 0;
+ std::vector<std::string> map_rows_;
+ std::vector<Json> messages_;
+ Json status_ = Json::object();
+ Json inventory_ = {
+ {"known", false},
+ {"stale", true},
+ {"entries", Json::array()},
+ };
+ std::vector<MenuEntry> menu_entries_;
+ std::string menu_prompt_;
+ std::string text_window_;
+
+ static WindowAdapter* active_adapter_;
+};
+
+} // namespace nethack_mcp
diff --git a/src/engine_probe.cpp b/src/engine_probe.cpp
new file mode 100644
index 0000000..3bdcf8a
--- /dev/null
+++ b/src/engine_probe.cpp
@@ -0,0 +1,183 @@
+#include <cstdarg>
+#include <cstdlib>
+#include <cstdio>
+#include <cstring>
+#include <map>
+#include <vector>
+#include <unistd.h>
+
+extern "C"
+{
+#include "config.h"
+#include "integer.h"
+#include "tradstdc.h"
+#include "global.h"
+#include "wintype.h"
+}
+
+extern "C"
+{
+
+int nhmain(int argc, char* argv[]);
+
+using ShimCallback = void (*)(const char*, void*, const char*, ...);
+
+void shim_graphics_set_callback(ShimCallback callback);
+
+}
+
+namespace
+{
+
+std::map<int, std::vector<anything>> menus;
+bool map_was_drawn = false;
+
+void set_int_return(void* return_ptr, int value)
+{
+ if(return_ptr != nullptr)
+ {
+ *static_cast<int*>(return_ptr) = value;
+ }
+}
+
+void set_string_return(void* return_ptr, const char* value)
+{
+ if(return_ptr == nullptr)
+ {
+ return;
+ }
+
+ auto** destination = static_cast<char**>(return_ptr);
+ *destination = const_cast<char*>(value);
+}
+
+void probe_callback(const char* name, void* return_ptr, const char* fmt, ...)
+{
+ if(name == nullptr)
+ {
+ return;
+ }
+
+ va_list arguments;
+ va_start(arguments, fmt);
+ if(std::strcmp(name, "shim_raw_print") == 0
+ || std::strcmp(name, "shim_raw_print_bold") == 0)
+ {
+ const char* message = va_arg(arguments, const char*);
+ std::fprintf(stderr, "NetHack: %s\n",
+ message == nullptr ? "(null)" : message);
+ }
+ else if(std::strcmp(name, "shim_start_menu") == 0)
+ {
+ int window = va_arg(arguments, int);
+ (void) va_arg(arguments, unsigned long);
+ menus[window].clear();
+ }
+ else if(std::strcmp(name, "shim_add_menu") == 0)
+ {
+ int window = va_arg(arguments, int);
+ (void) va_arg(arguments, const void*);
+ const auto* identifier = va_arg(arguments, const anything*);
+ (void) va_arg(arguments, int);
+ (void) va_arg(arguments, int);
+ (void) va_arg(arguments, int);
+ (void) va_arg(arguments, int);
+ (void) va_arg(arguments, const char*);
+ (void) va_arg(arguments, unsigned int);
+ if(identifier != nullptr)
+ {
+ menus[window].push_back(*identifier);
+ }
+ }
+ else if(std::strcmp(name, "shim_select_menu") == 0)
+ {
+ int window = va_arg(arguments, int);
+ int how = va_arg(arguments, int);
+ auto** menu_list = va_arg(arguments, menu_item**);
+ const auto found = menus.find(window);
+ if(how != 0 && menu_list != nullptr && found != menus.end()
+ && !found->second.empty())
+ {
+ auto* selected = static_cast<menu_item*>(
+ std::malloc(sizeof(menu_item)));
+ if(selected != nullptr)
+ {
+ selected[0].item = found->second.front();
+ selected[0].count = -1;
+ selected[0].itemflags = 0;
+ *menu_list = selected;
+ va_end(arguments);
+ set_int_return(return_ptr, 1);
+ return;
+ }
+ }
+ va_end(arguments);
+ set_int_return(return_ptr, 0);
+ return;
+ }
+ else if(std::strcmp(name, "shim_print_glyph") == 0)
+ {
+ map_was_drawn = true;
+ }
+ else if(map_was_drawn
+ && (std::strcmp(name, "shim_nhgetch") == 0
+ || std::strcmp(name, "shim_nh_poskey") == 0))
+ {
+ std::fprintf(stderr, "NetHack library startup reached the first key.\n");
+ std::_Exit(0);
+ }
+ va_end(arguments);
+ if(name == nullptr || return_ptr == nullptr)
+ {
+ return;
+ }
+
+ if(std::strcmp(name, "shim_nhgetch") == 0
+ || std::strcmp(name, "shim_nh_poskey") == 0
+ || std::strcmp(name, "shim_message_menu") == 0)
+ {
+ *static_cast<int*>(return_ptr) = 27;
+ }
+ else if(std::strcmp(name, "shim_yn_function") == 0)
+ {
+ *static_cast<char*>(return_ptr) = 27;
+ }
+ else if(std::strcmp(name, "shim_get_ext_cmd") == 0)
+ {
+ *static_cast<int*>(return_ptr) = -1;
+ }
+ else if(std::strcmp(name, "shim_select_menu") == 0)
+ {
+ *static_cast<int*>(return_ptr) = 0;
+ }
+ else if(std::strcmp(name, "shim_get_color_string") == 0)
+ {
+ set_string_return(return_ptr, "");
+ }
+}
+
+} // namespace
+
+int main(int argc, char* argv[])
+{
+#ifdef NETHACK_PROBE_RUNTIME_DIR
+ if(chdir(NETHACK_PROBE_RUNTIME_DIR) != 0)
+ {
+ std::perror("chdir");
+ return 1;
+ }
+#endif
+
+ shim_graphics_set_callback(probe_callback);
+
+ if(argc == 1)
+ {
+ char program[] = "nethack_engine_probe";
+ char name_option[] = "-u";
+ char name[] = "Agent";
+ char* probe_argv[] = {program, name_option, name, nullptr};
+ return nhmain(3, probe_argv);
+ }
+
+ return nhmain(argc, argv);
+}
diff --git a/src/engine_process.cpp b/src/engine_process.cpp
new file mode 100644
index 0000000..d977f76
--- /dev/null
+++ b/src/engine_process.cpp
@@ -0,0 +1,225 @@
+#include "engine_process.hpp"
+
+#include <cerrno>
+#include <csignal>
+#include <cstdio>
+#include <cstring>
+#include <fcntl.h>
+#include <spawn.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include <array>
+#include <stdexcept>
+#include <vector>
+
+extern char** environ;
+
+namespace nethack_mcp
+{
+
+EngineProcess::~EngineProcess()
+{
+ terminate();
+}
+
+bool EngineProcess::start(const Json& start_message, EventCallback callback,
+ std::string& error)
+{
+ if(running_)
+ {
+ error = "a NetHack worker is already running";
+ return false;
+ }
+
+ int channel_fds[2] = {-1, -1};
+ int diagnostic_fds[2] = {-1, -1};
+ if(::socketpair(AF_UNIX, SOCK_STREAM, 0, channel_fds) != 0)
+ {
+ error = std::string("could not create worker IPC: ")
+ + std::strerror(errno);
+ return false;
+ }
+ if(::pipe(diagnostic_fds) != 0)
+ {
+ error = std::string("could not create worker diagnostics pipe: ")
+ + std::strerror(errno);
+ ::close(channel_fds[0]);
+ ::close(channel_fds[1]);
+ return false;
+ }
+
+ posix_spawn_file_actions_t actions;
+ posix_spawn_file_actions_init(&actions);
+ posix_spawn_file_actions_adddup2(&actions, channel_fds[1], 3);
+ if(channel_fds[1] != 3)
+ {
+ posix_spawn_file_actions_addclose(&actions, channel_fds[1]);
+ }
+ posix_spawn_file_actions_addclose(&actions, channel_fds[0]);
+ posix_spawn_file_actions_adddup2(&actions, diagnostic_fds[1], STDOUT_FILENO);
+ posix_spawn_file_actions_adddup2(&actions, diagnostic_fds[1], STDERR_FILENO);
+ if(diagnostic_fds[1] != STDOUT_FILENO
+ && diagnostic_fds[1] != STDERR_FILENO)
+ {
+ posix_spawn_file_actions_addclose(&actions, diagnostic_fds[1]);
+ }
+ posix_spawn_file_actions_addclose(&actions, diagnostic_fds[0]);
+
+ std::string executable = executablePath();
+ std::vector<char*> arguments;
+ std::vector<char> executable_storage(executable.begin(), executable.end());
+ executable_storage.push_back('\0');
+ char engine_option[] = "--engine";
+ char ipc_option[] = "--ipc-fd";
+ char ipc_descriptor[] = "3";
+ arguments.push_back(executable_storage.data());
+ arguments.push_back(engine_option);
+ arguments.push_back(ipc_option);
+ arguments.push_back(ipc_descriptor);
+ arguments.push_back(nullptr);
+
+ pid_t child = -1;
+ const int spawn_result = posix_spawn(
+ &child, executable.c_str(), &actions, nullptr, arguments.data(),
+ environ);
+ posix_spawn_file_actions_destroy(&actions);
+ ::close(channel_fds[1]);
+ ::close(diagnostic_fds[1]);
+ if(spawn_result != 0)
+ {
+ ::close(channel_fds[0]);
+ ::close(diagnostic_fds[0]);
+ error = std::string("could not spawn NetHack worker: ")
+ + std::strerror(spawn_result);
+ return false;
+ }
+
+ process_id_ = child;
+ callback_ = std::move(callback);
+ channel_ = std::make_unique<FramedChannel>(channel_fds[0]);
+ running_ = true;
+ diagnostic_thread_ = std::thread(
+ &EngineProcess::diagnosticLoop, this, diagnostic_fds[0]);
+ reader_thread_ = std::thread(&EngineProcess::readLoop, this);
+
+ if(!send(start_message, error))
+ {
+ terminate();
+ return false;
+ }
+ return true;
+}
+
+bool EngineProcess::send(const Json& message, std::string& error)
+{
+ std::lock_guard lock(state_mutex_);
+ if(!channel_ || !running_)
+ {
+ error = "NetHack worker is not running";
+ return false;
+ }
+ return channel_->send(message, error);
+}
+
+void EngineProcess::terminate()
+{
+ const pid_t process_id = process_id_;
+ if(process_id > 0 && running_)
+ {
+ ::kill(process_id, SIGTERM);
+ }
+ if(channel_)
+ {
+ channel_->close();
+ }
+ if(reader_thread_.joinable())
+ {
+ reader_thread_.join();
+ }
+ if(diagnostic_thread_.joinable())
+ {
+ diagnostic_thread_.join();
+ }
+ channel_.reset();
+ process_id_ = -1;
+ running_ = false;
+}
+
+bool EngineProcess::running() const
+{
+ return running_;
+}
+
+void EngineProcess::readLoop()
+{
+ Json message;
+ std::string error;
+ while(channel_ && channel_->receive(message, error))
+ {
+ if(callback_)
+ {
+ callback_(message);
+ }
+ }
+
+ const pid_t process_id = process_id_;
+ int status = 0;
+ if(process_id > 0)
+ {
+ while(::waitpid(process_id, &status, 0) < 0 && errno == EINTR)
+ {
+ }
+ }
+ running_ = false;
+ Json exiting = {
+ {"type", "exiting"},
+ {"exit_code", WIFEXITED(status) ? WEXITSTATUS(status) : -1},
+ {"signal", WIFSIGNALED(status) ? WTERMSIG(status) : 0},
+ {"error", error},
+ };
+ if(callback_)
+ {
+ callback_(exiting);
+ }
+}
+
+void EngineProcess::diagnosticLoop(int descriptor)
+{
+ std::array<char, 4096> buffer{};
+ while(true)
+ {
+ const ssize_t count = ::read(descriptor, buffer.data(), buffer.size());
+ if(count == 0)
+ {
+ break;
+ }
+ if(count < 0)
+ {
+ if(errno == EINTR)
+ {
+ continue;
+ }
+ break;
+ }
+ std::fwrite(buffer.data(), 1, static_cast<std::size_t>(count), stderr);
+ std::fflush(stderr);
+ }
+ ::close(descriptor);
+}
+
+std::string EngineProcess::executablePath() const
+{
+ std::array<char, 4096> buffer{};
+ const ssize_t length = ::readlink("/proc/self/exe", buffer.data(),
+ buffer.size() - 1);
+ if(length <= 0)
+ {
+ throw std::runtime_error("could not resolve the server executable");
+ }
+ buffer[static_cast<std::size_t>(length)] = '\0';
+ return buffer.data();
+}
+
+} // namespace nethack_mcp
diff --git a/src/engine_worker.cpp b/src/engine_worker.cpp
new file mode 100644
index 0000000..f14feec
--- /dev/null
+++ b/src/engine_worker.cpp
@@ -0,0 +1,108 @@
+#include "engine_worker.hpp"
+
+#include "protocol.hpp"
+#include "window_adapter.hpp"
+
+#include <cstdio>
+#include <cstdlib>
+#include <filesystem>
+#include <stdexcept>
+#include <string>
+#include <unistd.h>
+#include <vector>
+
+extern "C"
+{
+int nhmain(int argc, char* argv[]);
+}
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+int findIntegerArgument(int argc, char* argv[], const char* name)
+{
+ for(int index = 0; index + 1 < argc; ++index)
+ {
+ if(std::string(argv[index]) == name)
+ {
+ return std::stoi(argv[index + 1]);
+ }
+ }
+ return -1;
+}
+
+} // namespace
+
+int runEngineWorker(int argc, char* argv[])
+{
+ const int descriptor = findIntegerArgument(argc, argv, "--ipc-fd");
+ if(descriptor < 0)
+ {
+ std::fputs("--engine requires --ipc-fd\n", stderr);
+ return 2;
+ }
+
+ try
+ {
+ FramedChannel channel(descriptor);
+ Json hello = {
+ {"type", "hello"},
+ {"ipc_version", IPC_VERSION},
+ {"game_id", nullptr},
+ {"nethack_commit",
+ "c94fd5225beef48143244bfb7bc42682aad58741"},
+ {"lua_version", "5.4.8"},
+ };
+ std::string error;
+ if(!channel.send(hello, error))
+ {
+ throw std::runtime_error(error);
+ }
+
+ Json start;
+ if(!channel.receive(start, error)
+ || start.value("type", "") != "start")
+ {
+ throw std::runtime_error(
+ error.empty() ? "worker did not receive start" : error);
+ }
+
+ const std::string game_id = start.at("game_id").get<std::string>();
+ const std::filesystem::path run_directory =
+ start.at("run_dir").get<std::string>();
+ const std::string player_name =
+ start.value("name", std::string("Agent"));
+
+ std::filesystem::current_path(run_directory);
+ setenv("HOME", run_directory.c_str(), 1);
+ setenv("HACKDIR", run_directory.c_str(), 1);
+ setenv("NETHACKDIR", run_directory.c_str(), 1);
+ setenv("NETHACKOPTIONS", "showexp,showscore,time", 1);
+
+ WindowAdapter adapter(channel, game_id);
+ adapter.install();
+
+ char program[] = "nethack_mcp";
+ char name_option[] = "-u";
+ std::vector<char> name(player_name.begin(), player_name.end());
+ name.push_back('\0');
+ char* nethack_argv[] = {
+ program,
+ name_option,
+ name.data(),
+ nullptr,
+ };
+ return nhmain(3, nethack_argv);
+ }
+ catch(const std::exception& exception)
+ {
+ std::fprintf(stderr, "NetHack worker failed: %s\n",
+ exception.what());
+ return 2;
+ }
+}
+
+} // namespace nethack_mcp
diff --git a/src/engine_worker_stub.cpp b/src/engine_worker_stub.cpp
new file mode 100644
index 0000000..093123a
--- /dev/null
+++ b/src/engine_worker_stub.cpp
@@ -0,0 +1,15 @@
+#include "engine_worker.hpp"
+
+#include <cstdio>
+
+namespace nethack_mcp
+{
+
+int runEngineWorker([[maybe_unused]] int argc,
+ [[maybe_unused]] char* argv[])
+{
+ std::fputs("this build does not include the NetHack engine\n", stderr);
+ return 2;
+}
+
+} // namespace nethack_mcp
diff --git a/src/game_session.cpp b/src/game_session.cpp
new file mode 100644
index 0000000..46617a8
--- /dev/null
+++ b/src/game_session.cpp
@@ -0,0 +1,667 @@
+#include "game_session.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <cctype>
+#include <cstring>
+#include <fstream>
+#include <random>
+#include <sstream>
+#include <utility>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+using Clock = std::chrono::steady_clock;
+
+std::string jsonString(const Json& value, const char* key,
+ std::string fallback = {})
+{
+ if(!value.contains(key) || !value.at(key).is_string())
+ {
+ return fallback;
+ }
+ return value.at(key).get<std::string>();
+}
+
+} // namespace
+
+GameSession::GameSession(std::filesystem::path data_root,
+ std::filesystem::path runtime_dir,
+ std::string viewer_url)
+ : data_root_(std::move(data_root)), runtime_dir_(std::move(runtime_dir)),
+ viewer_url_(std::move(viewer_url)), observations_(viewer_url_)
+{
+ std::filesystem::create_directories(data_root_);
+}
+
+ToolResult GameSession::newGame(const Json& arguments)
+{
+ std::unique_lock action_lock(action_mutex_);
+ {
+ std::lock_guard session_lock(session_mutex_);
+ if(process_ && process_->running())
+ {
+ return errorResult("GAME_ACTIVE", "a game is already active");
+ }
+ if(process_)
+ {
+ process_.reset();
+ }
+ }
+
+ std::string name = jsonString(arguments, "name", "Agent");
+ if(name.empty() || name.size() > 30)
+ {
+ return errorResult("INVALID_RESPONSE",
+ "name must contain between 1 and 30 bytes");
+ }
+ for(unsigned char character : name)
+ {
+ if(character < 0x20 || character > 0x7e)
+ {
+ return errorResult("INVALID_RESPONSE",
+ "name must contain printable ASCII only");
+ }
+ }
+
+ const std::string game_id = makeGameId();
+ const std::filesystem::path run_directory = data_root_ / game_id;
+ std::string error;
+ if(!copyRuntimeFiles(run_directory, error))
+ {
+ return errorResult("ENGINE_FAILURE", error);
+ }
+
+ Json starting = observations_.snapshot();
+ starting["game_id"] = game_id;
+ starting["lifecycle"] = "starting";
+ starting["pending"] = nullptr;
+ starting["operation"] = nullptr;
+ starting["viewer_url"] = viewer_url_;
+ const std::uint64_t starting_revision = observations_.publish(
+ std::move(starting));
+
+ stop_requested_ = false;
+ {
+ std::lock_guard session_lock(session_mutex_);
+ process_ = std::make_unique<EngineProcess>();
+ }
+
+ Json start_message = {
+ {"type", "start"},
+ {"ipc_version", IPC_VERSION},
+ {"game_id", game_id},
+ {"run_dir", run_directory.string()},
+ {"name", name},
+ };
+ std::string spawn_error;
+ if(!process_->start(
+ start_message,
+ [this](const Json& message) {
+ handleWorkerMessage(message);
+ },
+ spawn_error))
+ {
+ Json failed = observations_.snapshot();
+ failed["lifecycle"] = "failed";
+ failed["pending"] = nullptr;
+ observations_.publish(std::move(failed));
+ return errorResult("ENGINE_FAILURE", spawn_error);
+ }
+
+ Json state = observations_.waitForRevision(
+ starting_revision, std::chrono::seconds(10));
+ if(state.value("game_id", "") != game_id)
+ {
+ return errorResult("ENGINE_FAILURE",
+ "worker returned a different game identifier");
+ }
+ return {true, std::move(state), {}, {}};
+}
+
+ToolResult GameSession::observe(const Json& arguments)
+{
+ const Json state = observations_.snapshot();
+ std::string error;
+ if(arguments.contains("game_id")
+ && !validGameId(arguments, state, error))
+ {
+ return errorResult("STALE_GAME", error);
+ }
+
+ int wait_ms = 0;
+ if(arguments.contains("wait_ms"))
+ {
+ if(!arguments.at("wait_ms").is_number_integer())
+ {
+ return errorResult("INVALID_RESPONSE", "wait_ms must be an integer");
+ }
+ wait_ms = arguments.at("wait_ms").get<int>();
+ if(wait_ms < 0 || wait_ms > 10000)
+ {
+ return errorResult("INVALID_RESPONSE",
+ "wait_ms must be between 0 and 10000");
+ }
+ }
+
+ Json result = wait_ms == 0
+ ? state
+ : observations_.waitForRevision(
+ state.value("revision", 0ULL),
+ std::chrono::milliseconds(wait_ms));
+
+ if(arguments.contains("after_message_id"))
+ {
+ if(!arguments.at("after_message_id").is_number_unsigned())
+ {
+ return errorResult("INVALID_RESPONSE",
+ "after_message_id must be an integer");
+ }
+ const std::uint64_t after = arguments.at("after_message_id").get<
+ std::uint64_t>();
+ const Json all_messages = result.value("messages", Json::array());
+ Json filtered = Json::array();
+ for(const Json& message : all_messages)
+ {
+ if(message.value("id", 0ULL) > after)
+ {
+ filtered.push_back(message);
+ }
+ }
+ result["messages"] = std::move(filtered);
+ }
+ return {true, std::move(result), {}, {}};
+}
+
+ToolResult GameSession::press(const Json& arguments)
+{
+ int key = 0;
+ std::string error;
+ if(!parseKey(arguments.value("key", Json()), key, error))
+ {
+ return errorResult("INVALID_RESPONSE", error);
+ }
+ return sendInput(arguments, {{"value", key}}, "key");
+}
+
+ToolResult GameSession::respond(const Json& arguments)
+{
+ const Json state = observations_.snapshot();
+ std::string error;
+ if(!validGameId(arguments, state, error))
+ {
+ return errorResult("STALE_GAME", error);
+ }
+ const Json pending = state.value("pending", Json());
+ if(!pending.is_object())
+ {
+ return errorResult("WRONG_INPUT_KIND", "there is no pending input");
+ }
+
+ int response_variants = 0;
+ response_variants += arguments.contains("text") ? 1 : 0;
+ response_variants += arguments.contains("choice") ? 1 : 0;
+ response_variants += arguments.contains("command") ? 1 : 0;
+ response_variants += arguments.contains("acknowledge") ? 1 : 0;
+ response_variants += arguments.contains("cancel") ? 1 : 0;
+ if(response_variants != 1)
+ {
+ return errorResult("INVALID_RESPONSE",
+ "respond requires exactly one response variant");
+ }
+
+ Json response;
+ if(arguments.contains("text"))
+ {
+ if(!arguments.at("text").is_string()
+ || pending.value("kind", "") != "text")
+ {
+ return errorResult("WRONG_INPUT_KIND",
+ "the pending input does not accept text");
+ }
+ const std::string text = arguments.at("text").get<std::string>();
+ if(text.find('\n') != std::string::npos
+ || text.find('\r') != std::string::npos
+ || text.size() > pending.value("max_bytes", 255U))
+ {
+ return errorResult("LIMIT_EXCEEDED", "text response is invalid");
+ }
+ response = {{"text", text}};
+ }
+ else if(arguments.contains("choice"))
+ {
+ if(!arguments.at("choice").is_string()
+ || arguments.at("choice").get<std::string>().size() != 1
+ || pending.value("kind", "") != "choice")
+ {
+ return errorResult("WRONG_INPUT_KIND",
+ "the pending input does not accept a choice");
+ }
+ const char choice = arguments.at("choice").get<std::string>()[0];
+ const std::string choices = pending.value("choices", "");
+ if(choice != 27 && choices.find(choice) == std::string::npos)
+ {
+ return errorResult("INVALID_RESPONSE",
+ "choice is not in the offered choices");
+ }
+ response = {{"value", static_cast<int>(choice)}};
+ }
+ else if(arguments.contains("command"))
+ {
+ if(!arguments.at("command").is_string()
+ || pending.value("kind", "") != "command")
+ {
+ return errorResult("WRONG_INPUT_KIND",
+ "the pending input does not accept a command");
+ }
+ const std::string command = arguments.at("command").get<std::string>();
+ const Json offered = pending.value("commands", Json::array());
+ if(std::find(offered.begin(), offered.end(), command) == offered.end())
+ {
+ return errorResult("INVALID_RESPONSE",
+ "command is not offered by NetHack");
+ }
+ response = {{"command", command}};
+ }
+ else if(arguments.contains("acknowledge"))
+ {
+ if(!arguments.at("acknowledge").is_boolean()
+ || !arguments.at("acknowledge").get<bool>())
+ {
+ return errorResult("INVALID_RESPONSE",
+ "acknowledge must be true");
+ }
+ if(pending.value("kind", "") != "acknowledge")
+ {
+ return errorResult("WRONG_INPUT_KIND",
+ "the pending input is not an acknowledgement");
+ }
+ response = {{"acknowledge", true}};
+ }
+ else if(arguments.contains("cancel"))
+ {
+ if(!arguments.at("cancel").is_boolean()
+ || !arguments.at("cancel").get<bool>())
+ {
+ return errorResult("INVALID_RESPONSE", "cancel must be true");
+ }
+ response = {{"cancel", true}};
+ }
+ return sendInput(arguments, std::move(response),
+ pending.value("kind", ""));
+}
+
+ToolResult GameSession::selectMenu(const Json& arguments)
+{
+ const Json state = observations_.snapshot();
+ std::string error;
+ if(!validGameId(arguments, state, error))
+ {
+ return errorResult("STALE_GAME", error);
+ }
+ const Json pending = state.value("pending", Json());
+ if(!pending.is_object() || pending.value("kind", "") != "menu")
+ {
+ return errorResult("WRONG_INPUT_KIND", "there is no pending menu");
+ }
+ if(!arguments.contains("selections")
+ || !arguments.at("selections").is_array())
+ {
+ return errorResult("INVALID_SELECTION",
+ "selections must be an array");
+ }
+ if(arguments.contains("cancel")
+ && !arguments.at("cancel").is_boolean())
+ {
+ return errorResult("INVALID_SELECTION", "cancel must be boolean");
+ }
+ Json response = {
+ {"selections", arguments.at("selections")},
+ {"cancel", arguments.value("cancel", false)},
+ };
+ std::vector<int> seen;
+ for(const Json& selection : response["selections"])
+ {
+ if(!selection.is_object()
+ || !selection.contains("entry_id")
+ || !selection.at("entry_id").is_number_integer())
+ {
+ return errorResult("INVALID_SELECTION",
+ "each selection needs an integer entry_id");
+ }
+ const int entry_id = selection.at("entry_id").get<int>();
+ if(std::find(seen.begin(), seen.end(), entry_id) != seen.end())
+ {
+ return errorResult("INVALID_SELECTION",
+ "menu entries may only be selected once");
+ }
+ seen.push_back(entry_id);
+ if(selection.contains("count")
+ && (!selection.at("count").is_number_integer()
+ || selection.at("count").get<long long>() <= 0))
+ {
+ return errorResult("INVALID_SELECTION",
+ "menu count must be positive");
+ }
+ }
+ const int mode = pending.value("mode", 0);
+ if(mode == 0 && !response["selections"].empty())
+ {
+ return errorResult("INVALID_SELECTION",
+ "this menu does not accept selections");
+ }
+ if(mode == 1 && response["selections"].size() > 1)
+ {
+ return errorResult("INVALID_SELECTION",
+ "this menu accepts only one selection");
+ }
+ for(const Json& selection : response["selections"])
+ {
+ const int entry_id = selection.at("entry_id").get<int>();
+ const auto entry = std::find_if(
+ pending.at("entries").begin(), pending.at("entries").end(),
+ [entry_id](const Json& item) {
+ return item.value("entry_id", 0) == entry_id;
+ });
+ if(entry == pending.at("entries").end()
+ || !entry->value("selectable", false))
+ {
+ return errorResult("INVALID_SELECTION",
+ "menu entry is not selectable");
+ }
+ }
+ if(response.value("cancel", false) && !response["selections"].empty())
+ {
+ return errorResult("INVALID_SELECTION",
+ "cancel cannot include selections");
+ }
+ return sendInput(arguments, std::move(response), "menu");
+}
+
+ToolResult GameSession::quitGame(const Json& arguments)
+{
+ const Json state = observations_.snapshot();
+ std::string error;
+ if(!validGameId(arguments, state, error))
+ {
+ return errorResult("STALE_GAME", error);
+ }
+ std::unique_lock action_lock(action_mutex_);
+ stop_requested_ = true;
+ if(process_)
+ {
+ process_->terminate();
+ }
+ Json result = observations_.snapshot();
+ if(result.value("lifecycle", "") == "waiting"
+ || result.value("lifecycle", "") == "starting")
+ {
+ result["lifecycle"] = "aborted";
+ result["pending"] = nullptr;
+ result["operation"] = nullptr;
+ observations_.publish(result);
+ }
+ return {true, std::move(result), {}, {}};
+}
+
+void GameSession::shutdown()
+{
+ std::unique_lock action_lock(action_mutex_);
+ stop_requested_ = true;
+ if(process_)
+ {
+ process_->terminate();
+ }
+}
+
+const std::string& GameSession::viewerUrl() const
+{
+ return viewer_url_;
+}
+
+Json GameSession::snapshot() const
+{
+ return observations_.snapshot();
+}
+
+ToolResult GameSession::sendInput(const Json& arguments, Json response,
+ const std::string& expected_kind)
+{
+ std::unique_lock action_lock(action_mutex_);
+ const Json state = observations_.snapshot();
+ std::string error;
+ if(!validGameId(arguments, state, error))
+ {
+ return errorResult("STALE_GAME", error);
+ }
+ const Json pending = state.value("pending", Json());
+ if(!pending.is_object()
+ || pending.value("kind", "") != expected_kind)
+ {
+ return errorResult("WRONG_INPUT_KIND",
+ "the requested response does not match pending input");
+ }
+ if(state.value("operation", Json()) != nullptr)
+ {
+ return errorResult("BUSY", "another gameplay operation is running");
+ }
+ if(!process_ || !process_->running())
+ {
+ return errorResult("ENGINE_FAILURE", "NetHack worker is not running");
+ }
+ if(!arguments.contains("input_id")
+ || !arguments.at("input_id").is_number_integer())
+ {
+ return errorResult("INVALID_RESPONSE", "input_id must be an integer");
+ }
+ const long long provided_input_id =
+ arguments.at("input_id").get<long long>();
+ const std::uint64_t pending_input_id =
+ pending.value("input_id", 0ULL);
+ if(provided_input_id < 0
+ || static_cast<std::uint64_t>(provided_input_id)
+ != pending_input_id)
+ {
+ return errorResult("STALE_INPUT",
+ "input_id does not identify the pending boundary");
+ }
+
+ Json operation = {
+ {"operation_id", "op_" + std::to_string(++operation_counter_)},
+ {"state", "running"},
+ };
+ Json running_state = state;
+ running_state["operation"] = operation;
+ const std::uint64_t operation_revision = observations_.publish(
+ std::move(running_state));
+
+ Json input = {
+ {"type", "input"},
+ {"ipc_version", IPC_VERSION},
+ {"game_id", state.value("game_id", "")},
+ {"input_id", pending.value("input_id", 0ULL)},
+ {"response", std::move(response)},
+ };
+ if(!process_->send(input, error))
+ {
+ Json failed = observations_.snapshot();
+ failed["lifecycle"] = "failed";
+ failed["operation"] = nullptr;
+ observations_.publish(std::move(failed));
+ return errorResult("ENGINE_FAILURE", error);
+ }
+
+ Json result = observations_.waitForRevision(
+ operation_revision, std::chrono::seconds(10));
+ return {true, std::move(result), {}, {}};
+}
+
+ToolResult GameSession::errorResult(std::string code,
+ std::string message) const
+{
+ return {false, observations_.snapshot(), std::move(code),
+ std::move(message)};
+}
+
+void GameSession::handleWorkerMessage(const Json& message)
+{
+ const std::string type = message.value("type", "");
+ if(type == "hello")
+ {
+ if(message.value("ipc_version", 0U) != IPC_VERSION)
+ {
+ Json failed = observations_.snapshot();
+ failed["lifecycle"] = "failed";
+ failed["messages"].push_back({
+ {"id", 0},
+ {"text", "worker IPC version mismatch"},
+ });
+ observations_.publish(std::move(failed));
+ }
+ return;
+ }
+ if(type == "snapshot" && message.contains("snapshot"))
+ {
+ Json snapshot = message.at("snapshot");
+ snapshot["viewer_url"] = viewer_url_;
+ snapshot["operation"] = nullptr;
+ observations_.publish(std::move(snapshot));
+ return;
+ }
+ if(type == "exiting")
+ {
+ Json snapshot = observations_.snapshot();
+ if(stop_requested_)
+ {
+ snapshot["lifecycle"] = "aborted";
+ }
+ else if(message.value("exit_code", -1) == 0
+ && message.value("signal", 0) == 0)
+ {
+ snapshot["lifecycle"] = "ended";
+ }
+ else
+ {
+ snapshot["lifecycle"] = "failed";
+ }
+ snapshot["pending"] = nullptr;
+ snapshot["operation"] = nullptr;
+ observations_.publish(std::move(snapshot));
+ }
+}
+
+bool GameSession::copyRuntimeFiles(
+ const std::filesystem::path& run_directory, std::string& error) const
+{
+ try
+ {
+ std::filesystem::create_directories(run_directory / "save");
+ const std::filesystem::perms owner_permissions =
+ std::filesystem::perms::owner_read
+ | std::filesystem::perms::owner_write
+ | std::filesystem::perms::owner_exec;
+ std::filesystem::permissions(run_directory, owner_permissions,
+ std::filesystem::perm_options::replace);
+ std::filesystem::permissions(run_directory / "save",
+ owner_permissions,
+ std::filesystem::perm_options::replace);
+ for(const char* filename : {"nhdat", "symbols", "license", "sysconf"})
+ {
+ const auto source = runtime_dir_ / filename;
+ if(!std::filesystem::is_regular_file(source))
+ {
+ error = "NetHack runtime file is missing: " + source.string();
+ return false;
+ }
+ std::filesystem::copy_file(source, run_directory / filename);
+ }
+ for(const char* filename : {"perm", "record", "logfile",
+ "xlogfile", "livelog"})
+ {
+ const auto destination = run_directory / filename;
+ std::ofstream file(destination);
+ file.close();
+ std::filesystem::permissions(
+ destination,
+ std::filesystem::perms::owner_read
+ | std::filesystem::perms::owner_write,
+ std::filesystem::perm_options::replace);
+ }
+ return true;
+ }
+ catch(const std::exception& exception)
+ {
+ error = exception.what();
+ return false;
+ }
+}
+
+bool GameSession::validGameId(const Json& arguments, const Json& state,
+ std::string& error)
+{
+ const std::string current_id = state.contains("game_id")
+ && state.at("game_id").is_string()
+ ? state.at("game_id").get<std::string>() : std::string();
+ if(!arguments.contains("game_id") || !arguments.at("game_id").is_string())
+ {
+ error = "game_id is required";
+ return false;
+ }
+ if(current_id.empty() || arguments.at("game_id").get<std::string>()
+ != current_id)
+ {
+ error = "game_id does not identify the active game";
+ return false;
+ }
+ return true;
+}
+
+bool GameSession::parseKey(const Json& key, int& value, std::string& error)
+{
+ if(!key.is_string())
+ {
+ error = "key must be a string";
+ return false;
+ }
+ const std::string text = key.get<std::string>();
+ if(text.size() == 1
+ && static_cast<unsigned char>(text[0]) >= 0x20
+ && static_cast<unsigned char>(text[0]) <= 0x7e)
+ {
+ value = static_cast<unsigned char>(text[0]);
+ return true;
+ }
+ if(text == "ENTER") value = '\n';
+ else if(text == "ESC") value = 27;
+ else if(text == "SPACE") value = ' ';
+ else if(text == "TAB") value = '\t';
+ else if(text == "BACKSPACE") value = '\b';
+ else if(text.size() == 6 && text.rfind("CTRL_", 0) == 0
+ && text[5] >= 'A' && text[5] <= 'Z')
+ value = text[5] - 'A' + 1;
+ else if(text.size() == 6 && text.rfind("META_", 0) == 0
+ && static_cast<unsigned char>(text[5]) >= 0x20
+ && static_cast<unsigned char>(text[5]) <= 0x7e)
+ value = 0x80 | static_cast<unsigned char>(text[5]);
+ else
+ {
+ error = "key must be one printable ASCII byte or a named key";
+ return false;
+ }
+ return value != 0;
+}
+
+std::string GameSession::makeGameId()
+{
+ static std::atomic<std::uint64_t> counter = 0;
+ const auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
+ Clock::now().time_since_epoch()).count();
+ return "g_" + std::to_string(now) + "_"
+ + std::to_string(++counter);
+}
+
+} // namespace nethack_mcp
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..8d78de3
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,103 @@
+#include "engine_worker.hpp"
+#include "game_session.hpp"
+#include "mcp_server.hpp"
+#include "spectator_server.hpp"
+
+#include <cstdio>
+#include <cstdlib>
+#include <exception>
+#include <filesystem>
+#include <iostream>
+#include <string>
+
+namespace
+{
+
+struct Options
+{
+ std::filesystem::path data_root;
+ int port = 8765;
+};
+
+bool parseOptions(int argc, char* argv[], Options& options)
+{
+ options.data_root = std::filesystem::temp_directory_path()
+ / "nethack-mcp";
+ for(int index = 1; index < argc; ++index)
+ {
+ const std::string argument = argv[index];
+ if(argument == "--data-root" && index + 1 < argc)
+ {
+ options.data_root = argv[++index];
+ }
+ else if(argument == "--port" && index + 1 < argc)
+ {
+ try
+ {
+ options.port = std::stoi(argv[++index]);
+ }
+ catch(const std::exception&)
+ {
+ return false;
+ }
+ if(options.port < 1 || options.port > 65535)
+ {
+ return false;
+ }
+ }
+ else if(argument == "--help")
+ {
+ std::fputs(
+ "Usage: nethack_mcp [--data-root PATH] [--port PORT]\n",
+ stdout);
+ return false;
+ }
+ else
+ {
+ return false;
+ }
+ }
+ return true;
+}
+
+} // namespace
+
+int main(int argc, char* argv[])
+{
+ if(argc > 1 && std::string(argv[1]) == "--engine")
+ {
+ return nethack_mcp::runEngineWorker(argc, argv);
+ }
+
+ Options options;
+ if(!parseOptions(argc, argv, options))
+ {
+ return argc > 1 && std::string(argv[1]) == "--help" ? 0 : 2;
+ }
+
+ try
+ {
+ nethack_mcp::GameSession session(
+ options.data_root,
+ NETHACK_RUNTIME_DIR,
+ "http://127.0.0.1:" + std::to_string(options.port) + "/");
+ nethack_mcp::SpectatorServer spectator(session, options.port);
+ std::string error;
+ if(!spectator.startServer(error))
+ {
+ std::fprintf(stderr, "%s\n", error.c_str());
+ return 1;
+ }
+ std::fprintf(stderr, "Spectator: %s\n", session.viewerUrl().c_str());
+
+ nethack_mcp::McpServer server(session);
+ const int result = server.run(std::cin, std::cout);
+ spectator.stopServer();
+ return result;
+ }
+ catch(const std::exception& exception)
+ {
+ std::fprintf(stderr, "nethack_mcp: %s\n", exception.what());
+ return 1;
+ }
+}
diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp
new file mode 100644
index 0000000..42445c5
--- /dev/null
+++ b/src/mcp_server.cpp
@@ -0,0 +1,323 @@
+#include "mcp_server.hpp"
+
+#include <cstddef>
+#include <iostream>
+#include <string>
+#include <utility>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+constexpr std::size_t MAX_STDIO_MESSAGE_SIZE = 1024U * 1024U;
+
+bool supportedProtocolVersion(const std::string& version)
+{
+ return version == "2025-11-25"
+ || version == "2025-06-18"
+ || version == "2025-03-26"
+ || version == "2024-11-05";
+}
+
+Json objectSchema(Json properties, Json required = Json::array())
+{
+ return {
+ {"type", "object"},
+ {"properties", std::move(properties)},
+ {"required", std::move(required)},
+ {"additionalProperties", false},
+ };
+}
+
+Json stringProperty()
+{
+ return {{"type", "string"}};
+}
+
+Json gameIdProperty()
+{
+ return {
+ {"type", "string"},
+ {"minLength", 1},
+ {"maxLength", 128},
+ };
+}
+
+} // namespace
+
+McpServer::McpServer(GameSession& session)
+ : session_(session)
+{}
+
+int McpServer::run(std::istream& input, std::ostream& output)
+{
+ std::string line;
+ while(std::getline(input, line))
+ {
+ if(line.size() > MAX_STDIO_MESSAGE_SIZE)
+ {
+ output << jsonRpcError(nullptr, -32600,
+ "JSON-RPC message exceeds 1 MiB")
+ .dump() << '\n' << std::flush;
+ continue;
+ }
+
+ Json request;
+ try
+ {
+ request = Json::parse(line);
+ }
+ catch(const Json::parse_error& exception)
+ {
+ output << jsonRpcError(nullptr, -32700, exception.what()).dump()
+ << '\n' << std::flush;
+ continue;
+ }
+
+ bool should_respond = true;
+ Json response = dispatch(request, should_respond);
+ if(should_respond)
+ {
+ output << response.dump() << '\n' << std::flush;
+ }
+ }
+ session_.shutdown();
+ return 0;
+}
+
+Json McpServer::dispatch(const Json& request, bool& should_respond)
+{
+ should_respond = true;
+ if(!request.is_object()
+ || !request.contains("jsonrpc")
+ || !request.at("jsonrpc").is_string()
+ || request.at("jsonrpc").get<std::string>() != "2.0"
+ || !request.contains("method") || !request.at("method").is_string())
+ {
+ const Json id = request.is_object() && request.contains("id")
+ ? request.at("id") : Json(nullptr);
+ return jsonRpcError(id, -32600, "invalid JSON-RPC request");
+ }
+
+ const Json id = request.contains("id") ? request.at("id")
+ : Json(nullptr);
+
+ const std::string method = request.at("method").get<std::string>();
+ if(!request.contains("id"))
+ {
+ should_respond = false;
+ }
+ if(method == "notifications/initialized"
+ || method == "notifications/cancelled")
+ {
+ should_respond = false;
+ return Json();
+ }
+ if(method == "initialize")
+ {
+ const Json params = request.value("params", Json::object());
+ if(!params.is_object())
+ {
+ return jsonRpcError(id, -32602,
+ "initialize params must be an object");
+ }
+ if(params.contains("protocolVersion")
+ && !params.at("protocolVersion").is_string())
+ {
+ return jsonRpcError(id, -32602,
+ "protocolVersion must be a string");
+ }
+ const std::string version = params.value("protocolVersion", "");
+ if(!version.empty() && !supportedProtocolVersion(version))
+ {
+ return jsonRpcError(id, -32602,
+ "unsupported MCP protocol version: " + version);
+ }
+ const std::string negotiated_version = version.empty()
+ ? "2025-11-25" : version;
+ return {
+ {"jsonrpc", "2.0"},
+ {"id", id},
+ {"result", {
+ {"protocolVersion", negotiated_version},
+ {"capabilities", {{"tools", Json::object()}}},
+ {"serverInfo", {
+ {"name", "nethack-mcp"},
+ {"version", "0.1.0"},
+ }},
+ }},
+ };
+ }
+ if(method == "ping")
+ {
+ return {{"jsonrpc", "2.0"}, {"id", id}, {"result", {}}};
+ }
+ if(method == "tools/list")
+ {
+ return {
+ {"jsonrpc", "2.0"},
+ {"id", id},
+ {"result", {{"tools", tools()}}},
+ };
+ }
+ if(method == "tools/call")
+ {
+ const Json params = request.value("params", Json::object());
+ if(!params.is_object() || !params.contains("name")
+ || !params.at("name").is_string())
+ {
+ return jsonRpcError(id, -32602, "tools/call needs a tool name");
+ }
+ const Json arguments = params.value("arguments", Json::object());
+ if(!arguments.is_object())
+ {
+ return jsonRpcError(id, -32602, "tool arguments must be an object");
+ }
+
+ const std::string name = params.at("name").get<std::string>();
+ ToolResult result;
+ if(name == "new_game") result = session_.newGame(arguments);
+ else if(name == "observe") result = session_.observe(arguments);
+ else if(name == "press") result = session_.press(arguments);
+ else if(name == "select_menu") result = session_.selectMenu(arguments);
+ else if(name == "respond") result = session_.respond(arguments);
+ else if(name == "quit_game") result = session_.quitGame(arguments);
+ else
+ {
+ return jsonRpcError(id, -32602, "unknown tool: " + name);
+ }
+ return {
+ {"jsonrpc", "2.0"},
+ {"id", id},
+ {"result", toolResult(result)},
+ };
+ }
+ return jsonRpcError(id, -32601, "method not found: " + method);
+}
+
+Json McpServer::tools() const
+{
+ const Json game_id = gameIdProperty();
+ const Json selection = objectSchema(
+ {
+ {"entry_id", {{"type", "integer"}, {"minimum", 1}}},
+ {"count", {{"type", "integer"}, {"minimum", 1}}},
+ },
+ {"entry_id"});
+ return Json::array({
+ {
+ {"name", "new_game"},
+ {"description", "Start one fresh NetHack game."},
+ {"inputSchema", objectSchema({
+ {"name", {{"type", "string"}, {"maxLength", 30}}},
+ {"role", stringProperty()},
+ {"race", stringProperty()},
+ {"gender", stringProperty()},
+ {"alignment", stringProperty()},
+ })},
+ },
+ {
+ {"name", "observe"},
+ {"description", "Read the latest complete game observation."},
+ {"inputSchema", objectSchema({
+ {"game_id", game_id},
+ {"detail", {{"type", "string"},
+ {"enum", {"compact", "full"}}}},
+ {"after_message_id", {{"type", "integer"}, {"minimum", 0}}},
+ {"wait_ms", {{"type", "integer"},
+ {"minimum", 0}, {"maximum", 10000}}},
+ })},
+ },
+ {
+ {"name", "press"},
+ {"description", "Send one key to a pending key boundary."},
+ {"inputSchema", objectSchema({
+ {"game_id", game_id},
+ {"input_id", {{"type", "integer"}, {"minimum", 1}}},
+ {"key", stringProperty()},
+ }, {"game_id", "input_id", "key"})},
+ },
+ {
+ {"name", "select_menu"},
+ {"description", "Submit complete selections for a menu."},
+ {"inputSchema", objectSchema({
+ {"game_id", game_id},
+ {"input_id", {{"type", "integer"}, {"minimum", 1}}},
+ {"selections", {{"type", "array"},
+ {"items", selection}}},
+ {"cancel", {{"type", "boolean"}}},
+ }, {"game_id", "input_id", "selections"})},
+ },
+ {
+ {"name", "respond"},
+ {"description", "Answer a non-key pending boundary."},
+ {"inputSchema", {
+ {"type", "object"},
+ {"properties", {
+ {"game_id", game_id},
+ {"input_id", {{"type", "integer"}, {"minimum", 1}}},
+ {"text", stringProperty()},
+ {"choice", stringProperty()},
+ {"command", stringProperty()},
+ {"acknowledge", {{"type", "boolean"}}},
+ {"cancel", {{"type", "boolean"}}},
+ }},
+ {"required", {"game_id", "input_id"}},
+ {"additionalProperties", false},
+ {"oneOf", {
+ {{"required", {"text"}}},
+ {{"required", {"choice"}}},
+ {{"required", {"command"}}},
+ {{"required", {"acknowledge"}}},
+ {{"required", {"cancel"}}},
+ }},
+ }},
+ },
+ {
+ {"name", "quit_game"},
+ {"description", "Stop the active worker administratively."},
+ {"inputSchema", objectSchema({
+ {"game_id", game_id},
+ }, {"game_id"})},
+ },
+ });
+}
+
+Json McpServer::toolResult(const ToolResult& result) const
+{
+ Json structured = result.value;
+ Json text_value = result.success
+ ? result.value
+ : Json({
+ {"error", {
+ {"code", result.code},
+ {"message", result.message},
+ }},
+ {"state", result.value},
+ });
+ Json response = {
+ {"content", Json::array({
+ {{"type", "text"}, {"text", text_value.dump()}},
+ })},
+ {"structuredContent", structured},
+ };
+ if(!result.success)
+ {
+ response["isError"] = true;
+ }
+ return response;
+}
+
+Json McpServer::jsonRpcError(const Json& id, int code,
+ const std::string& message) const
+{
+ return {
+ {"jsonrpc", "2.0"},
+ {"id", id},
+ {"error", {{"code", code}, {"message", message}}},
+ };
+}
+
+} // namespace nethack_mcp
diff --git a/src/observation_store.cpp b/src/observation_store.cpp
new file mode 100644
index 0000000..c33aa67
--- /dev/null
+++ b/src/observation_store.cpp
@@ -0,0 +1,62 @@
+#include "observation_store.hpp"
+
+#include <utility>
+
+namespace nethack_mcp
+{
+
+ObservationStore::ObservationStore(std::string viewer_url)
+ : snapshot_({
+ {"schema_version", 1},
+ {"game_id", nullptr},
+ {"revision", 0},
+ {"lifecycle", "idle"},
+ {"operation", nullptr},
+ {"map", {
+ {"width", 79},
+ {"height", 21},
+ {"origin", {{"x", 1}, {"y", 0}}},
+ {"rows", Json::array()},
+ {"cursor", nullptr},
+ }},
+ {"status", Json::object()},
+ {"messages", Json::array()},
+ {"messages_truncated", false},
+ {"inventory", {
+ {"known", false},
+ {"stale", true},
+ {"entries", Json::array()},
+ }},
+ {"pending", nullptr},
+ {"viewer_url", std::move(viewer_url)},
+ })
+{}
+
+Json ObservationStore::snapshot() const
+{
+ std::lock_guard lock(mutex_);
+ return snapshot_;
+}
+
+std::uint64_t ObservationStore::publish(Json snapshot)
+{
+ std::lock_guard lock(mutex_);
+ ++revision_;
+ snapshot["revision"] = revision_;
+ snapshot_ = std::move(snapshot);
+ condition_.notify_all();
+ return revision_;
+}
+
+Json ObservationStore::waitForRevision(
+ std::uint64_t revision,
+ std::chrono::milliseconds timeout) const
+{
+ std::unique_lock lock(mutex_);
+ condition_.wait_for(lock, timeout, [&] {
+ return revision_ > revision;
+ });
+ return snapshot_;
+}
+
+} // namespace nethack_mcp
diff --git a/src/protocol.cpp b/src/protocol.cpp
new file mode 100644
index 0000000..49586ce
--- /dev/null
+++ b/src/protocol.cpp
@@ -0,0 +1,134 @@
+#include "protocol.hpp"
+
+#include <cerrno>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <arpa/inet.h>
+
+namespace nethack_mcp
+{
+
+FramedChannel::FramedChannel(int descriptor)
+ : descriptor_(descriptor)
+{}
+
+FramedChannel::~FramedChannel()
+{
+ close();
+}
+
+bool FramedChannel::send(const Json& message, std::string& error)
+{
+ const std::string payload = message.dump();
+ if(payload.size() > MAX_IPC_FRAME_SIZE)
+ {
+ error = "IPC payload exceeds the 4 MiB limit";
+ return false;
+ }
+
+ const std::uint32_t length = htonl(
+ static_cast<std::uint32_t>(payload.size()));
+ std::lock_guard lock(write_mutex_);
+ return writeExact(&length, sizeof(length), error)
+ && writeExact(payload.data(), payload.size(), error);
+}
+
+bool FramedChannel::receive(Json& message, std::string& error)
+{
+ std::uint32_t encoded_length = 0;
+ if(!readExact(&encoded_length, sizeof(encoded_length), error))
+ {
+ return false;
+ }
+
+ const std::uint32_t length = ntohl(encoded_length);
+ if(length > MAX_IPC_FRAME_SIZE)
+ {
+ error = "IPC payload exceeds the 4 MiB limit";
+ return false;
+ }
+
+ std::string payload(length, '\0');
+ if(!readExact(payload.data(), payload.size(), error))
+ {
+ return false;
+ }
+
+ try
+ {
+ message = Json::parse(payload);
+ }
+ catch(const Json::parse_error& exception)
+ {
+ error = std::string("invalid IPC JSON: ") + exception.what();
+ return false;
+ }
+ return true;
+}
+
+void FramedChannel::close()
+{
+ if(descriptor_ >= 0)
+ {
+ ::shutdown(descriptor_, SHUT_RDWR);
+ ::close(descriptor_);
+ descriptor_ = -1;
+ }
+}
+
+bool FramedChannel::readExact(void* buffer, std::size_t size,
+ std::string& error)
+{
+ auto* destination = static_cast<char*>(buffer);
+ std::size_t offset = 0;
+ while(offset < size)
+ {
+ const ssize_t result = ::read(descriptor_, destination + offset,
+ size - offset);
+ if(result == 0)
+ {
+ error = "IPC channel closed";
+ return false;
+ }
+ if(result < 0)
+ {
+ if(errno == EINTR)
+ {
+ continue;
+ }
+ error = std::string("IPC read failed: ") + std::strerror(errno);
+ return false;
+ }
+ offset += static_cast<std::size_t>(result);
+ }
+ return true;
+}
+
+bool FramedChannel::writeExact(const void* buffer, std::size_t size,
+ std::string& error)
+{
+ const auto* source = static_cast<const char*>(buffer);
+ std::size_t offset = 0;
+ while(offset < size)
+ {
+ const ssize_t result = ::write(descriptor_, source + offset,
+ size - offset);
+ if(result < 0)
+ {
+ if(errno == EINTR)
+ {
+ continue;
+ }
+ error = std::string("IPC write failed: ") + std::strerror(errno);
+ return false;
+ }
+ offset += static_cast<std::size_t>(result);
+ }
+ return true;
+}
+
+} // namespace nethack_mcp
diff --git a/src/spectator_server.cpp b/src/spectator_server.cpp
new file mode 100644
index 0000000..59942f3
--- /dev/null
+++ b/src/spectator_server.cpp
@@ -0,0 +1,247 @@
+#include "spectator_server.hpp"
+
+#include "game_session.hpp"
+
+#include <string>
+
+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}`;
+ }
+ }
+
+ 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;
+}
+
+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)
+ : mw::HTTPServer(mw::IPSocketInfo{"127.0.0.1", port}),
+ session_(session), port_(port)
+{}
+
+SpectatorServer::~SpectatorServer()
+{
+ stopServer();
+}
+
+bool SpectatorServer::startServer(std::string& error)
+{
+ setup();
+ if(!server.bind_to_port("127.0.0.1", port_))
+ {
+ error = "could not bind spectator server to 127.0.0.1:" +
+ std::to_string(port_);
+ return false;
+ }
+ started_ = true;
+ server_thread_ = std::thread([this] {
+ server.listen_after_bind();
+ started_ = false;
+ });
+ return true;
+}
+
+void SpectatorServer::stopServer()
+{
+ server.stop();
+ if(server_thread_.joinable())
+ {
+ server_thread_.join();
+ }
+ started_ = false;
+}
+
+void SpectatorServer::setup()
+{
+ server.Get("/", [this](const Request& request, Response& response) {
+ servePage(request, response);
+ });
+ server.Get("/viewer.js", [this](const Request& request,
+ Response& response) {
+ serveScript(request, response);
+ });
+ server.Get("/viewer.css", [this](const Request& request,
+ Response& response) {
+ serveStyle(request, response);
+ });
+ server.Get("/api/state", [this](const Request& request,
+ Response& response) {
+ serveState(request, response);
+ });
+ server.Get("/health", [this](const Request& request,
+ Response& response) {
+ serveHealth(request, response);
+ });
+}
+
+bool SpectatorServer::validHost(const Request& request) const
+{
+ const std::string host = request.get_header_value("Host");
+ return host == "127.0.0.1" || host == "127.0.0.1:" +
+ std::to_string(port_) || host == "localhost" ||
+ host == "localhost:" + std::to_string(port_);
+}
+
+void SpectatorServer::servePage(const Request& request, Response& response)
+{
+ if(!validHost(request))
+ {
+ response.status = 403;
+ return;
+ }
+ response.set_content(VIEWER_HTML, "text/html; charset=utf-8");
+}
+
+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");
+}
+
+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");
+}
+
+void SpectatorServer::serveState(const Request& request, Response& response)
+{
+ if(!validHost(request))
+ {
+ response.status = 403;
+ return;
+ }
+ const Json state = session_.snapshot();
+ const std::string etag = "\"" +
+ std::to_string(state.value("revision", 0ULL)) + "\"";
+ response.set_header("ETag", etag);
+ if(request.get_header_value("If-None-Match") == etag)
+ {
+ response.status = 304;
+ return;
+ }
+ response.set_content(state.dump(), "application/json; charset=utf-8");
+}
+
+void SpectatorServer::serveHealth(const Request& request, Response& response)
+{
+ if(!validHost(request))
+ {
+ response.status = 403;
+ return;
+ }
+ const Json state = session_.snapshot();
+ response.set_content(Json({
+ {"ready", true},
+ {"lifecycle", state.value("lifecycle", "idle")},
+ {"viewer_url", session_.viewerUrl()},
+ }).dump(), "application/json; charset=utf-8");
+}
+
+} // namespace nethack_mcp
diff --git a/src/window_adapter.cpp b/src/window_adapter.cpp
new file mode 100644
index 0000000..615ebb9
--- /dev/null
+++ b/src/window_adapter.cpp
@@ -0,0 +1,614 @@
+#include "window_adapter.hpp"
+
+#include <algorithm>
+#include <cctype>
+#include <cstdlib>
+#include <cstdio>
+#include <cstring>
+#include <exception>
+#include <stdexcept>
+#include <utility>
+
+extern "C"
+{
+#include "config.h"
+#include "integer.h"
+#include "tradstdc.h"
+#include "global.h"
+#include "wintype.h"
+#include "func_tab.h"
+}
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+using ShimCallback = void (*)(const char*, void*, const char*, ...);
+
+extern "C" void shim_graphics_set_callback(ShimCallback callback);
+
+constexpr int MAP_WIDTH = 79;
+constexpr int MAP_HEIGHT = 21;
+constexpr std::size_t MAX_MESSAGES = 500;
+constexpr std::size_t MAX_TEXT_BYTES = 4096;
+
+std::string copyString(const char* value)
+{
+ return value == nullptr ? std::string() : std::string(value);
+}
+
+} // namespace
+
+WindowAdapter* WindowAdapter::active_adapter_ = nullptr;
+
+WindowAdapter::WindowAdapter(FramedChannel& channel, std::string game_id)
+ : channel_(channel), game_id_(std::move(game_id)),
+ map_rows_(MAP_HEIGHT, std::string(MAP_WIDTH, ' '))
+{}
+
+void WindowAdapter::install()
+{
+ active_adapter_ = this;
+ shim_graphics_set_callback(&WindowAdapter::callback);
+}
+
+void WindowAdapter::callback(const char* name, void* return_ptr,
+ const char* format, ...)
+{
+ if(active_adapter_ == nullptr)
+ {
+ return;
+ }
+
+ va_list arguments;
+ va_start(arguments, format);
+ try
+ {
+ active_adapter_->handleCallback(name, return_ptr, format, arguments);
+ }
+ catch(const std::exception& exception)
+ {
+ std::fprintf(stderr, "NetHack callback failed: %s\n",
+ exception.what());
+ std::_Exit(2);
+ }
+ catch(...)
+ {
+ std::fputs("NetHack callback failed with an unknown exception\n",
+ stderr);
+ std::_Exit(2);
+ }
+ va_end(arguments);
+}
+
+void WindowAdapter::handleCallback(const char* name, void* return_ptr,
+ const char* format,
+ std::va_list arguments)
+{
+ if(name == nullptr)
+ {
+ throw std::runtime_error("NetHack sent a nameless callback");
+ }
+
+ if(std::strcmp(name, "shim_init_nhwindows") == 0)
+ {
+ return;
+ }
+
+ if(std::strcmp(name, "shim_create_nhwindow") == 0)
+ {
+ const int type = va_arg(arguments, int);
+ const int window = next_window_id_++;
+ window_types_[window] = type;
+ setIntegerReturn(return_ptr, window);
+ return;
+ }
+
+ if(std::strcmp(name, "shim_destroy_nhwindow") == 0)
+ {
+ return;
+ }
+
+ if(std::strcmp(name, "shim_clear_nhwindow") == 0)
+ {
+ const int window = va_arg(arguments, int);
+ if(window_types_[window] == NHW_MAP)
+ {
+ map_rows_.assign(MAP_HEIGHT, std::string(MAP_WIDTH, ' '));
+ }
+ return;
+ }
+
+ if(std::strcmp(name, "shim_curs") == 0)
+ {
+ const int window = va_arg(arguments, int);
+ const int x = va_arg(arguments, int);
+ const int y = va_arg(arguments, int);
+ if(window_types_[window] == NHW_MAP)
+ {
+ cursor_x_ = x;
+ cursor_y_ = y;
+ }
+ return;
+ }
+
+ if(std::strcmp(name, "shim_print_glyph") == 0)
+ {
+ const int window = va_arg(arguments, int);
+ const int x = va_arg(arguments, int);
+ const int y = va_arg(arguments, int);
+ const auto* glyph = va_arg(arguments, const glyph_info*);
+ (void) va_arg(arguments, const glyph_info*);
+ if(window_types_[window] == NHW_MAP && glyph != nullptr
+ && y >= 0 && y < MAP_HEIGHT
+ && x >= 1 && x <= MAP_WIDTH)
+ {
+ const unsigned char symbol = static_cast<unsigned char>(
+ glyph->ttychar);
+ map_rows_[y][x - 1] = std::isprint(symbol)
+ ? static_cast<char>(symbol) : '?';
+ }
+ return;
+ }
+
+ if(std::strcmp(name, "shim_putstr") == 0)
+ {
+ const int window = va_arg(arguments, int);
+ (void) va_arg(arguments, int);
+ const char* text = va_arg(arguments, const char*);
+ if(text == nullptr)
+ {
+ return;
+ }
+ if(window_types_[window] == NHW_STATUS)
+ {
+ status_["text"] = text;
+ }
+ else if(window_types_[window] == NHW_MESSAGE)
+ {
+ addMessage(text);
+ }
+ else if(window_types_[window] == NHW_TEXT)
+ {
+ text_window_.append(text);
+ if(text_window_.size() > MAX_TEXT_BYTES)
+ {
+ text_window_.resize(MAX_TEXT_BYTES);
+ }
+ }
+ return;
+ }
+
+ if(std::strcmp(name, "shim_raw_print") == 0
+ || std::strcmp(name, "shim_raw_print_bold") == 0)
+ {
+ addMessage(va_arg(arguments, const char*));
+ return;
+ }
+
+ if(std::strcmp(name, "shim_putmsghistory") == 0)
+ {
+ addMessage(va_arg(arguments, const char*));
+ (void) va_arg(arguments, int);
+ return;
+ }
+
+ if(std::strcmp(name, "shim_start_menu") == 0)
+ {
+ active_menu_window_ = va_arg(arguments, int);
+ (void) va_arg(arguments, unsigned long);
+ menu_entries_.clear();
+ menu_prompt_.clear();
+ next_menu_entry_id_ = 1;
+ return;
+ }
+
+ if(std::strcmp(name, "shim_add_menu") == 0)
+ {
+ (void) va_arg(arguments, int);
+ (void) va_arg(arguments, const glyph_info*);
+ const auto* identifier = va_arg(arguments, const anything*);
+ (void) va_arg(arguments, int);
+ (void) va_arg(arguments, int);
+ (void) va_arg(arguments, int);
+ (void) va_arg(arguments, int);
+ const char* text = va_arg(arguments, const char*);
+ const unsigned item_flags = va_arg(arguments, unsigned int);
+ MenuEntry entry;
+ entry.entry_id = next_menu_entry_id_++;
+ entry.text = copyString(text);
+ entry.selectable = identifier != nullptr;
+ entry.selected = (item_flags & MENU_ITEMFLAGS_SELECTED) != 0;
+ if(identifier != nullptr)
+ {
+ entry.identifier.resize(sizeof(anything));
+ std::memcpy(entry.identifier.data(), identifier,
+ sizeof(anything));
+ }
+ menu_entries_.push_back(std::move(entry));
+ return;
+ }
+
+ if(std::strcmp(name, "shim_end_menu") == 0)
+ {
+ (void) va_arg(arguments, int);
+ menu_prompt_ = copyString(va_arg(arguments, const char*));
+ return;
+ }
+
+ if(std::strcmp(name, "shim_select_menu") == 0)
+ {
+ const int window = va_arg(arguments, int);
+ const int how = va_arg(arguments, int);
+ auto** menu_list = va_arg(arguments, menu_item**);
+ (void) window;
+ Json pending = makePending("menu", name);
+ pending["mode"] = how;
+ pending["prompt"] = menu_prompt_;
+ pending["entries"] = Json::array();
+ for(const MenuEntry& entry : menu_entries_)
+ {
+ pending["entries"].push_back({
+ {"entry_id", entry.entry_id},
+ {"text", entry.text},
+ {"selectable", entry.selectable},
+ {"selected", entry.selected},
+ });
+ }
+ const Json response = waitForInput(std::move(pending));
+ if(menu_list == nullptr || response.value("cancel", false))
+ {
+ setIntegerReturn(return_ptr, 0);
+ return;
+ }
+
+ const Json selections = response.value("selections", Json::array());
+ if(selections.empty())
+ {
+ setIntegerReturn(return_ptr, 0);
+ return;
+ }
+ auto* selected = static_cast<menu_item*>(std::malloc(
+ sizeof(menu_item) * selections.size()));
+ if(selected == nullptr)
+ {
+ throw std::runtime_error("could not allocate menu response");
+ }
+ std::size_t selected_count = 0;
+ for(const Json& item : selections)
+ {
+ const int entry_id = item.at("entry_id").get<int>();
+ const auto found = std::find_if(
+ menu_entries_.begin(), menu_entries_.end(),
+ [entry_id](const MenuEntry& entry) {
+ return entry.entry_id == entry_id && entry.selectable;
+ });
+ if(found == menu_entries_.end())
+ {
+ std::free(selected);
+ throw std::runtime_error("unknown menu entry response");
+ }
+ std::memset(&selected[selected_count].item, 0,
+ sizeof(selected[selected_count].item));
+ std::memcpy(&selected[selected_count].item,
+ found->identifier.data(), sizeof(anything));
+ selected[selected_count].count = item.value("count", -1L);
+ selected[selected_count].itemflags =
+ MENU_ITEMFLAGS_SELECTED;
+ ++selected_count;
+ }
+ *menu_list = selected;
+ setIntegerReturn(return_ptr, static_cast<int>(selected_count));
+ return;
+ }
+
+ if(std::strcmp(name, "shim_display_file") == 0)
+ {
+ const std::string filename = copyString(
+ va_arg(arguments, const char*));
+ (void) va_arg(arguments, int);
+ Json pending = makePending("acknowledge", name);
+ pending["filename"] = filename;
+ pending["text"] = text_window_;
+ (void) waitForInput(std::move(pending));
+ text_window_.clear();
+ return;
+ }
+
+ if(std::strcmp(name, "shim_display_nhwindow") == 0)
+ {
+ const int window = va_arg(arguments, int);
+ const int blocking = va_arg(arguments, int);
+ if(blocking != 0 && window_types_[window] != NHW_MENU)
+ {
+ Json pending = makePending("acknowledge", name);
+ pending["text"] = text_window_;
+ (void) waitForInput(std::move(pending));
+ text_window_.clear();
+ }
+ return;
+ }
+
+ if(std::strcmp(name, "shim_wait_synch") == 0)
+ {
+ Json pending = makePending("acknowledge", name);
+ (void) waitForInput(std::move(pending));
+ return;
+ }
+
+ if(std::strcmp(name, "shim_nhgetch") == 0
+ || std::strcmp(name, "shim_nh_poskey") == 0)
+ {
+ Json pending = makePending("key", name);
+ const Json response = waitForInput(std::move(pending));
+ const int value = response.value("value", 27);
+ if(std::strcmp(name, "shim_nh_poskey") == 0)
+ {
+ auto* x = va_arg(arguments, coordxy*);
+ auto* y = va_arg(arguments, coordxy*);
+ auto* modifier = va_arg(arguments, int*);
+ if(x != nullptr) *x = static_cast<coordxy>(cursor_x_);
+ if(y != nullptr) *y = static_cast<coordxy>(cursor_y_);
+ if(modifier != nullptr) *modifier = 0;
+ }
+ setIntegerReturn(return_ptr, value);
+ return;
+ }
+
+ if(std::strcmp(name, "shim_yn_function") == 0)
+ {
+ const std::string query = copyString(va_arg(arguments, const char*));
+ const std::string choices = copyString(
+ va_arg(arguments, const char*));
+ const int default_value = va_arg(arguments, int);
+ Json pending = makePending("choice", name);
+ pending["query"] = query;
+ pending["choices"] = choices;
+ pending["default"] = default_value;
+ const Json response = waitForInput(std::move(pending));
+ const int value = response.value("value", 27);
+ setCharacterReturn(return_ptr, static_cast<char>(value));
+ return;
+ }
+
+ if(std::strcmp(name, "shim_getlin") == 0)
+ {
+ const std::string query = copyString(va_arg(arguments, const char*));
+ auto* buffer = va_arg(arguments, char*);
+ Json pending = makePending("text", name);
+ pending["query"] = query;
+ pending["max_bytes"] = 255;
+ const Json response = waitForInput(std::move(pending));
+ const std::string text = response.value("cancel", false)
+ ? std::string(1, 27)
+ : response.value("text", std::string());
+ if(buffer != nullptr)
+ {
+ const std::size_t length = std::min<std::size_t>(
+ text.size(), 255);
+ std::memcpy(buffer, text.data(), length);
+ buffer[length] = '\0';
+ }
+ return;
+ }
+
+ if(std::strcmp(name, "shim_get_ext_cmd") == 0)
+ {
+ Json pending = makePending("command", name);
+ pending["commands"] = Json::array();
+ for(const ext_func_tab* command = extcmdlist;
+ command != nullptr && command->ef_txt != nullptr; ++command)
+ {
+ pending["commands"].push_back(command->ef_txt);
+ }
+ const Json response = waitForInput(std::move(pending));
+ setIntegerReturn(return_ptr,
+ resolveCommand(response.value("command", "")));
+ return;
+ }
+
+ if(std::strcmp(name, "shim_get_color_string") == 0)
+ {
+ copyTextReturn(return_ptr, "");
+ return;
+ }
+
+ if(std::strcmp(name, "shim_getmsghistory") == 0)
+ {
+ (void) va_arg(arguments, int);
+ if(return_ptr != nullptr)
+ {
+ *static_cast<char**>(return_ptr) = nullptr;
+ }
+ return;
+ }
+
+ if(std::strcmp(name, "shim_update_inventory") == 0)
+ {
+ inventory_["stale"] = true;
+ return;
+ }
+
+ if(std::strcmp(name, "shim_exit_nhwindows") == 0)
+ {
+ addMessage(va_arg(arguments, const char*));
+ return;
+ }
+
+ if(std::strcmp(name, "shim_nhbell") == 0
+ || std::strcmp(name, "shim_mark_synch") == 0
+ || std::strcmp(name, "shim_delay_output") == 0
+ || std::strcmp(name, "shim_resume_nhwindows") == 0
+ || std::strcmp(name, "shim_suspend_nhwindows") == 0
+ || std::strcmp(name, "shim_number_pad") == 0
+ || std::strcmp(name, "shim_change_color") == 0
+ || std::strcmp(name, "shim_change_background") == 0
+ || std::strcmp(name, "shim_preference_update") == 0
+ || std::strcmp(name, "shim_status_init") == 0
+ || std::strcmp(name, "shim_status_enablefield") == 0
+ || std::strcmp(name, "shim_status_update") == 0
+ || std::strcmp(name, "shim_player_selection") == 0
+ || std::strcmp(name, "shim_ctrl_nhwindow") == 0)
+ {
+ return;
+ }
+
+ if(return_ptr != nullptr && format != nullptr && format[0] != 'v')
+ {
+ std::fprintf(stderr, "Unhandled NetHack callback: %s (%s)\n",
+ name, format);
+ std::memset(return_ptr, 0, sizeof(int));
+ }
+}
+
+Json WindowAdapter::makeSnapshot() const
+{
+ Json snapshot = {
+ {"schema_version", 1},
+ {"game_id", game_id_},
+ {"lifecycle", "waiting"},
+ {"operation", nullptr},
+ {"map", {
+ {"width", MAP_WIDTH},
+ {"height", MAP_HEIGHT},
+ {"origin", {{"x", 1}, {"y", 0}}},
+ {"rows", map_rows_},
+ {"cursor", {{"x", cursor_x_}, {"y", cursor_y_}}},
+ }},
+ {"status", status_},
+ {"messages", messages_},
+ {"messages_truncated", false},
+ {"inventory", inventory_},
+ {"pending", nullptr},
+ };
+ return snapshot;
+}
+
+Json WindowAdapter::makePending(std::string kind, std::string source) const
+{
+ return {
+ {"input_id", input_id_ + 1},
+ {"kind", std::move(kind)},
+ {"source", std::move(source)},
+ };
+}
+
+Json WindowAdapter::waitForInput(Json pending)
+{
+ input_id_++;
+ pending["input_id"] = input_id_;
+ Json snapshot = makeSnapshot();
+ snapshot["pending"] = pending;
+ publishSnapshot(std::move(snapshot));
+
+ while(true)
+ {
+ Json message;
+ std::string error;
+ if(!channel_.receive(message, error))
+ {
+ throw std::runtime_error(error);
+ }
+ if(message.value("type", "") == "shutdown")
+ {
+ throw std::runtime_error("worker shutdown requested");
+ }
+ if(message.value("type", "") != "input"
+ || message.value("game_id", "") != game_id_
+ || message.value("input_id", 0ULL) != input_id_)
+ {
+ continue;
+ }
+ return message.value("response", Json::object());
+ }
+}
+
+void WindowAdapter::publishSnapshot(Json snapshot)
+{
+ Json message = {
+ {"type", "snapshot"},
+ {"ipc_version", IPC_VERSION},
+ {"game_id", game_id_},
+ {"sequence", ++sequence_},
+ {"snapshot", std::move(snapshot)},
+ };
+ std::string error;
+ if(!channel_.send(message, error))
+ {
+ throw std::runtime_error(error);
+ }
+}
+
+void WindowAdapter::addMessage(const char* message)
+{
+ const std::string text = copyString(message);
+ if(text.empty())
+ {
+ return;
+ }
+ static std::uint64_t message_id = 0;
+ messages_.push_back({
+ {"id", ++message_id},
+ {"text", text},
+ });
+ if(messages_.size() > MAX_MESSAGES)
+ {
+ messages_.erase(messages_.begin());
+ }
+}
+
+void WindowAdapter::setIntegerReturn(void* return_ptr, int value) const
+{
+ if(return_ptr != nullptr)
+ {
+ *static_cast<int*>(return_ptr) = value;
+ }
+}
+
+void WindowAdapter::setCharacterReturn(void* return_ptr, char value) const
+{
+ if(return_ptr != nullptr)
+ {
+ *static_cast<char*>(return_ptr) = value;
+ }
+}
+
+void WindowAdapter::copyTextReturn(void* return_ptr,
+ const std::string& text) const
+{
+ if(return_ptr != nullptr)
+ {
+ auto** destination = static_cast<char**>(return_ptr);
+ if(text.empty())
+ {
+ static char empty[] = "";
+ *destination = empty;
+ }
+ else
+ {
+ *destination = const_cast<char*>(text.c_str());
+ }
+ }
+}
+
+int WindowAdapter::resolveCommand(const std::string& command) const
+{
+ if(command.empty())
+ {
+ return -1;
+ }
+ int index = 0;
+ for(const ext_func_tab* entry = extcmdlist;
+ entry != nullptr && entry->ef_txt != nullptr; ++entry, ++index)
+ {
+ if(command == entry->ef_txt)
+ {
+ return index;
+ }
+ }
+ return -1;
+}
+
+} // namespace nethack_mcp