diff --git a/CMakeLists.txt b/CMakeLists.txt index 3d738d1..fac78dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -235,7 +235,7 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) 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 Server/server_commands.cpp Extension/Throwdowns/throwdown_wire.cpp) + Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp) target_include_directories(dingosdk_server_load_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_include_directories(dingosdk_server_load_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") target_link_libraries(dingosdk_server_load_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json diff --git a/Server/README.txt b/Server/README.txt index 199765d..d27c820 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -32,6 +32,27 @@ tells players that name, and the version from manifest.json when the file is there. A map under any other folder name works as before: players install it themselves. +Park mods +--------- +A park built in the game's Park Editor and saved as a mod (a folder with +manifest.json and parks/.park.json) can be put up by the server for +everyone. Copy the mod's folder into the Mods folder next to the server, list it +in maps.park_mods with a name and the map it was built on, and spawn it with +"park-mod add " while that map runs: from the console, as an admin in chat +(/park-mod add street 30), or by a player vote (see votes.custom). With minutes +(1-1440) it goes by itself when the time is up; players are told a minute before +and when it goes. Without, it stays until removed. Every park goes when the map +changes, and none come back after a restart. + +A spawned park belongs to the server, not to a player: it counts against nobody's +object limit, nobody can move or delete it, and it stays when players leave. +Players need no mod for it: its objects are Build Kit items the game has. An +object players' games would not show (not a Build Kit item, or broken) is left +out, and the log says which. A park only fits the map it was built on, so the +server spawns it on that map alone, even where custom maps share a base map's +parks (most use bam). The log has a [parks] line for each park spawned or +removed, and for each one that could not be. + Players connect through Steam's relay network, so no ports need opening. If you do forward UDP 27015-27016 (port, query_port), the browser also shows the server's ping and players can join a little faster. @@ -117,6 +138,20 @@ rotation_minutes Minutes on each map before the server moves to the next parks Layout for each park lot, e.g. "skatepark_01", or "empty". world_layer_sync Force the "layers" below on every player. layers World layer key -> "on" / "off". +park_mods Parks built in the game's Park Editor and shared as mods, + which the server can spawn for everyone (see Park mods). + A list, e.g. + [{"name": "street", "folder": "popular skate 2 street park", + "map": "San Vansterdam", "key": "bam"}] + name: what park-mod and votes call it, 1-16 of a-z, 0-9, - + and _. folder: the mod's folder in Mods next to the server + (copied from the game's Mods folder; spaces are fine). + map: the map it was built on; it is only spawned while that + map runs. key: which of the mod's parks, by the base map it + is for (the file parks/.park.json: bam, grom, mpr, + ftue, stadium_1, stadium_2); empty when the mod has one. +park_limits How much may be spawned at once (default {"parks": 3, + "objects": 512}): parks 1-16, their objects in all 1-1024. "players" - What players may do. allow_noclip, allow_no_bail, allow_boosts @@ -296,6 +331,11 @@ custom Votes of your own: a list, each running a server command "command": "map {map}", "percent": 60} {"name": "noclip", "description": "Turn noclip on or off", "command": "noclip {arg}", "choices": ["on", "off"]} + {"name": "park", "description": "Spawn a park for 30 min", + "command": "park-mod add {arg} 30", + "choices": ["street", "plaza"], "percent": 60} + {"name": "unpark", "description": "Remove a park", + "command": "park-mod remove {arg}", "choices": ["street", "plaza"]} Players start them with /vote restart, /vote noclip off; /vote list shows them. A name and each choice is 1-16 of a-z, 0-9, - and _, and not one of the server's own (map, kick, tod, @@ -464,6 +504,11 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu. "[teleport] player(s) to by ". park park random Randomize all three park slots (excludes empty lots). + park-mod list The park mods (maps.park_mods); spawned ones with + their minutes left. + park-mod add [...] [minutes] Spawn parks on the current map: + for 1-1440 minutes, or until removed. All or none. + park-mod remove [...] park-mod clear Remove spawned parks. layer-sync on|off layer default|on|off layers = ... Several world layers at once, each default, on or off. tod diff --git a/Server/main.cpp b/Server/main.cpp index e2f9388..e20ef19 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -347,6 +347,12 @@ int run(int argc, char **argv, bool skip_update) { map = level->name; renamed = true; } + for (auto &park : config.park_mods) // and so are the maps the park mods belong to + if (const auto *level = find_level(park.map); level && level->name != park.map) { + park.map = level->name; + renamed = true; + } + config.mods = here / "Mods"; if (renamed) try { save_config(config); } catch (...) {} if (const auto error = config_error(config); !error.empty()) { write_log("Config problem: " + error); diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 15199bc..70c7b5b 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -27,6 +27,7 @@ constexpr std::string_view help_text = "announce | announce-to | announcements [list|add |remove |clear|interval |off]\n" "map-pool [add|remove |clear] | rotation [|off]\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\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 ]\n" "reserved [slots | add|remove ] | roles [reload] | role |none | update | quit"; @@ -669,6 +670,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { ++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(); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 5b1effc..59af001 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -92,6 +92,20 @@ Layout layout(const ServerConfig &c) { auto layers = Json::object(); for (const auto &[key, mode] : c.layers) layers[key] = mode; maps.set("layers", std::move(layers)); + auto park_mods = Json::array(); + for (const auto &park : c.park_mods) { + auto item = Json::object(); + item["name"] = park.name; + item["folder"] = park.folder; + item["map"] = park.map; + item["key"] = park.key; + park_mods.push_back(std::move(item)); + } + maps.set("park_mods", std::move(park_mods)); + auto park_limits = Json::object(); + park_limits["parks"] = c.park_limits.parks; + park_limits["objects"] = c.park_limits.objects; + maps.set("park_limits", std::move(park_limits)); auto &players = root.section("players"); players.set("allow_boosts", c.boosts); @@ -317,6 +331,18 @@ ServerConfig load_config(const std::filesystem::path &file, std::vectoris_object()) for (const auto &[key, mode] : layers->items()) if (mode.is_string()) c.layers[key] = mode.string(); + if (const auto *parks = find("maps", "park_mods"); parks && !parks->is_null()) { + if (!parks->is_array()) throw std::runtime_error("park_mods must be a list of parks, each a JSON object."); + for (const auto &item : *parks) { + if (!item.is_object()) throw std::runtime_error("park_mods must be a list of parks, each a JSON object."); + c.park_mods.push_back({item.value("name", std::string{}), item.value("folder", std::string{}), + item.value("map", std::string{}), item.value("key", std::string{})}); + } + } + if (const auto *limits = find("maps", "park_limits"); limits && limits->is_object()) { + c.park_limits.parks = limits->value("parks", c.park_limits.parks); + c.park_limits.objects = limits->value("objects", c.park_limits.objects); + } c.boosts = get("players", "allow_boosts", c.boosts, {"boosts"}); c.no_bail = get("players", "allow_no_bail", c.no_bail, {"no_bail"}); @@ -717,7 +743,7 @@ bool custom_command_name_free(std::string_view name) noexcept { "ban", "bans", "board-check", "bone-scale", "boosts", "boosts-allow", "chat-color", "chat-colour", "clear-objects", "crowd", "distances", "effects", "kick", "layer", "layer-sync", "layers", "listed", "map", "map-pool", "maps", "msg", "msg-admins", "msg-party", "name", "net", "nobail", "nobail-allow", "noclip", "noclip-allow", "object-limit", - "object-placement", "object-scaling", "objects", "park", "parties", "party-size", "password", "placement", + "object-placement", "object-scaling", "objects", "park", "park-mod", "parties", "party-size", "password", "placement", "players", "rate", "reserved", "rotation", "say", "score-allow", "score-check", "speed-check", "status", "teleport", "tod", "tpall", "tphere", "tps", "tuning", "tuning-enforce", "unban", "voice", "voice-allow", "voice-range", "vote-cancel", "votes", "welcome", "world-layer-sync", "quit", "exit", "stop", "update"}) @@ -745,6 +771,40 @@ std::string custom_commands_error(const std::vector &commands) { } return {}; } +bool valid_mod_folder(std::string_view folder) noexcept { + // What the game's mod list takes (mods::valid_mod_name), without blanks at either end. + if (folder.empty() || folder.size() > 64 || folder.front() == '.' || folder.front() == ' ' || folder.back() == ' ') return false; + return std::all_of(folder.begin(), folder.end(), [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == ' '; + }); +} +bool valid_park_key(std::string_view key) noexcept { + return !key.empty() && key.size() <= 32 && + std::all_of(key.begin(), key.end(), [](char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; }); +} +std::string park_mods_error(const ServerConfig &c) { + using namespace multiplayer; + if (c.park_limits.parks < 1 || c.park_limits.parks > max_spawned_parks) + return "park_limits.parks must be 1 to " + std::to_string(max_spawned_parks) + "."; + if (c.park_limits.objects < 1 || c.park_limits.objects > max_object_limit) + return "park_limits.objects must be 1 to " + std::to_string(max_object_limit) + "."; + if (c.park_mods.size() > max_park_mods) return "park_mods: at most " + std::to_string(max_park_mods) + " parks."; + for (std::size_t i = 0; i < c.park_mods.size(); ++i) { + const auto &park = c.park_mods[i]; + if (!valid_server_vote_name(park.name)) + return "park_mods: each park needs a name of 1 to 16 lowercase letters, digits, - or _ (\"" + park.name + "\" is not)."; + const auto where = "park_mods \"" + park.name + "\": "; + for (std::size_t j = 0; j < i; ++j) + if (c.park_mods[j].name == park.name) return where + "two parks have that name."; + if (!valid_mod_folder(park.folder)) + return where + "folder must be the name of a mod folder in Mods (letters, digits, spaces, _ - and ., no path)."; + if (!find_level(park.map) || !valid_map_destination(map_destination(park.map))) + return where + "map \"" + park.map + "\" is not a single known map. Use the map's name, e.g. \"San Vansterdam\"."; + if (!park.key.empty() && !valid_park_key(park.key)) + return where + "key must be a base map's key such as bam, grom or stadium_1, or empty."; + } + return {}; +} std::string config_error(const ServerConfig &c) { using namespace multiplayer; if (!parse_colour(c.chat_color)) return "chat_color must be a colour like #8E5CFF."; @@ -792,6 +852,7 @@ std::string config_error(const ServerConfig &c) { for (unsigned lot = 0; lot < park_lots.size(); ++lot) if (c.parks[lot].empty() || !valid_park(lot, c.parks[lot])) return "parks." + std::string(park_lots[lot].key) + " is not a park layout (e.g. skatepark_01, or empty)."; + if (auto error = park_mods_error(c); !error.empty()) return error; if (!c.port || !c.query_port || c.port == c.query_port) return "port and query_port must differ."; if (const auto error = roles_error(c.roles); !error.empty()) return "roles: " + error; return {}; diff --git a/Server/server_config.h b/Server/server_config.h index f522159..82e34cc 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -62,6 +62,22 @@ struct CustomCommand { std::vector commands; bool admin{}; // only admins may use it }; +// A park built in the Park Editor and shipped as a mod: \\parks\.park.json. +// Its objects only fit the map it was built on, so it is bound to a map by name (custom maps +// share a base map's key, "bam" for many). It is spawned with "park-mod add " while that +// map runs, as objects the server owns. +struct ParkMod { + std::string name; // 1-16 of a-z 0-9 - _: what commands and votes use + std::string folder; // the mod folder in Mods (may contain spaces) + std::string map; // the map it belongs to, named as "map" names one + std::string key; // which base map's park in the mod (bam, grom...); empty: the only one +}; +// How much of the parks can be spawned at once: parks, and their objects in all. +struct ParkLimits { + unsigned parks = 3, objects = 512; +}; +inline constexpr std::size_t max_park_mods = 32; +inline constexpr unsigned max_spawned_parks = 16; // ReSkateServer.json. Every setting an admin or the console changes is saved // back, so a restart keeps it. inline constexpr unsigned dedicated_tps = 20; @@ -176,6 +192,10 @@ struct ServerConfig { // Needs world-layers.json (the players' catalog) next to the server. bool world_layer_sync{}; std::map layers; + std::vector park_mods; + ParkLimits park_limits; + // Where the park mods' folders are: Mods next to the server. Not part of the file. + std::filesystem::path mods; Roles roles; // data/roles.json std::vector bans; }; @@ -211,6 +231,13 @@ std::string custom_votes_error(const std::vector &votes); bool custom_command_name_free(std::string_view name) noexcept; std::string custom_commands_error(const std::vector &commands); bool custom_vote_name_free(std::string_view name) noexcept; +// Why the park mods or their limits cannot run, or empty. A folder that is not there is not one: +// it is reported when the park is spawned. +std::string park_mods_error(const ServerConfig &config); +// A mod folder's name as the game's mod list allows it: no path, no leading dot. +bool valid_mod_folder(std::string_view folder) noexcept; +// A base map's key as park files are named by it: bam, grom, stadium_1... (1-32 of a-z 0-9 _). +bool valid_park_key(std::string_view key) noexcept; // A scoring fingerprint as the config and console write it (16 hex digits), and read back // (nothing for text that is not one, or for 0: the game's own scoring needs no entry). std::string scoring_text(std::uint64_t fingerprint); diff --git a/Server/server_engine.cpp b/Server/server_engine.cpp index bf3e783..8874699 100644 --- a/Server/server_engine.cpp +++ b/Server/server_engine.cpp @@ -176,6 +176,13 @@ bool ServerEngine::load_environment(const std::filesystem::path& folder, const s renamed = true; } } + for (auto &park : config_.park_mods) { // and so are the maps the park mods belong to + if (const auto *level = find_level(park.map); level && level->name != park.map) { + park.map = level->name; + renamed = true; + } + } + config_.mods = folder_ / "Mods"; if (renamed) try { save_config(config_); } catch (...) {} if (const auto error = config_error(config_); !error.empty()) { std::string err = "Config problem: " + error; diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 1894362..893e270 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -871,7 +871,9 @@ void Host::change_map(std::string_view map) { if (!valid_map_destination(map_destination(map_setting(map)))) throw std::invalid_argument("That map does not name a destination."); if (!installed_map(map)) throw std::invalid_argument("This server does not have that map."); - // Everything tied to the old world goes; admission and player slots stay. + // Everything tied to the old world goes; admission and player slots stay. Park mods only fit + // the map they were built on. + remove_parks("map change"); for (auto &[id, guest] : guests_) { auto &g = *guest; auto old = std::exchange(g, Guest{}); @@ -1453,6 +1455,8 @@ void Host::sync_objects() { std::vector sources; for (const auto &[id, guest] : guests_) if (guest->handshaken && guest->world_ready) sources.push_back({id, guest->member.epoch, &guest->shared}); + // The spawned park mods: the server's own layout, sent like a player's. + if (park_objects_.revision()) sources.push_back({id_, epoch_, &park_objects_}); for (auto &[id, guest] : guests_) { auto &peer = *guest; if (!peer.handshaken || !peer.world_ready || sources.empty()) continue; @@ -1839,6 +1843,7 @@ void Host::tick(std::uint64_t now) { travel_started_ = 0; log_("Everyone has loaded " + map_name() + "."); } + tick_parks(); sync_objects(); activity_.tick(now_); if (std::exchange(vote_recount_, false)) check_vote(false); diff --git a/Server/server_host.h b/Server/server_host.h index a3b59fc..7ef55ca 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -436,5 +436,22 @@ class Host { void send_party(Guest &to, PartyAction, std::uint64_t player); void party_notice(std::uint32_t party, std::string_view text, std::uint64_t except = 0); std::string party_status(std::uint64_t id) const; + // Park mods (server_parks.cpp): parks from config_.park_mods, spawned for everyone as the + // layout of the server's own SteamID and epoch, which no player can have. + struct SpawnedPark { + std::string name; + std::vector objects; // with the server's own IDs + unsigned minutes{}; // 0: until removed + std::uint64_t expires{}; + bool warned{}; // told a minute before + }; + std::vector parks_spawned_; + ObjectState park_objects_; // never reset: its revisions only grow, so no game holds a newer one + std::uint64_t park_object_ids_{}; + bool by_vote_{}; // a passed custom vote's command is running (as the console) + std::string park_mod_command(std::string_view argument, std::uint64_t admin); + void publish_parks(); + void remove_parks(std::string_view reason); // all of them, with the reason for the log + void tick_parks(); // expiry, and the minute's warning }; } // namespace dingosdk::server diff --git a/Server/server_parks.cpp b/Server/server_parks.cpp new file mode 100644 index 0000000..004ec49 --- /dev/null +++ b/Server/server_parks.cpp @@ -0,0 +1,312 @@ +#include "server_parks.h" +#include "server_host.h" +#include "server_text.h" +#include "Engine/Core/Json/json.h" +#include "Engine/Core/Platform/path_text.h" +#include "Extension/Multiplayer/Session/object_state.h" +#include +#include +#include +#include + +// Park mods: the owner lists them in ReSkateServer.json (maps.park_mods), and the console, an +// admin or a custom vote spawns one while the map it was built on runs. Its objects belong to the +// server itself: they are sent as the layout of the server's own SteamID and epoch, which every +// player's game already takes from the server as it takes another player's (a game server's +// SteamID is never a player's). They count against nobody's object limit, nobody can move or +// delete them, and they stay when players leave; they go on a map change or a restart. +namespace dingosdk::server { +namespace fs = std::filesystem; +namespace { +constexpr std::string_view park_suffix = ".park.json"; +std::string read_file(const fs::path &file) { + std::error_code error; + const auto size = fs::file_size(file, error); + if (error) throw std::runtime_error("Cannot read " + path_utf8(file.filename()) + "."); + if (size > max_park_file_bytes) throw std::runtime_error(path_utf8(file.filename()) + " is larger than 2 MB."); + std::ifstream in(file, std::ios::binary); + std::string text(static_cast(size), '\0'); + if (!in.read(text.data(), static_cast(text.size()))) + throw std::runtime_error("Cannot read " + path_utf8(file.filename()) + "."); + return text; +} +bool read_floats(const Json &list, float *out, std::size_t count) { + if (!list.is_array() || list.size() != count) return false; + for (std::size_t i = 0; i < count; ++i) { + if (!list.at(i).is_number()) return false; + out[i] = list.at(i).get(); + } + return true; +} +} // namespace + +LoadedPark load_park_mod(const fs::path &mods, const ParkMod &park) { + if (!valid_mod_folder(park.folder)) throw std::runtime_error("\"" + park.folder + "\" is not a mod folder name."); + const auto folder = mods / fs::path(park.folder); + std::error_code error; + if (!fs::is_directory(folder, error)) + throw std::runtime_error("The mod folder \"" + park.folder + "\" is not in " + path_utf8(mods) + "."); + const auto parks = folder / "parks"; + LoadedPark loaded; + if (!park.key.empty() && !valid_park_key(park.key)) throw std::runtime_error("\"" + park.key + "\" is not a base map's key."); + loaded.key = park.key; + if (loaded.key.empty()) { + // Without a key, the mod's only park. + std::vector keys; + for (fs::directory_iterator it(parks, error), end; !error && it != end; it.increment(error)) { + auto name = path_utf8(it->path().filename()); + if (!it->is_regular_file(error) || name.size() <= park_suffix.size() || !name.ends_with(park_suffix)) continue; + name.resize(name.size() - park_suffix.size()); + if (valid_park_key(name)) keys.push_back(std::move(name)); + } + std::sort(keys.begin(), keys.end()); + if (keys.empty()) throw std::runtime_error("The mod \"" + park.folder + "\" has no park (parks/.park.json)."); + if (keys.size() > 1) { + std::string list; + for (const auto &key : keys) list += (list.empty() ? "" : ", ") + key; + throw std::runtime_error("The mod \"" + park.folder + "\" has parks for " + list + ": set \"key\" to one of them."); + } + loaded.key = keys.front(); + } + const auto file = parks / fs::path(loaded.key + std::string(park_suffix)); + if (!fs::is_regular_file(file, error)) + throw std::runtime_error("The mod \"" + park.folder + "\" has no park for " + loaded.key + " (parks/" + loaded.key + ".park.json)."); + const auto where = park.folder + "/parks/" + loaded.key + ".park.json"; + Json root; + try { + root = Json::parse(read_file(file)); + } catch (const std::exception &e) { + throw std::runtime_error(where + " cannot be read: " + e.what()); + } + if (!root.is_object() || !root.contains("format") || !root.at("format").is_string() || root.at("format").string() != "reskate-park") + throw std::runtime_error(where + " is not a ReSkate park."); + if (root.contains("schema_version") && (!root.at("schema_version").is_number() || root.at("schema_version").get() != 1)) + throw std::runtime_error(where + " is from a newer ReSkate. Update the server."); + if (!root.contains("maps") || !root.at("maps").is_object() || !root.at("maps").contains(loaded.key)) + throw std::runtime_error(where + " has no objects for " + loaded.key + "."); + const auto &entries = root.at("maps").at(loaded.key); + if (!entries.is_array()) throw std::runtime_error(where + " is not a ReSkate park."); + if (entries.size() > multiplayer::max_owned_objects) + throw std::runtime_error(where + " has " + std::to_string(entries.size()) + " objects; at most " + + std::to_string(multiplayer::max_owned_objects) + " can be shown."); + // Sizes other than 1 are an extension of the format: "object_scales": {map: {id: scale}}. + const Json *scales{}; + if (root.contains("object_scales") && root.at("object_scales").is_object() && root.at("object_scales").contains(loaded.key) && + root.at("object_scales").at(loaded.key).is_object()) + scales = &root.at("object_scales").at(loaded.key); + for (std::size_t index = 0; index < entries.size(); ++index) { + const auto &entry = entries.at(index); + NetworkObject object; + object.id = loaded.objects.size() + 1; + bool ok = entry.is_object() && entry.contains("item") && entry.at("item").is_string() && entry.contains("position") && + entry.contains("rotation") && read_floats(entry.at("position"), object.position.data(), 3) && + read_floats(entry.at("rotation"), object.rotation.data(), 4); + if (ok) object.item = entry.at("item").string(); + if (ok && scales && entry.contains("id") && entry.at("id").is_number()) { + const auto id = std::to_string(entry.at("id").get()); + if (scales->contains(id)) { + ok = scales->at(id).is_number(); + if (ok) object.scale = scales->at(id).get(); + } + } + // What every player's game checks of an object it is sent: a Build Kit item, a place on + // the map, a real rotation, a size it can show. + if (!ok || !multiplayer::valid_network_object(object)) { + auto item = ok ? object.item : std::string("(broken entry)"); + if (item.size() > 64) item.resize(64); + loaded.skipped.push_back("#" + std::to_string(index + 1) + " " + item); + continue; + } + loaded.objects.push_back(std::move(object)); + } + if (loaded.objects.empty()) + throw std::runtime_error(where + (entries.empty() ? " has no objects." : " has no objects players' games can show (Build Kit items).")); + return loaded; +} + +namespace { +std::string listed(const std::vector &names) { + std::string text; + for (std::size_t i = 0; i < names.size(); ++i) + text += (i ? (i + 1 == names.size() ? " and " : ", ") : "") + names[i]; + return text; +} +} // namespace + +void Host::publish_parks() { + std::vector layout; + for (const auto &park : parks_spawned_) layout.insert(layout.end(), park.objects.begin(), park.objects.end()); + if (!park_objects_.revision() && layout.empty()) return; + park_objects_.replace(layout); +} + +void Host::remove_parks(std::string_view reason) { + if (parks_spawned_.empty()) return; + for (const auto &park : parks_spawned_) log_("[parks] " + park.name + " removed (" + std::string(reason) + ")"); + parks_spawned_.clear(); + publish_parks(); +} + +void Host::tick_parks() { + std::vector gone; + for (auto &park : parks_spawned_) { + if (!park.expires) continue; + if (now_ >= park.expires) { + gone.push_back(park.name); + } else if (!park.warned && park.minutes > 1 && park.expires - now_ <= 60000000) { + park.warned = true; + send_chat("The " + park.name + " park goes in a minute."); + } + } + if (gone.empty()) return; + std::erase_if(parks_spawned_, [&](const SpawnedPark &park) { return std::find(gone.begin(), gone.end(), park.name) != gone.end(); }); + for (const auto &name : gone) { + log_("[parks] " + name + " removed (expired)"); + send_chat("The " + name + " park is gone."); + } + publish_parks(); +} + +// "park-mod list | add [...] [minutes] | remove [...] | clear". What it +// did is a "[parks]" line: the console and a vote log the reply, so it is logged here only for an +// admin in game. A vote's refusal also goes to chat, where the players who voted can read it. +std::string Host::park_mod_command(std::string_view argument, std::uint64_t admin) { + const auto [action, rest] = split(argument); + const auto verb = lower(action); + const auto *by = admin ? find(admin) : nullptr; + const std::string who = by_vote_ ? "a vote" : by ? guest_name(*by) : admin ? std::to_string(admin) : "console"; + const auto done = [&](const std::vector &lines) { + std::string text; + for (const auto &line : lines) { + if (admin) log_(line); + text += (text.empty() ? "" : "\n") + line; + } + return text; + }; + const auto spawned = [&](std::string_view name) { + return std::find_if(parks_spawned_.begin(), parks_spawned_.end(), [&](const auto &park) { return park.name == name; }); + }; + const auto configured = [&](std::string_view name) -> const ParkMod * { + for (const auto &park : config_.park_mods) + if (park.name == name) return &park; + return nullptr; + }; + const auto objects = [&] { + std::size_t count{}; + for (const auto &park : parks_spawned_) count += park.objects.size(); + return count; + }; + std::vector words; + for (auto left = rest; !left.empty();) { + const auto [word, more] = split(left); + words.push_back(lower(word)); + left = more; + } + if (verb.empty() || verb == "list") { + if (config_.park_mods.empty()) return "No park mods are set up (maps.park_mods in ReSkateServer.json)."; + std::string text = "Park mods (" + std::to_string(parks_spawned_.size()) + " of " + std::to_string(config_.park_limits.parks) + + " spawned, " + std::to_string(objects()) + " of " + std::to_string(config_.park_limits.objects) + " objects):"; + for (const auto &park : config_.park_mods) { + text += "\n " + park.name + " \"" + park.folder + "\"" + (park.key.empty() ? "" : " (" + park.key + ")") + " on " + park.map; + if (const auto found = spawned(park.name); found != parks_spawned_.end()) { + text += " spawned, " + std::to_string(found->objects.size()) + " objects, "; + text += found->expires ? std::to_string((found->expires - std::min(found->expires, now_) + 59999999) / 60000000) + " min left" + : std::string("until removed"); + } + } + return text; + } + if (verb == "add") { + std::optional minutes; + if (!words.empty() && number(words.back())) { + const auto value = *number(words.back()); + words.pop_back(); + if (value < 1 || value > 1440) return "Minutes must be 1 to 1440."; + minutes = static_cast(value); + } + if (words.empty()) return "park-mod add [...] [minutes]"; + const auto names = listed(words); + // Everything is checked before anything is spawned: all of them, or none. + const auto refuse = [&](const std::string &why) { + if (by_vote_) send_chat("The " + names + " park" + (words.size() > 1 ? "s" : "") + " could not be spawned: " + why); + return done({"[parks] " + names + " could not be spawned: " + why}); + }; + std::vector chosen; + for (const auto &name : words) { + const auto *park = configured(name); + if (!park) return refuse("there is no park mod called \"" + name + "\" (park-mod list)."); + if (std::find(chosen.begin(), chosen.end(), park) != chosen.end()) return refuse(name + " is named twice."); + if (spawned(name) != parks_spawned_.end()) return refuse(name + " is already spawned."); + if (!same_map(park->map)) return refuse(name + " belongs on " + park->map + ", and the server is on " + map_name() + "."); + chosen.push_back(park); + } + if (parks_spawned_.size() + chosen.size() > config_.park_limits.parks) + return refuse("at most " + std::to_string(config_.park_limits.parks) + " parks can be spawned at once (" + + std::to_string(parks_spawned_.size()) + " are)."); + std::vector loaded; + auto total = objects(); + for (const auto *park : chosen) { + try { + loaded.push_back(load_park_mod(config_.mods, *park)); + } catch (const std::exception &e) { + return refuse(e.what()); + } + total += loaded.back().objects.size(); + } + if (total > config_.park_limits.objects) + return refuse("that makes " + std::to_string(total) + " park objects, and at most " + std::to_string(config_.park_limits.objects) + + " can be spawned at once."); + std::vector lines; + for (std::size_t i = 0; i < chosen.size(); ++i) { + SpawnedPark park; + park.name = chosen[i]->name; + park.objects = std::move(loaded[i].objects); + // IDs of the server's own: two parks may number their objects alike. + for (auto &object : park.objects) object.id = ++park_object_ids_; + if (minutes) { + park.minutes = *minutes; + park.expires = now_ + std::uint64_t{*minutes} * 60000000; + } + lines.push_back("[parks] " + park.name + " spawned (" + std::to_string(park.objects.size()) + " objects, " + + (minutes ? std::to_string(*minutes) + " min" : std::string("until removed")) + ") by " + who); + if (const auto &skipped = loaded[i].skipped; !skipped.empty()) { + std::string items; + for (std::size_t j = 0; j < skipped.size() && j < 8; ++j) items += (j ? ", " : "") + skipped[j]; + lines.push_back("[parks] " + park.name + ": skipped " + std::to_string(skipped.size()) + + " object(s) players' games would not show: " + items + (skipped.size() > 8 ? ", ..." : "")); + } + parks_spawned_.push_back(std::move(park)); + } + publish_parks(); + send_chat((words.size() > 1 ? "The " + names + " parks are" : "The " + names + " park is") + + (minutes ? " here for " + std::to_string(*minutes) + " min." : std::string(" here."))); + return done(lines); + } + if (verb == "remove" || verb == "clear") { + if (verb == "clear") { + words.clear(); + for (const auto &park : parks_spawned_) words.push_back(park.name); + if (words.empty()) return "No parks are spawned."; + } + if (words.empty()) return "park-mod remove [...]"; + for (std::size_t i = 1; i < words.size(); ++i) + if (std::find(words.begin(), words.begin() + static_cast(i), words[i]) != words.begin() + static_cast(i)) + return done({"[parks] " + words[i] + " could not be removed: it is named twice."}); + for (const auto &name : words) + if (spawned(name) == parks_spawned_.end()) { + const auto why = configured(name) ? name + " is not spawned." : "there is no park mod called \"" + name + "\" (park-mod list)."; + if (by_vote_) send_chat("The " + name + " park could not be removed: " + why); + return done({"[parks] " + name + " could not be removed: " + why}); + } + std::vector lines; + for (const auto &name : words) { + parks_spawned_.erase(spawned(name)); + lines.push_back("[parks] " + name + " removed (by " + who + ")"); + } + publish_parks(); + send_chat(words.size() > 1 ? "The " + listed(words) + " parks were removed." : "The " + words.front() + " park was removed."); + return done(lines); + } + return "park-mod list | add [...] [minutes] | remove [...] | clear"; +} +} // namespace dingosdk::server diff --git a/Server/server_parks.h b/Server/server_parks.h new file mode 100644 index 0000000..dd1c4fe --- /dev/null +++ b/Server/server_parks.h @@ -0,0 +1,24 @@ +#pragma once +#include "server_config.h" +#include "Engine/Game/World/network_objects.h" +#include +#include +#include + +// Park mods on a dedicated server: parks built in the Park Editor and shipped as mods +// (\\parks\.park.json, as Extension/Objects/ParkEditor/park_mods.h writes +// them), spawned for everyone as objects the server owns (server_parks.cpp). +namespace dingosdk::server { +inline constexpr std::size_t max_park_file_bytes = 2 * 1024 * 1024; +struct LoadedPark { + std::string key; // the base map's park it is (bam...) + // Numbered 1, 2... in the file's order; the server gives them IDs of its own when spawned. + std::vector objects; + // Objects players' games would not take (multiplayer::valid_network_object): not Build Kit + // items, or a broken position, rotation or scale. "# ", n counting from 1. + std::vector skipped; +}; +// Reads `park` from the mods folder. Throws std::runtime_error saying what is wrong: no such +// folder or file, not a ReSkate park, no objects players' games can show. +LoadedPark load_park_mod(const std::filesystem::path &mods, const ParkMod &park); +} // namespace dingosdk::server diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index 8e2c0b2..3b7bb04 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -509,7 +509,12 @@ void Host::check_vote(bool expired) { switch (done.kind) { case VoteKind::map: log_(command("map " + done.value)); break; case VoteKind::time: log_(command("tod " + done.value)); break; - case VoteKind::custom: log_(command(done.value)); break; // the owner's command, as the console + case VoteKind::custom: { // the owner's command, as the console + by_vote_ = true; + struct Reset { bool &flag; ~Reset() { flag = false; } } reset{by_vote_}; + log_(command(done.value)); + break; + } case VoteKind::kick: if (find(done.target)) { kicked_.insert(done.target); diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 8a892c5..a3710cf 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -184,7 +184,7 @@ if(WIN32) 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 Server/server_commands.cpp Extension/Throwdowns/throwdown_wire.cpp + Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp "${CMAKE_CURRENT_BINARY_DIR}/generated/launcher_resources.rc") target_include_directories(dingosdk_server SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") target_link_libraries(dingosdk_server PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json @@ -201,7 +201,7 @@ else() 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 Server/server_commands.cpp Extension/Throwdowns/throwdown_wire.cpp) + Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp) target_include_directories(dingosdk_server SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") target_link_libraries(dingosdk_server PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dl pthread)