BareGit
#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