#include "game_session.hpp"
#include "identity.hpp"
#include <algorithm>
#include <chrono>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <sstream>
#include <utility>
#ifdef NETHACK_HAS_ENGINE
extern "C"
{
#include "hack.h"
int str2role(const char*);
int str2race(const char*);
int str2gend(const char*);
int str2align(const char*);
boolean validrace(int, int);
boolean validgend(int, int, int);
boolean validalign(int, int, int);
}
#endif
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>();
}
#ifdef NETHACK_HAS_ENGINE
bool resolveCharacter(const Json& arguments, Json& character,
std::string& error)
{
struct Requested
{
const char* key;
int (*parse)(const char*);
int value = ROLE_NONE;
} requested[] = {
{"role", str2role}, {"race", str2race},
{"gender", str2gend}, {"alignment", str2align},
};
bool has_request = false;
for(Requested& item : requested)
{
if(!arguments.contains(item.key)) continue;
if(!arguments.at(item.key).is_string())
{
error = std::string(item.key) + " must be a string";
return false;
}
item.value = item.parse(
arguments.at(item.key).get<std::string>().c_str());
if(item.value < 0)
{
error = std::string("unsupported ") + item.key;
return false;
}
has_request = true;
}
if(!has_request) return true;
for(int role = 0; roles[role].name.m != nullptr; ++role)
{
if(requested[0].value >= 0 && requested[0].value != role)
continue;
for(int race = 0; races[race].noun != nullptr; ++race)
{
if((requested[1].value >= 0 && requested[1].value != race)
|| !validrace(role, race)) continue;
for(int gender = 0; gender < ROLE_GENDERS; ++gender)
{
if((requested[2].value >= 0
&& requested[2].value != gender)
|| !validgend(role, race, gender)) continue;
for(int alignment = 0; alignment < ROLE_ALIGNS;
++alignment)
{
if((requested[3].value >= 0
&& requested[3].value != alignment)
|| !validalign(role, race, alignment)) continue;
character = {
{"role", roles[role].filecode},
{"race", races[race].filecode},
{"gender", genders[gender].filecode},
{"alignment", aligns[alignment].filecode},
};
return true;
}
}
}
}
error = "requested character settings are incompatible";
return false;
}
#endif
} // namespace
GameSession::GameSession(std::filesystem::path data_root,
std::filesystem::path runtime_dir,
std::string game_id,
std::string control_token,
std::string character_name,
std::string model_slug,
Clock::time_point created_at,
std::shared_ptr<GameRecordStore> records,
const ServerConfig& config)
: data_root_(std::move(data_root)), runtime_dir_(std::move(runtime_dir)),
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_),
idle_timeout_(config.idle_timeout),
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_()
{
if(!secureRandom(control_salt_.data(), control_salt_.size()))
{
throw std::runtime_error("operating system random source failed");
}
control_digest_ = hashControlToken(control_salt_, control_token);
}
ToolResult GameSession::startGame(const Json& arguments)
{
std::unique_lock action_lock(action_mutex_);
std::string name = jsonString(arguments, "name", "Agent");
if(name.empty() || name.size() > max_character_name_bytes_)
{
return errorResult("INVALID_RESPONSE",
"name has an invalid byte length");
}
for(unsigned char character : name)
{
if(character < 0x20 || character > 0x7e)
{
return errorResult("INVALID_RESPONSE",
"name must contain printable ASCII only");
}
}
Json character = Json::object();
#ifdef NETHACK_HAS_ENGINE
std::string character_error;
if(!resolveCharacter(arguments, character, character_error))
{
return errorResult("INVALID_RESPONSE", character_error);
}
#else
for(const char* key : {"role", "race", "gender", "alignment"})
{
if(arguments.contains(key))
{
return errorResult("ENGINE_FAILURE",
"character selection requires NetHack");
}
}
#endif
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;
const std::uint64_t starting_revision = observations_.publish(
std::move(starting));
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},
{"character", character},
};
std::string spawn_error;
if(!process_->start(
start_message,
[this](const Json& message) {
handleWorkerMessage(message);
},
spawn_error, max_worker_output_bytes_))
{
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");
}
if(state.value("lifecycle", "") == "failed")
{
return errorResult("ENGINE_FAILURE",
"NetHack worker failed during startup");
}
return {true, std::move(state), {}, {}};
}
ToolResult GameSession::authorize(const Json& arguments)
{
if(!arguments.contains("game_id") || !arguments.at("game_id").is_string()
|| arguments.at("game_id").get<std::string>() != game_id_)
{
return errorResult("GAME_NOT_FOUND", "game was not found");
}
if(!arguments.contains("control_token")
|| !arguments.at("control_token").is_string())
{
return errorResult("FORBIDDEN", "game control credentials are invalid");
}
const std::string token = arguments.at("control_token").get<std::string>();
if(token.size() != 43)
{
return errorResult("FORBIDDEN", "game control credentials are invalid");
}
for(char character : token)
{
const bool valid = (character >= 'A' && character <= 'Z')
|| (character >= 'a' && character <= 'z')
|| (character >= '0' && character <= '9')
|| character == '-' || character == '_';
if(!valid)
{
return errorResult(
"FORBIDDEN", "game control credentials are invalid");
}
}
const auto digest = hashControlToken(control_salt_, token);
if(!secureDigestEqual(digest, control_digest_))
{
return errorResult("FORBIDDEN", "game control credentials are invalid");
}
std::string expired_reason;
const auto now = Clock::now();
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
if(closing_)
{
return errorResult("GAME_CLOSING", "game cleanup has begun");
}
if(now >= created_at_ + max_game_duration_)
{
expired_reason = "time_limit";
}
else if(now >= last_agent_activity_ + idle_timeout_)
{
expired_reason = "idle_timeout";
}
}
if(!expired_reason.empty())
{
expireIfNeeded();
return errorResult("GAME_CLOSING", "game lifetime has ended");
}
return {true, Json::object(), {}, {}};
}
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);
}
if(arguments.contains("detail")
&& (!arguments.at("detail").is_string()
|| (arguments.at("detail") != "compact"
&& arguments.at("detail") != "full")))
{
return errorResult("INVALID_RESPONSE", "detail is invalid");
}
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");
}
}
std::optional<std::uint64_t> after_message_id;
if(arguments.contains("after_message_id"))
{
if(!arguments.at("after_message_id").is_number_integer()
|| arguments.at("after_message_id").get<long long>() < 0)
{
return errorResult("INVALID_RESPONSE",
"after_message_id must be a nonnegative integer");
}
after_message_id = arguments.at("after_message_id").get<
std::uint64_t>();
}
std::int64_t activity_at_s = 0;
ToolResult refreshed = refreshAgentActivity(activity_at_s);
if(!refreshed.success)
{
expireIfNeeded();
return refreshed;
}
if(!persistAgentActivity(activity_at_s))
{
return errorResult("RECORD_FAILURE",
"could not update game activity record");
}
Json result = wait_ms == 0
? state
: observations_.waitForRevision(
state.value("revision", 0ULL),
std::chrono::milliseconds(wait_ms));
if(after_message_id)
{
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)
{
filtered.push_back(message);
}
}
result["messages"] = std::move(filtered);
result["messages_truncated"] = messages_truncated;
}
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", "");
const unsigned char ascii = static_cast<unsigned char>(choice);
if(ascii < 0x20 || ascii > 0x7e)
{
return errorResult("INVALID_RESPONSE",
"choice must be one printable ASCII byte");
}
if(pending.value("has_choices", true)
&& 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::int64_t activity_at_s = 0;
ToolResult refreshed = refreshAgentActivity(activity_at_s);
if(!refreshed.success)
{
expireIfNeeded();
return refreshed;
}
if(!persistAgentActivity(activity_at_s))
{
return errorResult("RECORD_FAILURE",
"could not update game activity record");
}
markTerminal("quit", "observed", "ended");
{
std::unique_lock action_lock(action_mutex_);
if(process_)
{
process_->terminate(true);
process_.reset();
}
}
cleanup();
return {true, observations_.snapshot(), {}, {}};
}
void GameSession::shutdown()
{
std::unique_lock action_lock(action_mutex_);
shutdown_requested_ = true;
if(process_)
{
process_->terminate(true);
process_.reset();
}
}
bool GameSession::expireIfNeeded()
{
std::string reason;
const auto now = Clock::now();
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
if(closing_)
{
return false;
}
if(now >= created_at_ + max_game_duration_)
{
reason = "time_limit";
}
else if(now >= last_agent_activity_ + idle_timeout_)
{
reason = "idle_timeout";
}
}
if(reason.empty())
{
return false;
}
return markTerminal(reason, "observed", "ended");
}
bool GameSession::cleanup()
{
{
std::unique_lock action_lock(action_mutex_);
if(process_)
{
process_->terminate(true);
process_.reset();
}
}
std::optional<std::string> end_reason;
std::optional<std::string> end_time_kind;
std::int64_t terminal_at_s = 0;
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
end_reason = end_reason_;
end_time_kind = end_time_kind_;
terminal_at_s = terminal_at_s_;
}
if(end_reason && end_time_kind
&& !finishRecord(*end_reason, *end_time_kind, terminal_at_s))
{
return false;
}
std::error_code error;
std::filesystem::remove_all(data_root_ / game_id_, error);
if(error)
{
std::fprintf(stderr, "could not remove game directory %s: %s\n",
(data_root_ / game_id_).c_str(), error.message().c_str());
return false;
}
return true;
}
bool GameSession::terminal() const
{
std::lock_guard lock(lifecycle_mutex_);
return closing_;
}
bool GameSession::workerRunning() const
{
std::lock_guard action_lock(action_mutex_);
return process_ && process_->running();
}
Clock::time_point GameSession::nextDeadline() const
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
if(closing_)
{
return Clock::now();
}
const auto absolute_deadline = created_at_ + max_game_duration_;
const auto idle_deadline = last_agent_activity_ + idle_timeout_;
return absolute_deadline < idle_deadline
? absolute_deadline : idle_deadline;
}
const std::string& GameSession::gameId() const
{
return game_id_;
}
Json GameSession::snapshot() const
{
return observations_.snapshot();
}
bool GameSession::markTerminal(const std::string& reason,
const std::string& end_time_kind,
const std::string& lifecycle)
{
std::string terminal_reason = reason;
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
if(closing_)
{
return false;
}
const auto now = Clock::now();
if(now >= created_at_ + max_game_duration_)
{
terminal_reason = "time_limit";
}
else if(now >= last_agent_activity_ + idle_timeout_)
{
terminal_reason = "idle_timeout";
}
closing_ = true;
end_reason_ = terminal_reason;
end_time_kind_ = end_time_kind;
terminal_at_s_ = wallClockSeconds();
}
Json state = observations_.snapshot();
state["lifecycle"] = lifecycle;
state["end_reason"] = terminal_reason;
state["pending"] = nullptr;
state["operation"] = nullptr;
observations_.publish(std::move(state));
return true;
}
bool GameSession::finishRecord(const std::string& reason,
const std::string& end_time_kind,
std::int64_t ended_at_s)
{
auto result = records_->finishGame(game_id_, ended_at_s,
end_time_kind, reason);
if(!result)
{
std::fprintf(stderr, "could not finalize game %s: %s\n",
game_id_.c_str(), mw::errorMsg(result.error()).c_str());
return false;
}
return true;
}
std::int64_t GameSession::wallClockSeconds()
{
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
ToolResult GameSession::sendInput(const Json& arguments, Json response,
const std::string& expected_kind)
{
if(expireIfNeeded())
{
return errorResult("GAME_CLOSING", "game lifetime has ended");
}
std::unique_lock action_lock(action_mutex_);
if(terminal())
{
return errorResult("GAME_CLOSING", "game cleanup has begun");
}
std::string expired_reason;
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
const auto now = Clock::now();
if(now >= created_at_ + max_game_duration_)
{
expired_reason = "time_limit";
}
else if(now >= last_agent_activity_ + idle_timeout_)
{
expired_reason = "idle_timeout";
}
}
if(!expired_reason.empty())
{
action_lock.unlock();
markTerminal(expired_reason, "observed", "ended");
return errorResult("GAME_CLOSING", "game lifetime has ended");
}
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");
}
std::int64_t activity_at_s = 0;
ToolResult refreshed = refreshAgentActivity(activity_at_s);
if(!refreshed.success)
{
action_lock.unlock();
expireIfNeeded();
return refreshed;
}
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)},
};
bool sent = false;
std::string terminal_race_reason;
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
const auto now = Clock::now();
if(closing_)
{
terminal_race_reason = end_reason_.value_or("time_limit");
}
else if(now >= created_at_ + max_game_duration_)
{
terminal_race_reason = "time_limit";
}
else
{
sent = process_->send(input, error);
}
}
if(!terminal_race_reason.empty())
{
action_lock.unlock();
markTerminal(terminal_race_reason, "observed", "ended");
return errorResult("GAME_CLOSING", "game lifetime has ended");
}
if(!sent)
{
action_lock.unlock();
if(!persistAgentActivity(activity_at_s))
{
std::fprintf(stderr, "could not update activity for game %s\n",
game_id_.c_str());
}
markTerminal("failed", "observed", "failed");
return errorResult("ENGINE_FAILURE", error);
}
action_lock.unlock();
if(!persistAgentActivity(activity_at_s))
{
std::fprintf(stderr, "could not update activity for game %s\n",
game_id_.c_str());
}
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)};
}
ToolResult GameSession::refreshAgentActivity(std::int64_t& activity_at_s)
{
std::string expired_reason;
const auto now = Clock::now();
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
if(closing_)
{
return errorResult("GAME_CLOSING", "game cleanup has begun");
}
if(now >= created_at_ + max_game_duration_)
{
expired_reason = "time_limit";
}
else if(now >= last_agent_activity_ + idle_timeout_)
{
expired_reason = "idle_timeout";
}
else
{
last_agent_activity_ = now;
activity_at_s = wallClockSeconds();
}
}
if(!expired_reason.empty())
{
return errorResult("GAME_CLOSING", "game lifetime has ended");
}
return {true, Json::object(), {}, {}};
}
bool GameSession::persistAgentActivity(std::int64_t activity_at_s)
{
auto updated = records_->updateActivity(game_id_, activity_at_s);
if(!updated)
{
std::fprintf(stderr, "could not update activity for game %s: %s\n",
game_id_.c_str(), mw::errorMsg(updated.error()).c_str());
return false;
}
return true;
}
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"},
});
if(failed["messages"].size() > 10)
{
failed["messages"].erase(failed["messages"].begin());
failed["messages_truncated"] = true;
}
observations_.publish(std::move(failed));
}
return;
}
if(type == "snapshot" && message.contains("snapshot"))
{
Json snapshot = message.at("snapshot");
std::optional<int> depth;
if(snapshot.contains("private_location")
&& snapshot.at("private_location").is_object()
&& snapshot.at("private_location").contains("depth")
&& snapshot.at("private_location").at("depth").is_number_integer())
{
depth = snapshot.at("private_location").at("depth").get<int>();
}
snapshot.erase("private_location");
snapshot["operation"] = nullptr;
observations_.publish(std::move(snapshot));
if(depth)
{
bool changed = false;
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
changed = !last_depth_ || *last_depth_ != *depth;
}
if(changed)
{
auto updated = records_->updateLocation(game_id_, *depth);
if(!updated)
{
std::fprintf(stderr,
"could not update location for game %s: %s\n",
game_id_.c_str(),
mw::errorMsg(updated.error()).c_str());
}
else
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
last_depth_ = *depth;
}
}
}
return;
}
if(type == "terminal_result")
{
const std::string reason = message.value("end_reason", "");
if(reason == "ascended" || reason == "escaped" || reason == "died"
|| reason == "quit" || reason == "failed")
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
if(!closing_)
{
terminal_result_ = reason;
}
}
return;
}
if(type == "exiting")
{
if(shutdown_requested_)
{
return;
}
std::string reason = "failed";
{
std::lock_guard lifecycle_lock(lifecycle_mutex_);
if(closing_)
{
return;
}
if(terminal_result_)
{
reason = *terminal_result_;
}
}
if(reason == "failed")
{
markTerminal("failed", "observed", "failed");
}
else
{
markTerminal(reason, "observed", "ended");
}
}
}
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;
}
} // namespace nethack_mcp