#include "server_config.h" #include "Engine/Core/Json/json.h" #include "Extension/Multiplayer/Net/protocol.h" #include "Engine/Game/World/world_names.h" #include #include #include #include namespace dingosdk::server { namespace { std::string placement_text(ObjectPlacement policy) { return policy == ObjectPlacement::everyone ? "everyone" : policy == ObjectPlacement::host_only ? "admins" : "nobody"; } Json to_json(const ServerConfig &c) { auto root = Json::object(); root["name"] = c.name; root["map"] = c.map; root["max_players"] = c.max_players; root["password"] = c.password; root["welcome"] = c.welcome; root["listed"] = c.listed; root["auto_update"] = c.auto_update; root["activity_log"] = c.activity_log; root["announce_throwdowns"] = c.announce_throwdowns; root["parties"] = c.parties; root["party_size"] = c.party_size; root["speed_check"] = c.speed_check; root["score_check"] = c.score_check; auto allowed = Json::array(); for (const auto fingerprint : c.score_allow) allowed.push_back(scoring_text(fingerprint)); root["score_allow"] = std::move(allowed); root["port"] = static_cast(c.port); root["query_port"] = static_cast(c.query_port); root["tps"] = c.tps; root["voice_chat"] = c.voice_chat; root["voice_range"] = static_cast(c.voice_range); auto distances = Json::object(); distances["full_rate_return"] = c.distances.full_rate_return; distances["half_rate_start"] = c.distances.half_rate_start; distances["half_rate_return"] = c.distances.half_rate_return; distances["low_rate_start"] = c.distances.low_rate_start; root["distances"] = std::move(distances); root["object_placement"] = placement_text(c.object_placement); root["noclip"] = c.noclip; root["no_bail"] = c.no_bail; root["boosts"] = c.boosts; root["enforce_tuning"] = c.enforce_tuning; auto votes = Json::object(); const auto vote = [](const VoteSetting &v) { auto item = Json::object(); item["enabled"] = v.enabled; item["percent"] = v.percent; return item; }; votes["map"] = vote(c.votes.map); votes["kick"] = vote(c.votes.kick); votes["time_of_day"] = vote(c.votes.time); votes["seconds"] = c.votes.seconds; votes["cooldown_seconds"] = c.votes.cooldown; root["votes"] = std::move(votes); auto parks = Json::object(); for (unsigned lot = 0; lot < park_lots.size(); ++lot) parks[park_lots[lot].key] = c.parks[lot]; root["parks"] = std::move(parks); root["world_layer_sync"] = c.world_layer_sync; auto layers = Json::object(); for (const auto &[key, mode] : c.layers) layers[key] = mode; root["layers"] = std::move(layers); // SteamID64s are written as strings: JSON readers often lose 64-bit precision. auto admins = Json::array(); for (const auto id : c.admins) admins.push_back(std::to_string(id)); root["admins"] = std::move(admins); auto bans = Json::array(); for (const auto &ban : c.bans) { auto row = Json::object(); row["id"] = std::to_string(ban.id); row["name"] = ban.name; row["added"] = ban.added; bans.push_back(std::move(row)); } root["bans"] = std::move(bans); return root; } std::uint64_t steam_id(const Json &value) { if (value.is_string()) return std::stoull(value.string()); return value.get(); } // The settings `expected` has that `file` lacks, nested objects included. void missing_settings(const Json &file, const Json &expected, const std::string &prefix, std::vector &out) { for (const auto &[key, value] : expected.items()) { if (!file.contains(key)) out.push_back(prefix + key); else if (value.is_object() && file.at(key).is_object()) missing_settings(file.at(key), value, prefix + key + ".", out); } } } // namespace ServerConfig load_config(const std::filesystem::path &file, std::vector *added) { ServerConfig c; c.file = file; if (!std::filesystem::exists(file)) { save_config(c); return c; } std::stringstream text; { // Closed before any write-back: Windows cannot replace a file that is still open. std::ifstream in(file, std::ios::binary); text << in.rdbuf(); } const auto root = Json::parse(text.str()); if (!root.is_object()) throw std::runtime_error("The server config must be a JSON object."); c.name = root.value("name", c.name); c.map = root.value("map", c.map); c.max_players = root.value("max_players", c.max_players); c.password = root.value("password", c.password); c.welcome = root.value("welcome", c.welcome); c.listed = root.value("listed", c.listed); c.auto_update = root.value("auto_update", c.auto_update); c.activity_log = root.value("activity_log", c.activity_log); c.announce_throwdowns = root.value("announce_throwdowns", c.announce_throwdowns); c.parties = root.value("parties", c.parties); c.party_size = std::clamp(root.value("party_size", c.party_size), 2U, 8U); c.speed_check = root.value("speed_check", c.speed_check); if (c.speed_check != "off" && c.speed_check != "warn" && c.speed_check != "kick") c.speed_check = "warn"; c.score_check = root.value("score_check", c.score_check); if (c.score_check != "off" && c.score_check != "warn" && c.score_check != "kick") c.score_check = "warn"; if (root.contains("score_allow") && root.at("score_allow").is_array()) for (const auto &value : root.at("score_allow")) if (value.is_string()) if (const auto fingerprint = parse_scoring(value.string())) c.score_allow.push_back(*fingerprint); // Checked here, not in config_error: once narrowed, 70000 is just port 4464, and the // next save would write that over the owner's typo. const auto read_port = [&](const char *key, std::uint16_t fallback) { const auto value = root.value(key, static_cast(fallback)); if (value < 1 || value > 65535) throw std::runtime_error(std::string(key) + " must be 1 to 65535."); return static_cast(value); }; c.port = read_port("port", c.port); c.query_port = read_port("query_port", c.query_port); c.tps = root.value("tps", c.tps); c.voice_chat = root.value("voice_chat", c.voice_chat); c.voice_range = root.value("voice_range", c.voice_range); if (root.contains("distances")) { const auto &d = root.at("distances"); c.distances.full_rate_return = d.value("full_rate_return", c.distances.full_rate_return); c.distances.half_rate_start = d.value("half_rate_start", c.distances.half_rate_start); c.distances.half_rate_return = d.value("half_rate_return", c.distances.half_rate_return); c.distances.low_rate_start = d.value("low_rate_start", c.distances.low_rate_start); } c.noclip = root.value("noclip", c.noclip); c.no_bail = root.value("no_bail", c.no_bail); c.boosts = root.value("boosts", c.boosts); c.enforce_tuning = root.value("enforce_tuning", c.enforce_tuning); if (root.contains("votes") && root.at("votes").is_object()) { const auto &votes = root.at("votes"); const auto read_vote = [&](const char *key, VoteSetting &v) { if (!votes.contains(key) || !votes.at(key).is_object()) return; const auto &item = votes.at(key); v.enabled = item.value("enabled", v.enabled); v.percent = std::clamp(item.value("percent", v.percent), 1U, 100U); }; read_vote("map", c.votes.map); read_vote("kick", c.votes.kick); read_vote("time_of_day", c.votes.time); c.votes.seconds = std::clamp(votes.value("seconds", c.votes.seconds), 10U, 300U); c.votes.cooldown = std::clamp(votes.value("cooldown_seconds", c.votes.cooldown), 0U, 3600U); } const auto placement = root.value("object_placement", placement_text(c.object_placement)); // On a dedicated server the protocol's "host only" means its admins. c.object_placement = placement == "nobody" ? ObjectPlacement::nobody : placement == "admins" || placement == "host" ? ObjectPlacement::host_only : ObjectPlacement::everyone; if (root.contains("parks")) for (unsigned lot = 0; lot < park_lots.size(); ++lot) c.parks[lot] = root.at("parks").value(park_lots[lot].key, c.parks[lot]); c.world_layer_sync = root.value("world_layer_sync", c.world_layer_sync); if (root.contains("layers") && root.at("layers").is_object()) for (const auto &[key, mode] : root.at("layers").items()) if (mode.is_string()) c.layers[key] = mode.string(); if (root.contains("admins")) for (const auto &id : root.at("admins")) c.admins.push_back(steam_id(id)); if (root.contains("bans")) for (const auto &row : root.at("bans")) c.bans.push_back({steam_id(row.at("id")), row.value("name", std::string{}), row.value("added", std::int64_t{})}); // A config from an older version: write the new settings into it so owners can see them. std::vector missing; missing_settings(root, to_json(c), {}, missing); if (!missing.empty()) { save_config(c); if (added) *added = std::move(missing); } return c; } void save_config(const ServerConfig &c) { if (c.file.has_parent_path()) std::filesystem::create_directories(c.file.parent_path()); const auto temporary = std::filesystem::path(c.file).concat(".tmp"); { std::ofstream out(temporary, std::ios::binary | std::ios::trunc); out << to_json(c).dump(2) << '\n'; if (!out) throw std::runtime_error("Cannot write " + temporary.string()); } std::filesystem::rename(temporary, c.file); } std::string scoring_text(std::uint64_t fingerprint) { static constexpr char digits[] = "0123456789abcdef"; std::string text(16, '0'); for (int i = 15; i >= 0; --i, fingerprint >>= 4) text[static_cast(i)] = digits[fingerprint & 15]; return text; } std::optional parse_scoring(std::string_view text) { if (text.empty() || text.size() > 16) return std::nullopt; std::uint64_t value{}; for (const auto c : text) { const auto digit = c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1; if (digit < 0) return std::nullopt; value = value << 4 | static_cast(digit); } if (!value) return std::nullopt; return value; } std::string config_error(const ServerConfig &c) { using namespace multiplayer; if (c.name.empty() || c.name.size() > 64 || !valid_member_name(c.name)) return "name must be 1 to 64 characters."; if (c.map.empty() || !valid_map_destination(map_destination(c.map))) return "map \"" + c.map + "\" is not a known map. Use a name like \"San Vansterdam\", or put the map's mod " "folder in Mods next to the server."; if (c.max_players < 1 || c.max_players + 1 > max_players) return "max_players must be 1 to " + std::to_string(max_players - 1) + "."; if (c.password.size() > 64) return "password must be at most 64 characters."; if (!c.welcome.empty() && !valid_chat_text(c.welcome)) return "welcome must be one chat line (at most 200 bytes)."; if (!valid_multiplayer_tps(c.tps)) return "tps must be 20, 30, 60 or 120."; if (!valid_voice_range(c.voice_range)) return "voice_range must be 50 to 1000."; if (!c.distances.valid()) return "distances must be ordered: full_rate_return < half_rate_start <= half_rate_return < low_rate_start <= 10000."; for (unsigned lot = 0; lot < park_lots.size(); ++lot) if (c.parks[lot].empty() || !valid_park(lot, c.parks[lot])) return "parks." + std::string(park_lots[lot].key) + " is not a park layout (e.g. skatepark_01, or empty)."; if (!c.port || !c.query_port || c.port == c.query_port) return "port and query_port must differ."; for (const auto id : c.admins) if (!individual_steam_id(id)) return "admins must be SteamID64s (17 digits starting 7656119)."; return {}; } namespace { constexpr std::string_view root_level = "Levels/Game/DingoLevel_Root/DingoLevel_Root"; std::string folded(std::string_view text) { std::string result(text); for (auto &c : result) { if (c >= 'A' && c <= 'Z') c = static_cast(c + 32); if (c == '\\') c = '/'; } return result; } bool same(std::string_view a, std::string_view b) { return folded(a) == folded(b); } bool starts(std::string_view text, std::string_view prefix) { return folded(text).starts_with(folded(prefix)); } std::vector &level_list() { static std::vector value; return value; } } // namespace std::vector load_levels(const std::filesystem::path &mods) { auto &list = level_list(); list.clear(); // The retail maps players can skate together; the game names them the same way. for (const char *asset : {"Levels/Game/BAM_LevelRoot/BAM_LevelRoot", "Levels/Game/DingoLevel_Isle_of_Grom/DingoLevel_Isle_of_Grom", "Levels/Game/DingoLevel_MPR/DingoLevel_MPR", "Levels/Game/DingoLevel_FTUE_Island/DingoLevel_FTUE_Island", "Levels/Game/DingoLevel_SDM/DingoLevel_SDM_Int_001/DingoLevel_SDM_Int_001", "Levels/Game/DingoLevel_SDM/DingoLevel_SDM_Int_002/DingoLevel_SDM_Int_002"}) list.push_back({asset, world_level_name(asset)}); std::vector problems; std::error_code error; if (!std::filesystem::is_directory(mods, error)) return problems; for (const auto &entry : std::filesystem::directory_iterator(mods, error)) { const auto manifest = entry.path() / "reskate-levels.json"; if (!entry.is_directory() || !std::filesystem::exists(manifest)) continue; try { std::ifstream in(manifest, std::ios::binary); std::stringstream text; text << in.rdbuf(); const auto root = Json::parse(text.str()); for (const auto &level : root.at("levels")) { const auto asset = level.at("asset").string(); const auto name = level.value("displayName", world_level_name(asset)); if (std::none_of(list.begin(), list.end(), [&](const auto &l) { return same(l.asset, asset); })) list.push_back({asset, name.empty() ? world_level_name(asset) : name}); } } catch (const std::exception &e) { problems.push_back(entry.path().filename().string() + ": " + e.what()); } } return problems; } const std::vector &levels() { return level_list(); } const ServerLevel *find_level(std::string_view map) { if (map.empty()) return nullptr; for (const auto &level : level_list()) if (same(level.asset, map)) return &level; for (const bool exact : {true, false}) { const ServerLevel *result{}; for (const auto &level : level_list()) { const auto alias = world_level_short_name(level.asset); if (exact ? (same(level.name, map) || same(alias, map)) : (starts(level.name, map) || starts(alias, map) || starts(level.asset, map))) { if (result) return nullptr; // ambiguous result = &level; } } if (result) return result; } return nullptr; } std::string map_destination(std::string_view map) { if (map.find('|') != std::string_view::npos) return std::string(map); if (const auto *level = find_level(map)) return std::string(root_level) + "|" + level->asset; // A level path the server has no manifest for: players with that map can still load it. if (map.find('/') != std::string_view::npos) return std::string(root_level) + "|" + std::string(map); return {}; } std::string map_setting(std::string_view map) { std::string_view level = map; if (const auto split = map.find('|'); split != std::string_view::npos) { const auto root = map.substr(0, split), detached = map.substr(split + 1); // Anything not loaded into the usual root level keeps its full destination. if (!same(root, root_level) || detached.empty()) return std::string(map); level = detached; } if (const auto *known = find_level(level)) return known->name; return std::string(level); } std::string map_label(std::string_view map) { if (const auto *level = find_level(map)) return level->name; return world_level_name(world_destination_asset(map_destination(map))); } } // namespace dingosdk::server