diff --git a/Server/main.cpp b/Server/main.cpp index 7494cebb..2bbfb489 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -611,31 +611,38 @@ int run(int argc, char **argv, bool skip_update) { return restart ? restart_for_update : 0; } +namespace { +// Both entry points: run the server, and install an update and start it when run() asks. +int serve(int argc, auto **argv) { #ifdef _WIN32 -int wmain(int argc, wchar_t **argv) { + constexpr const char *exe = "ReSkateServer.exe"; +#else + constexpr const char *exe = "ReSkateServer"; +#endif bool skip_update{}; - int code{}; for (;;) { - code = run(argc, argv, skip_update); - if (code != restart_for_update) break; + const int code = run(argc, argv, skip_update); + if (code != restart_for_update) return code; // Steam has shut down, so every server file can be replaced now. try { write_log("Installing server update " + update_version + "..."); install_update(folder()); write_log("Server update " + update_version + " installed; starting it."); console().flush(std::chrono::seconds(2)); - if (relaunch()) { - code = 0; - break; - } - write_log("Could not start the updated server; start ReSkateServer.exe again."); - code = 1; - break; + if (relaunch()) return 0; + write_log(std::string("Could not start the updated server; start ") + exe + " again."); + return 1; } catch (const std::exception &e) { write_log(std::string("Server update failed (") + e.what() + "); carrying on with this version."); skip_update = true; } } +} +} // namespace + +#ifdef _WIN32 +int wmain(int argc, wchar_t **argv) { + const int code = serve(argc, argv); finished = true; // Keep a failure on screen until the host has read it, instead of the window // vanishing with it. Closing the window or Ctrl+C still ends it at once. @@ -650,28 +657,7 @@ int wmain(int argc, wchar_t **argv) { } #else int main(int argc, char **argv) { - bool skip_update{}; - int code{}; - for (;;) { - code = run(argc, argv, skip_update); - if (code != restart_for_update) break; - try { - write_log("Installing server update " + update_version + "..."); - install_update(folder()); - write_log("Server update " + update_version + " installed; starting it."); - console().flush(std::chrono::seconds(2)); - if (relaunch()) { - code = 0; - break; - } - write_log("Could not start the updated server; start ReSkateServer again."); - code = 1; - break; - } catch (const std::exception &e) { - write_log(std::string("Server update failed (") + e.what() + "); carrying on with this version."); - skip_update = true; - } - } + const int code = serve(argc, argv); finished = true; console().flush(std::chrono::seconds(2)); return code; diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp new file mode 100644 index 00000000..e185451f --- /dev/null +++ b/Server/server_commands.cpp @@ -0,0 +1,744 @@ +#include "server_host.h" +#include "server_text.h" +#include "Engine/Core/Text/word_filter.h" +#include "Engine/Game/World/park_randomization.h" +#include +#include +#include +#include +#include +#include + +// Admin commands, from the server console or an admin's game, and the anti-cheat checks they switch. +namespace dingosdk::server { +namespace { +constexpr std::string_view help_text = + "status | net [player] | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" + "map | maps | name | password | welcome | listed on|off\n" + "voice on|off | voice-range <50-1000> | distances | crowd |off | rate | bone-scale <1-8>|off\n" + "placement everyone|admins|nobody | objects |off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" + "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" + "map-pool [add|remove |clear] | rotation [|off]\n" + "park | park random | layer-sync on|off | layer default|on|off | tod \n" + "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | speed-check off|warn|kick\n" + "score-check [off|warn|kick] | score-allow [|remove ]\n" + "reserved [slots | add|remove ] | admin add|remove | admins | update | quit"; +} // namespace + +std::string Host::command(std::string_view line, std::uint64_t admin) { + const bool console = admin == 0; + auto [action, argument] = split(line); + const auto verb = lower(action); + // The in-game menu's names for the same settings. + const std::string name = verb == "voice-allow" ? "voice" : verb == "object-placement" ? "placement" + : verb == "object-limit" ? "objects" + : verb == "world-layer-sync" ? "layer-sync" : verb == "noclip-allow" ? "noclip" + : verb == "nobail-allow" ? "nobail" : verb == "boosts-allow" ? "boosts" + : verb == "tuning-enforce" ? "tuning" : verb; + const auto target = [&](std::string_view text) -> Guest * { + // A SteamID64 (optionally followed by the player's session epoch, as the + // in-game menu sends it), or the start of a connected player's name. + // Every name starts with "", so a bare `kick` would pick the only player. + if (text.empty()) return nullptr; + const auto [first, rest] = split(text); + (void)rest; + if (const auto id = number(first)) return find(*id); + Guest *match{}; + for (auto &[id, guest] : guests_) + if (guest->handshaken && lower(guest->member.name).starts_with(lower(text))) { + if (match) return nullptr; + match = guest.get(); + } + return match; + }; + const auto changed = [&](std::string text) { + ++bans_revision_; // cheap: admins only get a fresh list when it moved + save(); + roster_dirty_ = true; + if (!console) log_(text); // the console logs its own replies + return text; + }; + if (name.empty() || name == "help") return std::string(help_text); + if (name == "status") + return config_.name + " | " + map_name() + " | " + std::to_string(players()) + "/" + + std::to_string(config_.max_players) + " players | " + std::to_string(config_.tps) + " TPS | voice " + + (voice_policy_.allowed ? "on" : "off") + " (" + std::to_string(static_cast(config_.voice_range)) + + " m) | password " + (password_ ? "on" : "off") + " | code " + invite(); + if (name == "net") return network_report(argument, console); + if (name == "players") { + std::string text = std::to_string(players()) + " players"; + for (const auto &[id, guest] : guests_) + if (guest->handshaken) + text += "\n " + std::to_string(id) + " " + guest_name(*guest) + (is_admin(id) ? " (admin)" : ""); + return text; + } + if (name == "say") { + if (!console) return "Use chat to talk to everyone."; + if (argument.empty()) return "say "; + send_chat(argument); + return "[chat] Server: " + clean_chat_text(argument); + } + if (name == "msg" || name == "msg-party" || name == "msg-admins") { + // Direct messages from the console or an admin, marked "[DM from ...]" so nobody takes them for chat. + const auto *sender = console ? nullptr : find(admin); + const std::string from = sender ? guest_name(*sender) : "Server"; + const bool to_admins = name == "msg-admins"; + const auto [who, text] = to_admins ? std::pair{{}, trim(argument)} : split(argument); + if (text.empty()) return to_admins ? "msg-admins " : name + " "; + std::vector recipients; + std::string scope, label; + if (to_admins) { + scope = label = "admins"; + for (auto &[id, guest] : guests_) + if (guest->handshaken && is_admin(id)) recipients.push_back(guest.get()); + } else { + auto *guest = match_player(who); + if (!guest) return "No single connected player matches \"" + std::string(who) + "\"."; + label = guest_name(*guest); + if (name == "msg") { + recipients.push_back(guest); + } else { + const auto *details = parties_.party(parties_.party_of(guest->member.id)); + if (!details) return label + " is not in a party."; + scope = "party"; + label += "'s party"; + for (const auto member : details->members) + if (auto *found = find(member); found && found->handshaken) recipients.push_back(found); + } + } + if (recipients.empty()) return "No admins are online."; + const auto message = dm_line(from, scope, text, multiplayer_chat_max_bytes); + for (auto *guest : recipients) send_chat(message, guest); + const auto done = "Sent to " + label + (recipients.size() > 1 || to_admins ? " (" + std::to_string(recipients.size()) + " players)" : "") + "."; + if (!console) log_("[dm] " + from + " -> " + label + ": " + clean_chat_text(text)); + return done; + } + if (name == "kick") { + auto *guest = target(argument); + if (!guest || !guest->handshaken) return "No single connected player matches \"" + std::string(argument) + "\"."; + // Admins answer to the console, not to each other. + if (!console && is_admin(guest->member.id)) return "Admins cannot kick other admins."; + const auto label = guest_name(*guest); + kicked_.insert(guest->member.id); + drop(guest->member.id, "You were kicked from this server."); + return changed(label + " was kicked until the server restarts."); + } + if (name == "ban") { + auto [who, reason] = split(argument); + auto *guest = target(who); + std::uint64_t id = guest ? guest->member.id : number(who).value_or(0); + if (!individual_steam_id(id)) return "Enter a connected player or a SteamID64 (17 digits starting 7656119)."; + if (id == admin) return "You cannot ban yourself."; + if (!console && is_admin(id)) return "Admins cannot ban other admins."; + if (is_banned(id)) return std::to_string(id) + " is already banned."; + auto label = guest ? guest->member.name : clean_chat_text(reason); + cut_text(label, 64); + config_.bans.push_back({id, label, static_cast(std::time(nullptr))}); + if (guest) drop(id, "You were banned from this server."); + return changed((label.empty() ? std::to_string(id) : label) + " was banned."); + } + if (name == "unban") { + const auto id = number(argument).value_or(0); + const auto found = std::find_if(config_.bans.begin(), config_.bans.end(), [&](const auto &b) { return b.id == id; }); + if (found == config_.bans.end()) return "That SteamID64 is not banned."; + const auto label = found->name.empty() ? std::to_string(id) : found->name; + config_.bans.erase(found); + kicked_.erase(id); + return changed(label + " was unbanned."); + } + if (name == "bans") { + std::string text = std::to_string(config_.bans.size()) + " banned"; + for (const auto &ban : config_.bans) text += "\n " + std::to_string(ban.id) + " " + ban.name; + return text; + } + if (name == "map") { + // The map as it will be stored must still name a destination: one that does not would + // leave the server unable to tell players where to go, and unable to start again. + if (argument.empty() || !valid_map_destination(map_destination(argument)) || + !valid_map_destination(map_destination(map_setting(argument)))) + return "No single map is called \"" + std::string(argument) + "\". Type maps for the list."; + // Only a map the server has: the game's own, or one from a mod in its Mods folder. + if (!installed_map(argument)) + return "This server does not have that map. Put the map's mod folder in Mods next to the server, then restart it."; + if (map_hash(map_destination(argument)) == map_) return "The server is already on that map."; + change_map(argument); + save(); + return changed("Changing map to " + map_name()); + } + if (name == "maps") { + std::string text = std::to_string(levels().size()) + " maps (custom maps come from Mods next to the server)"; + for (const auto &level : levels()) + text += "\n " + level.name + (same_map(level.asset) ? " (now)" : "") + + (!config_.map_pool.empty() && in_map_pool(config_, level.asset) ? " (pool)" : ""); + return text; + } + if (name == "map-pool") { // map-pool [add|remove |clear] + const auto [what_text, map] = split(argument); + const auto what = lower(what_text); + if (what.empty()) return pool_text(); + if (what == "clear") { + config_.map_pool.clear(); + resend_maps(); + return changed("The map pool is cleared: players vote between every map, and the rotation goes through them all."); + } + if (what != "add" && what != "remove") return "map-pool [add|remove |clear]"; + const auto *level = find_level(map); + if (!level || !valid_map_destination(map_destination(level->asset))) + return "No single map is called \"" + std::string(map) + "\". Type maps for the list."; + const auto pooled = [&](const std::string &entry) { return find_level(entry) == level; }; + const bool listed = std::any_of(config_.map_pool.begin(), config_.map_pool.end(), pooled); + if (what == "add") { + if (listed || config_.map_pool.empty()) return level->name + " is already in the map pool."; + config_.map_pool.push_back(level->name); + } else { + if (config_.map_pool.empty()) // every map: keep all the others + for (const auto *other : pool_levels(config_)) config_.map_pool.push_back(other->name); + else if (!listed) return level->name + " is not in the map pool."; + if (std::all_of(config_.map_pool.begin(), config_.map_pool.end(), pooled)) + return "The map pool needs at least one map. map-pool clear allows every map again."; + std::erase_if(config_.map_pool, pooled); + } + resend_maps(); + return changed(level->name + (what == "add" ? " added to" : " removed from") + " the map pool."); + } + if (name == "rotation") { // rotation [|off] + if (argument.empty()) return rotation_text(); + const auto value = lower(argument); + const auto minutes = value == "off" ? std::optional(0) : number(value); + if (!minutes || *minutes > max_map_rotation) return "rotation <1-1440 minutes>|off"; + config_.map_rotation = static_cast(*minutes); + map_since_ = now_; + rotation_warned_ = false; + resend_maps(); + return changed(rotation_text()); + } + if (name == "name") { + if (!valid_server_name(argument)) return std::string("Server names are ") + server_name_rule + "."; + config_.name = argument; + if (text::contains_bad_words(config_.name)) + return changed("Server renamed to " + config_.name + + ". That name contains blocked words, so the server stays out of the server browser."); + return changed("Server renamed to " + config_.name + "."); + } + if (name == "password") { + if (argument.size() > 64) return "Passwords are at most 64 characters."; + config_.password = argument == "off" ? std::string{} : std::string(argument); + erase_key(password_); + password_ = config_.password.empty() ? std::nullopt : password_key(config_.password, secret_); + return changed(config_.password.empty() ? "Password removed. Anyone can join." + : "Password set. Players already here stay; new ones need it."); + } + if (name == "welcome") { + if (argument != "off" && !argument.empty() && !valid_chat_text(argument)) return "The welcome message is one chat line."; + config_.welcome = argument == "off" ? std::string{} : std::string(argument); + return changed(config_.welcome.empty() ? "Welcome message removed." : "Welcome message set."); + } + if (name == "chat-color" || name == "chat-colour") { + // chat-color <#badge> [<#text>]: the server's own lines in chat. + const auto [badge, text] = split(argument); + if (badge.empty() || !parse_colour(badge) || (!text.empty() && !parse_colour(text))) + return "chat-color <#RRGGBB badge> [<#RRGGBB text>] (now " + config_.chat_color + " " + config_.chat_text_color + ")"; + config_.chat_color = std::string(badge); + if (!text.empty()) config_.chat_text_color = std::string(text); + return changed("The server's chat lines are " + config_.chat_color + " with " + config_.chat_text_color + " text."); + } + if (name == "announce-throwdowns") { + const auto value = on_off(argument); + if (!value) return std::string("announce-throwdowns on|off (now ") + (config_.announce_throwdowns ? "on" : "off") + ")"; + config_.announce_throwdowns = *value; + return changed(*value ? "Placed throwdowns are announced in chat." : "Placed throwdowns are no longer announced."); + } + if (name == "parties") { + const auto value = on_off(argument); + if (argument.empty()) return std::string(config_.parties ? "Parties are on.\n" : "Parties are off.\n") + party_status(0); + if (!value) return "parties on|off"; + config_.parties = *value; + if (!*value) { + for (auto &[id, guest] : guests_) parties_.remove(id); + parties_.take_withdrawn(); + } + return changed(*value ? "Players can form parties." : "Parties are off; every party was ended."); + } + if (name == "speed-check") { + const auto value = lower(argument); + if (value != "off" && value != "warn" && value != "kick") + return "speed-check off|warn|kick (now " + config_.speed_check + ")"; + config_.speed_check = value; + if (value == "off") + for (auto &[id, guest] : guests_) { + guest->speed.restart(); + guest->speeding = false; + } + return changed(value == "off" ? "Game speed is no longer checked." + : value == "kick" ? "Players whose game runs fast are kicked." + : "Players whose game runs fast are taken out of throwdowns and challenges."); + } + if (name == "score-check") { + const auto value = lower(argument); + if (value.empty()) { + std::string text = "score-check " + config_.score_check + " (off|warn|kick)"; + for (const auto &[id, guest] : guests_) { + if (!guest->handshaken) continue; + text += "\n " + guest_name(*guest) + ": "; + if (!guest->scoring) text += "not reported"; + else if (!*guest->scoring) text += "the game's own scoring"; + else + text += scoring_text(*guest->scoring) + (guest->scoring_mods.empty() ? "" : " (" + guest->scoring_mods + ")") + + (guest->scoring_flagged ? ", out of throwdowns" : ", allowed"); + } + return text; + } + if (value != "off" && value != "warn" && value != "kick") return "score-check off|warn|kick (now " + config_.score_check + ")"; + config_.score_check = value; + // Kicking changes the guest list: collect first. + std::vector ids; + for (const auto &[id, guest] : guests_) ids.push_back(id); + for (const auto id : ids) + if (auto *guest = find(id)) check_scoring(*guest); + return changed(value == "off" ? "Mods that change scoring or physics are no longer checked." + : value == "kick" ? "Players whose mods change scoring or physics are kicked." + : "Players whose mods change scoring or physics are taken out of throwdowns and challenges."); + } + if (name == "score-allow") { + auto [what, rest] = split(argument); + if (what.empty()) { + std::string text = "score-allow | score-allow remove . Accepted besides the game's own:"; + if (config_.score_allow.empty()) text += " none"; + for (const auto fingerprint : config_.score_allow) text += "\n " + scoring_text(fingerprint); + return text; + } + const bool remove = lower(what) == "remove"; + const auto fingerprint = parse_scoring(remove ? rest : what); + if (!fingerprint) return "A fingerprint is 16 hex digits, as score-check lists it."; + const auto found = std::find(config_.score_allow.begin(), config_.score_allow.end(), *fingerprint); + if (remove) { + if (found == config_.score_allow.end()) return scoring_text(*fingerprint) + " was not accepted."; + config_.score_allow.erase(found); + } else if (found == config_.score_allow.end()) { + config_.score_allow.push_back(*fingerprint); + } + std::vector ids; + for (const auto &[id, guest] : guests_) ids.push_back(id); + for (const auto id : ids) + if (auto *guest = find(id)) check_scoring(*guest); + return changed(remove ? "Scoring " + scoring_text(*fingerprint) + " is no longer accepted." + : "Scoring " + scoring_text(*fingerprint) + " is accepted like the game's own."); + } + if (name == "rate") { + // rate : what the server may send each player, from now on and to those on. + const auto value = number(argument); + if (!value || *value < 128 || *value > 16384) + return "rate <128-16384> (KB/s for each player, now " + std::to_string(config_.send_rate) + ")"; + config_.send_rate = static_cast(*value); + const bool applied = transport_.set_send_rate(static_cast(config_.send_rate * 1024)); + return changed("Each player is sent at most " + std::to_string(config_.send_rate) + " KB/s" + + (applied ? "." : ": players who join from now on. Steam did not change the connections already open.")); + } + if (name == "bone-scale") { + // bone-scale <1-8>|off: how far a mod may resize part of a skater for the other players. + float value{}; + const bool off = argument == "off" || argument == "0"; + const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value); + if (!off && (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !(value >= 1.f && value <= 8.f))) + return "bone-scale <1-8>|off: 1 shows every skater at the game's own proportions, off allows anything (now " + + (config_.bone_scale_limit >= 1.f ? std::to_string(config_.bone_scale_limit).substr(0, 4) : std::string("off")) + ")"; + config_.bone_scale_limit = off ? 0.f : value; + // Whole states go again so that nobody keeps a reference with the old sizes in it. + for (auto &[id, guest] : guests_) + for (const auto &[other, unused] : guests_) guest->sender.forget(other, PacketKind::pose); + return changed(off ? std::string("Mods may resize skaters' body parts freely.") + : value == 1.f ? std::string("Skaters show at the game's own proportions: resized body parts are not passed on.") + : "Resized body parts show at up to " + std::to_string(value).substr(0, 4) + "x."); + } + if (name == "crowd") { + // crowd |off: the most one player is sent (crowd_limits). + auto value = number(argument); + if (argument == "off") value = 0; + if (!value || *value > max_crowd_budget || !valid_crowd_budget(static_cast(*value))) + return "crowd <" + std::to_string(min_crowd_budget) + "-" + std::to_string(max_crowd_budget) + ">|off (now " + + (config_.crowd_budget ? std::to_string(config_.crowd_budget) : std::string("off")) + ")"; + config_.crowd_budget = static_cast(*value); + next_crowd_ = 0; + return changed(*value ? "Each player is sent at most " + std::to_string(*value) + " poses a second: about " + + std::to_string(*value / config_.tps) + " players near them at the full rate." + : std::string("No crowd limit: every player near is sent at the full rate.")); + } + if (name == "party-size") { + const auto value = number(argument); + if (!value || *value < 2 || *value > 8) return "party-size <2-8> (now " + std::to_string(config_.party_size) + ")"; + config_.party_size = static_cast(*value); + parties_.set_limit(config_.party_size); + return changed("Parties hold up to " + std::to_string(config_.party_size) + " players. Larger ones stay until members leave."); + } + if (name == "activity-log") { + const auto value = on_off(argument); + if (!value) return std::string("activity-log on|off (now ") + (config_.activity_log ? "on" : "off") + ")"; + config_.activity_log = *value; + if (!*value) activity_.clear(); + return changed(*value ? "Player activity (throwdowns, objects, loading) is logged." + : "Player activity is no longer logged."); + } + if (name == "listed") { + const auto value = on_off(argument); + if (!value) return "listed on|off"; + config_.listed = *value; + return changed(*value ? "The server is listed in the server browser." : "The server is hidden; players need the code."); + } + if (name == "tps") { + return "Dedicated servers run at " + std::to_string(dedicated_tps) + " TPS for now; it cannot be changed."; + } + if (name == "voice") { + const auto value = on_off(argument); + if (!value) return "voice on|off"; + config_.voice_chat = *value; + if (voice_policy_.allowed != *value) { + voice_policy_.allowed = *value; + if (!++voice_policy_.revision) ++voice_policy_.revision; + } + return changed(*value ? "Voice chat allowed." : "Voice chat disabled for everyone."); + } + if (name == "voice-range") { + float range{}; + const auto result = std::from_chars(argument.data(), argument.data() + argument.size(), range); + if (result.ec != std::errc{} || result.ptr != argument.data() + argument.size() || !valid_voice_range(range)) + return "Choose a voice range from 50 to 1000 m."; + config_.voice_range = range; + return changed("Voice range set to " + std::to_string(static_cast(range)) + " m."); + } + if (name == "distances") { + MultiplayerDistances value; + auto rest = argument; + for (auto *field : {&value.full_rate_return, &value.half_rate_start, &value.half_rate_return, &value.low_rate_start}) { + const auto [token, remaining] = split(rest); + const auto result = std::from_chars(token.data(), token.data() + token.size(), *field); + if (token.empty() || result.ec != std::errc{} || result.ptr != token.data() + token.size()) + return "distances (whole metres)"; + rest = remaining; + } + if (!rest.empty() || !value.valid()) + return "Use ordered distances: full < half <= half-return < low (at most 10000 m)."; + config_.distances = value; + for (auto &[id, guest] : guests_) guest->pose_delivery = {}; + return changed("TPS distances updated."); + } + if (name == "placement") { + // The protocol's "host only" is admins only here: the server has no skater of its own. + const auto policy = parse_object_placement(argument == "admins" ? "host" : argument, config_.object_placement); + if (!policy) return "placement everyone|admins|nobody"; + config_.object_placement = *policy; + return changed(*policy == ObjectPlacement::everyone ? "Everyone can place objects." + : *policy == ObjectPlacement::host_only ? "Only admins can place objects. Everyone else's are frozen." + : "Object placement is off. Existing objects stay."); + } + if (name == "objects") { + const auto limit = parse_object_limit(argument); + if (!limit) return "objects <1-" + std::to_string(max_object_limit) + ">|off"; + config_.object_limit = *limit; + for (auto &[id, guest] : guests_) guest->shared_from = 0; // look at every layout again + return changed(*limit ? "Each player can place up to " + std::to_string(*limit) + " objects. Admins are not limited." + : std::string("Players can place as many objects as they like.")); + } + if (name == "votes") { + // votes | votes on|off| | votes seconds|cooldown + const auto [what_text, value_text] = split(argument); + const auto what = lower(what_text); + const auto describe = [&](const char *label, const VoteSetting &v) { + return std::string(label) + ": " + (v.enabled ? "on, " + std::to_string(v.percent) + "% to pass" : "off"); + }; + if (what.empty()) + return describe("map votes", config_.votes.map) + "\n" + describe("kick votes", config_.votes.kick) + "\n" + + describe("time of day votes", config_.votes.time) + + (config_.world_layer_sync ? "" : " (needs layer-sync on)") + "\nvotes last " + + std::to_string(config_.votes.seconds) + " s; a player waits " + std::to_string(config_.votes.cooldown) + + " s between votes" + (vote_ ? "\nrunning: a vote to " + vote_->label : std::string{}); + const auto value = lower(value_text); + if (what == "seconds" || what == "cooldown") { + const auto n = number(value); + const bool seconds = what == "seconds"; + if (!n || (seconds ? *n < 10 || *n > 300 : *n > 3600)) + return seconds ? "votes seconds <10-300>" : "votes cooldown <0-3600>"; + (seconds ? config_.votes.seconds : config_.votes.cooldown) = static_cast(*n); + return changed(seconds ? "Votes now last " + std::to_string(*n) + " s." + : "Players now wait " + std::to_string(*n) + " s between votes."); + } + VoteSetting *setting = what == "map" ? &config_.votes.map : what == "kick" ? &config_.votes.kick + : what == "tod" || what == "time" ? &config_.votes.time : nullptr; + if (!setting) return "votes [map|kick|tod on|off|] | votes seconds | votes cooldown "; + const auto label = what == "map" ? std::string("Map votes") : what == "kick" ? std::string("Kick votes") + : std::string("Time of day votes"); + if (const auto toggle = on_off(value)) { + setting->enabled = *toggle; + if (!*toggle && vote_ && vote_setting(vote_->kind).enabled == false) cancel_vote("that vote was switched off"); + return changed(label + (*toggle ? " are on (" + std::to_string(setting->percent) + "% to pass)." : " are off.")); + } + const auto percent = number(value.ends_with("%") ? std::string_view(value).substr(0, value.size() - 1) : std::string_view(value)); + if (!percent || *percent < 1 || *percent > 100) return "votes " + what + " on|off|<1-100>"; + setting->percent = static_cast(*percent); + return changed(label + " now need " + std::to_string(*percent) + "% to pass."); + } + if (name == "vote-cancel") { + if (!vote_) return "No vote is running."; + cancel_vote(console ? "the server cancelled it" : "an admin cancelled it"); + return "Vote cancelled."; + } + if (name == "tpall" || name == "tphere") { + // Where they go: the admin who asked, or (tpall from the console) the named player. + Guest *to{}; + std::vector movers; + if (name == "tpall") { + to = argument.empty() ? (console ? nullptr : find(admin)) : target(argument); + if (!to || !to->handshaken) + return console && argument.empty() ? "tpall : everyone goes to that player." + : "No single connected player matches \"" + std::string(argument) + "\"."; + for (auto &[id, guest] : guests_) + if (guest->handshaken && guest->world_ready && guest.get() != to) movers.push_back(guest.get()); + } else { + if (console) return "tphere is for admins in the game; the console can use tpall ."; + to = find(admin); + auto *who = target(argument); + if (!who || !who->handshaken) return "No single connected player matches \"" + std::string(argument) + "\"."; + if (who == to) return "That is you."; + movers.push_back(who); + } + if (!to || !to->latest_root) return "There is no position for " + (to ? guest_name(*to) : std::string("you")) + " yet."; + if (movers.empty()) return "Nobody else is in the world."; + const auto at = to->latest_root->position; + unsigned sent{}; + for (std::size_t i = 0; i < movers.size(); ++i) { + // A ring around them, so nobody lands inside anyone else. + const float angle = 6.2831853f * static_cast(i) / static_cast(movers.size()); + auto p = packet(PacketKind::teleport, now_); + p.teleport = {at[0] + 2.5f * std::cos(angle), at[1] + 1.0f, at[2] + 2.5f * std::sin(angle)}; + if (send_packet(*movers[i], p, true, false)) ++sent; + } + const auto text = movers.size() == 1 && sent ? guest_name(*movers[0]) + " was teleported to " + guest_name(*to) + "." + : std::to_string(sent) + " player(s) teleported to " + guest_name(*to) + "."; + if (!console) log_(text); + return text; + } + if (name == "noclip" || name == "nobail" || name == "boosts") { + auto &allowed = name == "noclip" ? config_.noclip : name == "nobail" ? config_.no_bail : config_.boosts; + const auto value = argument == "toggle" ? std::optional(!allowed) : on_off(argument); + if (!value) return name + " on|off (now " + (allowed ? "on" : "off") + ")"; + allowed = *value; + const std::string tool = name == "noclip" ? "Noclip and teleporting" : name == "nobail" ? "No Bail" : "Boosts"; + return changed(tool + (*value ? (name == "boosts" ? " are" : " is") + std::string(" allowed for everyone.") + : (name == "boosts" ? " are" : " is") + std::string(" off for players; admins keep it."))); + } + if (name == "tuning") { + const auto value = argument == "toggle" ? std::optional(!config_.enforce_tuning) : on_off(argument); + if (!value) return std::string("tuning on|off (now ") + (config_.enforce_tuning ? "on" : "off") + ")"; + config_.enforce_tuning = *value; + return changed(*value ? "Players skate with the game's own physics tuning." + : "Players skate with their own physics tuning."); + } + if (name == "clear-objects") { + std::size_t removed{}; + for (auto &[id, guest] : guests_) { + if (!guest->handshaken) continue; + for (const auto *state : {&guest->objects, &guest->shared}) + for (const auto &[object, value] : state->objects()) { + (void)value; + guest->cleared.insert(object); + } + removed += guest->shared.objects().size(); + if (guest->shared.revision()) guest->shared.replace({}); + guest->shared_from = guest->objects.revision(); + } + ++object_clears_; + return changed("Deleted " + std::to_string(removed) + " placed object" + (removed == 1 ? "." : "s.")); + } + if (name == "park") { + if (lower(argument) == "random") { + config_.parks = random_park_choices(); + return changed("Random layouts selected for every park slot."); + } + const auto [lot_name, layout] = split(argument); + const auto lot = std::find_if(park_lots.begin(), park_lots.end(), [&](const auto &l) { return l.key == lot_name; }); + if (lot == park_lots.end()) return "park construction|historic|financial "; + const auto index = static_cast(lot - park_lots.begin()); + if (layout.empty() || !valid_park(index, layout)) return "That is not a layout for this lot."; + config_.parks[index] = layout; + return changed(std::string(lot->label) + " now shows " + park_label(layout) + "."); + } + if ((name == "layer-sync" || name == "layer" || name == "layers" || name == "tod") && world_layers().empty()) + return "World layers need world-layers.json next to the server (copy it from a player's " + "%LOCALAPPDATA%\\ReSkate\\cache folder for the same game build)."; + if (name == "layer-sync") { + const auto value = on_off(argument); + if (!value) return "layer-sync on|off"; + config_.world_layer_sync = *value; + apply_layers(); + return changed(*value ? "Everyone now follows the server's world layers." : "Players choose their own world layers."); + } + if (name == "layers") { + // Several at once, as key=mode pairs: the in-game time of day sends seven. + std::vector> changes; + for (auto rest = argument; !rest.empty();) { + const auto [pair, remaining] = split(rest); + rest = remaining; + const auto equals = pair.find('='); + if (equals == std::string_view::npos) return "layers =default|on|off ..."; + const auto key = pair.substr(0, equals), mode = pair.substr(equals + 1); + if (std::none_of(world_layers().begin(), world_layers().end(), [&](const auto &l) { return l.key == key; })) + return "No world layer is called \"" + std::string(key) + "\"."; + if (!valid_world_layer_mode(mode)) return "layers =default|on|off ..."; + changes.emplace_back(key, mode); + } + if (changes.empty()) return "layers =default|on|off ..."; + for (const auto &[key, mode] : changes) { + if (mode == "default") config_.layers.erase(key); + else config_.layers[key] = mode; + } + apply_layers(); + return changed(std::to_string(changes.size()) + " world layer" + (changes.size() == 1 ? "" : "s") + " changed" + + (config_.world_layer_sync ? "." : ". Turn on layer-sync to apply them to everyone.")); + } + if (name == "tod") { + // Every map's seven time layers ("_tod__"): one on and the rest off, or + // all back to the level's own. Set for every map, so it holds across map changes. + static constexpr std::array times{"default", "morning", "noon", "afternoon", + "evening", "night", "weatherday", "weathernight"}; + const auto wanted = lower(argument); + const auto found = std::find(times.begin(), times.end(), wanted); + if (found == times.end()) return "tod default|morning|noon|afternoon|evening|night|weatherday|weathernight"; + const auto slot = static_cast('0' + (found - times.begin())); + unsigned count{}; + for (const auto &layer : world_layers()) { + const auto at = layer.key.find("_tod_"); + if (at == std::string::npos || at + 5 >= layer.key.size()) continue; + if (slot == '0') config_.layers.erase(layer.key); + else config_.layers[layer.key] = layer.key[at + 5] == slot ? "on" : "off"; + ++count; + } + if (!count) return "world-layers.json has no time-of-day layers."; + apply_layers(); + return changed("Time of day set to " + std::string(*found) + + (config_.world_layer_sync ? " for everyone." : ". Turn on layer-sync to apply it to everyone.")); + } + if (name == "layer") { + const auto [key, mode] = split(argument); + const auto found = std::find_if(world_layers().begin(), world_layers().end(), [&](const auto &l) { return l.key == key; }); + if (found == world_layers().end()) return "No world layer is called \"" + std::string(key) + "\"."; + if (!valid_world_layer_mode(mode)) return "layer default|on|off"; + if (mode == "default") config_.layers.erase(std::string(key)); + else config_.layers[std::string(key)] = mode; + apply_layers(); + return changed(found->label + " set to " + std::string(mode) + + (config_.world_layer_sync ? "." : ". Turn on layer-sync to apply it to everyone.")); + } + if (name == "reserved") { + // reserved | reserved add|remove + if (!console) return "Only the server console manages reserved slots."; + const auto [sub, who] = split(argument); + if (sub.empty()) { + std::string text = std::to_string(extra_slots(config_)) + " extra slots beyond the " + std::to_string(config_.max_players) + + ": the admins and these players can join when the server is full"; + for (const auto id : config_.reserved) { + const auto *guest = find(id); + text += "\n " + std::to_string(id) + (guest ? " " + guest_name(*guest) : std::string{}); + } + return text; + } + auto *guest = target(who); + const auto id = guest ? guest->member.id : number(who).value_or(0); + if (!individual_steam_id(id) || (sub != "add" && sub != "remove")) return "reserved | reserved add|remove "; + const bool listed = std::find(config_.reserved.begin(), config_.reserved.end(), id) != config_.reserved.end(); + if (sub == "add") { + if (!listed && config_.reserved.size() >= 1024) return "The reserved list is full."; + if (!listed) config_.reserved.push_back(id); + return changed(std::to_string(id) + " has a reserved slot: they can join when the server is full."); + } + std::erase(config_.reserved, id); + return changed(std::to_string(id) + " no longer has a reserved slot."); + } + if (name == "admins" || name == "admin") { + if (!console) return "Only the server console manages admins."; + const auto [sub, who] = split(argument); + if (name == "admins" || sub.empty()) { + std::string text = std::to_string(config_.admins.size()) + " admins"; + for (const auto id : config_.admins) { + const auto *guest = find(id); + text += "\n " + std::to_string(id) + (guest ? " " + guest_name(*guest) : std::string{}); + } + return text; + } + auto *guest = target(who); + const auto id = guest ? guest->member.id : number(who).value_or(0); + if (!individual_steam_id(id)) return "admin add|remove "; + if (sub == "add") { + if (!is_admin(id)) config_.admins.push_back(id); + resend_maps(); + return changed(std::to_string(id) + " is an admin."); + } + if (sub == "remove") { + std::erase(config_.admins, id); + resend_maps(); + return changed(std::to_string(id) + " is no longer an admin."); + } + return "admin add|remove "; + } + return "Unknown command \"" + std::string(action) + "\". Type help."; +} +// A player's mods change how tricks score (their report; Engine/Vfs/mod_scoring.h): flagged +// players are taken out of linked throwdowns and coop challenges (the roster's scoring flag, and +// the server relays none of theirs), or kicked. The game keeps a scoring mod's points until it +// restarts, so the flag lasts for the session. +void Host::check_scoring(Guest &guest) { + if (!guest.handshaken) return; + const bool changed = guest.scoring && *guest.scoring && + std::find(config_.score_allow.begin(), config_.score_allow.end(), *guest.scoring) == config_.score_allow.end(); + const auto name = guest_name(guest); + if (config_.score_check == "off" || !changed) { + if (!guest.scoring_flagged) return; + guest.scoring_flagged = false; + roster_dirty_ = true; + log_("[anticheat] " + name + " may take part in throwdowns again (score-check " + config_.score_check + ")."); + return; + } + if (guest.scoring_flagged) return; + const auto mods = guest.scoring_mods.empty() ? std::string("their mods") : guest.scoring_mods; + log_("[anticheat] " + name + "'s mods change scoring or physics: " + mods + " (scoring " + scoring_text(*guest.scoring) + ")."); + if (config_.score_check == "kick") + return drop(guest.member.id, "Your mods change scoring or physics (" + mods + "). Turn them off and restart Skate to play here."); + // Every player's game says so in chat when the roster flags someone, the player themself included. + guest.scoring_flagged = true; + roster_dirty_ = true; +} +// A speedhack runs the player's game clock, and so their pose timestamps, faster than real +// time. Flagged players are taken out of linked throwdowns and coop challenges (the roster's +// speeding flag: every client drops them from those), or kicked; the flag clears after a minute +// of normal speed. +bool Host::check_speed(Guest &guest, std::uint64_t sent) { + if (config_.speed_check == "off" || !guest.handshaken || !guest.world_ready || !sent) return true; + if (!guest.speed.sample(sent, now_)) return true; + const auto name = guest_name(guest); + const auto speed = guest.speed.speed(); + if (guest.speed.flagged() && !guest.speeding) { + char text[160]; + std::snprintf(text, sizeof text, "%s's game is running at %.2fx speed (a speed hack?).", name.c_str(), speed); + log_(std::string("[anticheat] ") + text); + if (config_.speed_check == "kick") { + drop(guest.member.id, "Your game is running faster than normal. Turn off speed hacks to play here."); + return false; + } + guest.speeding = true; + guest.speed_normal_since = 0; + roster_dirty_ = true; + send_chat("The server measured your game running faster than normal: throwdowns and challenges are off for you until it's back to normal speed.", &guest); + // Only the player is told in the game; for the admins it is in the log. + return true; + } + if (!guest.speeding) return true; + if (speed < SpeedCheck::limit) { + if (!guest.speed_normal_since) guest.speed_normal_since = now_; + if (now_ - guest.speed_normal_since >= 60000000) { + guest.speeding = false; + roster_dirty_ = true; + log_("[anticheat] " + name + "'s game speed is back to normal."); + send_chat("Your game speed is back to normal: throwdowns and challenges are on again.", &guest); + } + } else guest.speed_normal_since = 0; + return true; +} +} // namespace dingosdk::server diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 862bc5ce..cf7db544 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -44,17 +44,6 @@ std::uint64_t nonce() { #endif return value; } -constexpr std::string_view help_text = - "status | net [player] | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" - "map | maps | name | password | welcome | listed on|off\n" - "voice on|off | voice-range <50-1000> | distances | crowd |off | rate | bone-scale <1-8>|off\n" - "placement everyone|admins|nobody | objects |off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" - "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" - "map-pool [add|remove |clear] | rotation [|off]\n" - "park | park random | layer-sync on|off | layer default|on|off | tod \n" - "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | speed-check off|warn|kick\n" - "score-check [off|warn|kick] | score-allow [|remove ]\n" - "reserved [slots | add|remove ] | admin add|remove | admins | update | quit"; } // namespace Host::Host(ServerConfig &config, SteamTransport &transport, Log log) @@ -1521,721 +1510,4 @@ void Host::tick(std::uint64_t now) { join_backoff_.prune(now_); } -// ---- Commands -------------------------------------------------------------------------------- -std::string Host::command(std::string_view line, std::uint64_t admin) { - const bool console = admin == 0; - auto [action, argument] = split(line); - const auto verb = lower(action); - // The in-game menu's names for the same settings. - const std::string name = verb == "voice-allow" ? "voice" : verb == "object-placement" ? "placement" - : verb == "object-limit" ? "objects" - : verb == "world-layer-sync" ? "layer-sync" : verb == "noclip-allow" ? "noclip" - : verb == "nobail-allow" ? "nobail" : verb == "boosts-allow" ? "boosts" - : verb == "tuning-enforce" ? "tuning" : verb; - const auto target = [&](std::string_view text) -> Guest * { - // A SteamID64 (optionally followed by the player's session epoch, as the - // in-game menu sends it), or the start of a connected player's name. - // Every name starts with "", so a bare `kick` would pick the only player. - if (text.empty()) return nullptr; - const auto [first, rest] = split(text); - (void)rest; - if (const auto id = number(first)) return find(*id); - Guest *match{}; - for (auto &[id, guest] : guests_) - if (guest->handshaken && lower(guest->member.name).starts_with(lower(text))) { - if (match) return nullptr; - match = guest.get(); - } - return match; - }; - const auto changed = [&](std::string text) { - ++bans_revision_; // cheap: admins only get a fresh list when it moved - save(); - roster_dirty_ = true; - if (!console) log_(text); // the console logs its own replies - return text; - }; - if (name.empty() || name == "help") return std::string(help_text); - if (name == "status") - return config_.name + " | " + map_name() + " | " + std::to_string(players()) + "/" + - std::to_string(config_.max_players) + " players | " + std::to_string(config_.tps) + " TPS | voice " + - (voice_policy_.allowed ? "on" : "off") + " (" + std::to_string(static_cast(config_.voice_range)) + - " m) | password " + (password_ ? "on" : "off") + " | code " + invite(); - if (name == "net") return network_report(argument, console); - if (name == "players") { - std::string text = std::to_string(players()) + " players"; - for (const auto &[id, guest] : guests_) - if (guest->handshaken) - text += "\n " + std::to_string(id) + " " + guest_name(*guest) + (is_admin(id) ? " (admin)" : ""); - return text; - } - if (name == "say") { - if (!console) return "Use chat to talk to everyone."; - if (argument.empty()) return "say "; - send_chat(argument); - return "[chat] Server: " + clean_chat_text(argument); - } - if (name == "msg" || name == "msg-party" || name == "msg-admins") { - // Direct messages from the console or an admin, marked "[DM from ...]" so nobody takes them for chat. - const auto *sender = console ? nullptr : find(admin); - const std::string from = sender ? guest_name(*sender) : "Server"; - const bool to_admins = name == "msg-admins"; - const auto [who, text] = to_admins ? std::pair{{}, trim(argument)} : split(argument); - if (text.empty()) return to_admins ? "msg-admins " : name + " "; - std::vector recipients; - std::string scope, label; - if (to_admins) { - scope = label = "admins"; - for (auto &[id, guest] : guests_) - if (guest->handshaken && is_admin(id)) recipients.push_back(guest.get()); - } else { - auto *guest = match_player(who); - if (!guest) return "No single connected player matches \"" + std::string(who) + "\"."; - label = guest_name(*guest); - if (name == "msg") { - recipients.push_back(guest); - } else { - const auto *details = parties_.party(parties_.party_of(guest->member.id)); - if (!details) return label + " is not in a party."; - scope = "party"; - label += "'s party"; - for (const auto member : details->members) - if (auto *found = find(member); found && found->handshaken) recipients.push_back(found); - } - } - if (recipients.empty()) return "No admins are online."; - const auto message = dm_line(from, scope, text, multiplayer_chat_max_bytes); - for (auto *guest : recipients) send_chat(message, guest); - const auto done = "Sent to " + label + (recipients.size() > 1 || to_admins ? " (" + std::to_string(recipients.size()) + " players)" : "") + "."; - if (!console) log_("[dm] " + from + " -> " + label + ": " + clean_chat_text(text)); - return done; - } - if (name == "kick") { - auto *guest = target(argument); - if (!guest || !guest->handshaken) return "No single connected player matches \"" + std::string(argument) + "\"."; - // Admins answer to the console, not to each other. - if (!console && is_admin(guest->member.id)) return "Admins cannot kick other admins."; - const auto label = guest_name(*guest); - kicked_.insert(guest->member.id); - drop(guest->member.id, "You were kicked from this server."); - return changed(label + " was kicked until the server restarts."); - } - if (name == "ban") { - auto [who, reason] = split(argument); - auto *guest = target(who); - std::uint64_t id = guest ? guest->member.id : number(who).value_or(0); - if (!individual_steam_id(id)) return "Enter a connected player or a SteamID64 (17 digits starting 7656119)."; - if (id == admin) return "You cannot ban yourself."; - if (!console && is_admin(id)) return "Admins cannot ban other admins."; - if (is_banned(id)) return std::to_string(id) + " is already banned."; - auto label = guest ? guest->member.name : clean_chat_text(reason); - cut_text(label, 64); - config_.bans.push_back({id, label, static_cast(std::time(nullptr))}); - if (guest) drop(id, "You were banned from this server."); - return changed((label.empty() ? std::to_string(id) : label) + " was banned."); - } - if (name == "unban") { - const auto id = number(argument).value_or(0); - const auto found = std::find_if(config_.bans.begin(), config_.bans.end(), [&](const auto &b) { return b.id == id; }); - if (found == config_.bans.end()) return "That SteamID64 is not banned."; - const auto label = found->name.empty() ? std::to_string(id) : found->name; - config_.bans.erase(found); - kicked_.erase(id); - return changed(label + " was unbanned."); - } - if (name == "bans") { - std::string text = std::to_string(config_.bans.size()) + " banned"; - for (const auto &ban : config_.bans) text += "\n " + std::to_string(ban.id) + " " + ban.name; - return text; - } - if (name == "map") { - // The map as it will be stored must still name a destination: one that does not would - // leave the server unable to tell players where to go, and unable to start again. - if (argument.empty() || !valid_map_destination(map_destination(argument)) || - !valid_map_destination(map_destination(map_setting(argument)))) - return "No single map is called \"" + std::string(argument) + "\". Type maps for the list."; - // Only a map the server has: the game's own, or one from a mod in its Mods folder. - if (!installed_map(argument)) - return "This server does not have that map. Put the map's mod folder in Mods next to the server, then restart it."; - if (map_hash(map_destination(argument)) == map_) return "The server is already on that map."; - change_map(argument); - save(); - return changed("Changing map to " + map_name()); - } - if (name == "maps") { - std::string text = std::to_string(levels().size()) + " maps (custom maps come from Mods next to the server)"; - for (const auto &level : levels()) - text += "\n " + level.name + (same_map(level.asset) ? " (now)" : "") + - (!config_.map_pool.empty() && in_map_pool(config_, level.asset) ? " (pool)" : ""); - return text; - } - if (name == "map-pool") { // map-pool [add|remove |clear] - const auto [what_text, map] = split(argument); - const auto what = lower(what_text); - if (what.empty()) return pool_text(); - if (what == "clear") { - config_.map_pool.clear(); - resend_maps(); - return changed("The map pool is cleared: players vote between every map, and the rotation goes through them all."); - } - if (what != "add" && what != "remove") return "map-pool [add|remove |clear]"; - const auto *level = find_level(map); - if (!level || !valid_map_destination(map_destination(level->asset))) - return "No single map is called \"" + std::string(map) + "\". Type maps for the list."; - const auto pooled = [&](const std::string &entry) { return find_level(entry) == level; }; - const bool listed = std::any_of(config_.map_pool.begin(), config_.map_pool.end(), pooled); - if (what == "add") { - if (listed || config_.map_pool.empty()) return level->name + " is already in the map pool."; - config_.map_pool.push_back(level->name); - } else { - if (config_.map_pool.empty()) // every map: keep all the others - for (const auto *other : pool_levels(config_)) config_.map_pool.push_back(other->name); - else if (!listed) return level->name + " is not in the map pool."; - if (std::all_of(config_.map_pool.begin(), config_.map_pool.end(), pooled)) - return "The map pool needs at least one map. map-pool clear allows every map again."; - std::erase_if(config_.map_pool, pooled); - } - resend_maps(); - return changed(level->name + (what == "add" ? " added to" : " removed from") + " the map pool."); - } - if (name == "rotation") { // rotation [|off] - if (argument.empty()) return rotation_text(); - const auto value = lower(argument); - const auto minutes = value == "off" ? std::optional(0) : number(value); - if (!minutes || *minutes > max_map_rotation) return "rotation <1-1440 minutes>|off"; - config_.map_rotation = static_cast(*minutes); - map_since_ = now_; - rotation_warned_ = false; - resend_maps(); - return changed(rotation_text()); - } - if (name == "name") { - if (!valid_server_name(argument)) return std::string("Server names are ") + server_name_rule + "."; - config_.name = argument; - if (text::contains_bad_words(config_.name)) - return changed("Server renamed to " + config_.name + - ". That name contains blocked words, so the server stays out of the server browser."); - return changed("Server renamed to " + config_.name + "."); - } - if (name == "password") { - if (argument.size() > 64) return "Passwords are at most 64 characters."; - config_.password = argument == "off" ? std::string{} : std::string(argument); - erase_key(password_); - password_ = config_.password.empty() ? std::nullopt : password_key(config_.password, secret_); - return changed(config_.password.empty() ? "Password removed. Anyone can join." - : "Password set. Players already here stay; new ones need it."); - } - if (name == "welcome") { - if (argument != "off" && !argument.empty() && !valid_chat_text(argument)) return "The welcome message is one chat line."; - config_.welcome = argument == "off" ? std::string{} : std::string(argument); - return changed(config_.welcome.empty() ? "Welcome message removed." : "Welcome message set."); - } - if (name == "chat-color" || name == "chat-colour") { - // chat-color <#badge> [<#text>]: the server's own lines in chat. - const auto [badge, text] = split(argument); - if (badge.empty() || !parse_colour(badge) || (!text.empty() && !parse_colour(text))) - return "chat-color <#RRGGBB badge> [<#RRGGBB text>] (now " + config_.chat_color + " " + config_.chat_text_color + ")"; - config_.chat_color = std::string(badge); - if (!text.empty()) config_.chat_text_color = std::string(text); - return changed("The server's chat lines are " + config_.chat_color + " with " + config_.chat_text_color + " text."); - } - if (name == "announce-throwdowns") { - const auto value = on_off(argument); - if (!value) return std::string("announce-throwdowns on|off (now ") + (config_.announce_throwdowns ? "on" : "off") + ")"; - config_.announce_throwdowns = *value; - return changed(*value ? "Placed throwdowns are announced in chat." : "Placed throwdowns are no longer announced."); - } - if (name == "parties") { - const auto value = on_off(argument); - if (argument.empty()) return std::string(config_.parties ? "Parties are on.\n" : "Parties are off.\n") + party_status(0); - if (!value) return "parties on|off"; - config_.parties = *value; - if (!*value) { - for (auto &[id, guest] : guests_) parties_.remove(id); - parties_.take_withdrawn(); - } - return changed(*value ? "Players can form parties." : "Parties are off; every party was ended."); - } - if (name == "speed-check") { - const auto value = lower(argument); - if (value != "off" && value != "warn" && value != "kick") - return "speed-check off|warn|kick (now " + config_.speed_check + ")"; - config_.speed_check = value; - if (value == "off") - for (auto &[id, guest] : guests_) { - guest->speed.restart(); - guest->speeding = false; - } - return changed(value == "off" ? "Game speed is no longer checked." - : value == "kick" ? "Players whose game runs fast are kicked." - : "Players whose game runs fast are taken out of throwdowns and challenges."); - } - if (name == "score-check") { - const auto value = lower(argument); - if (value.empty()) { - std::string text = "score-check " + config_.score_check + " (off|warn|kick)"; - for (const auto &[id, guest] : guests_) { - if (!guest->handshaken) continue; - text += "\n " + guest_name(*guest) + ": "; - if (!guest->scoring) text += "not reported"; - else if (!*guest->scoring) text += "the game's own scoring"; - else - text += scoring_text(*guest->scoring) + (guest->scoring_mods.empty() ? "" : " (" + guest->scoring_mods + ")") + - (guest->scoring_flagged ? ", out of throwdowns" : ", allowed"); - } - return text; - } - if (value != "off" && value != "warn" && value != "kick") return "score-check off|warn|kick (now " + config_.score_check + ")"; - config_.score_check = value; - // Kicking changes the guest list: collect first. - std::vector ids; - for (const auto &[id, guest] : guests_) ids.push_back(id); - for (const auto id : ids) - if (auto *guest = find(id)) check_scoring(*guest); - return changed(value == "off" ? "Mods that change scoring or physics are no longer checked." - : value == "kick" ? "Players whose mods change scoring or physics are kicked." - : "Players whose mods change scoring or physics are taken out of throwdowns and challenges."); - } - if (name == "score-allow") { - auto [what, rest] = split(argument); - if (what.empty()) { - std::string text = "score-allow | score-allow remove . Accepted besides the game's own:"; - if (config_.score_allow.empty()) text += " none"; - for (const auto fingerprint : config_.score_allow) text += "\n " + scoring_text(fingerprint); - return text; - } - const bool remove = lower(what) == "remove"; - const auto fingerprint = parse_scoring(remove ? rest : what); - if (!fingerprint) return "A fingerprint is 16 hex digits, as score-check lists it."; - const auto found = std::find(config_.score_allow.begin(), config_.score_allow.end(), *fingerprint); - if (remove) { - if (found == config_.score_allow.end()) return scoring_text(*fingerprint) + " was not accepted."; - config_.score_allow.erase(found); - } else if (found == config_.score_allow.end()) { - config_.score_allow.push_back(*fingerprint); - } - std::vector ids; - for (const auto &[id, guest] : guests_) ids.push_back(id); - for (const auto id : ids) - if (auto *guest = find(id)) check_scoring(*guest); - return changed(remove ? "Scoring " + scoring_text(*fingerprint) + " is no longer accepted." - : "Scoring " + scoring_text(*fingerprint) + " is accepted like the game's own."); - } - if (name == "rate") { - // rate : what the server may send each player, from now on and to those on. - const auto value = number(argument); - if (!value || *value < 128 || *value > 16384) - return "rate <128-16384> (KB/s for each player, now " + std::to_string(config_.send_rate) + ")"; - config_.send_rate = static_cast(*value); - const bool applied = transport_.set_send_rate(static_cast(config_.send_rate * 1024)); - return changed("Each player is sent at most " + std::to_string(config_.send_rate) + " KB/s" + - (applied ? "." : ": players who join from now on. Steam did not change the connections already open.")); - } - if (name == "bone-scale") { - // bone-scale <1-8>|off: how far a mod may resize part of a skater for the other players. - float value{}; - const bool off = argument == "off" || argument == "0"; - const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value); - if (!off && (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !(value >= 1.f && value <= 8.f))) - return "bone-scale <1-8>|off: 1 shows every skater at the game's own proportions, off allows anything (now " + - (config_.bone_scale_limit >= 1.f ? std::to_string(config_.bone_scale_limit).substr(0, 4) : std::string("off")) + ")"; - config_.bone_scale_limit = off ? 0.f : value; - // Whole states go again so that nobody keeps a reference with the old sizes in it. - for (auto &[id, guest] : guests_) - for (const auto &[other, unused] : guests_) guest->sender.forget(other, PacketKind::pose); - return changed(off ? std::string("Mods may resize skaters' body parts freely.") - : value == 1.f ? std::string("Skaters show at the game's own proportions: resized body parts are not passed on.") - : "Resized body parts show at up to " + std::to_string(value).substr(0, 4) + "x."); - } - if (name == "crowd") { - // crowd |off: the most one player is sent (crowd_limits). - auto value = number(argument); - if (argument == "off") value = 0; - if (!value || *value > max_crowd_budget || !valid_crowd_budget(static_cast(*value))) - return "crowd <" + std::to_string(min_crowd_budget) + "-" + std::to_string(max_crowd_budget) + ">|off (now " + - (config_.crowd_budget ? std::to_string(config_.crowd_budget) : std::string("off")) + ")"; - config_.crowd_budget = static_cast(*value); - next_crowd_ = 0; - return changed(*value ? "Each player is sent at most " + std::to_string(*value) + " poses a second: about " + - std::to_string(*value / config_.tps) + " players near them at the full rate." - : std::string("No crowd limit: every player near is sent at the full rate.")); - } - if (name == "party-size") { - const auto value = number(argument); - if (!value || *value < 2 || *value > 8) return "party-size <2-8> (now " + std::to_string(config_.party_size) + ")"; - config_.party_size = static_cast(*value); - parties_.set_limit(config_.party_size); - return changed("Parties hold up to " + std::to_string(config_.party_size) + " players. Larger ones stay until members leave."); - } - if (name == "activity-log") { - const auto value = on_off(argument); - if (!value) return std::string("activity-log on|off (now ") + (config_.activity_log ? "on" : "off") + ")"; - config_.activity_log = *value; - if (!*value) activity_.clear(); - return changed(*value ? "Player activity (throwdowns, objects, loading) is logged." - : "Player activity is no longer logged."); - } - if (name == "listed") { - const auto value = on_off(argument); - if (!value) return "listed on|off"; - config_.listed = *value; - return changed(*value ? "The server is listed in the server browser." : "The server is hidden; players need the code."); - } - if (name == "tps") { - return "Dedicated servers run at " + std::to_string(dedicated_tps) + " TPS for now; it cannot be changed."; - } - if (name == "voice") { - const auto value = on_off(argument); - if (!value) return "voice on|off"; - config_.voice_chat = *value; - if (voice_policy_.allowed != *value) { - voice_policy_.allowed = *value; - if (!++voice_policy_.revision) ++voice_policy_.revision; - } - return changed(*value ? "Voice chat allowed." : "Voice chat disabled for everyone."); - } - if (name == "voice-range") { - float range{}; - const auto result = std::from_chars(argument.data(), argument.data() + argument.size(), range); - if (result.ec != std::errc{} || result.ptr != argument.data() + argument.size() || !valid_voice_range(range)) - return "Choose a voice range from 50 to 1000 m."; - config_.voice_range = range; - return changed("Voice range set to " + std::to_string(static_cast(range)) + " m."); - } - if (name == "distances") { - MultiplayerDistances value; - auto rest = argument; - for (auto *field : {&value.full_rate_return, &value.half_rate_start, &value.half_rate_return, &value.low_rate_start}) { - const auto [token, remaining] = split(rest); - const auto result = std::from_chars(token.data(), token.data() + token.size(), *field); - if (token.empty() || result.ec != std::errc{} || result.ptr != token.data() + token.size()) - return "distances (whole metres)"; - rest = remaining; - } - if (!rest.empty() || !value.valid()) - return "Use ordered distances: full < half <= half-return < low (at most 10000 m)."; - config_.distances = value; - for (auto &[id, guest] : guests_) guest->pose_delivery = {}; - return changed("TPS distances updated."); - } - if (name == "placement") { - // The protocol's "host only" is admins only here: the server has no skater of its own. - const auto policy = parse_object_placement(argument == "admins" ? "host" : argument, config_.object_placement); - if (!policy) return "placement everyone|admins|nobody"; - config_.object_placement = *policy; - return changed(*policy == ObjectPlacement::everyone ? "Everyone can place objects." - : *policy == ObjectPlacement::host_only ? "Only admins can place objects. Everyone else's are frozen." - : "Object placement is off. Existing objects stay."); - } - if (name == "objects") { - const auto limit = parse_object_limit(argument); - if (!limit) return "objects <1-" + std::to_string(max_object_limit) + ">|off"; - config_.object_limit = *limit; - for (auto &[id, guest] : guests_) guest->shared_from = 0; // look at every layout again - return changed(*limit ? "Each player can place up to " + std::to_string(*limit) + " objects. Admins are not limited." - : std::string("Players can place as many objects as they like.")); - } - if (name == "votes") { - // votes | votes on|off| | votes seconds|cooldown - const auto [what_text, value_text] = split(argument); - const auto what = lower(what_text); - const auto describe = [&](const char *label, const VoteSetting &v) { - return std::string(label) + ": " + (v.enabled ? "on, " + std::to_string(v.percent) + "% to pass" : "off"); - }; - if (what.empty()) - return describe("map votes", config_.votes.map) + "\n" + describe("kick votes", config_.votes.kick) + "\n" + - describe("time of day votes", config_.votes.time) + - (config_.world_layer_sync ? "" : " (needs layer-sync on)") + "\nvotes last " + - std::to_string(config_.votes.seconds) + " s; a player waits " + std::to_string(config_.votes.cooldown) + - " s between votes" + (vote_ ? "\nrunning: a vote to " + vote_->label : std::string{}); - const auto value = lower(value_text); - if (what == "seconds" || what == "cooldown") { - const auto n = number(value); - const bool seconds = what == "seconds"; - if (!n || (seconds ? *n < 10 || *n > 300 : *n > 3600)) - return seconds ? "votes seconds <10-300>" : "votes cooldown <0-3600>"; - (seconds ? config_.votes.seconds : config_.votes.cooldown) = static_cast(*n); - return changed(seconds ? "Votes now last " + std::to_string(*n) + " s." - : "Players now wait " + std::to_string(*n) + " s between votes."); - } - VoteSetting *setting = what == "map" ? &config_.votes.map : what == "kick" ? &config_.votes.kick - : what == "tod" || what == "time" ? &config_.votes.time : nullptr; - if (!setting) return "votes [map|kick|tod on|off|] | votes seconds | votes cooldown "; - const auto label = what == "map" ? std::string("Map votes") : what == "kick" ? std::string("Kick votes") - : std::string("Time of day votes"); - if (const auto toggle = on_off(value)) { - setting->enabled = *toggle; - if (!*toggle && vote_ && vote_setting(vote_->kind).enabled == false) cancel_vote("that vote was switched off"); - return changed(label + (*toggle ? " are on (" + std::to_string(setting->percent) + "% to pass)." : " are off.")); - } - const auto percent = number(value.ends_with("%") ? std::string_view(value).substr(0, value.size() - 1) : std::string_view(value)); - if (!percent || *percent < 1 || *percent > 100) return "votes " + what + " on|off|<1-100>"; - setting->percent = static_cast(*percent); - return changed(label + " now need " + std::to_string(*percent) + "% to pass."); - } - if (name == "vote-cancel") { - if (!vote_) return "No vote is running."; - cancel_vote(console ? "the server cancelled it" : "an admin cancelled it"); - return "Vote cancelled."; - } - if (name == "tpall" || name == "tphere") { - // Where they go: the admin who asked, or (tpall from the console) the named player. - Guest *to{}; - std::vector movers; - if (name == "tpall") { - to = argument.empty() ? (console ? nullptr : find(admin)) : target(argument); - if (!to || !to->handshaken) - return console && argument.empty() ? "tpall : everyone goes to that player." - : "No single connected player matches \"" + std::string(argument) + "\"."; - for (auto &[id, guest] : guests_) - if (guest->handshaken && guest->world_ready && guest.get() != to) movers.push_back(guest.get()); - } else { - if (console) return "tphere is for admins in the game; the console can use tpall ."; - to = find(admin); - auto *who = target(argument); - if (!who || !who->handshaken) return "No single connected player matches \"" + std::string(argument) + "\"."; - if (who == to) return "That is you."; - movers.push_back(who); - } - if (!to || !to->latest_root) return "There is no position for " + (to ? guest_name(*to) : std::string("you")) + " yet."; - if (movers.empty()) return "Nobody else is in the world."; - const auto at = to->latest_root->position; - unsigned sent{}; - for (std::size_t i = 0; i < movers.size(); ++i) { - // A ring around them, so nobody lands inside anyone else. - const float angle = 6.2831853f * static_cast(i) / static_cast(movers.size()); - auto p = packet(PacketKind::teleport, now_); - p.teleport = {at[0] + 2.5f * std::cos(angle), at[1] + 1.0f, at[2] + 2.5f * std::sin(angle)}; - if (send_packet(*movers[i], p, true, false)) ++sent; - } - const auto text = movers.size() == 1 && sent ? guest_name(*movers[0]) + " was teleported to " + guest_name(*to) + "." - : std::to_string(sent) + " player(s) teleported to " + guest_name(*to) + "."; - if (!console) log_(text); - return text; - } - if (name == "noclip" || name == "nobail" || name == "boosts") { - auto &allowed = name == "noclip" ? config_.noclip : name == "nobail" ? config_.no_bail : config_.boosts; - const auto value = argument == "toggle" ? std::optional(!allowed) : on_off(argument); - if (!value) return name + " on|off (now " + (allowed ? "on" : "off") + ")"; - allowed = *value; - const std::string tool = name == "noclip" ? "Noclip and teleporting" : name == "nobail" ? "No Bail" : "Boosts"; - return changed(tool + (*value ? (name == "boosts" ? " are" : " is") + std::string(" allowed for everyone.") - : (name == "boosts" ? " are" : " is") + std::string(" off for players; admins keep it."))); - } - if (name == "tuning") { - const auto value = argument == "toggle" ? std::optional(!config_.enforce_tuning) : on_off(argument); - if (!value) return std::string("tuning on|off (now ") + (config_.enforce_tuning ? "on" : "off") + ")"; - config_.enforce_tuning = *value; - return changed(*value ? "Players skate with the game's own physics tuning." - : "Players skate with their own physics tuning."); - } - if (name == "clear-objects") { - std::size_t removed{}; - for (auto &[id, guest] : guests_) { - if (!guest->handshaken) continue; - for (const auto *state : {&guest->objects, &guest->shared}) - for (const auto &[object, value] : state->objects()) { - (void)value; - guest->cleared.insert(object); - } - removed += guest->shared.objects().size(); - if (guest->shared.revision()) guest->shared.replace({}); - guest->shared_from = guest->objects.revision(); - } - ++object_clears_; - return changed("Deleted " + std::to_string(removed) + " placed object" + (removed == 1 ? "." : "s.")); - } - if (name == "park") { - if (lower(argument) == "random") { - config_.parks = random_park_choices(); - return changed("Random layouts selected for every park slot."); - } - const auto [lot_name, layout] = split(argument); - const auto lot = std::find_if(park_lots.begin(), park_lots.end(), [&](const auto &l) { return l.key == lot_name; }); - if (lot == park_lots.end()) return "park construction|historic|financial "; - const auto index = static_cast(lot - park_lots.begin()); - if (layout.empty() || !valid_park(index, layout)) return "That is not a layout for this lot."; - config_.parks[index] = layout; - return changed(std::string(lot->label) + " now shows " + park_label(layout) + "."); - } - if ((name == "layer-sync" || name == "layer" || name == "layers" || name == "tod") && world_layers().empty()) - return "World layers need world-layers.json next to the server (copy it from a player's " - "%LOCALAPPDATA%\\ReSkate\\cache folder for the same game build)."; - if (name == "layer-sync") { - const auto value = on_off(argument); - if (!value) return "layer-sync on|off"; - config_.world_layer_sync = *value; - apply_layers(); - return changed(*value ? "Everyone now follows the server's world layers." : "Players choose their own world layers."); - } - if (name == "layers") { - // Several at once, as key=mode pairs: the in-game time of day sends seven. - std::vector> changes; - for (auto rest = argument; !rest.empty();) { - const auto [pair, remaining] = split(rest); - rest = remaining; - const auto equals = pair.find('='); - if (equals == std::string_view::npos) return "layers =default|on|off ..."; - const auto key = pair.substr(0, equals), mode = pair.substr(equals + 1); - if (std::none_of(world_layers().begin(), world_layers().end(), [&](const auto &l) { return l.key == key; })) - return "No world layer is called \"" + std::string(key) + "\"."; - if (!valid_world_layer_mode(mode)) return "layers =default|on|off ..."; - changes.emplace_back(key, mode); - } - if (changes.empty()) return "layers =default|on|off ..."; - for (const auto &[key, mode] : changes) { - if (mode == "default") config_.layers.erase(key); - else config_.layers[key] = mode; - } - apply_layers(); - return changed(std::to_string(changes.size()) + " world layer" + (changes.size() == 1 ? "" : "s") + " changed" + - (config_.world_layer_sync ? "." : ". Turn on layer-sync to apply them to everyone.")); - } - if (name == "tod") { - // Every map's seven time layers ("_tod__"): one on and the rest off, or - // all back to the level's own. Set for every map, so it holds across map changes. - static constexpr std::array times{"default", "morning", "noon", "afternoon", - "evening", "night", "weatherday", "weathernight"}; - const auto wanted = lower(argument); - const auto found = std::find(times.begin(), times.end(), wanted); - if (found == times.end()) return "tod default|morning|noon|afternoon|evening|night|weatherday|weathernight"; - const auto slot = static_cast('0' + (found - times.begin())); - unsigned count{}; - for (const auto &layer : world_layers()) { - const auto at = layer.key.find("_tod_"); - if (at == std::string::npos || at + 5 >= layer.key.size()) continue; - if (slot == '0') config_.layers.erase(layer.key); - else config_.layers[layer.key] = layer.key[at + 5] == slot ? "on" : "off"; - ++count; - } - if (!count) return "world-layers.json has no time-of-day layers."; - apply_layers(); - return changed("Time of day set to " + std::string(*found) + - (config_.world_layer_sync ? " for everyone." : ". Turn on layer-sync to apply it to everyone.")); - } - if (name == "layer") { - const auto [key, mode] = split(argument); - const auto found = std::find_if(world_layers().begin(), world_layers().end(), [&](const auto &l) { return l.key == key; }); - if (found == world_layers().end()) return "No world layer is called \"" + std::string(key) + "\"."; - if (!valid_world_layer_mode(mode)) return "layer default|on|off"; - if (mode == "default") config_.layers.erase(std::string(key)); - else config_.layers[std::string(key)] = mode; - apply_layers(); - return changed(found->label + " set to " + std::string(mode) + - (config_.world_layer_sync ? "." : ". Turn on layer-sync to apply it to everyone.")); - } - if (name == "reserved") { - // reserved | reserved add|remove - if (!console) return "Only the server console manages reserved slots."; - const auto [sub, who] = split(argument); - if (sub.empty()) { - std::string text = std::to_string(extra_slots(config_)) + " extra slots beyond the " + std::to_string(config_.max_players) + - ": the admins and these players can join when the server is full"; - for (const auto id : config_.reserved) { - const auto *guest = find(id); - text += "\n " + std::to_string(id) + (guest ? " " + guest_name(*guest) : std::string{}); - } - return text; - } - auto *guest = target(who); - const auto id = guest ? guest->member.id : number(who).value_or(0); - if (!individual_steam_id(id) || (sub != "add" && sub != "remove")) return "reserved | reserved add|remove "; - const bool listed = std::find(config_.reserved.begin(), config_.reserved.end(), id) != config_.reserved.end(); - if (sub == "add") { - if (!listed && config_.reserved.size() >= 1024) return "The reserved list is full."; - if (!listed) config_.reserved.push_back(id); - return changed(std::to_string(id) + " has a reserved slot: they can join when the server is full."); - } - std::erase(config_.reserved, id); - return changed(std::to_string(id) + " no longer has a reserved slot."); - } - if (name == "admins" || name == "admin") { - if (!console) return "Only the server console manages admins."; - const auto [sub, who] = split(argument); - if (name == "admins" || sub.empty()) { - std::string text = std::to_string(config_.admins.size()) + " admins"; - for (const auto id : config_.admins) { - const auto *guest = find(id); - text += "\n " + std::to_string(id) + (guest ? " " + guest_name(*guest) : std::string{}); - } - return text; - } - auto *guest = target(who); - const auto id = guest ? guest->member.id : number(who).value_or(0); - if (!individual_steam_id(id)) return "admin add|remove "; - if (sub == "add") { - if (!is_admin(id)) config_.admins.push_back(id); - resend_maps(); - return changed(std::to_string(id) + " is an admin."); - } - if (sub == "remove") { - std::erase(config_.admins, id); - resend_maps(); - return changed(std::to_string(id) + " is no longer an admin."); - } - return "admin add|remove "; - } - return "Unknown command \"" + std::string(action) + "\". Type help."; -} -// A player's mods change how tricks score (their report; Engine/Vfs/mod_scoring.h): flagged -// players are taken out of linked throwdowns and coop challenges (the roster's scoring flag, and -// the server relays none of theirs), or kicked. The game keeps a scoring mod's points until it -// restarts, so the flag lasts for the session. -void Host::check_scoring(Guest &guest) { - if (!guest.handshaken) return; - const bool changed = guest.scoring && *guest.scoring && - std::find(config_.score_allow.begin(), config_.score_allow.end(), *guest.scoring) == config_.score_allow.end(); - const auto name = guest_name(guest); - if (config_.score_check == "off" || !changed) { - if (!guest.scoring_flagged) return; - guest.scoring_flagged = false; - roster_dirty_ = true; - log_("[anticheat] " + name + " may take part in throwdowns again (score-check " + config_.score_check + ")."); - return; - } - if (guest.scoring_flagged) return; - const auto mods = guest.scoring_mods.empty() ? std::string("their mods") : guest.scoring_mods; - log_("[anticheat] " + name + "'s mods change scoring or physics: " + mods + " (scoring " + scoring_text(*guest.scoring) + ")."); - if (config_.score_check == "kick") - return drop(guest.member.id, "Your mods change scoring or physics (" + mods + "). Turn them off and restart Skate to play here."); - // Every player's game says so in chat when the roster flags someone, the player themself included. - guest.scoring_flagged = true; - roster_dirty_ = true; -} -// A speedhack runs the player's game clock, and so their pose timestamps, faster than real -// time. Flagged players are taken out of linked throwdowns and coop challenges (the roster's -// speeding flag: every client drops them from those), or kicked; the flag clears after a minute -// of normal speed. -bool Host::check_speed(Guest &guest, std::uint64_t sent) { - if (config_.speed_check == "off" || !guest.handshaken || !guest.world_ready || !sent) return true; - if (!guest.speed.sample(sent, now_)) return true; - const auto name = guest_name(guest); - const auto speed = guest.speed.speed(); - if (guest.speed.flagged() && !guest.speeding) { - char text[160]; - std::snprintf(text, sizeof text, "%s's game is running at %.2fx speed (a speed hack?).", name.c_str(), speed); - log_(std::string("[anticheat] ") + text); - if (config_.speed_check == "kick") { - drop(guest.member.id, "Your game is running faster than normal. Turn off speed hacks to play here."); - return false; - } - guest.speeding = true; - guest.speed_normal_since = 0; - roster_dirty_ = true; - send_chat("The server measured your game running faster than normal: throwdowns and challenges are off for you until it's back to normal speed.", &guest); - // Only the player is told in the game; for the admins it is in the log. - return true; - } - if (!guest.speeding) return true; - if (speed < SpeedCheck::limit) { - if (!guest.speed_normal_since) guest.speed_normal_since = now_; - if (now_ - guest.speed_normal_since >= 60000000) { - guest.speeding = false; - roster_dirty_ = true; - log_("[anticheat] " + name + "'s game speed is back to normal."); - send_chat("Your game speed is back to normal: throwdowns and challenges are on again.", &guest); - } - } else guest.speed_normal_since = 0; - return true; -} } // namespace dingosdk::server diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 2ab1b2da..fafb13d5 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -174,7 +174,7 @@ add_executable(dingosdk_server Server/main.cpp Server/server_host.cpp Server/ser Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp - Server/server_activity.cpp Server/server_votes.cpp Extension/Throwdowns/throwdown_wire.cpp) + Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp Extension/Throwdowns/throwdown_wire.cpp) target_include_directories(dingosdk_server SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") if(WIN32) target_link_libraries(dingosdk_server PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json