Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ef0b0a4..58dd6d4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -147,7 +147,6 @@ set(NETHACK_MCP_SOURCES
set(STATIC_FILES
"${CMAKE_CURRENT_SOURCE_DIR}/web/home.html"
- "${CMAKE_CURRENT_SOURCE_DIR}/web/record.html"
"${CMAKE_CURRENT_SOURCE_DIR}/web/home.css"
"${CMAKE_CURRENT_SOURCE_DIR}/web/local_time.js"
"${CMAKE_CURRENT_SOURCE_DIR}/web/index.html"
diff --git a/README.md b/README.md
index 6e4c93c..5d1c331 100644
--- a/README.md
+++ b/README.md
@@ -41,8 +41,8 @@ The endpoint supports the sessionless MCP `2026-07-28` protocol and the
2025 handshake revisions.
The home page lists the ten most recently completed games. Active games use
-`/g/<game-id>` and poll `/api/games/<game-id>/state`; completed game pages
-show their stored outcome and floor record.
+`/g/<game-id>` and poll `/api/games/<game-id>/state`. Once a game ends, its
+viewer returns to the home page. Completed games have no individual page.
## Public deployment
@@ -70,7 +70,6 @@ that fit the host):
--max-rate-limit-clients 4096 \
--max-concurrent-requests 64 \
--max-open-connections 128 \
- --max-viewer-long-polls 48 \
--max-worker-output-bytes 1048576
```
@@ -85,8 +84,13 @@ Set the application limits together with the host's task, memory, and file
descriptor limits. Each active game uses a worker process and parent I/O
threads; set the active-game cap from measurements on the deployment host.
`/metrics` reports active games and workers, task and memory estimates,
-runtime bytes, database write latency, HTTP latency, viewer waits, and expiry
-cleanup failures. Cgroup task and memory values are available when the service
+runtime bytes, database write latency, HTTP latency, and expiry cleanup
+failures. The spectator page checks state every two seconds after its previous
+request finishes; unchanged state receives an immediate `304` response. State
+responses contain at most the ten most recent messages; MCP observations keep
+the same ten-message history. Older messages are dropped, and an observation
+reports when its message cursor has missed them.
+Cgroup task and memory values are available when the service
runs under a visible Linux cgroup v2 hierarchy.
Back up the SQLite database with SQLite's backup support while the service is
diff --git a/designs/design-0-mcp.md b/designs/design-0-mcp.md
index 7a6e7cd..16f6dda 100644
--- a/designs/design-0-mcp.md
+++ b/designs/design-0-mcp.md
@@ -255,8 +255,7 @@ illustrative; `map.rows` in a real snapshot contains 21 complete rows.
"messages": [{"id": 8, "text": "Hello Agent!"}],
"messages_truncated": false,
"inventory": {"known": false, "stale": true, "entries": []},
- "pending": {"input_id": 5, "kind": "key", "source": "nh_poskey"},
- "viewer_url": "http://127.0.0.1:8765/"
+ "pending": {"input_id": 5, "kind": "key", "source": "nh_poskey"}
}
```
@@ -276,8 +275,10 @@ Normalize only fields with verified types; hunger and conditions require
their own representation. A missing or disabled field is absent, not
zero. Turn count, if exposed, comes from the displayed time field; a
snapshot revision is never treated as a game turn.
+For gold, remove NetHack's encoded currency glyph and colon so both the
+text and numeric value contain only the amount.
-Store up to 500 recent messages with monotonically increasing message
+Store up to ten recent messages with monotonically increasing message
IDs. `observe(after_message_id)` can return only newer messages and must
report truncation if the cursor predates the retained buffer. Ordinary
tool results include a bounded recent tail, so repeated observations do
diff --git a/designs/design-1-multigame.md b/designs/design-1-multigame.md
index a1d11f6..8cab565 100644
--- a/designs/design-1-multigame.md
+++ b/designs/design-1-multigame.md
@@ -26,7 +26,7 @@ The externally visible URLs are:
| URL | Purpose |
| --- | --- |
| `https://my.domain/` | Introduction and ten recent completed records |
-| `https://my.domain/g/<game-id>` | Live spectator or completed-game summary |
+| `https://my.domain/g/<game-id>` | Live spectator; completed games redirect to `/` |
| `https://my.domain/mcp` | Streamable HTTP MCP endpoint |
`my.domain` is a deployment example, not a value compiled into the binary.
@@ -61,8 +61,8 @@ flowchart LR
```
The registry lock protects only lookup, insertion, and removal. It must not
-remain held while spawning a worker, waiting for NetHack, reading SQLite, or
-holding a long-poll response. Once a request obtains a session handle, that
+remain held while spawning a worker, waiting for NetHack, or reading SQLite.
+Once a request obtains a session handle, that
handle keeps the session alive until the request completes, even if the
registry removes the game. Cleanup marks a game as closing before removing
it, so a concurrent request cannot send input to a worker being terminated.
@@ -138,7 +138,9 @@ return an envelope such as:
}
```
-The example UUID and token are illustrative. `new_game` retains the
+The viewer URL appears only in the `new_game` envelope, not in its nested
+state or later observations. The example UUID and token are illustrative.
+`new_game` retains the
existing character options and requires `model_slug`. Reject a missing,
empty, or invalid slug before allocating a game ID, creating files, or
inserting a record. Accept 1 to 128 bytes of printable ASCII and store the
@@ -184,8 +186,8 @@ At a deadline, cleanup performs these steps:
2. Mark each selected session `closing` under its session lock. New MCP
calls then fail with `GAME_CLOSING`.
3. Publish a terminal observation with reason `idle_timeout` or
- `time_limit`, and notify waiting MCP and browser requests. Never leave
- a long poll waiting for its full timeout.
+ `time_limit`, and notify waiting MCP requests. The next spectator poll
+ observes the terminal state.
4. Terminate and reap the worker, including its IPC reader threads.
5. Finalize the SQLite record with the same end reason and the latest
known floor, then remove the per-game directory.
@@ -227,7 +229,7 @@ before `nh_terminate()`. Map `ASCENDED` to `ascended`, `ESCAPED` to
panic or missing terminal-result message is `failed`, unless an earlier
administrative quit or deadline already ended the session. This makes
ascension an explicit, verifiable outcome in the parent server. The root
-page and completed-game summary can display “Won” exactly for records
+page can display “Won” exactly for records
whose `end_reason` is `ascended`.
The existing `status.dungeon_level` field is display text. Parsing a
@@ -337,24 +339,30 @@ mode is active can omit recent transactions.
The root page contains a short description of the service, a link or
instructions for connecting an MCP client, and ten completed records.
Query exactly the latest ten rows ordered by `ended_at_s DESC, game_id
-DESC`. Each row links to `/g/<game-id>` and displays character name, start
+DESC`. Each row displays character name, start
and end times, end reason, deepest floor, end floor, and the supplied model
slug. A game that has started but is still running
does not appear in this completed-record list.
-`GET /g/<game-id>` serves the spectator shell for an active game. For a
-completed game whose runtime state has been removed, it serves a summary
-from SQLite with the same record fields. A valid UUID with no record gets
-404; an invalid path gets 404 without hitting the database. Render names
-and model slugs as text, never as raw HTML.
+`GET /g/<game-id>` serves the spectator shell for an active game. A
+completed game redirects to `/`, where recent completed games are listed.
+A valid UUID with no record gets 404; an invalid path gets 404 without
+hitting the database. Render names and model slugs as text, never as raw
+HTML. Completed rows have no links to individual game pages.
The browser loads active state from `GET /api/games/<game-id>/state`.
-Retain conditional requests and the current 15-second long-poll behavior,
-but make the observation store and ETag game-specific. A terminal event
-wakes the poll. Once the live session is gone, this API returns `410 Gone`
+Retain conditional requests with a game-specific ETag. Each request returns
+immediately with a snapshot or `304 Not Modified`; the browser waits two
+seconds after that response before polling again. Once the live session is
+gone, this API returns `410 Gone`
for a known completed record, or `404 Not Found` for an unknown ID; the
-browser then loads the record summary. An ETag must include the game ID
-and revision, so a cached revision from another game cannot match.
+browser then returns to the root page. An ETag must include the game ID
+and revision, so a cached revision from another game cannot match. State
+responses include only the ten most recent messages. The worker retains the
+same ten-message tail for MCP tool results, so messages do not grow across
+repeated observations. Set `messages_truncated` after history eviction; for
+`observe(after_message_id)`, report whether messages newer than that cursor
+were lost.
Provide a compact `GET /api/recent` response for the root page, or render
the ten rows directly in server HTML. Either route must use the same SQL
@@ -362,10 +370,9 @@ ordering and output fields. Give successful pages a bounded cache policy;
game-control responses and tokens must not be cached. The root page and
spectator pages contain no gameplay controls.
-At high spectator counts, blocking long polls can consume the HTTP
-server's worker pool. Measure concurrent viewers as well as concurrent
-games. Section 11 examines the worker cost and possible replacements.
-Do not hold registry locks while a poll waits.
+At high spectator counts, periodic polls can still consume substantial
+HTTP capacity during network spikes. Measure concurrent viewers as well as
+concurrent games. Section 11 examines the worker cost.
## 9. Public deployment and resource bounds
@@ -389,13 +396,11 @@ deadline migration in this phase.
| `idle_timeout_seconds` | 600 | End a game after no valid agent call |
| `max_game_duration_seconds` | 86400 | End a game 24 hours after creation |
| `lifecycle_sweep_seconds` | 15 | Fallback scan and cleanup cadence |
-| `state_long_poll_seconds` | 15 | Maximum wait for one viewer state call |
| `max_active_games` | Required deployment value | Refuse new games at capacity |
| `new_games_per_client` | Required deployment value | Limit creation rate |
| `new_game_rate_window_seconds` | Required deployment value | Rate window |
| `max_concurrent_requests` | Required deployment value | Bound HTTP work |
| `max_open_connections` | Required deployment value | Bound sockets |
-| `max_viewer_long_polls` | Required deployment value | Bound waiting viewers |
| `max_mcp_body_bytes` | 1048576 | Reject oversized MCP requests |
| `max_worker_output_bytes` | Required deployment value | Bound diagnostics |
@@ -437,8 +442,8 @@ until routing changes are complete:
and route all MCP tools by game ID with a separate control token.
5. Add configurable idle and absolute deadlines with a race-safe terminal
transition. Wake observers, reap workers, and delete runtime directories.
-6. Add the root listing, per-game spectator path, per-game state API,
- and completed-game summary. Make public URLs configurable.
+6. Add the root listing, per-game spectator path, and per-game state API.
+ Make public URLs configurable.
7. Add public-host configuration, reverse-proxy deployment files, limits,
and operational metrics.
@@ -466,29 +471,26 @@ caller-supplied model slug; it is not verified by the server.
## 11. Spectator transport and slow client connections
-The current spectator API uses a 15-second conditional long poll. When its
-ETag matches the latest game revision, the handler waits for a new snapshot
-or a timeout. The browser starts another poll as soon as the response
-arrives. The pinned [cpp-httplib server](https://github.com/yhirose/cpp-httplib/blob/278c2979e8c68468960c3073e28e1c51b098d6a4/httplib.h)
-assigns each accepted connection to a thread-pool task. A worker remains
-occupied while the handler waits and may remain attached to the socket
-during HTTP keep-alive. A continuously watching spectator can therefore
-occupy a worker almost continuously.
-
-The current example limits are 64 HTTP workers and 48 simultaneous viewer
-long polls. At that limit, only 16 workers remain for MCP calls and other
-HTTP requests, before considering idle keep-alive sockets. The bounded
-task queue limits additional accepted connections, but it does not make
-waiting polls cheap. These numbers require measurement on the deployment
-host rather than an assumption that each spectator uses negligible work.
-
-The alternatives discussed so far have different costs:
+The spectator API now uses periodic conditional GETs. A matching ETag gets
+an immediate `304 Not Modified`; otherwise the handler returns the current
+snapshot. The browser waits two seconds after each response before starting
+another request. It waits five seconds after a network error. Because it
+never overlaps requests, a browser-visible request that takes more than ten
+seconds does not accumulate additional polls. The HTTP server allows one
+request per backend connection, so idle keep-alive sockets do not retain
+cpp-httplib workers. A slow response can still occupy a worker until the
+server finishes sending it.
+
+The previous 15-second long poll held a worker for each unchanged viewer.
+With 64 HTTP workers and 48 allowed viewer waits, only 16 workers remained
+for MCP and other requests, before considering idle keep-alive sockets.
+The transport choices discussed were:
| Transport | Worker use | Other cost |
| --- | --- | --- |
-| Current long poll | One per waiting viewer | Sustained worker use |
+| Previous long poll | One per waiting viewer | Sustained worker use |
| SSE in cpp-httplib | One per open stream | Fewer requests |
-| Periodic GET | Released after response and socket close | More requests; update lag |
+| Chosen periodic GET | Released after response and socket close | More requests; update lag |
| Asynchronous HTTP | No worker per idle viewer | More implementation work |
SSE is a one-way HTTP stream from server to browser. Its value here depends
@@ -498,7 +500,8 @@ stream. For periodic GETs, the browser should start a new request only after
the previous one finishes; otherwise a network spike could accumulate
overlapping polls. With `N` continuously watching browsers and a poll
interval of `T` seconds, the steady request rate is roughly `N / T` per
-second. An asynchronous server could retain the same ETag and long-poll
+second, before accounting for request duration. An asynchronous server could
+retain the same ETag and long-poll
API: it would register a pending request, return the thread to an event
loop, and complete the request when the game's revision changes or a timer
expires. NetHack worker and SQLite operations must stay off that event
@@ -521,10 +524,9 @@ a backend finish sooner by buffering output, subject to buffer size and
memory use. Neither should be assumed to isolate the C++ server from all
slow clients without checking the actual Apache configuration and traffic.
-This discussion has not selected a replacement for the current long poll.
-Before choosing one, inspect the deployed Apache MPM and proxy settings,
-measure typical and
-maximum state-response sizes, and load-test concurrent viewers during
-network spikes. Record busy Apache and C++ workers, backend response time,
-viewer update delay, rejected connections, and MCP latency. The choice
-must preserve enough capacity for gameplay calls during those spikes.
+Periodic GET is the selected transport for this version. Inspect the
+deployed Apache MPM and proxy settings, measure typical and maximum state
+response sizes, and load-test concurrent viewers during network spikes.
+Record busy Apache and C++ workers, backend response time, viewer update
+delay, rejected connections, and MCP latency. Production limits must
+preserve enough capacity for gameplay calls during those spikes.
diff --git a/include/game_http_server.hpp b/include/game_http_server.hpp
index 58872ae..b31490a 100644
--- a/include/game_http_server.hpp
+++ b/include/game_http_server.hpp
@@ -18,7 +18,7 @@ class GameManager;
class McpServer;
struct GameRecord;
-/// Serve shared MCP, landing, spectator, and completed-record routes.
+/// Serve shared MCP, landing, and live spectator routes.
class GameHttpServer : public mw::HTTPServer
{
public:
@@ -61,7 +61,6 @@ private:
void rejectMcpStream(const Request& request, Response& response);
void rejectRequest(Response& response) const;
std::string recentPage();
- std::string recordPage(const GameRecord& record);
GameManager& manager_;
McpServer& mcp_;
@@ -69,7 +68,6 @@ private:
std::thread server_thread_;
std::atomic<bool> started_ = false;
mutable std::atomic<std::size_t> active_requests_ = 0;
- mutable std::atomic<std::size_t> viewer_long_polls_ = 0;
mutable std::atomic<std::uint64_t> request_count_ = 0;
mutable std::atomic<std::uint64_t> request_latency_total_us_ = 0;
};
diff --git a/include/game_session.hpp b/include/game_session.hpp
index e11248c..03c0dbc 100644
--- a/include/game_session.hpp
+++ b/include/game_session.hpp
@@ -37,7 +37,6 @@ public:
/// Create a session with a fixed public ID and one-time control secret.
GameSession(std::filesystem::path data_root,
std::filesystem::path runtime_dir,
- std::string viewer_url,
std::string game_id,
std::string control_token,
std::string character_name,
@@ -91,16 +90,9 @@ public:
/// Return the immutable UUID associated with this session.
const std::string& gameId() const;
- /// Return the configured spectator URL.
- const std::string& viewerUrl() const;
-
/// Return the latest state for the spectator server.
Json snapshot() const;
- /// Wait for a viewer snapshot newer than the given revision.
- Json waitForSnapshot(std::uint64_t revision,
- std::chrono::milliseconds timeout) const;
-
private:
ToolResult sendInput(const Json& arguments, Json response,
const std::string& expected_kind);
@@ -124,7 +116,6 @@ private:
std::filesystem::path data_root_;
std::filesystem::path runtime_dir_;
- std::string viewer_url_;
std::string game_id_;
std::string character_name_;
std::string model_slug_;
diff --git a/include/observation_store.hpp b/include/observation_store.hpp
index a8b6d5b..1c00815 100644
--- a/include/observation_store.hpp
+++ b/include/observation_store.hpp
@@ -15,7 +15,7 @@ class ObservationStore
{
public:
/// Construct an idle store with a stable initial snapshot.
- explicit ObservationStore(std::string viewer_url);
+ ObservationStore();
/// Return the latest complete snapshot.
Json snapshot() const;
diff --git a/include/server_config.hpp b/include/server_config.hpp
index 7191519..8edb16c 100644
--- a/include/server_config.hpp
+++ b/include/server_config.hpp
@@ -42,16 +42,12 @@ struct ServerConfig
std::size_t max_concurrent_requests = 64;
/// Maximum active plus queued HTTP connection tasks.
std::size_t max_open_connections = 128;
- /// Maximum concurrent spectator long polls.
- std::size_t max_viewer_long_polls = 48;
/// End a game after this long without an accepted agent call.
std::chrono::seconds idle_timeout{600};
/// Maximum time from accepted creation to game termination.
std::chrono::seconds max_game_duration{86400};
/// Fallback maintenance cadence for expiry and cleanup work.
std::chrono::seconds lifecycle_sweep_interval{15};
- /// Maximum duration of one browser state long poll.
- std::chrono::seconds state_long_poll_timeout{15};
/// Maximum accepted MCP request body size in bytes.
std::size_t max_mcp_body_bytes = 1024U * 1024U;
/// Maximum diagnostic output bytes logged for one worker.
diff --git a/include/window_adapter.hpp b/include/window_adapter.hpp
index 0f04745..1dba915 100644
--- a/include/window_adapter.hpp
+++ b/include/window_adapter.hpp
@@ -65,6 +65,7 @@ private:
int cursor_y_ = 0;
std::vector<std::string> map_rows_;
std::vector<Json> messages_;
+ bool messages_truncated_ = false;
Json status_ = Json::object();
Json inventory_ = {
{"known", false},
diff --git a/src/game_http_server.cpp b/src/game_http_server.cpp
index 5618315..4325f0d 100644
--- a/src/game_http_server.cpp
+++ b/src/game_http_server.cpp
@@ -241,6 +241,8 @@ bool GameHttpServer::running() const
void GameHttpServer::setup()
{
server.set_payload_max_length(config_.max_mcp_body_bytes);
+ // An idle backend connection must not occupy an HTTP worker.
+ server.set_keep_alive_max_count(1);
server.new_task_queue = [
max_threads = config_.max_concurrent_requests,
max_queued = config_.max_open_connections
@@ -568,7 +570,6 @@ void GameHttpServer::serveGamePage(const Request& request,
"text/html; charset=utf-8");
return;
}
- response.set_header("Cache-Control", "public, max-age=60");
auto record = manager_.getRecord(game_id);
if(!record)
{
@@ -580,7 +581,9 @@ void GameHttpServer::serveGamePage(const Request& request,
response.status = 404;
return;
}
- response.set_content(recordPage(**record), "text/html; charset=utf-8");
+ response.status = 302;
+ response.set_header("Location", "/");
+ response.set_header("Cache-Control", "no-store");
}
void GameHttpServer::serveScript(const Request& request,
@@ -644,28 +647,23 @@ void GameHttpServer::serveState(const Request& request, Response& response)
Json state = session->snapshot();
const std::string requested_etag = request.get_header_value(
"If-None-Match");
- std::string etag = "\"" + game_id + ":"
+ const std::string etag = "\"" + game_id + ":"
+ std::to_string(state.value("revision", 0ULL)) + "\"";
- if(requested_etag == etag)
- {
- CounterSlot poll_slot(
- viewer_long_polls_, config_.max_viewer_long_polls);
- if(!poll_slot.admitted())
- {
- rejectRequest(response);
- return;
- }
- state = session->waitForSnapshot(
- state.value("revision", 0ULL), config_.state_long_poll_timeout);
- etag = "\"" + game_id + ":"
- + std::to_string(state.value("revision", 0ULL)) + "\"";
- }
response.set_header("ETag", etag);
if(requested_etag == etag)
{
response.status = 304;
return;
}
+ constexpr std::size_t MAX_VIEWER_MESSAGES = 10;
+ Json& messages = state["messages"];
+ if(messages.is_array() && messages.size() > MAX_VIEWER_MESSAGES)
+ {
+ messages.erase(messages.begin(),
+ messages.begin()
+ + (messages.size() - MAX_VIEWER_MESSAGES));
+ state["messages_truncated"] = true;
+ }
response.set_content(state.dump(), "application/json; charset=utf-8");
}
@@ -761,10 +759,6 @@ void GameHttpServer::serveMetrics(const Request& request, Response& response)
"# TYPE nethack_mcp_http_request_latency_seconds gauge\n"
"nethack_mcp_http_request_latency_seconds "
+ std::to_string(average_request_seconds) + "\n"
- "# HELP nethack_mcp_viewer_long_polls Current viewer waits.\n"
- "# TYPE nethack_mcp_viewer_long_polls gauge\n"
- "nethack_mcp_viewer_long_polls "
- + std::to_string(viewer_long_polls_.load()) + "\n"
"# HELP nethack_mcp_max_active_games Configured game capacity.\n"
"# TYPE nethack_mcp_max_active_games gauge\n"
"nethack_mcp_max_active_games "
@@ -772,11 +766,7 @@ void GameHttpServer::serveMetrics(const Request& request, Response& response)
"# HELP nethack_mcp_max_http_workers Configured handler worker limit.\n"
"# TYPE nethack_mcp_max_http_workers gauge\n"
"nethack_mcp_max_http_workers "
- + std::to_string(config_.max_concurrent_requests) + "\n"
- "# HELP nethack_mcp_max_viewer_long_polls Configured viewer wait limit.\n"
- "# TYPE nethack_mcp_max_viewer_long_polls gauge\n"
- "nethack_mcp_max_viewer_long_polls "
- + std::to_string(config_.max_viewer_long_polls) + "\n";
+ + std::to_string(config_.max_concurrent_requests) + "\n";
response.set_header("Cache-Control", "no-store");
response.set_content(metrics, "text/plain; version=0.0.4; charset=utf-8");
}
@@ -802,8 +792,7 @@ std::string GameHttpServer::recentPage()
}
for(const GameRecord& record : *records)
{
- rows += "<tr><td><a href=\"/g/" + htmlEscape(record.game_id) + "\">"
- + htmlEscape(record.character_name) + "</a></td><td>"
+ rows += "<tr><td>" + htmlEscape(record.character_name) + "</td><td>"
+ htmlEscape(record.model_slug) + "</td><td>"
+ timeElement(record.started_at_s) + "</td><td>"
+ timeElement(record.ended_at_s) + "</td><td>"
@@ -818,29 +807,4 @@ std::string GameHttpServer::recentPage()
return html;
}
-std::string GameHttpServer::recordPage(const GameRecord& record)
-{
- const EmbeddedAsset* asset = findAsset("/record.html");
- if(asset == nullptr)
- {
- return "<!doctype html><title>NetHack game</title><h1>"
- "Page template is unavailable.</h1>";
- }
- std::string html(asset->content);
- replaceTemplateValue(html, "{{CHARACTER_NAME}}",
- htmlEscape(record.character_name));
- replaceTemplateValue(html, "{{GAME_ID}}", htmlEscape(record.game_id));
- replaceTemplateValue(html, "{{MODEL_SLUG}}", htmlEscape(record.model_slug));
- replaceTemplateValue(html, "{{STARTED_AT}}", timeElement(record.started_at_s));
- replaceTemplateValue(html, "{{ENDED_AT}}", timeElement(record.ended_at_s));
- replaceTemplateValue(html, "{{ENDED_TIME_LABEL}}",
- record.end_time_kind == "recovery"
- ? "Recovered at (actual end unknown)" : "Ended");
- replaceTemplateValue(html, "{{END_LABEL}}", htmlEscape(endLabel(record)));
- replaceTemplateValue(html, "{{DEEPEST_DEPTH}}",
- depthText(record.deepest_depth));
- replaceTemplateValue(html, "{{LAST_DEPTH}}", depthText(record.last_depth));
- return html;
-}
-
} // namespace nethack_mcp
diff --git a/src/game_manager.cpp b/src/game_manager.cpp
index c8b422f..ad40287 100644
--- a/src/game_manager.cpp
+++ b/src/game_manager.cpp
@@ -232,7 +232,7 @@ ToolResult GameManager::createGame(const Json& arguments,
try
{
session = std::make_shared<GameSession>(
- config_.data_root, runtime_source_, viewer_url, game_id,
+ config_.data_root, runtime_source_, game_id,
control_token, character_name, model_slug, accepted_at, records_,
config_);
}
diff --git a/src/game_session.cpp b/src/game_session.cpp
index ceb258e..14b6a5c 100644
--- a/src/game_session.cpp
+++ b/src/game_session.cpp
@@ -115,7 +115,6 @@ bool resolveCharacter(const Json& arguments, Json& character,
GameSession::GameSession(std::filesystem::path data_root,
std::filesystem::path runtime_dir,
- std::string viewer_url,
std::string game_id,
std::string control_token,
std::string character_name,
@@ -124,7 +123,7 @@ GameSession::GameSession(std::filesystem::path data_root,
std::shared_ptr<GameRecordStore> records,
const ServerConfig& config)
: data_root_(std::move(data_root)), runtime_dir_(std::move(runtime_dir)),
- viewer_url_(std::move(viewer_url)), game_id_(std::move(game_id)),
+ game_id_(std::move(game_id)),
character_name_(std::move(character_name)),
model_slug_(std::move(model_slug)), records_(std::move(records)),
created_at_(created_at), last_agent_activity_(created_at_),
@@ -132,7 +131,7 @@ GameSession::GameSession(std::filesystem::path data_root,
max_game_duration_(config.max_game_duration),
max_worker_output_bytes_(config.max_worker_output_bytes),
max_character_name_bytes_(config.max_character_name_bytes),
- observations_(viewer_url_)
+ observations_()
{
if(!secureRandom(control_salt_.data(), control_salt_.size()))
{
@@ -189,7 +188,6 @@ ToolResult GameSession::startGame(const Json& arguments)
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));
@@ -364,6 +362,14 @@ ToolResult GameSession::observe(const Json& arguments)
{
const Json all_messages = result.value("messages", Json::array());
Json filtered = Json::array();
+ bool messages_truncated = false;
+ if(!all_messages.empty())
+ {
+ const std::uint64_t first_id =
+ all_messages.front().value("id", 0ULL);
+ messages_truncated = first_id > 0
+ && *after_message_id < first_id - 1;
+ }
for(const Json& message : all_messages)
{
if(message.value("id", 0ULL) > *after_message_id)
@@ -372,6 +378,7 @@ ToolResult GameSession::observe(const Json& arguments)
}
}
result["messages"] = std::move(filtered);
+ result["messages_truncated"] = messages_truncated;
}
return {true, std::move(result), {}, {}};
}
@@ -723,22 +730,11 @@ const std::string& GameSession::gameId() const
return game_id_;
}
-const std::string& GameSession::viewerUrl() const
-{
- return viewer_url_;
-}
-
Json GameSession::snapshot() const
{
return observations_.snapshot();
}
-Json GameSession::waitForSnapshot(
- std::uint64_t revision, std::chrono::milliseconds timeout) const
-{
- return observations_.waitForRevision(revision, timeout);
-}
-
bool GameSession::markTerminal(const std::string& reason,
const std::string& end_time_kind,
const std::string& lifecycle)
@@ -999,6 +995,11 @@ void GameSession::handleWorkerMessage(const Json& message)
{"id", 0},
{"text", "worker IPC version mismatch"},
});
+ if(failed["messages"].size() > 10)
+ {
+ failed["messages"].erase(failed["messages"].begin());
+ failed["messages_truncated"] = true;
+ }
observations_.publish(std::move(failed));
}
return;
@@ -1015,7 +1016,6 @@ void GameSession::handleWorkerMessage(const Json& message)
depth = snapshot.at("private_location").at("depth").get<int>();
}
snapshot.erase("private_location");
- snapshot["viewer_url"] = viewer_url_;
snapshot["operation"] = nullptr;
observations_.publish(std::move(snapshot));
if(depth)
diff --git a/src/main.cpp b/src/main.cpp
index 7ca2535..a15b049 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -81,15 +81,13 @@ bool validResourceBounds(const nethack_mcp::ServerConfig& config)
&& config.new_games_per_client <= 1'000'000
&& config.control_failures_per_client <= 10'000
&& config.max_rate_limit_clients <= MAX_RATE_LIMIT_CLIENTS
- && config.max_viewer_long_polls <= MAX_HTTP_WORKERS
&& config.max_mcp_body_bytes <= MAX_MCP_BODY_BYTES
&& config.max_worker_output_bytes <= MAX_WORKER_OUTPUT_BYTES
&& valid_duration(config.new_game_rate_window)
&& valid_duration(config.control_failure_window)
&& valid_duration(config.idle_timeout)
&& valid_duration(config.max_game_duration)
- && valid_duration(config.lifecycle_sweep_interval)
- && valid_duration(config.state_long_poll_timeout);
+ && valid_duration(config.lifecycle_sweep_interval);
}
bool parsePort(const std::string& text, int& port)
@@ -122,11 +120,9 @@ void printUsage()
" --max-rate-limit-clients N Tracked client cap (4096)\n"
" --max-concurrent-requests N HTTP request worker limit\n"
" --max-open-connections N Total HTTP connection limit\n"
- " --max-viewer-long-polls N Simultaneous spectator waits\n"
" --idle-timeout-seconds N Idle game lifetime (600)\n"
" --max-game-duration-seconds N Absolute game lifetime (86400)\n"
" --lifecycle-sweep-seconds N Expiry sweep interval (15)\n"
- " --state-long-poll-seconds N Viewer state wait (15)\n"
" --max-mcp-body-bytes N MCP request size (1048576)\n"
" --max-worker-output-bytes N Worker log byte limit\n"
" --help Show this help\n",
@@ -154,7 +150,6 @@ ParseResult parseOptions(int argc, char* argv[],
bool rate_limit_clients_explicit = false;
bool concurrent_requests_explicit = false;
bool open_connections_explicit = false;
- bool long_polls_explicit = false;
bool worker_output_explicit = false;
for(int index = 1; index < argc; ++index)
{
@@ -260,12 +255,6 @@ ParseResult parseOptions(int argc, char* argv[],
return ParseResult::ERROR;
open_connections_explicit = true;
}
- else if(argument == "--max-viewer-long-polls")
- {
- if(!parsePositive(value, config.max_viewer_long_polls))
- return ParseResult::ERROR;
- long_polls_explicit = true;
- }
else if(argument == "--idle-timeout-seconds")
{
if(!parsePositive(value, number)) return ParseResult::ERROR;
@@ -281,11 +270,6 @@ ParseResult parseOptions(int argc, char* argv[],
if(!parsePositive(value, number)) return ParseResult::ERROR;
config.lifecycle_sweep_interval = std::chrono::seconds(number);
}
- else if(argument == "--state-long-poll-seconds")
- {
- if(!parsePositive(value, number)) return ParseResult::ERROR;
- config.state_long_poll_timeout = std::chrono::seconds(number);
- }
else if(argument == "--max-mcp-body-bytes")
{
if(!parsePositive(value, config.max_mcp_body_bytes))
@@ -364,13 +348,12 @@ ParseResult parseOptions(int argc, char* argv[],
&& (!active_games_explicit || !new_games_explicit
|| !rate_window_explicit || !rate_limit_clients_explicit
|| !concurrent_requests_explicit
- || !open_connections_explicit || !long_polls_explicit
+ || !open_connections_explicit
|| !worker_output_explicit))
{
return ParseResult::ERROR;
}
if(config.max_open_connections <= config.max_concurrent_requests
- || config.max_viewer_long_polls > config.max_concurrent_requests
|| !validResourceBounds(config))
{
return ParseResult::ERROR;
diff --git a/src/observation_store.cpp b/src/observation_store.cpp
index c33aa67..e780f19 100644
--- a/src/observation_store.cpp
+++ b/src/observation_store.cpp
@@ -5,7 +5,7 @@
namespace nethack_mcp
{
-ObservationStore::ObservationStore(std::string viewer_url)
+ObservationStore::ObservationStore()
: snapshot_({
{"schema_version", 1},
{"game_id", nullptr},
@@ -28,7 +28,6 @@ ObservationStore::ObservationStore(std::string viewer_url)
{"entries", Json::array()},
}},
{"pending", nullptr},
- {"viewer_url", std::move(viewer_url)},
})
{}
diff --git a/src/window_adapter.cpp b/src/window_adapter.cpp
index 5e915c2..de60635 100644
--- a/src/window_adapter.cpp
+++ b/src/window_adapter.cpp
@@ -28,7 +28,7 @@ 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_MESSAGES = 10;
constexpr std::size_t MAX_TEXT_BYTES = 4096;
constexpr const char* STATUS_NAMES[MAXBLSTATS] = {
"title", "strength", "dexterity", "constitution", "intelligence",
@@ -57,7 +57,10 @@ std::string statusKey(const std::string& name)
Json statusText(const char* value, bool parse_number)
{
- const std::string display = copyString(value);
+ const std::string encoded = copyString(value);
+ std::string display(encoded.size() + 1, '\0');
+ decode_mixed(display.data(), encoded.c_str());
+ display.resize(std::strlen(display.c_str()));
Json result = {{"text", display}};
long long number = 0;
const auto parsed = std::from_chars(display.data(),
@@ -572,8 +575,28 @@ void WindowAdapter::handleCallback(const char* name, void* return_ptr,
}
else
{
- status_[found->second] = statusText(
+ Json formatted = statusText(
static_cast<const char*>(value), field != BL_HUNGER);
+ if(field == BL_GOLD)
+ {
+ const std::string display =
+ formatted.at("text").get<std::string>();
+ const std::size_t colon = display.rfind(':');
+ if(colon != std::string::npos)
+ {
+ long long amount = 0;
+ const char* first = display.data() + colon + 1;
+ const char* last = display.data() + display.size();
+ const auto parsed = std::from_chars(first, last, amount);
+ if(first != last && parsed.ec == std::errc()
+ && parsed.ptr == last)
+ {
+ formatted["text"] = std::to_string(amount);
+ formatted["value"] = amount;
+ }
+ }
+ }
+ status_[found->second] = std::move(formatted);
}
return;
}
@@ -623,7 +646,7 @@ Json WindowAdapter::makeSnapshot() const
}},
{"status", status_},
{"messages", messages_},
- {"messages_truncated", false},
+ {"messages_truncated", messages_truncated_},
{"inventory", inventory_},
{"pending", nullptr},
{"private_location", {{"depth", static_cast<int>(depth(&u.uz))}}},
@@ -742,6 +765,7 @@ void WindowAdapter::addMessage(const char* message)
if(messages_.size() > MAX_MESSAGES)
{
messages_.erase(messages_.begin());
+ messages_truncated_ = true;
}
}
diff --git a/web/home.css b/web/home.css
index c4a3735..aacd7d5 100644
--- a/web/home.css
+++ b/web/home.css
@@ -18,19 +18,6 @@ td {
text-align: left;
}
-a {
- color: #154b37;
-}
-
code {
overflow-wrap: anywhere;
}
-
-dt {
- font-weight: bold;
- margin-top: 1rem;
-}
-
-dd {
- margin: .25rem 0;
-}
diff --git a/web/home.html b/web/home.html
index 2fc24e5..bd6f328 100644
--- a/web/home.html
+++ b/web/home.html
@@ -10,7 +10,7 @@
<main>
<h1>Public NetHack games</h1>
<p>Agents play through the shared MCP endpoint. Spectators can watch
- live games and browse completed records.</p>
+ live games, and recent results appear below.</p>
<p>MCP endpoint: <code>{{MCP_URL}}</code></p>
<h2>Ten most recent completed games</h2>
<table>
diff --git a/web/record.html b/web/record.html
deleted file mode 100644
index 8c73619..0000000
--- a/web/record.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<!doctype html>
-<html lang="en">
-<head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>Completed NetHack game</title>
- <link rel="stylesheet" href="/home.css">
-</head>
-<body>
- <main>
- <p><a href="/">Recent games</a></p>
- <h1>{{CHARACTER_NAME}}</h1>
- <dl>
- <dt>Game ID</dt><dd><code>{{GAME_ID}}</code></dd>
- <dt>Model</dt><dd>{{MODEL_SLUG}}</dd>
- <dt>Started</dt><dd>{{STARTED_AT}}</dd>
- <dt>{{ENDED_TIME_LABEL}}</dt><dd>{{ENDED_AT}}</dd>
- <dt>Outcome</dt><dd>{{END_LABEL}}</dd>
- <dt>Lowest floor visited</dt><dd>{{DEEPEST_DEPTH}}</dd>
- <dt>Ending floor</dt><dd>{{LAST_DEPTH}}</dd>
- </dl>
- </main>
- <script src="/local-time.js"></script>
-</body>
-</html>
diff --git a/web/viewer.js b/web/viewer.js
index 30b669a..8d65f67 100644
--- a/web/viewer.js
+++ b/web/viewer.js
@@ -119,7 +119,7 @@
}
async function poll() {
- let retry_delay = 0;
+ let retry_delay = 2000;
if(!game_id)
{
lifecycle.textContent = "Invalid game URL";
@@ -133,7 +133,7 @@
});
if(response.status === 410)
{
- window.location.replace(`/g/${game_id}`);
+ window.location.replace("/");
return;
}
if(response.status === 404)
@@ -152,7 +152,7 @@
{
lifecycle.textContent = `Disconnected: ${error.message}`;
etag = "";
- retry_delay = 1000;
+ retry_delay = 5000;
}
setTimeout(poll, retry_delay);
}