mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 16:17:06 +02:00
Merge pull request #163 from moelrobi/server-park-mods
Dedicated server: park mods as server-owned objects
This commit is contained in:
25 files changed
+1525
-9
No files matched your search
+19
-1
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+129
@@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user