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