Let server hosts choose the map pool and rotate through it

This commit is contained in:
xThrasherrr committed 2026-10-05 14:40:25 -06:00
1 parent 5bfe4f7bfa
commit b643f76c2f
8 files changed
+210 -10

No files matched your search

+12
View File
@@ -42,6 +42,14 @@ name Shown in the browser (1-64 characters).
map The map everyone skates, named like the game's load command:
"San Vansterdam", "Isle of Grom", "Super Ultra Mega Resort",
"Stadium 1", or a custom map such as "bbcity" (see Custom maps).
map_pool The maps players may vote for and the rotation goes through,
in order, e.g. ["San Vansterdam", "Isle of Grom", "bbcity"].
Empty (the default) allows every map the server knows.
Admins can still change to any map.
map_rotation_minutes Minutes on each map before the server moves to the next
one in map_pool (default 0: off). Players get a minute's
warning; the clock waits while nobody is on, and starts
over whenever the map changes (by a vote or an admin too).
max_players 1-249.
password Empty for anyone; otherwise players type it to join.
welcome A chat line sent to each player as they join.
@@ -53,6 +61,7 @@ global_bans Turn away players the ReSkate team has banned from multiplaye
"bans" apply either way.
votes Player votes, each off until turned on:
"map": {"enabled": true, "percent": 60} /vote map <map>
(a map in map_pool)
"kick": {"enabled": true, "percent": 60} /vote kick <player>
"time_of_day": {"enabled": true, "percent": 50} /vote tod <time>
(needs world_layer_sync)
@@ -128,6 +137,9 @@ and change voice, distances, placement and kicks from the Multiplayer menu.
ban <player or id> [name] For good. unban <id> bans
map <name> e.g. map San Vansterdam, map grom, map bbcity
maps The maps this server knows.
map-pool [add|remove <map>|clear] The maps players vote between and the
rotation uses (see map_pool).
rotation [<minutes>|off] Change the map on a timer (see map_rotation_minutes).
name <text> password <text|off> welcome <text|off> listed on|off
tps 20|30|60|120 voice on|off voice-range <m>
distances <full> <half> <half-return> <low>
+33 -1
View File
@@ -35,7 +35,7 @@ int run() {
const auto config = load_config(file, &added);
const auto has = [&](std::string_view name) { return std::ranges::find(added, name) != added.end(); };
check(has("enforce_tuning") && has("votes.seconds") && has("votes.kick") && has("score_check") && has("score_allow") &&
has("global_bans"),
has("global_bans") && has("map_pool") && has("map_rotation_minutes"),
"New settings not reported");
check(!has("name") && !has("tps") && !has("votes.map"), "Settings the file had reported as new");
const auto written = text(file);
@@ -85,6 +85,38 @@ int run() {
const auto edges = load_config(file);
check(edges.port == 65535 && edges.query_port == 1, "Ports at the ends of the range refused");
static_cast<void>(load_levels(folder / "Mods")); // no Mods folder: the retail maps only
ServerConfig pool;
check(pool_levels(pool).size() == levels().size() && in_map_pool(pool, "Stadium 2"),
"An empty pool does not allow every map");
pool.map_pool = {"Isle", "Isle of Grom", "San Vansterdam", "Stadium 1"};
check(pool_levels(pool).size() == 3, "Pool maps not resolved once each");
check(in_map_pool(pool, "San Vansterdam") && in_map_pool(pool, "Levels/Game/DingoLevel_SDM/DingoLevel_SDM_Int_001/DingoLevel_SDM_Int_001") &&
!in_map_pool(pool, "Super Ultra Mega Resort") && !in_map_pool(pool, "Nowhere"),
"Pool membership wrong");
const auto next = [&](std::string_view map) {
const auto *level = next_pool_map(pool, map);
return level ? level->name : std::string{};
};
check(next("Isle of Grom") == "San Vansterdam" && next("Stadium 1") == "Isle of Grom" &&
next("Super Ultra Mega Resort") == "Isle of Grom",
"Rotation does not follow the pool's order");
pool.map_pool = {"Isle of Grom"};
check(next("Isle of Grom").empty() && next("San Vansterdam") == "Isle of Grom", "A one-map pool rotated to itself");
check(config_error(pool).empty(), "A valid pool refused");
pool.map_pool = {"Isle of Grom", "Nowhere"};
check(!config_error(pool).empty(), "An unknown pool map accepted");
pool.map_pool = {"Isle of Grom", "Stadium 1"};
pool.map_rotation = 15;
pool.file = file;
save_config(pool);
const auto rotating = load_config(file);
check(rotating.map_pool == pool.map_pool && rotating.map_rotation == 15, "Map pool or rotation lost on save");
std::ofstream(file, std::ios::binary) << R"({"map_pool": ["Isle of Grom", 7, ""], "map_rotation_minutes": 5000})";
const auto odd_pool = load_config(file);
check(odd_pool.map_pool == std::vector<std::string>{"Isle of Grom"} && odd_pool.map_rotation == 1440,
"Bad pool entries kept, or rotation not capped");
std::filesystem::remove_all(folder);
if (failures) return 1;
std::cout << "server config: ok\n";
+8 -1
View File
@@ -312,10 +312,17 @@ int run(int argc, char **argv, bool skip_update) {
for (const auto &problem : load_levels(here / "Mods")) write_log("Mods: skipped " + problem);
if (levels().size() > 6) write_log("Mods: " + std::to_string(levels().size() - 6) + " custom map(s).");
// Older configs name the map by its full destination; keep the plain name instead.
bool renamed{};
if (const auto setting = map_setting(config.map); setting != config.map && !setting.empty()) {
config.map = setting;
try { save_config(config); } catch (...) {}
renamed = true;
}
for (auto &map : config.map_pool) // short pool names ("isle") are saved in full
if (const auto *level = find_level(map); level && level->name != map) {
map = level->name;
renamed = true;
}
if (renamed) try { save_config(config); } catch (...) {}
if (const auto error = config_error(config); !error.empty()) {
write_log("Config problem: " + error);
return 1;
+40
View File
@@ -16,6 +16,10 @@ Json to_json(const ServerConfig &c) {
auto root = Json::object();
root["name"] = c.name;
root["map"] = c.map;
auto pool = Json::array();
for (const auto &map : c.map_pool) pool.push_back(map);
root["map_pool"] = std::move(pool);
root["map_rotation_minutes"] = c.map_rotation;
root["max_players"] = c.max_players;
root["password"] = c.password;
root["welcome"] = c.welcome;
@@ -111,6 +115,10 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
if (!root.is_object()) throw std::runtime_error("The server config must be a JSON object.");
c.name = root.value("name", c.name);
c.map = root.value("map", c.map);
if (root.contains("map_pool") && root.at("map_pool").is_array())
for (const auto &map : root.at("map_pool"))
if (map.is_string() && !map.string().empty()) c.map_pool.push_back(map.string());
c.map_rotation = std::min(root.value("map_rotation_minutes", c.map_rotation), 1440U);
c.max_players = root.value("max_players", c.max_players);
c.password = root.value("password", c.password);
c.welcome = root.value("welcome", c.welcome);
@@ -226,6 +234,10 @@ std::string config_error(const ServerConfig &c) {
if (c.map.empty() || !valid_map_destination(map_destination(c.map)))
return "map \"" + c.map + "\" is not a known map. Use a name like \"San Vansterdam\", or put the map's mod "
"folder in Mods next to the server.";
for (const auto &map : c.map_pool)
if (!find_level(map) || !valid_map_destination(map_destination(map)))
return "map_pool: \"" + map + "\" is not a single known map. Use names like \"Isle of Grom\", or put the "
"map's mod folder in Mods next to the server.";
if (c.max_players < 1 || c.max_players + 1 > max_players)
return "max_players must be 1 to " + std::to_string(max_players - 1) + ".";
if (c.password.size() > 64) return "password must be at most 64 characters.";
@@ -333,4 +345,32 @@ std::string map_label(std::string_view map) {
if (const auto *level = find_level(map)) return level->name;
return world_level_name(world_destination_asset(map_destination(map)));
}
std::vector<const ServerLevel *> pool_levels(const ServerConfig &config) {
std::vector<const ServerLevel *> pool;
const auto add = [&](const ServerLevel *level) {
if (level && multiplayer::valid_map_destination(map_destination(level->asset)) &&
std::find(pool.begin(), pool.end(), level) == pool.end())
pool.push_back(level);
};
if (config.map_pool.empty())
for (const auto &level : level_list()) add(&level);
for (const auto &map : config.map_pool) add(find_level(map));
return pool;
}
bool in_map_pool(const ServerConfig &config, std::string_view map) {
if (config.map_pool.empty()) return true;
const auto pool = pool_levels(config);
const auto *level = find_level(map);
return level && std::find(pool.begin(), pool.end(), level) != pool.end();
}
const ServerLevel *next_pool_map(const ServerConfig &config, std::string_view map) {
const auto pool = pool_levels(config);
const auto *current = find_level(map);
const auto at = static_cast<std::size_t>(std::find(pool.begin(), pool.end(), current) - pool.begin());
for (std::size_t step = 1; step <= pool.size(); ++step) {
const auto *next = at == pool.size() ? pool[step - 1] : pool[(at + step) % pool.size()];
if (next != current) return next;
}
return nullptr;
}
} // namespace dingosdk::server
+5
View File
@@ -33,6 +33,8 @@ struct ServerConfig {
// The map everyone skates, named like the game's `load` command: "San Vansterdam",
// "Isle of Grom", or a custom map in Mods\ such as "bbcity".
std::string map = "San Vansterdam";
std::vector<std::string> map_pool; // maps for votes and the rotation, in order; empty: every map
unsigned map_rotation = 0; // minutes per map before the next pool map (0: off)
unsigned max_players = 16; // players; the server itself is not one
std::string password; // empty: anyone may join
std::string welcome; // sent to each player as they join
@@ -107,4 +109,7 @@ std::string map_destination(std::string_view map);
std::string map_setting(std::string_view map);
// A map's name for people: "San Vansterdam".
std::string map_label(std::string_view map);
std::vector<const ServerLevel *> pool_levels(const ServerConfig &config); // known pool maps once each; all when empty
bool in_map_pool(const ServerConfig &config, std::string_view map);
const ServerLevel *next_pool_map(const ServerConfig &config, std::string_view map); // after `map`; null if no other
} // namespace dingosdk::server
+57 -5
View File
@@ -47,6 +47,7 @@ constexpr std::string_view help_text =
"tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low>\n"
"placement everyone|admins|nobody | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n"
"tpall [player] | tphere <player> | votes [map|kick|tod on|off|<percent>] | vote-cancel\n"
"map-pool [add|remove <map>|clear] | rotation [<minutes>|off]\n"
"park <lot> <layout> | layer-sync on|off | layer <key> default|on|off | tod <time|default>\n"
"activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | speed-check off|warn|kick\n"
"score-check [off|warn|kick] | score-allow [<fingerprint>|remove <fingerprint>]\n"
@@ -322,12 +323,16 @@ void Host::send_bans(Guest &admin) {
}
if (send_packet(admin, list, true, false)) admin.bans_sent = bans_revision_;
}
// The maps an admin may switch to: the retail ones and the server's custom maps.
void Host::send_maps(Guest &admin) {
void Host::send_maps(Guest &guest) { // admins: every map; players: the pool
auto list = packet(PacketKind::maps, now_);
for (const auto &level : levels())
const auto add = [&](const ServerLevel &level) {
if (valid_map_asset(level.asset) && list.maps.size() < max_server_maps) list.maps.push_back(level.asset);
if (send_packet(admin, list, true, false)) admin.maps_sent = true;
};
if (is_admin(guest.member.id))
for (const auto &level : levels()) add(level);
else
for (const auto *level : pool_levels(config_)) add(*level);
if (send_packet(guest, list, true, false)) guest.maps_sent = true;
}
void Host::change_map(std::string_view map) {
if (!valid_map_destination(map_destination(map_setting(map))))
@@ -361,6 +366,8 @@ void Host::change_map(std::string_view map) {
activity_.clear(); // throwdowns end with the world
config_.map = map_setting(map);
map_ = map_hash(map_destination(config_.map));
map_since_ = now_;
rotation_warned_ = false;
travel_started_ = now_;
last_world_state_ = 0;
send_world_state();
@@ -807,6 +814,7 @@ void Host::tick(std::uint64_t now) {
activity_.tick(now_);
if (std::exchange(vote_recount_, false)) check_vote(false);
if (vote_ && now_ >= vote_->ends) check_vote(true);
tick_rotation();
std::erase_if(vote_cooldowns_, [&](const auto &entry) { return now_ >= entry.second; });
join_backoff_.prune(now_);
}
@@ -914,9 +922,51 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
}
if (name == "maps") {
std::string text = std::to_string(levels().size()) + " maps (custom maps come from Mods next to the server)";
for (const auto &level : levels()) text += "\n " + level.name + (same_map(level.asset) ? " (now)" : "");
for (const auto &level : levels())
text += "\n " + level.name + (same_map(level.asset) ? " (now)" : "") +
(!config_.map_pool.empty() && in_map_pool(config_, level.asset) ? " (pool)" : "");
return text;
}
if (name == "map-pool") { // map-pool [add|remove <map>|clear]
const auto [what_text, map] = split(argument);
const auto what = lower(what_text);
if (what.empty()) return pool_text();
if (what == "clear") {
config_.map_pool.clear();
resend_maps();
return changed("The map pool is cleared: players vote between every map, and the rotation goes through them all.");
}
if (what != "add" && what != "remove") return "map-pool [add|remove <map>|clear]";
const auto *level = find_level(map);
if (!level || !valid_map_destination(map_destination(level->asset)))
return "No single map is called \"" + std::string(map) + "\". Type maps for the list.";
const auto pooled = [&](const std::string &entry) { return find_level(entry) == level; };
const bool listed = std::any_of(config_.map_pool.begin(), config_.map_pool.end(), pooled);
if (what == "add") {
if (listed || config_.map_pool.empty()) return level->name + " is already in the map pool.";
config_.map_pool.push_back(level->name);
} else {
if (config_.map_pool.empty()) // every map: keep all the others
for (const auto *other : pool_levels(config_)) config_.map_pool.push_back(other->name);
else if (!listed) return level->name + " is not in the map pool.";
if (std::all_of(config_.map_pool.begin(), config_.map_pool.end(), pooled))
return "The map pool needs at least one map. map-pool clear allows every map again.";
std::erase_if(config_.map_pool, pooled);
}
resend_maps();
return changed(level->name + (what == "add" ? " added to" : " removed from") + " the map pool.");
}
if (name == "rotation") { // rotation [<minutes>|off]
if (argument.empty()) return rotation_text();
const auto value = lower(argument);
const auto minutes = value == "off" ? std::optional<std::uint64_t>(0) : number(value);
if (!minutes || *minutes > 1440) return "rotation <1-1440 minutes>|off";
config_.map_rotation = static_cast<unsigned>(*minutes);
map_since_ = now_;
rotation_warned_ = false;
resend_maps();
return changed(rotation_text());
}
if (name == "name") {
if (argument.empty() || argument.size() > 64 || !valid_member_name(argument)) return "Server names are 1 to 64 characters.";
config_.name = argument;
@@ -1292,10 +1342,12 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
if (!individual_steam_id(id)) return "admin add|remove <player or SteamID64>";
if (sub == "add") {
if (!is_admin(id)) config_.admins.push_back(id);
resend_maps();
return changed(std::to_string(id) + " is an admin.");
}
if (sub == "remove") {
std::erase(config_.admins, id);
resend_maps();
return changed(std::to_string(id) + " is no longer an admin.");
}
return "admin add|remove <player or SteamID64>";
+7 -1
View File
@@ -91,7 +91,7 @@ class Host {
bool scoring_flagged{}; // out of linked activities (config score_check)
ChatBudget scoring_budget;
std::uint64_t bans_sent{}; // the ban list revision this admin has
bool maps_sent{}; // this admin has the server's map list
bool maps_sent{}; // this player has the server's map list (send_maps)
// The owner's own upload, and what the server shares of it.
ObjectState objects, shared;
std::uint64_t shared_from{};
@@ -120,6 +120,8 @@ class Host {
std::optional<Vote> vote_;
bool vote_recount_{}; // a player left: recount in tick(), never while guests_ is being walked
std::map<std::uint64_t, std::uint64_t> vote_cooldowns_;
std::uint64_t map_since_{}; // rotation clock start: the last map change, or while nobody is on
bool rotation_warned_{}; // players were told the next map is a minute away
// Parties (server_party.cpp): the server owns them; each roster carries them to everyone.
PartyBook parties_;
std::uint64_t party_revision_{};
@@ -173,6 +175,10 @@ class Host {
std::uint8_t enabled_votes() const;
void reply(Guest &, std::string_view text);
Guest *match_player(std::string_view text);
void tick_rotation();
std::string pool_text() const; // the map pool, one map a line
std::string rotation_text() const; // the rotation's interval and next map
void resend_maps(); // after the pool or the admins change
// Parties (server_party.cpp).
void party_request(Guest &, PartyAction, std::uint64_t player);
void party_command(Guest &, std::string_view line); // "/party ..."
+48 -2
View File
@@ -5,7 +5,7 @@
#include <cmath>
// Player votes (map, kick, time of day) and the chat commands that run them. Each vote is
// switched on and given its pass percentage in ReSkateServer.json ("votes").
// switched on and given its pass percentage in ReSkateServer.json ("votes"); also the map pool and rotation.
namespace dingosdk::server {
namespace {
constexpr std::array<std::string_view, 8> times{"default", "morning", "noon", "afternoon",
@@ -67,6 +67,7 @@ void Host::chat_command(Guest &guest, std::string_view line) {
if (votes & server_vote_kick) text += "/vote kick <player>: start a vote to kick a player\n";
if (votes & server_vote_time) text += "/vote tod <time>: vote for a time of day (morning, noon, night...)\n";
if (votes) text += "/yes or /no: vote in the running vote\n";
if (config_.map_rotation) text += "The map changes every " + std::to_string(config_.map_rotation) + " min.\n";
if (config_.parties) text += "/party: your party (invite, accept, leave...; /party help); /p <message>: party chat\n";
if (is_admin(guest.member.id)) text += "Admins: any server command as /<command>, e.g. /kick, /map, /tpall, /votes\n";
return reply(guest, text.empty() ? "This server has no player votes. Type /tp <player> to teleport." : text);
@@ -117,8 +118,10 @@ void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument) {
case VoteKind::map: {
if (argument.empty()) return reply(guest, "/vote map <map>, e.g. /vote map grom");
// Players vote between the server's own maps (/maps); a raw level path is admins only.
if (!find_level(argument) || !valid_map_destination(map_destination(argument)))
const auto *level = find_level(argument);
if (!level || !valid_map_destination(map_destination(argument)))
return reply(guest, "No single map is called \"" + std::string(argument) + "\".");
if (!in_map_pool(config_, level->asset)) return reply(guest, level->name + " is not one of this server's maps.\n" + pool_text());
if (map_hash(map_destination(argument)) == map_) return reply(guest, "The server is already on that map.");
vote.value = std::string(argument);
vote.label = "change the map to " + map_label(argument);
@@ -219,4 +222,47 @@ void Host::check_vote(bool expired) {
break;
}
}
std::string Host::pool_text() const {
std::string text = config_.map_pool.empty() ? "Map pool: every map" : "Map pool:";
if (!config_.map_pool.empty())
for (const auto *level : pool_levels(config_)) text += "\n " + level->name + (same_map(level->asset) ? " (now)" : "");
return text;
}
std::string Host::rotation_text() const {
if (!config_.map_rotation) return "Map rotation is off.";
const auto every = "The map changes every " + std::to_string(config_.map_rotation) + " min";
const auto *next = next_pool_map(config_, config_.map);
if (!next) return every + ", but the map pool has no other map.";
if (!players()) return every + " while players are on. Next: " + next->name + ".";
const auto due = map_since_ + std::uint64_t{config_.map_rotation} * 60000000;
const auto left = due > now_ ? (due - now_ + 59999999) / 60000000 : 0;
return every + ". Next: " + next->name + " in about " + std::to_string(std::max<std::uint64_t>(left, 1)) + " min.";
}
void Host::resend_maps() {
for (auto &[id, guest] : guests_) guest->maps_sent = false;
}
void Host::tick_rotation() { // waits while nobody is on, and for a running map vote
if (!config_.map_rotation || !players()) {
map_since_ = now_;
rotation_warned_ = false;
return;
}
const auto due = map_since_ + std::uint64_t{config_.map_rotation} * 60000000;
if (now_ + 60000000 < due) return;
const auto *next = next_pool_map(config_, config_.map);
if (!next) {
map_since_ = now_;
return;
}
if (!rotation_warned_ && config_.map_rotation > 1) {
rotation_warned_ = true;
send_chat("Next map in 1 minute: " + next->name + ".");
}
if (now_ < due || (vote_ && vote_->kind == VoteKind::map)) return;
send_chat("Changing the map to " + next->name + ".");
log_("[rotation] Changing the map to " + next->name + ".");
change_map(next->name);
save();
}
} // namespace dingosdk::server