#include "game_record_store.hpp"
#include <chrono>
#include <filesystem>
#include <optional>
#include <stdexcept>
#include <string>
#include <unistd.h>
#include <tuple>
#include <utility>
#include <vector>
namespace nethack_mcp
{
namespace
{
using RecordRow = std::tuple<
std::string, std::string, std::string, std::int64_t, std::int64_t,
std::optional<std::int64_t>, std::optional<std::string>,
std::optional<std::string>, std::optional<int>, std::optional<int>>;
class WriteTimer
{
public:
explicit WriteTimer(std::atomic<std::uint64_t>& latency)
: latency_(latency), started_at_(std::chrono::steady_clock::now())
{}
~WriteTimer()
{
latency_ = static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - started_at_).count());
}
private:
std::atomic<std::uint64_t>& latency_;
std::chrono::steady_clock::time_point started_at_;
};
GameRecord makeRecord(const RecordRow& row)
{
return {
std::get<0>(row), std::get<1>(row), std::get<2>(row),
std::get<3>(row), std::get<4>(row), std::get<5>(row),
std::get<6>(row), std::get<7>(row), std::get<8>(row),
std::get<9>(row),
};
}
std::string recordSelect()
{
return "SELECT game_id, character_name, model_slug, started_at_s, "
"last_activity_at_s, ended_at_s, end_time_kind, end_reason, "
"last_depth, deepest_depth FROM game_records ";
}
std::filesystem::path prepareDatabaseDirectory(
const std::filesystem::path& database_path)
{
const std::filesystem::path absolute_path =
std::filesystem::absolute(database_path);
const std::filesystem::path parent = absolute_path.parent_path();
std::error_code error;
const bool already_exists = std::filesystem::exists(parent, error);
if(error)
{
throw std::filesystem::filesystem_error(
"could not inspect database directory", parent, error);
}
if(!already_exists)
{
std::filesystem::create_directories(parent);
std::filesystem::permissions(
parent, std::filesystem::perms::owner_all,
std::filesystem::perm_options::replace);
}
const auto status = std::filesystem::symlink_status(parent);
if(std::filesystem::is_symlink(status)
|| !std::filesystem::is_directory(status))
{
throw std::runtime_error(
"database parent must be a real directory");
}
if(::access(parent.c_str(), W_OK | X_OK) != 0)
{
throw std::runtime_error(
"database parent must be writable and searchable");
}
const bool database_exists = std::filesystem::exists(absolute_path, error);
if(error)
{
throw std::filesystem::filesystem_error(
"could not inspect database file", absolute_path, error);
}
if(database_exists)
{
const auto database_status =
std::filesystem::symlink_status(absolute_path);
if(std::filesystem::is_symlink(database_status)
|| !std::filesystem::is_regular_file(database_status))
{
throw std::runtime_error(
"database path must be a regular file, not a symlink");
}
}
return absolute_path;
}
} // namespace
Json GameRecord::toJson() const
{
return {
{"game_id", game_id},
{"character_name", character_name},
{"model_slug", model_slug},
{"started_at_s", started_at_s},
{"last_activity_at_s", last_activity_at_s},
{"ended_at_s", ended_at_s ? Json(*ended_at_s) : Json(nullptr)},
{"end_time_kind", end_time_kind ? Json(*end_time_kind) : Json(nullptr)},
{"end_reason", end_reason ? Json(*end_reason) : Json(nullptr)},
{"last_depth", last_depth ? Json(*last_depth) : Json(nullptr)},
{"deepest_depth", deepest_depth ? Json(*deepest_depth) : Json(nullptr)},
};
}
GameRecordStore::GameRecordStore(std::unique_ptr<mw::SQLite> database)
: database_(std::move(database))
{}
mw::E<std::unique_ptr<GameRecordStore>> GameRecordStore::open(
const std::filesystem::path& database_path)
{
std::filesystem::path absolute_database_path;
try
{
absolute_database_path = prepareDatabaseDirectory(database_path);
}
catch(const std::exception& exception)
{
return std::unexpected(mw::runtimeError(exception.what()));
}
auto database = mw::SQLite::connectFile(
absolute_database_path.string(), 5000);
if(!database)
{
return std::unexpected(database.error());
}
auto store = std::unique_ptr<GameRecordStore>(
new GameRecordStore(std::move(*database)));
auto migrated = store->migrate();
if(!migrated)
{
return std::unexpected(migrated.error());
}
try
{
std::filesystem::permissions(
absolute_database_path,
std::filesystem::perms::owner_read
| std::filesystem::perms::owner_write,
std::filesystem::perm_options::replace);
}
catch(const std::exception& exception)
{
return std::unexpected(mw::runtimeError(exception.what()));
}
return store;
}
mw::E<void> GameRecordStore::migrate()
{
std::lock_guard lock(mutex_);
auto version = database_->evalToValue<int>("PRAGMA user_version;");
if(!version)
{
return std::unexpected(version.error());
}
if(*version > 1)
{
return std::unexpected(mw::runtimeError(
"game database schema is newer than this server"));
}
if(*version == 1)
{
return {};
}
auto begun = database_->execute("BEGIN IMMEDIATE;");
if(!begun)
{
return std::unexpected(begun.error());
}
auto table = database_->execute(
"CREATE TABLE game_records ("
"game_id TEXT PRIMARY KEY,"
"character_name TEXT NOT NULL,"
"model_slug TEXT NOT NULL CHECK (length(model_slug) BETWEEN 1 AND 128),"
"started_at_s INTEGER NOT NULL,"
"last_activity_at_s INTEGER NOT NULL,"
"ended_at_s INTEGER,"
"end_time_kind TEXT,"
"end_reason TEXT,"
"last_depth INTEGER,"
"deepest_depth INTEGER,"
"CHECK (end_reason IS NULL OR end_reason IN "
"('ascended', 'escaped', 'died', 'quit', 'failed', "
"'idle_timeout', 'time_limit', 'interrupted')),"
"CHECK ((ended_at_s IS NULL) = (end_reason IS NULL))"
");");
if(!table)
{
[[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
return std::unexpected(table.error());
}
auto index = database_->execute(
"CREATE INDEX game_records_recent_idx "
"ON game_records (ended_at_s DESC, game_id DESC) "
"WHERE ended_at_s IS NOT NULL;");
if(!index)
{
[[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
return std::unexpected(index.error());
}
auto migration = database_->execute("PRAGMA user_version = 1;");
if(!migration)
{
[[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
return std::unexpected(migration.error());
}
return database_->execute("COMMIT;");
}
mw::E<bool> GameRecordStore::insertGame(const GameRecord& record)
{
WriteTimer timer(last_write_latency_us_);
std::lock_guard lock(mutex_);
auto statement = database_->statementFromStr(
"INSERT OR IGNORE INTO game_records "
"(game_id, character_name, model_slug, started_at_s, "
"last_activity_at_s) VALUES (?, ?, ?, ?, ?);");
if(!statement)
{
return std::unexpected(statement.error());
}
auto bound = statement->bind(
record.game_id, record.character_name, record.model_slug,
record.started_at_s, record.last_activity_at_s);
if(!bound)
{
return std::unexpected(bound.error());
}
auto inserted = database_->execute(std::move(*statement));
if(!inserted)
{
return std::unexpected(inserted.error());
}
return database_->changedRowsCount() == 1;
}
mw::E<void> GameRecordStore::updateLocation(
const std::string& game_id, int depth)
{
WriteTimer timer(last_write_latency_us_);
std::lock_guard lock(mutex_);
auto statement = database_->statementFromStr(
"UPDATE game_records SET last_depth = ?, "
"deepest_depth = CASE WHEN deepest_depth IS NULL OR deepest_depth < ? "
"THEN ? ELSE deepest_depth END "
"WHERE game_id = ? AND ended_at_s IS NULL;");
if(!statement)
{
return std::unexpected(statement.error());
}
auto bound = statement->bind(depth, depth, depth, game_id);
if(!bound)
{
return std::unexpected(bound.error());
}
return database_->execute(std::move(*statement));
}
mw::E<void> GameRecordStore::updateActivity(
const std::string& game_id, std::int64_t activity_at_s)
{
WriteTimer timer(last_write_latency_us_);
std::lock_guard lock(mutex_);
auto statement = database_->statementFromStr(
"UPDATE game_records SET last_activity_at_s = ? "
"WHERE game_id = ? AND ended_at_s IS NULL;");
if(!statement)
{
return std::unexpected(statement.error());
}
auto bound = statement->bind(activity_at_s, game_id);
if(!bound)
{
return std::unexpected(bound.error());
}
return database_->execute(std::move(*statement));
}
mw::E<bool> GameRecordStore::finishGame(
const std::string& game_id, std::int64_t ended_at_s,
const std::string& end_time_kind, const std::string& end_reason)
{
WriteTimer timer(last_write_latency_us_);
std::lock_guard lock(mutex_);
auto begun = database_->execute("BEGIN IMMEDIATE;");
if(!begun)
{
return std::unexpected(begun.error());
}
auto statement = database_->statementFromStr(
"UPDATE game_records SET ended_at_s = ?, end_time_kind = ?, "
"end_reason = ? WHERE game_id = ? AND ended_at_s IS NULL;");
if(!statement)
{
[[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
return std::unexpected(statement.error());
}
auto bound = statement->bind(
ended_at_s, end_time_kind, end_reason, game_id);
if(!bound)
{
[[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
return std::unexpected(bound.error());
}
auto updated = database_->execute(std::move(*statement));
if(!updated)
{
[[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
return std::unexpected(updated.error());
}
const bool won = database_->changedRowsCount() == 1;
auto committed = database_->execute("COMMIT;");
if(!committed)
{
return std::unexpected(committed.error());
}
return won;
}
mw::E<std::vector<GameRecord>> GameRecordStore::recentGames()
{
std::lock_guard lock(mutex_);
auto statement = database_->statementFromStr(
recordSelect() + "WHERE ended_at_s IS NOT NULL "
"ORDER BY ended_at_s DESC, game_id DESC LIMIT 10;");
if(!statement)
{
return std::unexpected(statement.error());
}
auto rows = database_->eval<
std::string, std::string, std::string, std::int64_t, std::int64_t,
std::optional<std::int64_t>, std::optional<std::string>,
std::optional<std::string>, std::optional<int>, std::optional<int>>(
std::move(*statement));
if(!rows)
{
return std::unexpected(rows.error());
}
std::vector<GameRecord> records;
records.reserve(rows->size());
for(const RecordRow& row : *rows)
{
records.push_back(makeRecord(row));
}
return records;
}
mw::E<std::optional<GameRecord>> GameRecordStore::getGame(
const std::string& game_id)
{
std::lock_guard lock(mutex_);
auto statement = database_->statementFromStr(
recordSelect() + "WHERE game_id = ? LIMIT 1;");
if(!statement)
{
return std::unexpected(statement.error());
}
auto bound = statement->bind(game_id);
if(!bound)
{
return std::unexpected(bound.error());
}
auto rows = database_->eval<
std::string, std::string, std::string, std::int64_t, std::int64_t,
std::optional<std::int64_t>, std::optional<std::string>,
std::optional<std::string>, std::optional<int>, std::optional<int>>(
std::move(*statement));
if(!rows)
{
return std::unexpected(rows.error());
}
if(rows->empty())
{
return std::optional<GameRecord>();
}
return std::optional<GameRecord>(makeRecord(rows->front()));
}
mw::E<void> GameRecordStore::recoverInterruptedGames(
std::int64_t recovery_time_s)
{
WriteTimer timer(last_write_latency_us_);
std::lock_guard lock(mutex_);
auto begun = database_->execute("BEGIN IMMEDIATE;");
if(!begun)
{
return std::unexpected(begun.error());
}
auto statement = database_->statementFromStr(
"UPDATE game_records SET ended_at_s = ?, end_time_kind = 'recovery', "
"end_reason = 'interrupted' WHERE ended_at_s IS NULL;");
if(!statement)
{
[[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
return std::unexpected(statement.error());
}
auto bound = statement->bind(recovery_time_s);
if(!bound)
{
[[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
return std::unexpected(bound.error());
}
auto updated = database_->execute(std::move(*statement));
if(!updated)
{
[[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
return std::unexpected(updated.error());
}
return database_->execute("COMMIT;");
}
std::uint64_t GameRecordStore::lastWriteLatencyMicroseconds() const
{
return last_write_latency_us_.load();
}
} // namespace nethack_mcp