Park mods: reload and set up parks while the server runs

- park-mod reload <name> [<name>...] 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 <name> | <mod folder> | <map> [| <key>] and
  park-mod config remove <name> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2G4APEkhHgVs59RDL3XWD
This commit is contained in:
Robin MöllerandClaude Opus 5.5 committed 2026-10-11 17:46:45 +00:00
1 parent c3751b9714
commit f8b01dc3d5
5 files changed
+196 -16

No files matched your search

@@ -533,6 +533,11 @@ std::vector<MultiplayerChatCommand> 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 <name> [<name>...]", "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 <name> [<name>...]",
"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 <name> | <mod folder> | <map> [| <key>]",
"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 <name>", "Admin: remove a park mod from the server's setup"});
list.erase(std::remove_if(list.begin() + static_cast<std::ptrdiff_t>(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).
+13
View File
@@ -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 <name>". 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 <name> [<name>...] [minutes] Spawn parks on the current map:
for 1-1440 minutes, or until removed. All or none.
park-mod remove <name> [<name>...] park-mod clear Remove spawned parks.
park-mod reload <name> [<name>...] 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 <name> | <mod folder> | <map> [| <key>] 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 <name> Take one out of the setup (removing it if spawned).
layer-sync on|off layer <key> default|on|off
layers <key>=<mode> ... Several world layers at once, each default, on or off.
tod <default|morning|noon|afternoon|evening|night|weatherday|weathernight>
+64 -1
View File
@@ -14,6 +14,7 @@
#include "Engine/Game/Build/supported_build.h"
#include <algorithm>
#include <fstream>
#include <cstdio>
#include <map>
#include <optional>
@@ -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 <name>") == 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);
+2 -1
View File
@@ -27,7 +27,8 @@ constexpr std::string_view help_text =
"announce <text> | announce-to <player> <text> | announcements [list|add <text>|remove <n>|clear|interval <minutes>|off]\n"
"map-pool [add|remove <map>|clear] | rotation [<minutes>|off]\n"
"park <lot> <layout> | park random | layer-sync on|off | layer <key> default|on|off | tod <time|default>\n"
"park-mod list | park-mod add <name> [<name>...] [minutes] | park-mod remove <name> [<name>...] | park-mod clear\n"
"park-mod list | park-mod add <name> [<name>...] [minutes] | park-mod remove <name> [<name>...] | park-mod clear | park-mod reload <name> [<name>...]\n"
"park-mod config add <name> | <mod folder> | <map> [| <key>] | park-mod config remove <name>\n"
"activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | afk-kick <minutes>|off | speed-check off|warn|kick | board-check off|warn|kick\n"
"score-check [off|warn|kick] | score-allow [<fingerprint>|remove <fingerprint>]\n"
"reserved [slots <n> | add|remove <SteamID64>] | roles [reload] | role <player or SteamID64> <role>|none | update | quit";
+112 -14
View File
@@ -167,9 +167,10 @@ void Host::tick_parks() {
publish_parks();
}
// "park-mod list | add <name> [<name>...] [minutes] | remove <name> [<name>...] | 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 <name> [<name>...] [minutes] | remove <name> [<name>...] | clear |
// reload <name> [<name>...] | config add <name> | <folder> | <map> [| <key>] | config remove <name>".
// 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<std::string> &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<std::string> &names) -> std::optional<std::string> {
for (std::size_t i = 1; i < names.size(); ++i)
if (std::find(names.begin(), names.begin() + static_cast<std::ptrdiff_t>(i), names[i]) != names.begin() + static_cast<std::ptrdiff_t>(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<std::string> 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 <name> | <mod folder> | <map> [| <key>], 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 <name>";
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<std::string> 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 <name> | <mod folder> | <map> [| <key>] | park-mod config remove <name>";
}
std::vector<std::string> 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 <name> | <mod folder> | <map>).";
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 <name> [<name>...]";
for (std::size_t i = 1; i < words.size(); ++i)
if (std::find(words.begin(), words.begin() + static_cast<std::ptrdiff_t>(i), words[i]) != words.begin() + static_cast<std::ptrdiff_t>(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 <name> [<name>...] [minutes] | remove <name> [<name>...] | 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 <name> [<name>...]";
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<LoadedPark> 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<std::string> 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 <name> [<name>...] [minutes] | remove <name> [<name>...] | clear | reload <name> [<name>...]\n"
"park-mod config add <name> | <mod folder> | <map> [| <key>] | park-mod config remove <name>";
}
} // namespace dingosdk::server