Merge pull request #163 from moelrobi/server-park-mods

Dedicated server: park mods as server-owned objects
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-11 13:20:30 -05:00
commit 0b28dbd7c9
25 files changed
+1525 -9

No files matched your search

+19 -1
View File
@@ -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
$<$<BOOL:${WIN32}>: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)
@@ -239,7 +257,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
+14 -2
View File
@@ -527,11 +527,23 @@ std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
list.push_back({"/rotation", "/rotation [<minutes>|off]", "Admin: change the map on a timer, through the map pool"});
list.push_back({"/role", "/role <player> <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 <name> [<name>...] [minutes]",
"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.
// "/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());
}
}
+3
View File
@@ -148,6 +148,9 @@ Map and world
between and the rotation uses.
rotation rotation [<minutes>|off]: change the map on a timer.
park park <lot> <layout>, 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 <key> default|on|off: one world layer.
+59
View File
@@ -32,6 +32,34 @@ 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/<map>.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 <name>" 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.
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
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 +145,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/<key>.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
@@ -301,6 +343,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,
@@ -493,6 +540,18 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu.
"[teleport] <n> player(s) to <target> by <who>".
park <construction|historic|financial> <layout>
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 <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>
@@ -0,0 +1 @@
{"format": "reskate-park", "maps": {"bam": [
@@ -0,0 +1 @@
{"name": "mixed park"}
@@ -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
}
}
}
@@ -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]}]}}
@@ -0,0 +1,6 @@
{
"author": "star",
"description": ":)",
"name": "popular skate 2 street park",
"version_number": "1.0"
}
@@ -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
]
}
]
}
}
@@ -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
]
}
]
}
}
@@ -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
]
}
]
}
}
+68 -1
View File
@@ -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,69 @@ 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");
// 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}},
{"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";
+479
View File
@@ -0,0 +1,479 @@
// 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 <Server/Test/fixtures/park_mods>
#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 <algorithm>
#include <fstream>
#include <cstdio>
#include <map>
#include <optional>
#include <string>
#include <vector>
namespace dingosdk::multiplayer {
namespace {
// What the fake transport holds: what each player is sent, and what they send.
struct Wire {
TransportStatus status;
std::vector<TransportMessage> inbound;
std::map<std::uint64_t, std::vector<std::vector<std::uint8_t>>> sent;
std::map<std::uint64_t, std::string> 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<std::string> 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<const Member>) {}
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<const std::uint8_t> 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<TransportSend> messages) {
for (auto &message : messages) message.sent = send(message.id, message.bytes, message.reliable, message.fresh, message.lane);
}
std::vector<TransportMessage> 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<TransportLink> 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<std::string> &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<Member> server;
ObjectState park; // the server's objects as this game holds them
std::vector<std::string> 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<float, 3>{-583.f, 1948.35f, 1651.f} &&
first.rotation == std::array<float, 4>{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;
};
// 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");
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";
// 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"},
{"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<std::string> 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<std::uint64_t, Game> 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); };
// Roles (Server/roles.h): the player is an admin, whose "*" gives them park-mod.
const std::uint64_t admin_id = 76561198000000001ULL;
config.roles = default_roles();
config.roles.players[admin_id] = "admin";
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);
// 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);
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");
// 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);
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 <park mod fixtures folder>\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;
}
}
+6
View File
@@ -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);
+3
View File
@@ -28,6 +28,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 | 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>] | words [reload | global on|off]\n"
"reserved [slots <n> | add|remove <SteamID64>] | roles [reload] | role <player or SteamID64> <role>|none | update | quit";
@@ -685,6 +687,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();
+62 -1
View File
@@ -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);
@@ -318,6 +332,18 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
if (const auto *layers = find("maps", "layers"); layers && layers->is_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"});
@@ -719,7 +745,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"})
@@ -747,6 +773,40 @@ std::string custom_commands_error(const std::vector<CustomCommand> &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.";
@@ -794,6 +854,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 {};
+27
View File
@@ -62,6 +62,22 @@ struct CustomCommand {
std::vector<std::string> commands;
bool admin{}; // only admins may use it
};
// A park built in the Park Editor and shipped as a mod: <Mods>\<folder>\parks\<key>.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 <name>" 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;
@@ -179,6 +195,10 @@ struct ServerConfig {
// Needs world-layers.json (the players' catalog) next to the server.
bool world_layer_sync{};
std::map<std::string, std::string> layers;
std::vector<ParkMod> 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<MultiplayerBan> bans;
};
@@ -214,6 +234,13 @@ std::string custom_votes_error(const std::vector<CustomVote> &votes);
bool custom_command_name_free(std::string_view name) noexcept;
std::string custom_commands_error(const std::vector<CustomCommand> &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);
+7
View File
@@ -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;
+6 -1
View File
@@ -876,7 +876,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{});
@@ -1458,6 +1460,8 @@ void Host::sync_objects() {
std::vector<Source> 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;
@@ -1845,6 +1849,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);
+17
View File
@@ -442,5 +442,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<NetworkObject> objects; // with the server's own IDs
unsigned minutes{}; // 0: until removed
std::uint64_t expires{};
bool warned{}; // told a minute before
};
std::vector<SpawnedPark> 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
+410
View File
@@ -0,0 +1,410 @@
#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 <algorithm>
#include <fstream>
#include <optional>
#include <stdexcept>
// 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<std::size_t>(size), '\0');
if (!in.read(text.data(), static_cast<std::streamsize>(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<float>();
}
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 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.");
loaded.key = park.key;
if (loaded.key.empty()) {
// Without a key, the mod's only park.
std::vector<std::string> 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/<map>.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<double>() != 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<std::uint64_t>());
if (scales->contains(id)) {
ok = scales->at(id).is_number();
if (ok) object.scale = scales->at(id).get<float>();
}
}
// 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<std::string> &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<NetworkObject> 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<std::string> 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 <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);
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<std::string> &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;
};
// 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);
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, 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) {
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<unsigned> 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<unsigned>(value);
}
if (words.empty()) return "park-mod add <name> [<name>...] [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<const ParkMod *> 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<LoadedPark> 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<std::string> 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 (!loaded[i].skipped.empty()) lines.push_back(skipped_line(park.name, loaded[i].skipped));
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 <name> [<name>...]";
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).";
if (by_vote_) send_chat("The " + name + " park could not be removed: " + why);
return done({"[parks] " + name + " could not be removed: " + why});
}
std::vector<std::string> 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);
}
// 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
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "server_config.h"
#include "Engine/Game/World/network_objects.h"
#include <filesystem>
#include <string>
#include <vector>
// Park mods on a dedicated server: parks built in the Park Editor and shipped as mods
// (<Mods>\<folder>\parks\<key>.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<NetworkObject> objects;
// Objects players' games would not take (multiplayer::valid_network_object): not Build Kit
// items, or a broken position, rotation or scale. "#<n> <item>", n counting from 1.
std::vector<std::string> 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
+6 -1
View File
@@ -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);
+2 -2
View File
@@ -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)