Files
ReSkate/Server/server_config.cpp
T
2026-10-02 14:40:41 -05:00

328 lines
16 KiB
C++

#include "server_config.h"
#include "Engine/Core/Json/json.h"
#include "Extension/Multiplayer/Net/protocol.h"
#include "Engine/Game/World/world_names.h"
#include <algorithm>
#include <fstream>
#include <sstream>
#include <stdexcept>
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<unsigned>(c.port);
root["query_port"] = static_cast<unsigned>(c.query_port);
root["tps"] = c.tps;
root["voice_chat"] = c.voice_chat;
root["voice_range"] = static_cast<double>(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<std::uint64_t>();
}
// 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<std::string> &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<std::string> *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);
c.port = static_cast<std::uint16_t>(root.value("port", static_cast<unsigned>(c.port)));
c.query_port = static_cast<std::uint16_t>(root.value("query_port", static_cast<unsigned>(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<std::string> 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<std::size_t>(i)] = digits[fingerprint & 15];
return text;
}
std::optional<std::uint64_t> 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<std::uint64_t>(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<char>(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<ServerLevel> &level_list() {
static std::vector<ServerLevel> value;
return value;
}
} // namespace
std::vector<std::string> 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<std::string> 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<ServerLevel> &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