#include "game_manager.hpp"
#include "identity.hpp"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <string>
#include <stdexcept>
#include <unistd.h>
#include <utility>
namespace nethack_mcp
{
namespace
{
std::int64_t wallClockSeconds()
{
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
bool validPrintableAscii(const std::string& value)
{
return std::all_of(value.begin(), value.end(), [](unsigned char byte) {
return byte >= 0x20 && byte <= 0x7e;
});
}
ToolResult managerError(std::string code, std::string message,
Json value = Json::object())
{
return {false, std::move(value), std::move(code), std::move(message)};
}
void preparePrivateDirectory(const std::filesystem::path& path)
{
std::error_code error;
const bool already_exists = std::filesystem::exists(path, error);
if(error)
{
throw std::filesystem::filesystem_error(
"could not inspect private directory", path, error);
}
if(!already_exists)
{
std::filesystem::create_directories(path);
std::filesystem::permissions(
path, std::filesystem::perms::owner_all,
std::filesystem::perm_options::replace);
}
const auto status = std::filesystem::symlink_status(path);
if(std::filesystem::is_symlink(status)
|| !std::filesystem::is_directory(status))
{
throw std::runtime_error("private data root must be a real directory");
}
constexpr auto SHARED_ACCESS = std::filesystem::perms::group_all
| std::filesystem::perms::others_all;
if((status.permissions() & SHARED_ACCESS) != std::filesystem::perms::none
|| ::access(path.c_str(), W_OK | X_OK) != 0)
{
throw std::runtime_error(
"private data root must be writable and accessible only to its owner");
}
}
} // namespace
mw::E<std::unique_ptr<GameManager>> GameManager::create(
ServerConfig config, std::filesystem::path runtime_source)
{
auto opened = GameRecordStore::open(config.database_path);
if(!opened)
{
return std::unexpected(opened.error());
}
auto records = std::shared_ptr<GameRecordStore>(std::move(*opened));
auto recovered = records->recoverInterruptedGames(wallClockSeconds());
if(!recovered)
{
return std::unexpected(recovered.error());
}
try
{
auto manager = std::unique_ptr<GameManager>(new GameManager(
std::move(config), std::move(runtime_source), std::move(records)));
manager->removeOrphanedDirectories();
manager->sweep_thread_ = std::thread(&GameManager::sweepLoop,
manager.get());
return manager;
}
catch(const std::exception& exception)
{
return std::unexpected(mw::runtimeError(exception.what()));
}
}
GameManager::GameManager(ServerConfig config,
std::filesystem::path runtime_source,
std::shared_ptr<GameRecordStore> records)
: config_(std::move(config)), runtime_source_(std::move(runtime_source)),
records_(std::move(records))
{
preparePrivateDirectory(config_.data_root);
}
GameManager::~GameManager()
{
shutdown();
}
ToolResult GameManager::createGame(const Json& arguments,
const std::string& client_id)
{
if(!arguments.contains("model_slug")
|| !arguments.at("model_slug").is_string())
{
return managerError(
"INVALID_RESPONSE", "model_slug is required and must be a string");
}
const std::string model_slug =
arguments.at("model_slug").get<std::string>();
if(model_slug.empty() || model_slug.size() > config_.max_model_slug_bytes
|| !validPrintableAscii(model_slug))
{
return managerError(
"INVALID_RESPONSE",
"model_slug must be 1 to 128 printable ASCII bytes");
}
if(arguments.contains("name") && !arguments.at("name").is_string())
{
return managerError("INVALID_RESPONSE", "name must be a string");
}
const std::string character_name = arguments.value(
"name", std::string("Agent"));
if(character_name.empty()
|| character_name.size() > config_.max_character_name_bytes
|| !validPrintableAscii(character_name))
{
return managerError(
"INVALID_RESPONSE",
"name must be printable ASCII and within the configured limit");
}
const auto now = std::chrono::steady_clock::now();
std::chrono::steady_clock::time_point accepted_at;
std::int64_t accepted_at_s = 0;
{
std::lock_guard registry_lock(registry_mutex_);
if(sessions_.size() + pending_creations_ >= config_.max_active_games)
{
return managerError(
"CAPACITY_REACHED", "active game capacity has been reached",
{{"retry_after_seconds",
config_.lifecycle_sweep_interval.count()}});
}
if(!allowCreation(client_id, now))
{
return managerError(
"RATE_LIMITED", "new game creation rate limit reached",
{{"retry_after_seconds", config_.new_game_rate_window.count()}});
}
accepted_at = std::chrono::steady_clock::now();
accepted_at_s = wallClockSeconds();
++pending_creations_;
}
std::string game_id;
std::string control_token;
bool record_inserted = false;
std::string error;
for(int attempt = 0; attempt < 8; ++attempt)
{
game_id = makeGameId();
control_token = makeControlToken();
if(game_id.empty() || control_token.empty())
{
error = "operating system random source failed";
break;
}
{
std::lock_guard registry_lock(registry_mutex_);
if(sessions_.contains(game_id) || reserved_ids_.contains(game_id))
{
game_id.clear();
continue;
}
reserved_ids_.insert(game_id);
}
GameRecord record;
record.game_id = game_id;
record.character_name = character_name;
record.model_slug = model_slug;
record.started_at_s = accepted_at_s;
record.last_activity_at_s = accepted_at_s;
auto inserted = records_->insertGame(record);
if(!inserted)
{
error = mw::errorMsg(inserted.error());
break;
}
if(*inserted)
{
record_inserted = true;
break;
}
{
std::lock_guard registry_lock(registry_mutex_);
reserved_ids_.erase(game_id);
}
}
if(!record_inserted)
{
releaseReservation(game_id);
if(error.empty())
{
error = "could not allocate a unique game identifier";
}
return managerError("RECORD_FAILURE", std::move(error));
}
const std::string viewer_url = config_.public_base_url + "g/" + game_id;
std::shared_ptr<GameSession> session;
try
{
session = std::make_shared<GameSession>(
config_.data_root, runtime_source_, game_id,
control_token, character_name, model_slug, accepted_at, records_,
config_);
}
catch(const std::exception& exception)
{
auto finished = records_->finishGame(
game_id, wallClockSeconds(), "observed", "failed");
[[maybe_unused]] const bool ignored = finished.has_value();
releaseReservation(game_id);
return managerError("ENGINE_FAILURE", exception.what());
}
ToolResult started;
try
{
started = session->startGame(arguments);
}
catch(const std::exception& exception)
{
auto finished = records_->finishGame(
game_id, wallClockSeconds(), "observed", "failed");
if(!finished)
{
std::fprintf(stderr, "could not record failed game %s: %s\n",
game_id.c_str(), mw::errorMsg(finished.error()).c_str());
}
session->cleanup();
releaseReservation(game_id);
return managerError("ENGINE_FAILURE", exception.what());
}
if(!started.success)
{
auto finished = records_->finishGame(
game_id, wallClockSeconds(), "observed", "failed");
if(!finished)
{
std::fprintf(stderr, "could not record failed game %s: %s\n",
game_id.c_str(), mw::errorMsg(finished.error()).c_str());
}
session->cleanup();
releaseReservation(game_id);
return started;
}
{
std::lock_guard registry_lock(registry_mutex_);
--pending_creations_;
reserved_ids_.erase(game_id);
sessions_.emplace(game_id, session);
}
sweep_condition_.notify_one();
return {
true,
{
{"game_id", game_id},
{"control_token", control_token},
{"viewer_url", viewer_url},
{"state", std::move(started.value)},
},
{},
{},
};
}
ToolResult GameManager::dispatch(const std::string& tool_name,
const Json& arguments,
const std::string& client_id)
{
if(!arguments.contains("game_id") || !arguments.at("game_id").is_string())
{
return managerError("GAME_NOT_FOUND", "game was not found");
}
const std::string game_id = arguments.at("game_id").get<std::string>();
if(!validGameId(game_id))
{
return managerError("GAME_NOT_FOUND", "game was not found");
}
auto session = findActive(game_id);
if(!session)
{
return managerError("GAME_NOT_FOUND", "game was not found");
}
if(!allowControlAttempt(client_id))
{
return managerError(
"RATE_LIMITED", "too many invalid game control credentials",
{{"retry_after_seconds", config_.control_failure_window.count()}});
}
ToolResult authorized = session->authorize(arguments);
if(!authorized.success)
{
if(authorized.code == "FORBIDDEN")
{
recordControlFailure(client_id);
}
return authorized;
}
clearControlFailures(client_id);
if(tool_name == "observe") return session->observe(arguments);
if(tool_name == "press") return session->press(arguments);
if(tool_name == "select_menu") return session->selectMenu(arguments);
if(tool_name == "respond") return session->respond(arguments);
if(tool_name == "quit_game") return session->quitGame(arguments);
return managerError("UNKNOWN_TOOL", "unknown game tool");
}
std::shared_ptr<GameSession> GameManager::findActive(
const std::string& game_id) const
{
std::lock_guard registry_lock(registry_mutex_);
const auto found = sessions_.find(game_id);
return found == sessions_.end() ? nullptr : found->second;
}
mw::E<std::optional<GameRecord>> GameManager::getRecord(
const std::string& game_id)
{
if(!validGameId(game_id))
{
return std::optional<GameRecord>();
}
return records_->getGame(game_id);
}
mw::E<std::vector<GameRecord>> GameManager::recentGames()
{
return records_->recentGames();
}
void GameManager::shutdown()
{
if(stopping_.exchange(true))
{
return;
}
sweep_condition_.notify_all();
if(sweep_thread_.joinable())
{
sweep_thread_.join();
}
std::vector<std::shared_ptr<GameSession>> sessions;
{
std::lock_guard registry_lock(registry_mutex_);
sessions.reserve(sessions_.size());
for(const auto& [game_id, session] : sessions_)
{
[[maybe_unused]] const std::string& ignored = game_id;
sessions.push_back(session);
}
}
for(const auto& session : sessions)
{
session->shutdown();
}
}
const std::string& GameManager::publicBaseUrl() const
{
return config_.public_base_url;
}
const ServerConfig& GameManager::config() const
{
return config_;
}
std::size_t GameManager::activeGameCount() const
{
std::lock_guard registry_lock(registry_mutex_);
return sessions_.size();
}
std::size_t GameManager::activeWorkerCount() const
{
std::vector<std::shared_ptr<GameSession>> sessions;
{
std::lock_guard registry_lock(registry_mutex_);
sessions.reserve(sessions_.size());
for(const auto& [game_id, session] : sessions_)
{
[[maybe_unused]] const std::string& ignored = game_id;
sessions.push_back(session);
}
}
return static_cast<std::size_t>(std::count_if(
sessions.begin(), sessions.end(), [](const auto& session) {
return session->workerRunning();
}));
}
std::uint64_t GameManager::runtimeBytes() const
{
std::uint64_t size = 0;
std::error_code error;
for(std::filesystem::recursive_directory_iterator iterator(
config_.data_root, error), end;
!error && iterator != end; iterator.increment(error))
{
if(iterator->is_regular_file(error))
{
size += iterator->file_size(error);
}
if(error)
{
error.clear();
}
}
return size;
}
std::uint64_t GameManager::expiryCleanupFailures() const
{
return expiry_cleanup_failures_.load();
}
std::uint64_t GameManager::databaseWriteLatencyMicroseconds() const
{
return records_->lastWriteLatencyMicroseconds();
}
void GameManager::sweepLoop()
{
std::unique_lock wait_lock(sweep_mutex_);
while(!stopping_)
{
std::chrono::steady_clock::duration wait_duration =
config_.lifecycle_sweep_interval;
std::vector<std::shared_ptr<GameSession>> scheduled_sessions;
{
std::lock_guard registry_lock(registry_mutex_);
scheduled_sessions.reserve(sessions_.size());
for(const auto& [game_id, session] : sessions_)
{
[[maybe_unused]] const std::string& ignored = game_id;
scheduled_sessions.push_back(session);
}
}
const auto now = std::chrono::steady_clock::now();
for(const auto& session : scheduled_sessions)
{
const auto deadline = session->nextDeadline();
const auto until_deadline = deadline <= now
? std::chrono::steady_clock::duration::zero()
: deadline - now;
wait_duration = std::min(wait_duration, until_deadline);
}
sweep_condition_.wait_for(wait_lock, wait_duration);
if(stopping_)
{
break;
}
wait_lock.unlock();
std::vector<std::pair<std::string, std::shared_ptr<GameSession>>> copy;
{
std::lock_guard registry_lock(registry_mutex_);
copy.reserve(sessions_.size());
for(const auto& item : sessions_)
{
copy.push_back(item);
}
}
for(const auto& [game_id, session] : copy)
{
session->expireIfNeeded();
if(session->terminal())
{
if(!session->cleanup())
{
++expiry_cleanup_failures_;
continue;
}
std::lock_guard registry_lock(registry_mutex_);
const auto found = sessions_.find(game_id);
if(found != sessions_.end() && found->second == session)
{
sessions_.erase(found);
}
}
}
removeOrphanedDirectories();
wait_lock.lock();
}
}
void GameManager::removeOrphanedDirectories()
{
std::unordered_set<std::string> active_ids;
{
std::lock_guard registry_lock(registry_mutex_);
active_ids.reserve(sessions_.size() + reserved_ids_.size());
for(const auto& [game_id, session] : sessions_)
{
[[maybe_unused]] const std::shared_ptr<GameSession>& ignored =
session;
active_ids.insert(game_id);
}
active_ids.insert(reserved_ids_.begin(), reserved_ids_.end());
}
std::error_code error;
if(!std::filesystem::exists(config_.data_root, error))
{
return;
}
for(std::filesystem::directory_iterator iterator(config_.data_root, error),
end;
!error && iterator != end; iterator.increment(error))
{
if(!iterator->is_directory(error))
{
continue;
}
const std::string game_id = iterator->path().filename().string();
if(!validGameId(game_id) || active_ids.contains(game_id))
{
continue;
}
std::error_code remove_error;
std::filesystem::remove_all(iterator->path(), remove_error);
if(remove_error)
{
++expiry_cleanup_failures_;
std::fprintf(stderr, "could not remove orphaned game directory %s: %s\n",
iterator->path().c_str(), remove_error.message().c_str());
}
}
if(error)
{
std::fprintf(stderr, "could not scan game data root: %s\n",
error.message().c_str());
}
}
void GameManager::releaseReservation(const std::string& game_id)
{
std::lock_guard registry_lock(registry_mutex_);
if(pending_creations_ > 0)
{
--pending_creations_;
}
reserved_ids_.erase(game_id);
}
bool GameManager::allowCreation(
const std::string& client_id,
std::chrono::steady_clock::time_point now)
{
const std::string key = client_id.empty() ? "unknown" : client_id;
pruneClientHistory(now);
auto found = client_history_.find(key);
if(found == client_history_.end())
{
if(client_history_.size() >= config_.max_rate_limit_clients)
{
return false;
}
found = client_history_.try_emplace(key).first;
}
auto& timestamps = found->second.creation_times;
if(timestamps.size() >= config_.new_games_per_client)
{
return false;
}
timestamps.push_back(now);
return true;
}
bool GameManager::allowControlAttempt(const std::string& client_id)
{
std::lock_guard registry_lock(registry_mutex_);
const auto now = std::chrono::steady_clock::now();
pruneClientHistory(now);
const std::string key = client_id.empty() ? "unknown" : client_id;
auto found = client_history_.find(key);
if(found == client_history_.end())
{
if(client_history_.size() >= config_.max_rate_limit_clients)
{
return false;
}
found = client_history_.try_emplace(key).first;
}
return found->second.auth_failures.size()
< config_.control_failures_per_client;
}
void GameManager::recordControlFailure(const std::string& client_id)
{
std::lock_guard registry_lock(registry_mutex_);
const auto now = std::chrono::steady_clock::now();
pruneClientHistory(now);
const std::string key = client_id.empty() ? "unknown" : client_id;
auto found = client_history_.find(key);
if(found == client_history_.end())
{
if(client_history_.size() >= config_.max_rate_limit_clients)
{
return;
}
found = client_history_.try_emplace(key).first;
}
found->second.auth_failures.push_back(now);
}
void GameManager::clearControlFailures(const std::string& client_id)
{
std::lock_guard registry_lock(registry_mutex_);
const std::string key = client_id.empty() ? "unknown" : client_id;
auto found = client_history_.find(key);
if(found != client_history_.end())
{
found->second.auth_failures.clear();
if(found->second.creation_times.empty())
{
client_history_.erase(found);
}
}
}
void GameManager::pruneClientHistory(
std::chrono::steady_clock::time_point now)
{
const auto creation_cutoff = now - config_.new_game_rate_window;
const auto auth_cutoff = now - config_.control_failure_window;
for(auto iterator = client_history_.begin();
iterator != client_history_.end();)
{
auto& history = iterator->second;
while(!history.creation_times.empty()
&& history.creation_times.front() <= creation_cutoff)
{
history.creation_times.pop_front();
}
while(!history.auth_failures.empty()
&& history.auth_failures.front() <= auth_cutoff)
{
history.auth_failures.pop_front();
}
if(history.creation_times.empty() && history.auth_failures.empty())
{
iterator = client_history_.erase(iterator);
}
else
{
++iterator;
}
}
}
} // namespace nethack_mcp