From 6909fe3a2e1a119903d4cee718ee6b4a91feba14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20M=C3=B6ller?= Date: Sat, 10 Oct 2026 15:01:29 +0000 Subject: [PATCH 1/6] Dedicated server: park mods as server-owned objects - maps.park_mods binds a Park Editor park mod (Mods//parks/.park.json) to a name and the map it was built on; maps.park_limits caps the parks and objects spawned at once (3 and 512 by default) - park-mod list | add [...] [minutes] | remove [...] | clear, from the console, as /park-mod by admins, and by players through custom votes - A spawned park is the layout of the server's own SteamID and epoch, sent like a player's: games since 1.0.0 already show it, it counts against no player's object limit, nobody can move or delete it, and it stays when players leave - Objects players' games would not take (not Build Kit items, broken values) are skipped and logged; a missing or broken park is an error line when spawning - Optional expiry (1-1440 min) with a warning in chat a minute before; every park goes on a map change and none survive a restart - [parks] log lines name who did it: console, the admin, or "a vote"; a vote's refusal reaches chat - park-mod is reserved for custom commands; README.txt documents the settings, commands and a park/unpark vote Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2G4APEkhHgVs59RDL3XWD --- CMakeLists.txt | 2 +- Server/README.txt | 45 ++++++ Server/main.cpp | 6 + Server/server_commands.cpp | 2 + Server/server_config.cpp | 63 +++++++- Server/server_config.h | 27 ++++ Server/server_engine.cpp | 7 + Server/server_host.cpp | 7 +- Server/server_host.h | 17 ++ Server/server_parks.cpp | 312 +++++++++++++++++++++++++++++++++++++ Server/server_parks.h | 24 +++ Server/server_votes.cpp | 7 +- cmake/Apps.cmake | 4 +- 13 files changed, 517 insertions(+), 6 deletions(-) create mode 100644 Server/server_parks.cpp create mode 100644 Server/server_parks.h 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) From 0193e4fadc4006b0db55e503ad777f90a554c288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20M=C3=B6ller?= Date: Sat, 10 Oct 2026 15:01:29 +0000 Subject: [PATCH 2/6] Server tests: park mods - server_config: park_mods and park_limits are new settings with defaults, round-trip (folders with spaces), and refuse bad names, folders, maps, keys and limits; a missing folder is not a config error - server_park_mods: reads the fixture parks (a shortened copy of a player's street park, objects to skip, a missing key, broken files) and drives the host over a fake transport: a player's game takes the park from the server's own SteamID through routed_source and ObjectState, several parks at once, the limits (all or none), expiry with its warning, admin and vote spawns, late joiners, clear, and removal on a map change Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2G4APEkhHgVs59RDL3XWD --- CMakeLists.txt | 18 + .../park_mods/broken/parks/bam.park.json | 1 + .../park_mods/mixed park/manifest.json | 1 + .../park_mods/mixed park/parks/grom.park.json | 82 ++++ .../park_mods/not a park/parks/bam.park.json | 1 + .../popular skate 2 street park/manifest.json | 6 + .../parks/bam.park.json | 129 ++++++ .../park_mods/two maps/parks/bam.park.json | 39 ++ .../park_mods/two maps/parks/mpr.park.json | 54 +++ Server/Test/server_config_tests.cpp | 65 ++- Server/Test/server_park_tests.cpp | 384 ++++++++++++++++++ 11 files changed, 779 insertions(+), 1 deletion(-) create mode 100644 Server/Test/fixtures/park_mods/broken/parks/bam.park.json create mode 100644 Server/Test/fixtures/park_mods/mixed park/manifest.json create mode 100644 Server/Test/fixtures/park_mods/mixed park/parks/grom.park.json create mode 100644 Server/Test/fixtures/park_mods/not a park/parks/bam.park.json create mode 100644 Server/Test/fixtures/park_mods/popular skate 2 street park/manifest.json create mode 100644 Server/Test/fixtures/park_mods/popular skate 2 street park/parks/bam.park.json create mode 100644 Server/Test/fixtures/park_mods/two maps/parks/bam.park.json create mode 100644 Server/Test/fixtures/park_mods/two maps/parks/mpr.park.json create mode 100644 Server/Test/server_park_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index fac78dc..8112ba0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -95,6 +95,24 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) dingosdk_lz4 dingosdk_zstd) endif() add_test(NAME server_config COMMAND dingosdk_server_config_tests) + # Park mods spawned by the server's host, over a transport that is not Steam's. + add_executable(dingosdk_server_park_tests Server/Test/server_park_tests.cpp Server/server_host.cpp Server/server_party.cpp + Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/server_update.cpp Server/server_release.cpp + $<$:Launcher/updater.cpp> Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp + Extension/Multiplayer/word_lists.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 + Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp) + target_include_directories(dingosdk_server_park_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_include_directories(dingosdk_server_park_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") + if(WIN32) + target_link_libraries(dingosdk_server_park_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json + dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm) + else() + target_link_libraries(dingosdk_server_park_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json + dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dl pthread) + endif() + add_test(NAME server_park_mods COMMAND dingosdk_server_park_tests "${PROJECT_SOURCE_DIR}/Server/Test/fixtures/park_mods") add_executable(dingosdk_server_release_tests Server/Test/server_release_tests.cpp Server/server_release.cpp) target_include_directories(dingosdk_server_release_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_server_release_tests PRIVATE dingosdk_json) diff --git a/Server/Test/fixtures/park_mods/broken/parks/bam.park.json b/Server/Test/fixtures/park_mods/broken/parks/bam.park.json new file mode 100644 index 0000000..0617418 --- /dev/null +++ b/Server/Test/fixtures/park_mods/broken/parks/bam.park.json @@ -0,0 +1 @@ +{"format": "reskate-park", "maps": {"bam": [ diff --git a/Server/Test/fixtures/park_mods/mixed park/manifest.json b/Server/Test/fixtures/park_mods/mixed park/manifest.json new file mode 100644 index 0000000..7f3cf67 --- /dev/null +++ b/Server/Test/fixtures/park_mods/mixed park/manifest.json @@ -0,0 +1 @@ +{"name": "mixed park"} diff --git a/Server/Test/fixtures/park_mods/mixed park/parks/grom.park.json b/Server/Test/fixtures/park_mods/mixed park/parks/grom.park.json new file mode 100644 index 0000000..b60c8b5 --- /dev/null +++ b/Server/Test/fixtures/park_mods/mixed park/parks/grom.park.json @@ -0,0 +1,82 @@ +{ + "format": "reskate-park", + "schema_version": 1, + "revision": 3, + "maps": { + "grom": [ + { + "id": 1, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + 0, + 10, + 0 + ], + "rotation": [ + 0, + 0, + 0, + 1 + ] + }, + { + "id": 2, + "item": "vehicle_car_00001", + "position": [ + 1, + 10, + 0 + ], + "rotation": [ + 0, + 0, + 0, + 1 + ] + }, + { + "id": 3, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + 2, + 10, + 0 + ], + "rotation": [ + 0, + 0, + 0, + 0 + ] + }, + { + "id": 4, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + 3, + 10 + ] + }, + { + "id": 5, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + 4, + 10, + 0 + ], + "rotation": [ + 0, + 0, + 0, + 1 + ] + } + ] + }, + "object_scales": { + "grom": { + "5": 2.5 + } + } +} diff --git a/Server/Test/fixtures/park_mods/not a park/parks/bam.park.json b/Server/Test/fixtures/park_mods/not a park/parks/bam.park.json new file mode 100644 index 0000000..5a8502f --- /dev/null +++ b/Server/Test/fixtures/park_mods/not a park/parks/bam.park.json @@ -0,0 +1 @@ +{"schema_version": 1, "revision": 0, "maps": {"bam": [{"id": 86, "item": "own_bkpads_generic_padlow_00001", "position": [-583.0, 1948.35, 1651.0], "rotation": [0.0, 0.3827, 0.0, 0.9239]}]}} diff --git a/Server/Test/fixtures/park_mods/popular skate 2 street park/manifest.json b/Server/Test/fixtures/park_mods/popular skate 2 street park/manifest.json new file mode 100644 index 0000000..5ccbe48 --- /dev/null +++ b/Server/Test/fixtures/park_mods/popular skate 2 street park/manifest.json @@ -0,0 +1,6 @@ +{ + "author": "star", + "description": ":)", + "name": "popular skate 2 street park", + "version_number": "1.0" +} diff --git a/Server/Test/fixtures/park_mods/popular skate 2 street park/parks/bam.park.json b/Server/Test/fixtures/park_mods/popular skate 2 street park/parks/bam.park.json new file mode 100644 index 0000000..ac58bec --- /dev/null +++ b/Server/Test/fixtures/park_mods/popular skate 2 street park/parks/bam.park.json @@ -0,0 +1,129 @@ +{ + "format": "reskate-park", + "schema_version": 1, + "revision": 0, + "maps": { + "bam": [ + { + "id": 86, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -583.0, + 1948.35, + 1651.0 + ], + "rotation": [ + 0.0, + 0.3827, + 0.0, + 0.9239 + ] + }, + { + "id": 87, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -579.0, + 1948.35, + 1648.0 + ], + "rotation": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + { + "id": 88, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -575.0, + 1948.35, + 1645.0 + ], + "rotation": [ + 0.0, + 0.3827, + 0.0, + 0.9239 + ] + }, + { + "id": 89, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -571.0, + 1948.35, + 1642.0 + ], + "rotation": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + { + "id": 90, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -567.0, + 1948.35, + 1639.0 + ], + "rotation": [ + 0.0, + 0.3827, + 0.0, + 0.9239 + ] + }, + { + "id": 91, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -563.0, + 1948.35, + 1636.0 + ], + "rotation": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + { + "id": 92, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -559.0, + 1948.35, + 1633.0 + ], + "rotation": [ + 0.0, + 0.3827, + 0.0, + 0.9239 + ] + }, + { + "id": 93, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -555.0, + 1948.35, + 1630.0 + ], + "rotation": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + } + ] + } +} diff --git a/Server/Test/fixtures/park_mods/two maps/parks/bam.park.json b/Server/Test/fixtures/park_mods/two maps/parks/bam.park.json new file mode 100644 index 0000000..0fd8e81 --- /dev/null +++ b/Server/Test/fixtures/park_mods/two maps/parks/bam.park.json @@ -0,0 +1,39 @@ +{ + "format": "reskate-park", + "schema_version": 1, + "revision": 0, + "maps": { + "bam": [ + { + "id": 86, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -583.0, + 1948.35, + 1651.0 + ], + "rotation": [ + 0.0, + 0.3827, + 0.0, + 0.9239 + ] + }, + { + "id": 87, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -579.0, + 1948.35, + 1648.0 + ], + "rotation": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + } + ] + } +} diff --git a/Server/Test/fixtures/park_mods/two maps/parks/mpr.park.json b/Server/Test/fixtures/park_mods/two maps/parks/mpr.park.json new file mode 100644 index 0000000..ac7c0e1 --- /dev/null +++ b/Server/Test/fixtures/park_mods/two maps/parks/mpr.park.json @@ -0,0 +1,54 @@ +{ + "format": "reskate-park", + "schema_version": 1, + "revision": 0, + "maps": { + "mpr": [ + { + "id": 86, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -583.0, + 1948.35, + 1651.0 + ], + "rotation": [ + 0.0, + 0.3827, + 0.0, + 0.9239 + ] + }, + { + "id": 87, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -579.0, + 1948.35, + 1648.0 + ], + "rotation": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + { + "id": 88, + "item": "own_bkpads_generic_padlow_00001", + "position": [ + -575.0, + 1948.35, + 1645.0 + ], + "rotation": [ + 0.0, + 0.3827, + 0.0, + 0.9239 + ] + } + ] + } +} diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 38595fe..b71c9c3 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -8,7 +8,7 @@ namespace { int failures{}; -void check(bool ok, const char *what) { +void check(bool ok, const std::string &what) { if (!ok) { std::cerr << "FAIL: " << what << '\n'; ++failures; @@ -46,6 +46,10 @@ int run() { check(has("votes.map.seconds") && has("votes.map.min_players") && has("votes.polls") && has("votes.custom") && has("announcements.messages") && has("announcements.interval_minutes") && has("commands"), "The new vote and announcement settings were not reported"); + // Park mods came after commands. + check(has("maps.park_mods") && has("maps.park_limits") && config.park_mods.empty() && config.park_limits.parks == 3 && + config.park_limits.objects == 512, + "park_mods and park_limits are not new settings with their defaults"); const auto written = text(file); check(written.find("\"enforce_tuning\"") != std::string::npos && written.find("\"seconds\"") != std::string::npos, "New settings not written into the file"); @@ -472,6 +476,65 @@ int run() { const auto again = load_config(old); check(again.roles.players == moved.roles.players && again.roles.default_role == "player", "The roles were not kept from one start to the next"); } + // Park mods: spawned by name on the map each belongs to; the folder may have spaces in it, and + // need not be there yet (spawning says so). + { + ServerConfig parks; + parks.file = file; + parks.park_mods = {{"street", "popular skate 2 street park", "San Vansterdam", "bam"}, + {"plaza-2", "Not Installed Yet", "Isle of Grom", ""}}; + parks.park_limits = {16, 1024}; + check(config_error(parks).empty(), "Valid park mods refused: " + config_error(parks)); + save_config(parks); + const auto written_parks = text(file); + check(written_parks.find("\"park_mods\"") != std::string::npos && written_parks.find("\"park_limits\"") != std::string::npos, + "Park mods not written into the maps section"); + const auto back = load_config(file); + check(back.park_mods.size() == 2 && back.park_mods[0].name == "street" && back.park_mods[0].folder == "popular skate 2 street park" && + back.park_mods[0].map == "San Vansterdam" && back.park_mods[0].key == "bam" && back.park_mods[1].key.empty() && + back.park_limits.parks == 16 && back.park_limits.objects == 1024, + "Park mods lost on save"); + // Written in by hand: a park needs no more than its name, folder and map. + std::ofstream(file, std::ios::binary) << R"({"maps": {"park_mods": [{"name": "street", "folder": "street park", "map": "San Vansterdam"}], + "park_limits": {"parks": 2}}})"; + const auto hand = load_config(file); + check(hand.park_mods.size() == 1 && hand.park_mods[0].key.empty() && hand.park_limits.parks == 2 && hand.park_limits.objects == 512, + "A hand-written park mod was not read"); + std::ofstream(file, std::ios::binary) << R"({"maps": {"park_mods": ["street"]}})"; + bool thrown{}; + try { load_config(file); } catch (const std::exception &) { thrown = true; } + check(thrown, "A park mod that is not an object was accepted"); + const auto turned_down = [&](auto change, const char *what) { + auto bad = parks; + change(bad); + check(config_error(bad).find("park_") != std::string::npos, what); + }; + turned_down([](ServerConfig &c) { c.park_mods[0].name = "Street!"; }, "A park name with capitals and marks accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].name = std::string(17, 'a'); }, "A park name over 16 characters accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].name.clear(); }, "A park without a name accepted"); + turned_down([](ServerConfig &c) { c.park_mods[1].name = "street"; }, "Two parks with one name accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].folder = "parks/street"; }, "A folder with a path accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].folder = "..\\street"; }, "A folder with a backslash accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].folder = ".."; }, "The parent folder accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].folder = " street"; }, "A folder starting with a blank accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].folder.clear(); }, "A park without a folder accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].map = "Nowhere"; }, "A park on an unknown map accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].map.clear(); }, "A park without a map accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].key = "BAM"; }, "A key that is not a base map's accepted"); + turned_down([](ServerConfig &c) { c.park_mods[0].key = "../bam"; }, "A key with a path accepted"); + turned_down([](ServerConfig &c) { c.park_limits.parks = 0; }, "A limit of no parks accepted"); + turned_down([](ServerConfig &c) { c.park_limits.parks = 17; }, "More than 16 parks at once accepted"); + turned_down([](ServerConfig &c) { c.park_limits.objects = 0; }, "A limit of no park objects accepted"); + turned_down([](ServerConfig &c) { c.park_limits.objects = 1025; }, "More park objects than one owner can have accepted"); + turned_down([](ServerConfig &c) { c.park_mods.resize(33, c.park_mods[0]); }, "More than 32 park mods accepted"); + check(!custom_command_name_free("park-mod"), "A custom command may be called park-mod"); + // The vote README.txt shows: "park" is a server command, and still a fine vote name. + auto voting = parks; + voting.votes.custom = {{"park", "Spawn a park for 30 min", "park-mod add {arg} 30", {"street", "plaza-2"}, {true, 60}}, + {"unpark", "Remove a park", "park-mod remove {arg}", {"street", "plaza-2"}, {true, 60}}}; + check(config_error(voting).empty(), "The park votes from README.txt were refused: " + config_error(voting)); + } + std::filesystem::remove_all(folder); if (failures) return 1; std::cout << "server config: ok\n"; diff --git a/Server/Test/server_park_tests.cpp b/Server/Test/server_park_tests.cpp new file mode 100644 index 0000000..5127202 --- /dev/null +++ b/Server/Test/server_park_tests.cpp @@ -0,0 +1,384 @@ +// Park mods on the dedicated server (server_parks.cpp): reading a park mod's file, and spawning, +// timing out and removing parks as the server's own objects, read the way a player's game reads +// them. The server runs over a transport that is not Steam's, with simulated players. +// +// dingosdk_server_park_tests +#include "Server/server_host.h" +#include "Server/server_parks.h" +#include "Extension/Multiplayer/Net/pose_batch.h" +#include "Extension/Multiplayer/Net/sound_codec.h" +#include "Extension/Multiplayer/Net/wire_codec.h" +#include "Extension/Multiplayer/Session/object_state.h" +#include "Extension/Multiplayer/Session/room.h" +#include "Extension/Multiplayer/Steam/steam_transport.h" +#include "Engine/Game/Build/supported_build.h" + +#include +#include +#include +#include +#include +#include + +namespace dingosdk::multiplayer { +namespace { +// What the fake transport holds: what each player is sent, and what they send. +struct Wire { + TransportStatus status; + std::vector inbound; + std::map>> sent; + std::map closed; +}; +Wire &wire() { + static Wire value; + return value; +} +} // namespace + +// A game server's SteamID, as Steam gives the server one: never a player's. +constexpr std::uint64_t server_steam_id = (1ULL << 56) | (4ULL << 52) | 1; + +struct SteamTransport::Impl {}; +SteamTransport::SteamTransport() = default; +SteamTransport::~SteamTransport() = default; +bool SteamTransport::open() { return true; } +bool SteamTransport::open_game_server(void *) { return true; } +bool SteamTransport::host(unsigned) { + wire().status.ready = wire().status.hosting = true; + wire().status.local_id = server_steam_id; + return true; +} +bool SteamTransport::join(std::uint64_t, std::uint32_t, std::uint16_t) { return false; } +bool SteamTransport::listen_direct(std::uint16_t) { return true; } +void SteamTransport::set_packing(unsigned) {} +std::vector SteamTransport::take_direct_notes() { return {}; } +bool SteamTransport::set_steam_debug(bool) { return false; } +bool SteamTransport::connect_peer(std::uint64_t) { return false; } +void SteamTransport::allow_peers(std::span) {} +bool SteamTransport::socket_test() { return true; } +void SteamTransport::stop() {} +void SteamTransport::disconnect(std::uint64_t id, const char *reason) { + wire().closed[id] = reason ? reason : ""; + std::erase_if(wire().status.peers, [&](const TransportPeer &peer) { return peer.id == id; }); +} +void SteamTransport::poll() {} +bool SteamTransport::send(std::uint64_t id, std::span bytes, bool, bool, TrafficLane) { + if (std::none_of(wire().status.peers.begin(), wire().status.peers.end(), [&](const auto &peer) { return peer.id == id; })) return false; + wire().sent[id].emplace_back(bytes.begin(), bytes.end()); + return true; +} +void SteamTransport::send_batch(std::span messages) { + for (auto &message : messages) message.sent = send(message.id, message.bytes, message.reliable, message.fresh, message.lane); +} +std::vector SteamTransport::receive() { return std::exchange(wire().inbound, {}); } +std::string SteamTransport::name(std::uint64_t) { return {}; } +const TransportStatus &SteamTransport::status() const { return wire().status; } +std::string SteamTransport::take_closed(std::uint64_t) { return {}; } +std::string SteamTransport::link_report(std::uint64_t) { return {}; } +std::int64_t SteamTransport::pending(std::uint64_t) const { return 0; } +std::string SteamTransport::relay_status() const { return "OK"; } +bool SteamTransport::set_send_rate(int) { return true; } +int SteamTransport::send_rate() const { return 900 * 1024; } +std::vector SteamTransport::links() { return {}; } +bool SteamTransport::bind(void *, void *, void *) { return true; } +} // namespace dingosdk::multiplayer + +namespace { +using namespace dingosdk; +using namespace dingosdk::multiplayer; +using namespace dingosdk::server; + +int failures{}; +void check(bool ok, const std::string &what) { + if (!ok) { + std::fprintf(stderr, "FAIL: %s\n", what.c_str()); + ++failures; + } +} +bool has(const std::vector &lines, std::string_view text) { + return std::any_of(lines.begin(), lines.end(), [&](const auto &line) { return line.find(text) != std::string::npos; }); +} + +// One player's game, as far as the parks go: what the server sent it, read the way its session +// reads it (session_receive.cpp): an object update counts only from a source the roster names, +// with that source's epoch, through the server. +struct Game { + std::uint64_t id{}, epoch{}, secret{}, map{}, world = 1; + std::uint32_t sequence{}; + std::string name; + DeltaReceiver receiver; + std::optional server; + ObjectState park; // the server's objects as this game holds them + std::vector chat; + bool refused{}; // an object update the game would not have taken + bool joined{}; + + Packet header(PacketKind kind, std::uint64_t now) { + Packet p; + p.kind = kind; + p.session = secret; + p.map = map; + p.world = world; + p.epoch = epoch; + p.source = id; + p.time_us = now; + p.sequence = ++sequence; + p.build = supported_build::game_sha256_bytes; + return p; + } + void send(const Packet &p) { wire().inbound.push_back({id, encode_wire(p), p.time_us}); } + void hello(std::uint64_t now) { + wire().status.peers.push_back({id, true}); + auto p = header(PacketKind::hello, now); + p.text = name; + send(p); + } + void say(std::string_view text, std::uint64_t now) { + auto p = header(PacketKind::chat, now); + p.text = std::string(text); + send(p); + } + // Once a second: the map is loaded here (and the server hears from the player). + void ready(std::uint64_t now) { + auto p = header(PacketKind::world_ready, now); + p.world_ready = true; + send(p); + } + void read() { + for (const auto &bytes : std::exchange(wire().sent[id], {})) { + if (pose_batch::is_batch(bytes) || pose_batch::Ack::read(bytes) || sound_codec::is_sound(bytes)) continue; + bool missing{}; + const auto p = receiver.receive(bytes, missing, 0); + if (!p) continue; + if (p->kind == PacketKind::welcome) joined = true; + if (p->kind == PacketKind::world_state) { + if (p->world > world) park = {}; // clear_world: a new map starts every layout over + world = p->world; + if (p->map) map = p->map; + } + if (p->kind == PacketKind::roster) + for (const auto &member : p->members) + if (member.id == p->source) server = member; + if (p->kind == PacketKind::chat) chat.push_back(p->text); + if (p->kind == PacketKind::objects) { + if (!server || p->source != server->id || !routed_source(*p, *server, server->id, false, server->id) || + park.receive(p->objects) == ObjectState::Result::invalid) + refused = true; + } + } + } +}; + +int run(const std::filesystem::path &fixtures) { + // ---- Reading park mods ------------------------------------------------------------------- + { + const auto street = load_park_mod(fixtures, {"street", "popular skate 2 street park", "San Vansterdam", "bam"}); + check(street.key == "bam" && street.objects.size() == 8 && street.skipped.empty(), "The street park did not load whole"); + const auto &first = street.objects.front(); + check(first.item == "own_bkpads_generic_padlow_00001" && first.position == std::array{-583.f, 1948.35f, 1651.f} && + first.rotation == std::array{0.f, .3827f, 0.f, .9239f} && first.scale == 1.f, + "The street park's first object is not the file's"); + // No key: the mod's only park. + check(load_park_mod(fixtures, {"street", "popular skate 2 street park", "San Vansterdam", ""}).key == "bam", + "A mod's only park was not found without a key"); + const auto mixed = load_park_mod(fixtures, {"plaza", "mixed park", "Isle of Grom", "grom"}); + check(mixed.objects.size() == 2 && mixed.skipped.size() == 3, "Objects players' games would not show were not skipped"); + check(mixed.skipped.size() == 3 && mixed.skipped[0] == "#2 vehicle_car_00001" && mixed.skipped[2].starts_with("#4 "), + "Skipped objects are not named by their place and item"); + check(mixed.objects.size() == 2 && mixed.objects[1].scale == 2.5f, "A park object's size was lost"); + const auto refused = [&](ParkMod park, std::string_view why) { + try { + load_park_mod(fixtures, park); + } catch (const std::exception &e) { + if (std::string(e.what()).find(why) != std::string::npos) return true; + std::fprintf(stderr, " (said: %s)\n", e.what()); + } + return false; + }; + check(refused({"x", "not installed", "San Vansterdam", "bam"}, "is not in"), "A missing mod folder was not reported"); + check(refused({"x", "popular skate 2 street park", "Isle of Grom", "grom"}, "has no park for grom"), "A missing park file was not reported"); + check(refused({"x", "two maps", "San Vansterdam", ""}, "set \"key\""), "A mod with parks for two maps did not ask for a key"); + check(load_park_mod(fixtures, {"x", "two maps", "Super Ultra Mega Resort", "mpr"}).objects.size() == 3, "A park chosen by its key did not load"); + check(refused({"x", "broken", "San Vansterdam", "bam"}, "cannot be read"), "A park file that is not JSON was not reported"); + check(refused({"x", "not a park", "San Vansterdam", "bam"}, "not a ReSkate park"), "A file without the park format was accepted"); + check(refused({"x", "../park_mods/broken", "San Vansterdam", "bam"}, "not a mod folder name"), "A path was taken for a folder"); + } + + // ---- The server -------------------------------------------------------------------------- + const auto empty = std::filesystem::temp_directory_path() / "reskate_server_park_tests_mods"; + std::filesystem::create_directories(empty); + load_levels(empty); // the retail maps + ServerConfig config; + config.file = std::filesystem::temp_directory_path() / "reskate_server_park_tests" / "ReSkateServer.json"; + config.mods = fixtures; + config.auto_update = false; + config.park_mods = {{"street", "popular skate 2 street park", "San Vansterdam", "bam"}, + {"street2", "popular skate 2 street park", "San Vansterdam", "bam"}, + {"street3", "popular skate 2 street park", "San Vansterdam", ""}, + {"plaza", "mixed park", "Isle of Grom", "grom"}, + {"gone", "not installed", "San Vansterdam", "bam"}}; + config.park_limits = {2, 20}; + config.votes.cooldown = 0; + CustomVote park_vote{"park", "Spawn a park for 30 min", "park-mod add {arg} 30", {"street", "plaza"}}; + CustomVote unpark_vote{"unpark", "Remove a park", "park-mod remove {arg}", {"street", "plaza"}}; + config.votes.custom = {park_vote, unpark_vote}; + check(config_error(config).empty(), "The test's config was refused: " + config_error(config)); + std::vector log; + SteamTransport transport; + Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); + std::string error; + if (!host.start(error)) { + std::fprintf(stderr, "The server did not start: %s\n", error.c_str()); + return 1; + } + const auto invite = parse_invite(host.invite()); + if (!invite) { + std::fprintf(stderr, "No invite.\n"); + return 1; + } + std::map games; + const auto add_game = [&](std::uint64_t id, std::string name) -> Game & { + auto &game = games[id]; + game.id = id; + game.epoch = 5000 + id % 1000; + game.secret = invite->secret; + game.map = map_hash(map_destination(config.map)); + game.name = std::move(name); + return game; + }; + std::uint64_t now = 1000000000ULL, next_ready = now; + const auto run_for = [&](std::uint64_t microseconds) { + for (const auto end = now + microseconds; now < end; now += 50000) { + if (now >= next_ready) { + next_ready = now + 1000000; + for (auto &[id, game] : games) + if (game.joined) game.ready(now); + } + host.tick(now); + for (auto &[id, game] : games) game.read(); + } + }; + const auto command = [&](std::string_view line, std::uint64_t admin = 0) { + const auto reply = host.command(line, admin); + if (!admin) log.push_back(reply); // as the console writes its replies to the log + return reply; + }; + const auto logged = [&](std::string_view text) { return has(log, text); }; + + const std::uint64_t admin_id = 76561198000000001ULL; + config.admins = {admin_id}; + auto &a = add_game(admin_id, "Robin"); + a.hello(now); + run_for(2000000); + check(a.joined && a.server && a.server->id == server_steam_id, "The player did not join, or the roster does not name the server"); + + // Spawned: the server's own objects, which the player's game takes from the server. + auto reply = command("park-mod add street 2"); + check(reply == "[parks] street spawned (8 objects, 2 min) by console", "Spawn reply: " + reply); + const auto spawned_at = now; + run_for(1000000); + check(!a.refused && a.park.objects().size() == 8, "The player's game did not receive the park as the server's objects"); + check(has(a.chat, "The street park is here for 2 min."), "Players were not told of the park"); + + // What cannot be spawned, and nothing is spawned of it. + check(command("park-mod add street").find("already spawned") != std::string::npos, "A park was spawned twice"); + reply = command("park-mod add plaza"); + check(reply.find("[parks] plaza could not be spawned: plaza belongs on Isle of Grom") == 0, "A park was spawned on another map: " + reply); + check(command("park-mod add nothing").find("no park mod called") != std::string::npos, "An unknown park was not refused"); + reply = command("park-mod add gone"); + check(reply.find("[parks] gone could not be spawned: The mod folder \"not installed\"") == 0, "A missing mod was not an error line: " + reply); + check(command("park-mod add street2 street3").find("at most 2 parks") != std::string::npos, "The parks limit was not kept"); + check(command("park-mod add street2 0").find("1 to 1440") != std::string::npos, "0 minutes was accepted"); + + // Several at once, each with IDs of its own. + reply = command("park-mod add street2"); + check(reply == "[parks] street2 spawned (8 objects, until removed) by console", "Second park: " + reply); + run_for(1000000); + check(!a.refused && a.park.objects().size() == 16, "Two parks did not reach the player as one layout"); + reply = command("park-mod list"); + check(reply.find("2 of 2 spawned, 16 of 20 objects") != std::string::npos && reply.find("street \"popular skate 2 street park\" (bam)") != std::string::npos && + reply.find("2 min left") != std::string::npos && reply.find("until removed") != std::string::npos, + "list: " + reply); + + // An admin in game: by their name. + a.say("/park-mod remove street2", now); + run_for(500000); + check(logged("[parks] street2 removed (by Robin)"), "An admin's removal was not logged by name"); + check(has(a.chat, "[parks] street2 removed (by Robin)"), "The admin was not told"); + run_for(500000); + check(a.park.objects().size() == 8, "A removed park stayed on the player's map"); + // The objects limit: all or none. + config.park_limits.parks = 3; + reply = command("park-mod add street2 street3"); + check(reply.find("that makes 24 park objects, and at most 20") != std::string::npos, "The objects limit was not kept: " + reply); + check(command("park-mod list").find("1 of 3 spawned, 8 of 20 objects") != std::string::npos, "Half of a refused add was spawned"); + config.park_limits = {3, 512}; + + // Expiry: a minute's warning, then gone. + run_for(spawned_at + 61000000 - now); + check(has(a.chat, "The street park goes in a minute."), "No warning a minute before a park went"); + check(!logged("removed (expired)"), "A park went early"); + run_for(spawned_at + 121000000 - now); + check(logged("[parks] street removed (expired)") && has(a.chat, "The street park is gone."), "An expired park was not removed"); + check(a.park.revision() && a.park.objects().empty(), "An expired park stayed on the player's map"); + + // Players vote for one (the owner's custom votes run park-mod as the console). + a.say("/vote park street", now); + run_for(1000000); + check(logged("[parks] street spawned (8 objects, 30 min) by a vote"), "A vote did not spawn the park"); + a.say("/vote unpark street", now); + run_for(1000000); + check(logged("[parks] street removed (by a vote)"), "A vote did not remove the park"); + a.say("/vote park plaza", now); + run_for(1000000); + check(has(a.chat, "The plaza park could not be spawned: plaza belongs on Isle of Grom"), "A vote's failure did not reach chat"); + + // clear, and a player who joins late is sent the park, and it stays when they leave. + command("park-mod add street3"); + auto &b = add_game(76561198000000002ULL, "Late"); + b.hello(now); + run_for(2000000); + check(!b.refused && b.park.objects().size() == 8, "A player who joined later was not sent the park"); + wire().status.peers.erase(std::remove_if(wire().status.peers.begin(), wire().status.peers.end(), + [&](const auto &peer) { return peer.id == b.id; }), + wire().status.peers.end()); + games.erase(b.id); + run_for(2000000); + check(command("park-mod list").find("1 of 3 spawned") != std::string::npos, "A park went with a player"); + reply = command("park-mod clear"); + check(reply == "[parks] street3 removed (by console)", "clear: " + reply); + check(command("park-mod clear") == "No parks are spawned.", "clear with nothing spawned"); + + // A map change takes the parks with it. + command("park-mod add street"); + run_for(1000000); + command("map Isle of Grom"); + check(logged("[parks] street removed (map change)"), "A map change did not remove the park"); + run_for(3000000); + check(a.park.objects().empty(), "A park stayed after the map changed"); + reply = command("park-mod add plaza 5"); + check(reply.starts_with("[parks] plaza spawned (2 objects, 5 min) by console\n[parks] plaza: skipped 3 object(s)"), "plaza: " + reply); + run_for(1000000); + check(!a.refused && a.park.objects().size() == 2, "The park on the new map did not reach the player"); + const auto &objects = a.park.objects(); + check(std::any_of(objects.begin(), objects.end(), [](const auto &row) { return row.second.scale == 2.5f; }), "A resized object lost its size"); + check(command("park-mod add street").find("belongs on San Vansterdam") != std::string::npos, "A park for the old map was spawned"); + check(!wire().closed.contains(admin_id), "The player was disconnected: " + (wire().closed.contains(admin_id) ? wire().closed[admin_id] : std::string{})); + return failures; +} +} // namespace + +int main(int argc, char **argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: dingosdk_server_park_tests \n"); + return 2; + } + try { + const auto result = run(argv[1]); + if (!result) std::printf("server park tests passed\n"); + return result ? 1 : 0; + } catch (const std::exception &e) { + std::fprintf(stderr, "FAIL: %s\n", e.what()); + return 1; + } +} From 6a8cbe8156549620c775c3a250eb157589e05ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20M=C3=B6ller?= Date: Sat, 10 Oct 2026 16:15:20 +0000 Subject: [PATCH 3/6] Client: park-mod in the admin "/" command list - A dedicated server's admins see /park-mod list, add, remove and clear with their usage in the chat's "/" menu, beside the server's other admin commands - No Tab completion for the park names: the roster does not carry them; the park/unpark votes already show with their choices as custom votes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2G4APEkhHgVs59RDL3XWD --- Extension/Multiplayer/Session/session_view.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 787671e..2ee94db 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -527,11 +527,18 @@ std::vector chat_commands(const Session &s) { list.push_back({"/rotation", "/rotation [|off]", "Admin: change the map on a timer, through the map pool"}); list.push_back({"/role", "/role |none", "Admin: give a player a role, or take theirs away", "player"}); list.push_back({"/roles", "/roles", "Admin: the server's roles and who has them"}); + // Park mods (Server/server_parks.cpp): their names are the server's own, not in the roster. + list.push_back({"/park-mod list", "/park-mod list", "Admin: the server's park mods, and which are spawned"}); + list.push_back({"/park-mod add", "/park-mod add [...] [minutes]", + "Admin: spawn park mods on this map, for 1-1440 minutes or until removed"}); + list.push_back({"/park-mod remove", "/park-mod remove [...]", "Admin: remove spawned park mods"}); + list.push_back({"/park-mod clear", "/park-mod clear", "Admin: remove every spawned park mod"}); list.erase(std::remove_if(list.begin() + static_cast(before_commands), list.end(), [&](const auto &entry) { - // "/poll end" is the polls permission's; the others are named for their command. + // "/poll end" is the polls permission's; the others are named for their command, the + // first word ("/park-mod add" is park-mod's). const std::string_view shown = entry.name; if (shown == "/poll end") return !(s.role_permissions & role_polls); - return !role_allows(s.role_all_commands, s.role_commands, shown.substr(1)); + return !role_allows(s.role_all_commands, s.role_commands, shown.substr(1, shown.find(' ') - 1)); }), list.end()); } } From c3751b9714ecb1bf5c1ff4f979a9bf0bc22d8398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20M=C3=B6ller?= Date: Sat, 10 Oct 2026 16:21:46 +0000 Subject: [PATCH 4/6] Park mods: name the Mods folder, not the server's path - A missing mod folder reads "The mod folder "x" is not in the Mods folder." instead of printing where the server is installed; the reason can reach chat through an admin's command or a vote - server_park_mods checks the exact message Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2G4APEkhHgVs59RDL3XWD --- Server/Test/server_park_tests.cpp | 4 +++- Server/server_parks.cpp | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Server/Test/server_park_tests.cpp b/Server/Test/server_park_tests.cpp index 5127202..ab8d539 100644 --- a/Server/Test/server_park_tests.cpp +++ b/Server/Test/server_park_tests.cpp @@ -195,7 +195,9 @@ int run(const std::filesystem::path &fixtures) { } return false; }; - check(refused({"x", "not installed", "San Vansterdam", "bam"}, "is not in"), "A missing mod folder was not reported"); + // Said without the server's own paths: the reason can reach chat (an admin, a vote). + check(refused({"x", "not installed", "San Vansterdam", "bam"}, "The mod folder \"not installed\" is not in the Mods folder."), + "A missing mod folder was not reported, or named the server's path"); check(refused({"x", "popular skate 2 street park", "Isle of Grom", "grom"}, "has no park for grom"), "A missing park file was not reported"); check(refused({"x", "two maps", "San Vansterdam", ""}, "set \"key\""), "A mod with parks for two maps did not ask for a key"); check(load_park_mod(fixtures, {"x", "two maps", "Super Ultra Mega Resort", "mpr"}).objects.size() == 3, "A park chosen by its key did not load"); diff --git a/Server/server_parks.cpp b/Server/server_parks.cpp index 004ec49..42abbbd 100644 --- a/Server/server_parks.cpp +++ b/Server/server_parks.cpp @@ -45,7 +45,7 @@ LoadedPark load_park_mod(const fs::path &mods, const ParkMod &park) { 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) + "."); + throw std::runtime_error("The mod folder \"" + park.folder + "\" is not in the Mods folder."); 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."); From f8b01dc3d5cfa57f8d097f1a66e5de92e1aa75a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20M=C3=B6ller?= Date: Sat, 10 Oct 2026 20:23:14 +0000 Subject: [PATCH 5/6] Park mods: reload and set up parks while the server runs - park-mod reload [...] reads a spawned park's file again and swaps its objects in place, keeping its timer; all or none, within park_limits, and players are told the park was updated - park-mod config add | | [| ] and park-mod config remove change maps.park_mods without a restart, validated like the startup config and saved like the map pool; removing a spawned park's entry removes the park too - The client's admin "/" menu lists reload, config add and config remove - README.txt and help cover the new commands; server_park_mods tests reload (in place, timer kept, refused when not spawned or broken) and config add/remove (saved, refusals, by an admin) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2G4APEkhHgVs59RDL3XWD --- .../Multiplayer/Session/session_view.cpp | 5 + Server/README.txt | 13 ++ Server/Test/server_park_tests.cpp | 65 ++++++++- Server/server_commands.cpp | 3 +- Server/server_parks.cpp | 126 ++++++++++++++++-- 5 files changed, 196 insertions(+), 16 deletions(-) diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 2ee94db..8e0b5f2 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -533,6 +533,11 @@ std::vector chat_commands(const Session &s) { "Admin: spawn park mods on this map, for 1-1440 minutes or until removed"}); list.push_back({"/park-mod remove", "/park-mod remove [...]", "Admin: remove spawned park mods"}); list.push_back({"/park-mod clear", "/park-mod clear", "Admin: remove every spawned park mod"}); + list.push_back({"/park-mod reload", "/park-mod reload [...]", + "Admin: read a spawned park mod's file again and update it in place"}); + list.push_back({"/park-mod config add", "/park-mod config add | | [| ]", + "Admin: set up a park mod from the server's Mods folder, without a restart"}); + list.push_back({"/park-mod config remove", "/park-mod config remove ", "Admin: remove a park mod from the server's setup"}); list.erase(std::remove_if(list.begin() + static_cast(before_commands), list.end(), [&](const auto &entry) { // "/poll end" is the polls permission's; the others are named for their command, the // first word ("/park-mod add" is park-mod's). diff --git a/Server/README.txt b/Server/README.txt index d27c820..65b3af1 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -44,6 +44,12 @@ in maps.park_mods with a name and the map it was built on, and spawn it with and when it goes. Without, it stays until removed. Every park goes when the map changes, and none come back after a restart. +Nothing needs a restart: a mod copied into Mods can be set up with +"park-mod config add" (saved to the config like the map pool), and a park whose +mod was updated is read again with "park-mod reload ". Every park-mod +add reads the park's file afresh. Don't edit ReSkateServer.json by hand while +the server runs: it writes its settings back whenever an admin changes one. + 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 @@ -509,6 +515,13 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu. 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. + park-mod reload [...] Read a spawned park's file again (its mod + was updated) and swap its objects in place; it keeps + its timer. + park-mod config add | | [| ] Set up a park mod + while the server runs, saved to maps.park_mods, e.g. + park-mod config add street | popular skate 2 street park | San Vansterdam | bam + park-mod config remove Take one out of the setup (removing it if spawned). layer-sync on|off layer default|on|off layers = ... Several world layers at once, each default, on or off. tod diff --git a/Server/Test/server_park_tests.cpp b/Server/Test/server_park_tests.cpp index ab8d539..b78b521 100644 --- a/Server/Test/server_park_tests.cpp +++ b/Server/Test/server_park_tests.cpp @@ -14,6 +14,7 @@ #include "Engine/Game/Build/supported_build.h" #include +#include #include #include #include @@ -212,7 +213,11 @@ int run(const std::filesystem::path &fixtures) { load_levels(empty); // the retail maps ServerConfig config; config.file = std::filesystem::temp_directory_path() / "reskate_server_park_tests" / "ReSkateServer.json"; - config.mods = fixtures; + // A copy of the fixtures the test may change, as an owner updates a mod while the server runs. + const auto mods = std::filesystem::temp_directory_path() / "reskate_server_park_tests_live"; + std::filesystem::remove_all(mods); + std::filesystem::copy(fixtures, mods, std::filesystem::copy_options::recursive); + config.mods = mods; config.auto_update = false; config.park_mods = {{"street", "popular skate 2 street park", "San Vansterdam", "bam"}, {"street2", "popular skate 2 street park", "San Vansterdam", "bam"}, @@ -351,6 +356,64 @@ int run(const std::filesystem::path &fixtures) { check(reply == "[parks] street3 removed (by console)", "clear: " + reply); check(command("park-mod clear") == "No parks are spawned.", "clear with nothing spawned"); + // Live: a spawned park's file read again, in place, keeping its timer. + const auto street_file = mods / "popular skate 2 street park" / "parks" / "bam.park.json"; + command("park-mod add street 10"); + run_for(1000000); + std::ofstream(street_file, std::ios::binary | std::ios::trunc) << R"({"format": "reskate-park", "schema_version": 1, "revision": 1, + "maps": {"bam": [{"id": 1, "item": "own_bkpads_generic_padlow_00001", "position": [0, 10, 0], "rotation": [0, 0, 0, 1]}, + {"id": 2, "item": "own_bkpads_generic_padlow_00001", "position": [4, 10, 0], "rotation": [0, 0, 0, 1]}, + {"id": 3, "item": "own_bkpads_generic_padlow_00001", "position": [8, 10, 0], "rotation": [0, 0, 0, 1]}]}})"; + reply = command("park-mod reload street"); + check(reply == "[parks] street reloaded (3 objects) by console", "reload: " + reply); + run_for(1000000); + check(!a.refused && a.park.objects().size() == 3, "A reloaded park did not reach the player"); + check(has(a.chat, "The street park was updated."), "Players were not told of the update"); + check(command("park-mod list").find("3 objects, 10 min left") != std::string::npos, "A reload lost the park's timer"); + check(command("park-mod reload street2").find("street2 is not spawned") != std::string::npos, "A park that is not up was reloaded"); + check(command("park-mod reload nothing").find("no park mod called") != std::string::npos, "An unknown park was reloaded"); + std::ofstream(street_file, std::ios::binary | std::ios::trunc) << "{ not json"; + check(command("park-mod reload street").find("[parks] street could not be reloaded: ") == 0, "A broken file was reloaded"); + run_for(1000000); + check(a.park.objects().size() == 3, "A failed reload changed the park"); + std::filesystem::copy_file(fixtures / "popular skate 2 street park" / "parks" / "bam.park.json", street_file, + std::filesystem::copy_options::overwrite_existing); + command("park-mod clear"); + + // Live: parks set up from the console or an admin, saved to the config, no restart. + reply = command("park-mod config add newpark | popular skate 2 street park | san vansterdam | bam"); + check(reply == "[parks] newpark set up (\"popular skate 2 street park\", bam on San Vansterdam) by console", "config add: " + reply); + { + const auto saved = load_config(config.file); + check(!saved.park_mods.empty() && saved.park_mods.back().name == "newpark" && saved.park_mods.back().map == "San Vansterdam" && + saved.park_mods.back().folder == "popular skate 2 street park" && saved.park_mods.back().key == "bam", + "A park set up live was not saved to the config"); + } + check(command("park-mod add newpark") == "[parks] newpark spawned (8 objects, until removed) by console", "A park set up live did not spawn"); + check(command("park-mod config add newpark | popular skate 2 street park | San Vansterdam").find("already set up") != std::string::npos, + "A park was set up twice"); + check(command("park-mod config add New Park! | x | San Vansterdam").find("could not be set up: park_mods") != std::string::npos, + "A bad park name was set up"); + check(command("park-mod config add elsewhere | x | Nowhere").find("is not a single known map") != std::string::npos, "A park on an unknown map was set up"); + check(command("park-mod config add later | ../x | San Vansterdam").find("folder must be") != std::string::npos, "A path was set up as a folder"); + check(command("park-mod config add half | x").find("park-mod config add ") == 0, "A park without a map was set up"); + reply = command("park-mod config add later | copied later | Isle of Grom"); + check(reply.find("its folder is not in the Mods folder yet") != std::string::npos, "A missing folder was not mentioned: " + reply); + a.say("/park-mod config remove later", now); + run_for(500000); + check(logged("[parks] later no longer set up (by Robin)"), "An admin could not remove a park from the setup"); + reply = command("park-mod config remove newpark"); + check(reply == "[parks] newpark removed (by console)\n[parks] newpark no longer set up (by console)", "config remove: " + reply); + run_for(1000000); + check(a.park.objects().empty(), "A park removed from the setup stayed on the map"); + { + const auto saved = load_config(config.file); + check(std::none_of(saved.park_mods.begin(), saved.park_mods.end(), + [](const auto &park) { return park.name == "newpark" || park.name == "later"; }), + "A park removed from the setup is still in the config"); + } + check(command("park-mod config remove newpark").find("no park mod called") != std::string::npos, "A park was removed from the setup twice"); + // A map change takes the parks with it. command("park-mod add street"); run_for(1000000); diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 70c7b5b..323017f 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -27,7 +27,8 @@ 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" + "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 ]\n" "reserved [slots | add|remove ] | roles [reload] | role |none | update | quit"; diff --git a/Server/server_parks.cpp b/Server/server_parks.cpp index 42abbbd..6c73390 100644 --- a/Server/server_parks.cpp +++ b/Server/server_parks.cpp @@ -167,9 +167,10 @@ void Host::tick_parks() { 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. +// "park-mod list | add [...] [minutes] | remove [...] | clear | +// reload [...] | config add | | [| ] | config remove ". +// 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); @@ -196,6 +197,69 @@ std::string Host::park_mod_command(std::string_view argument, std::uint64_t admi for (const auto &park : parks_spawned_) count += park.objects.size(); return count; }; + // What of a park's file players' games would not show, for the log. + const auto skipped_line = [](std::string_view name, const std::vector &skipped) { + std::string items; + for (std::size_t j = 0; j < skipped.size() && j < 8; ++j) items += (j ? ", " : "") + skipped[j]; + return "[parks] " + std::string(name) + ": skipped " + std::to_string(skipped.size()) + + " object(s) players' games would not show: " + items + (skipped.size() > 8 ? ", ..." : ""); + }; + const auto named_twice = [](const std::vector &names) -> std::optional { + for (std::size_t i = 1; i < names.size(); ++i) + if (std::find(names.begin(), names.begin() + static_cast(i), names[i]) != names.begin() + static_cast(i)) + return names[i]; + return {}; + }; + // Setting parks up while the server runs: saved to ReSkateServer.json like the map pool, so a + // park mod copied into Mods needs no restart. + if (verb == "config") { + const auto [what, fields] = split(rest); + const auto sub = lower(what); + if (sub == "add") { + std::vector parts; + for (auto left = fields;;) { + const auto bar = left.find('|'); + parts.emplace_back(trim(left.substr(0, bar))); + if (bar == std::string_view::npos) break; + left = left.substr(bar + 1); + } + if (parts.size() < 3 || parts.size() > 4 || parts[0].empty()) + return "park-mod config add | | [| ], e.g. park-mod config add street | " + "popular skate 2 street park | San Vansterdam | bam"; + ParkMod park{lower(parts[0]), parts[1], parts[2], parts.size() > 3 ? lower(parts[3]) : std::string{}}; + const auto refuse = [&](const std::string &why) { return done({"[parks] " + park.name + " could not be set up: " + why}); }; + if (configured(park.name)) return refuse(park.name + " is already set up (park-mod config remove " + park.name + " first)."); + if (const auto *level = find_level(park.map)) park.map = level->name; + auto next = config_; + next.park_mods.push_back(park); + if (const auto error = park_mods_error(next); !error.empty()) return refuse(error); + config_.park_mods.push_back(park); + save(); + std::error_code missing; + const bool there = std::filesystem::is_directory(config_.mods / std::filesystem::path(park.folder), missing); + return done({"[parks] " + park.name + " set up (\"" + park.folder + "\"" + (park.key.empty() ? "" : ", " + park.key) + " on " + + park.map + ") by " + who + (there ? "" : "; its folder is not in the Mods folder yet")}); + } + if (sub == "remove") { + const auto name = lower(trim(fields)); + if (name.empty()) return "park-mod config remove "; + const auto found = std::find_if(config_.park_mods.begin(), config_.park_mods.end(), [&](const auto &park) { return park.name == name; }); + if (found == config_.park_mods.end()) + return done({"[parks] " + name + " could not be removed from the setup: there is no park mod called \"" + name + "\" (park-mod list)."}); + std::vector lines; + if (const auto up = spawned(name); up != parks_spawned_.end()) { + parks_spawned_.erase(up); + publish_parks(); + send_chat("The " + name + " park was removed."); + lines.push_back("[parks] " + name + " removed (by " + who + ")"); + } + config_.park_mods.erase(found); + save(); + lines.push_back("[parks] " + name + " no longer set up (by " + who + ")"); + return done(lines); + } + return "park-mod config add | | [| ] | park-mod config remove "; + } std::vector words; for (auto left = rest; !left.empty();) { const auto [word, more] = split(left); @@ -203,7 +267,8 @@ std::string Host::park_mod_command(std::string_view argument, std::uint64_t admi 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)."; + if (config_.park_mods.empty()) + return "No park mods are set up (maps.park_mods in ReSkateServer.json, or park-mod config add | | )."; 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) { @@ -269,12 +334,7 @@ std::string Host::park_mod_command(std::string_view argument, std::uint64_t admi } 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 ? ", ..." : "")); - } + if (!loaded[i].skipped.empty()) lines.push_back(skipped_line(park.name, loaded[i].skipped)); parks_spawned_.push_back(std::move(park)); } publish_parks(); @@ -289,9 +349,7 @@ std::string Host::park_mod_command(std::string_view argument, std::uint64_t admi 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."}); + if (const auto twice = named_twice(words)) return done({"[parks] " + *twice + " 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)."; @@ -307,6 +365,46 @@ std::string Host::park_mod_command(std::string_view argument, std::uint64_t admi 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"; + // A spawned park's file read again (its mod was updated): its objects are swapped in place, and + // it keeps its timer. All of them, or none. + if (verb == "reload") { + if (words.empty()) return "park-mod reload [...]"; + const auto names = listed(words); + const auto refuse = [&](const std::string &why) { + if (by_vote_) send_chat("The " + names + " park" + (words.size() > 1 ? "s" : "") + " could not be reloaded: " + why); + return done({"[parks] " + names + " could not be reloaded: " + why}); + }; + if (const auto twice = named_twice(words)) return refuse(*twice + " is named twice."); + std::vector loaded; + auto total = objects(); + for (const auto &name : words) { + const auto *park = configured(name); + if (!park) return refuse("there is no park mod called \"" + name + "\" (park-mod list)."); + const auto up = spawned(name); + if (up == parks_spawned_.end()) return refuse(name + " is not spawned (park-mod add " + name + ")."); + try { + loaded.push_back(load_park_mod(config_.mods, *park)); + } catch (const std::exception &e) { + return refuse(e.what()); + } + total = total - up->objects.size() + 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 < words.size(); ++i) { + auto &park = *spawned(words[i]); + park.objects = std::move(loaded[i].objects); + for (auto &object : park.objects) object.id = ++park_object_ids_; + lines.push_back("[parks] " + park.name + " reloaded (" + std::to_string(park.objects.size()) + " objects) by " + who); + if (!loaded[i].skipped.empty()) lines.push_back(skipped_line(park.name, loaded[i].skipped)); + } + publish_parks(); + send_chat(words.size() > 1 ? "The " + names + " parks were updated." : "The " + names + " park was updated."); + return done(lines); + } + return "park-mod list | add [...] [minutes] | remove [...] | clear | reload [...]\n" + "park-mod config add | | [| ] | park-mod config remove "; } } // namespace dingosdk::server From dbb5cf8caaa861a2f0e8f0f410072b6ec4bd49d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20M=C3=B6ller?= Date: Sun, 11 Oct 2026 17:52:42 +0000 Subject: [PATCH 6/6] Park mods: fit to roles - park-mod is a command a role can list: the admin role's "*" gives it, the default moderator role does not (spawning parks and changing the saved setup are the owner's call); command() checks the caller's role as for every other command, and custom votes still run it as the console - The client's "/" menu shows the park-mod rows by role like the other server commands there, by the row's first word (resolved in the rebased client commit) - README-roles.txt lists park-mod; README.txt says who may use it - Tests: the admin role has park-mod and the moderator and player roles do not; in game a moderator is refused and a role listing park-mod is not Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2G4APEkhHgVs59RDL3XWD --- Server/README-roles.txt | 3 +++ Server/README.txt | 5 +++-- Server/Test/server_config_tests.cpp | 4 ++++ Server/Test/server_park_tests.cpp | 32 ++++++++++++++++++++++++++++- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Server/README-roles.txt b/Server/README-roles.txt index 2a2fd4d..a9ffe80 100644 --- a/Server/README-roles.txt +++ b/Server/README-roles.txt @@ -148,6 +148,9 @@ Map and world between and the rotation uses. rotation rotation [|off]: change the map on a timer. park park , park random: the park layouts. + park-mod The park mods (see Park mods in README.txt): list, add, + remove, clear and reload spawned parks, and "park-mod + config add|remove" to set one up (saved to the config). layer-sync layer-sync on|off: force the server's world layers on everyone. layer layer default|on|off: one world layer. diff --git a/Server/README.txt b/Server/README.txt index 65b3af1..1ddd9b1 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -38,8 +38,9 @@ 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 +"park-mod add " while that map runs: from the console, in chat by a player +whose role has park-mod (/park-mod add street 30; the admin role does, see +README-roles.txt), 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. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index b71c9c3..35a6ccf 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -528,6 +528,10 @@ int run() { turned_down([](ServerConfig &c) { c.park_limits.objects = 1025; }, "More park objects than one owner can have accepted"); turned_down([](ServerConfig &c) { c.park_mods.resize(33, c.park_mods[0]); }, "More than 32 park mods accepted"); check(!custom_command_name_free("park-mod"), "A custom command may be called park-mod"); + // A command a role can be given: the admin role's "*" has it, the moderator's list does not. + const auto roles = default_roles(); + check(allows(roles.find("admin"), "park-mod") && !allows(roles.find("moderator"), "park-mod") && !allows(roles.find("player"), "park-mod"), + "park-mod is not the admin role's alone among the default roles"); // The vote README.txt shows: "park" is a server command, and still a fine vote name. auto voting = parks; voting.votes.custom = {{"park", "Spawn a park for 30 min", "park-mod add {arg} 30", {"street", "plaza-2"}, {true, 60}}, diff --git a/Server/Test/server_park_tests.cpp b/Server/Test/server_park_tests.cpp index b78b521..3fa5770 100644 --- a/Server/Test/server_park_tests.cpp +++ b/Server/Test/server_park_tests.cpp @@ -272,8 +272,10 @@ int run(const std::filesystem::path &fixtures) { }; const auto logged = [&](std::string_view text) { return has(log, text); }; + // Roles (Server/roles.h): the player is an admin, whose "*" gives them park-mod. const std::uint64_t admin_id = 76561198000000001ULL; - config.admins = {admin_id}; + config.roles = default_roles(); + config.roles.players[admin_id] = "admin"; auto &a = add_game(admin_id, "Robin"); a.hello(now); run_for(2000000); @@ -307,6 +309,34 @@ int run(const std::filesystem::path &fixtures) { reply.find("2 min left") != std::string::npos && reply.find("until removed") != std::string::npos, "list: " + reply); + // Roles: a moderator's role does not list park-mod; a role that lists it gives it alone. + { + const std::uint64_t mod_id = 76561198000000003ULL; + config.roles.players[mod_id] = "moderator"; + auto &m = add_game(mod_id, "Moddy"); + m.hello(now); + run_for(2000000); + m.say("/park-mod list", now); + run_for(500000); + check(has(m.chat, "Your role (moderator) cannot use park-mod."), "A moderator could use park-mod"); + Role builder; + builder.name = "builder"; + builder.rank = 20; + builder.commands = {"park-mod"}; + config.roles.list.push_back(builder); + config.roles.settle(); + config.roles.players[mod_id] = "builder"; + m.say("/park-mod", now); // not the same line again: chat turns repeats away + run_for(500000); + check(has(m.chat, "Park mods (2 of 2 spawned"), "A role listing park-mod could not use it"); + check(!has(m.chat, "Your role (builder) cannot use"), "A role listing park-mod was refused it"); + wire().status.peers.erase(std::remove_if(wire().status.peers.begin(), wire().status.peers.end(), + [&](const auto &peer) { return peer.id == mod_id; }), + wire().status.peers.end()); + games.erase(mod_id); + run_for(1000000); + } + // An admin in game: by their name. a.say("/park-mod remove street2", now); run_for(500000);