#include "server_host.h" #include "server_words.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 #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 [minutes|restart] | kick-time |restart | ban [name] | unban | bans\n" "map | maps [reload] | name | password | welcome | listed on|off\n" "voice on|off | voice-range <50-1000> | distances | crowd |off | rate | bone-scale <1-8>|off | bone-reach <0.5-20>|off\n" "placement everyone|admins|nobody | objects |off | object-scaling on|off | effects on|off | drones on|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" "tpall [player] | tphere | teleport \n" "votes [ on|off||seconds|cooldown|min-players ] | vote-cancel\n" "votes polls off|admins|everyone | votes poll-seconds | votes starter-yes on|off\n" "vote > [argument] | poll | | ... | poll end\n" "poll-run | | | ...\n" "announce | announce-to | announcements [list|add |remove |clear|interval |off]\n" "map-pool [add|remove |clear] | rotation [|off | vote on|off | stay |off | announce on|off | extend [minutes]]\n" "park | park random | layer-sync on|off | layer default|on|off | tod \n" "park-mod list | park-mod add [...] [minutes] | park-mod remove [...] | park-mod clear | park-mod reload [...]\n" "park-mod config add | | [| ] | park-mod config remove \n" "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | afk-kick |off | speed-check off|warn|kick | board-check off|warn|kick\n" "score-check [off|warn|kick] | score-allow [|remove ] | words [reload | global on|off]\n" "reserved [slots | add|remove ] | roles [reload] | role |none | 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(canonical_command(verb)); // From a player's game: what their role gives them (roles.h). The console may do anything. if (!console && !can(admin, name)) { const auto *role = role_of(admin); return staff(role) ? "Your role (" + role->name + ") cannot use " + (name.empty() ? std::string("that") : name) + "." : std::string("You are not an admin on this server."); } // Nobody acts on a player whose rank is theirs or above, or hands out a role that is. const auto outranks = [&](std::uint64_t other) { return console || rank(admin) > rank(other); }; 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) + (role_of(id) ? " (" + role_of(id)->name + ")" : std::string{}); 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_staff(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") { // "kick [minutes|restart]". A last word that is a time is one only when what // comes before it is the player: the whole text is tried as a name first, so "Player // 1234" is never a player and a time. After a SteamID64 the in-game menu sends the // player's session epoch, a number far above any time, and the server window a reason. auto *guest = target(argument); std::optional minutes; if (const auto space = argument.rfind(' '); space != std::string_view::npos) { const auto word = lower(trim(argument.substr(space + 1))); const auto value = word == "restart" ? std::optional(0) : number(word); const bool by_id = number(split(argument).first).has_value(); if (value && (by_id || !guest || !guest->handshaken)) { auto *named = target(trim(argument.substr(0, space))); if (named && named->handshaken && *value <= 1440) { guest = named; minutes = static_cast(*value); } else if (named && named->handshaken && !by_id) { return "kick [minutes 1-1440|restart]"; } } } if (!guest || !guest->handshaken) return "No single connected player matches \"" + std::string(argument) + "\"."; if (!outranks(guest->member.id)) return "You cannot kick a player of your rank or above."; const auto label = guest_name(*guest); const auto how_long = keep_out(guest->member.id, minutes); drop(guest->member.id, "You were kicked from this server" + how_long + "."); return changed(label + " was kicked" + how_long + "."); } if (name == "kick-time") { const auto word = lower(argument); const auto value = word == "restart" || word == "off" ? std::optional(0) : number(argument); if (!value || *value > 1440) return "kick-time |restart (now " + (config_.kick_minutes ? std::to_string(config_.kick_minutes) + " min" : std::string("until the server restarts")) + ")"; config_.kick_minutes = static_cast(*value); return changed(config_.kick_minutes ? "Kicked players can join again after " + std::to_string(config_.kick_minutes) + " min. Players already kicked keep the time they were given." : std::string("Kicked players stay out until the server restarts. Players already kicked keep the time they were given.")); } 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 (!outranks(id)) return "You cannot ban a player of your rank or above."; if (is_banned(id)) return std::to_string(id) + " is already banned."; auto label = guest ? guest->member.name : clean_chat_text(reason); // Banned by SteamID after they left: the name they were last here under. if (const auto seen = seen_names_.find(id); label.empty() && seen != seen_names_.end()) label = seen->second; 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" && lower(argument) == "reload") { // A map mod copied into Mods while the server runs: its maps can be switched to at once. if (!console && !can(admin, "map")) return "Your role cannot reload the maps."; const auto text = reload_maps(); if (!console) log_(text); return text; } if (name == "maps" && lower(split(argument).first) == "update") { // Now, whatever "maps latest" is: Thunderstore is asked about each map mod (server_map_update.h). if (!console && !can(admin, "map")) return "Your role cannot update the maps."; map_update_requested_ = true; return std::string("Asking Thunderstore for the newest version of each of this server's maps; what it finds is in the log."); } if (name == "maps" && lower(split(argument).first) == "latest") { // maps latest [on|off | every ] if (!console && !can(admin, "map")) return "Your role cannot change how the maps are updated."; const auto [word, wanted] = split(split(argument).second); const auto now_text = std::string("maps latest on|off | maps latest every <5-1440 minutes> (now ") + (config_.latest_versions ? "on" : "off") + ", every " + std::to_string(config_.latest_check_minutes) + " min)"; if (lower(word) == "every") { const auto minutes = number(wanted); if (!minutes || *minutes < 5 || *minutes > 1440) return now_text; config_.latest_check_minutes = static_cast(*minutes); return changed("Thunderstore is asked about this server's maps every " + std::to_string(*minutes) + " minutes."); } const auto on = on_off(word); if (!on) return now_text; config_.latest_versions = *on; if (*on) map_update_requested_ = true; return changed(*on ? "This server keeps its maps at Thunderstore's newest version, and players are held to it." : "This server's maps stay at the versions in its Mods folder."); } 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 | vote on|off | stay |off | announce on|off | extend [minutes]] if (argument.empty()) return rotation_text(); const auto [setting, wanted] = split(argument); const auto which = lower(setting); if (which == "vote" || which == "announce") { const auto on = on_off(wanted); if (!on) return "rotation " + which + " on|off (now " + ((which == "vote" ? config_.rotation_vote : config_.rotation_announce) ? "on" : "off") + ")"; (which == "vote" ? config_.rotation_vote : config_.rotation_announce) = *on; return changed(which == "vote" ? (*on ? "When a map's time is up, players vote for the next one." : "The rotation goes to the pool's next map without a vote.") : (*on ? "Players are told when the map has 10, 5 and 1 minutes left." : "The minutes a map has left are no longer announced.")); } if (which == "stay") { // what the vote's "Stay here" adds const auto length = lower(wanted) == "off" ? std::optional(0) : number(wanted); if (!length || *length > 240) return "rotation stay <1-240 minutes>|off (now " + std::to_string(config_.rotation_extend) + ")"; config_.rotation_extend = static_cast(*length); return changed(*length ? "The vote for the next map offers staying " + std::to_string(*length) + " more minutes." : "The vote for the next map no longer offers staying."); } if (which == "extend") { // now: the map in play gets that much longer ("/vote extend" runs it) if (!config_.map_rotation) return std::string("Map rotation is off: there is no time to add to."); const auto length = wanted.empty() ? std::optional(config_.rotation_extend ? config_.rotation_extend : 15) : number(wanted); if (!length || !*length || *length > 240) return std::string("rotation extend [1-240 minutes]"); const auto due = map_since_ + std::uint64_t{config_.map_rotation} * 60000000; const auto left = due > now_ ? (due - now_) / 60000000 : 0; extend_map(static_cast(std::min(left + *length, 1440))); if (vote_ && vote_->next_map) cancel_vote("the map was given more time"); if (!next_maps_.empty()) next_maps_.clear(), ++next_maps_revision_; send_chat(map_name() + " stays " + std::to_string(*length) + " more minutes."); 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 == "board-check") { const auto value = lower(argument); if (value != "off" && value != "warn" && value != "kick") return "board-check off|warn|kick (now " + config_.board_check + ")"; config_.board_check = value; if (value == "off") for (auto &[id, guest] : guests_) { guest->board.restart(); guest->board_hidden = false; } return changed(value == "off" ? "Skateboards kept on other players are no longer checked for." : value == "kick" ? "Players who keep their skateboard on another player are kicked." : "A skateboard kept on another player is hidden from the others, and the admins are told."); } 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 == "bone-reach") { const bool off = argument == "off"; float value{}; 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 >= .5f && value <= 20.f))) return "bone-reach <0.5-20>|off: how many metres a bone of a skater may be from the one it hangs from (now " + (config_.bone_reach_limit > 0.f ? std::to_string(config_.bone_reach_limit).substr(0, 4) : std::string("off")) + ")"; config_.bone_reach_limit = off ? 0.f : value; // Whole states go again so that nobody keeps a reference with the old places in it. for (auto &[id, guest] : guests_) for (const auto &[other, unused] : guests_) guest->sender.forget(other, PacketKind::pose); return changed(off ? std::string("Skaters' bones may be moved any distance.") : "Skaters' bones show at most " + std::to_string(value).substr(0, 4) + " m from where they hang."); } 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 == "afk-kick") { const auto value = lower(argument) == "off" ? std::optional(0) : number(argument); if (!value || *value > 1440) return "afk-kick |off (now " + (config_.afk_kick ? std::to_string(config_.afk_kick) + " min" : std::string("off")) + ")"; config_.afk_kick = static_cast(*value); // Nobody is removed for time away before the rule was set. for (auto &[id, guest] : guests_) if (guest->active_at) active(*guest); return changed(config_.afk_kick ? "Players away for " + std::to_string(config_.afk_kick) + " min are removed. Admins are not." : std::string("Players are no longer removed for being away.")); } 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 == "words") { // This server's own chat words (server_words.h): the files are read again each time. const auto [what, setting] = split(argument); const auto global = what == "global" ? on_off(setting) : std::nullopt; if (!what.empty() && what != "reload" && !global) return std::string("words | words reload | words global on|off"); if (global) config_.global_words = *global; auto read = use_own_words(config_); if (read.sent != filtered_words_) { filtered_words_ = std::move(read.sent); ++words_revision_; // every player is sent them again (tick) } read.sent = filtered_words_; const auto now = own_words_text(config_, read); return global ? changed(now) : now; } 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 == "object-scaling") { const auto value = on_off(argument); if (!value) return std::string("object-scaling on|off (now ") + (config_.object_scaling ? "on" : "off") + ")"; config_.object_scaling = *value; for (auto &[id, guest] : guests_) guest->shared_from = 0; // look at every layout again return changed(*value ? "Players can resize the objects they place." : "Placed objects are their own size for everyone. Admins can still resize theirs."); } if (name == "effects") { const auto value = on_off(argument); if (!value) return std::string("effects on|off (now ") + (config_.sync_effects ? "on" : "off") + ")"; config_.sync_effects = *value; return changed(*value ? "Players see each other's skater effects." : "Players no longer see each other's skater effects. Ones already showing stay until that player's skater is shown again."); } if (name == "drones") { const auto value = on_off(argument); if (!value) return std::string("drones on|off (now ") + (config_.sync_drones ? "on" : "off") + ")"; config_.sync_drones = *value; return changed(*value ? "Players see each other's FPV drones." : "Players no longer see each other's FPV drones."); } if (name == "votes") { const auto [text, did] = votes_command(argument); return did ? changed(text) : text; } if (name == "announcements") { const auto [text, did] = announcements_command(argument); return did ? changed(text) : text; } if (name == "announce") { if (argument.empty()) return "announce "; announce(argument); return "Announced."; } if (name == "announce-to") { const auto [who, text] = split(argument); if (text.empty()) return "announce-to "; auto *guest = match_player(who); if (!guest) return "No single connected player matches \"" + std::string(who) + "\"."; announce(text, guest->member.id); return "Announced to " + guest_name(*guest) + "."; } 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 == "vote") { // A vote started by the server: the same card and checks as /vote, without a cooldown or a vote of its own. Guest *by = console ? nullptr : find(admin); const auto [what_text, rest] = split(argument); const auto what = lower(what_text); const auto started = [](std::string why) { return why.empty() ? std::string("Vote started.") : why; }; if (what.empty()) return vote_ ? running_vote_text() : std::string("vote > [argument]"); if (what == "map") return started(start_vote(by, VoteKind::map, rest)); if (what == "kick") return started(start_vote(by, VoteKind::kick, rest)); if (what == "tod" || what == "time") return started(start_vote(by, VoteKind::time, rest)); if (what == "extend") return started(start_vote(by, VoteKind::extend, rest)); for (std::size_t i = 0; i < config_.votes.custom.size(); ++i) if (config_.votes.custom[i].name == what) return started(start_vote(by, VoteKind::custom, rest, i)); return "No vote is called \"" + what + "\": vote map, kick, tod, extend or a custom vote's name (votes lists them)."; } if (name == "poll") { Guest *by = console ? nullptr : find(admin); if (lower(trim(argument)) == "end") { const auto why = end_poll(by); return why.empty() ? "Poll ended." : why; } const auto why = start_poll(by, argument); return why.empty() ? "Poll started." : why; } if (name == "poll-run") { // "poll-run | | ...": the winner's command runs as // the console's, so only the console may set one up. if (!console) return "poll-run is for the server console only."; const auto bar = argument.find('|'); const auto run = trim(argument.substr(0, bar == std::string_view::npos ? 0 : bar)); if (run.empty()) return "poll-run | | | ..., e.g. poll-run tod {answer} | Time of day? | morning | night"; const auto why = start_poll(nullptr, argument.substr(bar + 1), std::string(run)); return why.empty() ? "Poll started; the winning answer runs: " + std::string(run) : why; } 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 sent = teleport_ring(movers, to->latest_root->position); 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 == "teleport") { // teleport : for the console and scripts, so players by // SteamID64 and places by the coordinates the log prints. TeleportRequest request; if (auto why = parse_teleport(argument, request); !why.empty()) return why; Guest *to{}; std::string where; std::array at{}; if (request.point) { at = *request.point; char text[64]; std::snprintf(text, sizeof text, "(%.0f, %.0f, %.0f)", at[0], at[1], at[2]); where = text; } else { to = match_player(request.player); if (!to) return "No single connected player matches \"" + std::string(request.player) + "\"."; if (!to->latest_root) return "There is no position for " + guest_name(*to) + " yet."; at = to->latest_root->position; where = guest_name(*to); } if (!request.name.empty()) { auto *guest = match_player(request.name); if (!guest) return "No single connected player matches \"" + std::string(request.name) + "\"."; request.ids.push_back(guest->member.id); } std::vector movers; std::string skipped; const auto skip = [&](std::string who, std::string_view why) { skipped += (skipped.empty() ? " Skipped: " : ", ") + who + " (" + std::string(why) + ")"; }; for (const auto id : request.ids) { auto *guest = find(id); if (!guest || !guest->handshaken) skip(std::to_string(id), "not connected"); else if (!guest->world_ready) skip(guest_name(*guest), "not in the world yet"); else if (guest == to) skip(guest_name(*guest), "the target"); else movers.push_back(guest); } if (!skipped.empty()) skipped += "."; if (movers.empty()) return "Nobody to teleport." + skipped; // One player to a place lands on it; several stand round it as tpall's do. const auto sent = teleport_ring(movers, at, to || movers.size() > 1 ? 2.5f : 0.f); const auto *by = console ? nullptr : find(admin); log_("[teleport] " + std::to_string(sent) + " player(s) to " + where + " by " + (console ? std::string("console") : by ? guest_name(*by) : std::to_string(admin))); return std::to_string(sent) + " player(s) teleported to " + where + "." + skipped; } 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-mod") return park_mod_command(argument, admin); 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 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) + ": players whose role has a reserved slot 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") return "Admins are a role now: roles lists them, and role admin makes one (role ... none takes it away)."; if (name == "roles") { if (lower(argument) == "reload") { if (!console && !can(admin, "role")) return "Your role cannot reload the roles."; // The file as it is on disk now: an owner's edit, checked before anything changes. std::stringstream held; { std::ifstream in(roles_file(config_), std::ios::binary); if (!in) return "There is no data/roles.json to read."; held << in.rdbuf(); } try { config_.roles = read_roles(held.str()); } catch (const std::exception &e) { return std::string("data/roles.json was not loaded: ") + e.what(); } roles_changed(); return changed("Roles reloaded: " + std::to_string(config_.roles.list.size()) + " roles, " + std::to_string(config_.roles.players.size()) + " players with one."); } std::string text = std::to_string(config_.roles.list.size()) + " roles (data/roles.json)" + (config_.roles.default_role.empty() ? std::string{} : "; everybody given none is " + config_.roles.default_role); for (const auto &role : config_.roles.list) { text += "\n " + role.name + " (rank " + std::to_string(role.rank) + (role.tag.empty() ? std::string{} : ", tag " + role.tag) + "): "; text += role.granted_all ? "every command" : role.granted_commands.empty() ? "no commands" : std::to_string(role.granted_commands.size()) + " commands"; for (const auto &[id, held] : config_.roles.players) if (held == role.name) { const auto *guest = find(id); text += "\n " + std::to_string(id) + (guest ? " " + guest_name(*guest) : std::string{}); } } return text; } if (name == "role") { // role |none const auto [who, wanted_text] = split(argument); const auto wanted = lower(trim(wanted_text)); auto *guest = target(who); const auto id = guest ? guest->member.id : number(who).value_or(0); if (!individual_steam_id(id) || wanted.empty()) return "role |none"; if (id == admin) return "You cannot change your own role."; const auto *role = wanted == "none" ? nullptr : config_.roles.find(wanted); if (wanted != "none" && !role) return "There is no role \"" + wanted + "\". Type roles to list them."; if (!outranks(id)) return "You cannot change the role of a player of your rank or above."; if (role && !console && role->rank >= rank(admin)) return "You cannot give out a role of your rank or above."; if (!role && !config_.roles.players.contains(id)) return std::to_string(id) + " was given no role."; if (role && config_.roles.players.size() >= max_role_players && !config_.roles.players.contains(id)) return "Too many players have a role."; if (role) config_.roles.players[id] = role->name; else config_.roles.players.erase(id); roles_changed(); const auto label = guest ? guest_name(*guest) : std::to_string(id); if (guest && guest->handshaken) send_chat(role ? "Your role on this server is now " + role->name + "." : config_.roles.default_role.empty() ? std::string("You no longer have a role on this server.") : "Your role on this server is " + config_.roles.default_role + " again.", guest); return changed(role ? label + " is now " + role->name + "." : label + (config_.roles.default_role.empty() ? " no longer has a role." : " is " + config_.roles.default_role + " again.")); } return "Unknown command \"" + std::string(action) + "\". Type help."; } unsigned Host::teleport_ring(const std::vector &movers, const std::array &at, float radius) { unsigned sent{}; for (std::size_t i = 0; i < movers.size(); ++i) { const float angle = 6.2831853f * static_cast(i) / static_cast(movers.size()); auto p = packet(PacketKind::teleport, now_); p.teleport = {at[0] + radius * std::cos(angle), at[1] + 1.0f, at[2] + radius * std::sin(angle)}; if (send_packet(*movers[i], p, true, false)) ++sent; } return sent; } // 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. std::string Host::keep_out(std::uint64_t id, std::optional chosen) { const auto minutes = chosen.value_or(config_.kick_minutes); kicked_[id] = minutes ? now_ + minutes * 60000000ULL : 0; return minutes ? " for " + std::to_string(minutes) + (minutes == 1 ? " minute" : " minutes") : std::string(" until the server restarts"); } 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; } void Host::tell_admins(const std::string &text) { for (auto &[id, guest] : guests_) if (guest->handshaken && has(id, multiplayer::role_alerts)) send_chat(text, guest.get()); } // A hacked game can say its skateboard is wherever another player is, and keep it on them // (board_check.h). The board of a player doing that is no longer passed on to the others, the // log and the admins in the game are told who, and with "kick" the player is removed. It is // shown again once it has behaved for half a minute. bool Host::check_board(Guest &guest, const Pose &pose) { if (config_.board_check == "off" || !guest.handshaken || !guest.world_ready) return true; // The other players whose place is known now (a pose within the last second). std::array others; std::size_t count{}; for (const auto &[id, other] : guests_) if (other.get() != &guest && other->handshaken && other->world_ready && other->latest_root && other->pose_arrival && now_ >= other->pose_arrival && now_ - other->pose_arrival <= 1000000 && count < others.size()) others[count++] = {id, other->latest_root->position}; if (!guest.board.sample(now_, pose.root.position, pose.board.empty() ? nullptr : &pose.board.front().position, std::span(others.data(), count))) return true; const auto name = guest_name(guest); if (!guest.board.flagged()) { guest.board_hidden = false; log_("[anticheat] " + name + "'s skateboard is shown to the others again."); return true; } const auto *victim = find(guest.board.victim()); const auto text = name + " (" + std::to_string(guest.member.id) + ") is keeping their skateboard on " + (victim ? guest_name(*victim) : std::string("another player")) + " (a hacked game?)."; log_("[anticheat] " + text); if (config_.board_check == "kick") { tell_admins("[anticheat] " + text + " Kicked."); drop(guest.member.id, "Your skateboard was being kept on another player. Turn off cheats to play here."); return false; } guest.board_hidden = true; tell_admins("[anticheat] " + text + " Their board is hidden from everyone else."); return true; } } // namespace dingosdk::server