diff --git a/CMakeLists.txt b/CMakeLists.txt index 40cc1fc..e9e7d7d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 + $<$: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 diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 787671e..8e0b5f2 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -527,11 +527,23 @@ std::vector chat_commands(const Session &s) { list.push_back({"/rotation", "/rotation [|off]", "Admin: change the map on a timer, through the map pool"}); list.push_back({"/role", "/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 [...] [minutes]", + "Admin: spawn park mods on this map, for 1-1440 minutes or until removed"}); + list.push_back({"/park-mod remove", "/park-mod remove [...]", "Admin: remove spawned park mods"}); + list.push_back({"/park-mod clear", "/park-mod clear", "Admin: remove every spawned park mod"}); + list.push_back({"/park-mod reload", "/park-mod reload [...]", + "Admin: read a spawned park mod's file again and update it in place"}); + list.push_back({"/park-mod config add", "/park-mod config add | | [| ]", + "Admin: set up a park mod from the server's Mods folder, without a restart"}); + list.push_back({"/park-mod config remove", "/park-mod config remove ", "Admin: remove a park mod from the server's setup"}); list.erase(std::remove_if(list.begin() + static_cast(before_commands), list.end(), [&](const auto &entry) { - // "/poll end" is the polls permission's; the others are named for their command. + // "/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()); } } diff --git a/Server/README-roles.txt b/Server/README-roles.txt index 2a2fd4d..a9ffe80 100644 --- a/Server/README-roles.txt +++ b/Server/README-roles.txt @@ -148,6 +148,9 @@ Map and world between and the rotation uses. rotation rotation [|off]: change the map on a timer. park park , 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 default|on|off: one world layer. diff --git a/Server/README.txt b/Server/README.txt index 3bdd7d3..99d96ab 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -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/.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 " 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 ". 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/.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] player(s) to by ". park 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 [...] [minutes] Spawn parks on the current map: + for 1-1440 minutes, or until removed. All or none. + park-mod remove [...] park-mod clear Remove spawned parks. + park-mod reload [...] Read a spawned park's file again (its mod + was updated) and swap its objects in place; it keeps + its timer. + park-mod config add | | [| ] Set up a park mod + while the server runs, saved to maps.park_mods, e.g. + park-mod config add street | popular skate 2 street park | San Vansterdam | bam + park-mod config remove Take one out of the setup (removing it if spawned). layer-sync on|off layer default|on|off layers = ... Several world layers at once, each default, on or off. tod diff --git a/Server/Test/fixtures/park_mods/broken/parks/bam.park.json b/Server/Test/fixtures/park_mods/broken/parks/bam.park.json new file mode 100644 index 0000000..0617418 --- /dev/null +++ b/Server/Test/fixtures/park_mods/broken/parks/bam.park.json @@ -0,0 +1 @@ +{"format": "reskate-park", "maps": {"bam": [ diff --git a/Server/Test/fixtures/park_mods/mixed park/manifest.json b/Server/Test/fixtures/park_mods/mixed park/manifest.json new file mode 100644 index 0000000..7f3cf67 --- /dev/null +++ b/Server/Test/fixtures/park_mods/mixed park/manifest.json @@ -0,0 +1 @@ +{"name": "mixed park"} diff --git a/Server/Test/fixtures/park_mods/mixed park/parks/grom.park.json b/Server/Test/fixtures/park_mods/mixed park/parks/grom.park.json new file mode 100644 index 0000000..b60c8b5 --- /dev/null +++ b/Server/Test/fixtures/park_mods/mixed park/parks/grom.park.json @@ -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 + } + } +} diff --git a/Server/Test/fixtures/park_mods/not a park/parks/bam.park.json b/Server/Test/fixtures/park_mods/not a park/parks/bam.park.json new file mode 100644 index 0000000..5a8502f --- /dev/null +++ b/Server/Test/fixtures/park_mods/not a park/parks/bam.park.json @@ -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]}]}} diff --git a/Server/Test/fixtures/park_mods/popular skate 2 street park/manifest.json b/Server/Test/fixtures/park_mods/popular skate 2 street park/manifest.json new file mode 100644 index 0000000..5ccbe48 --- /dev/null +++ b/Server/Test/fixtures/park_mods/popular skate 2 street park/manifest.json @@ -0,0 +1,6 @@ +{ + "author": "star", + "description": ":)", + "name": "popular skate 2 street park", + "version_number": "1.0" +} diff --git a/Server/Test/fixtures/park_mods/popular skate 2 street park/parks/bam.park.json b/Server/Test/fixtures/park_mods/popular skate 2 street park/parks/bam.park.json new file mode 100644 index 0000000..ac58bec --- /dev/null +++ b/Server/Test/fixtures/park_mods/popular skate 2 street park/parks/bam.park.json @@ -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 + ] + } + ] + } +} diff --git a/Server/Test/fixtures/park_mods/two maps/parks/bam.park.json b/Server/Test/fixtures/park_mods/two maps/parks/bam.park.json new file mode 100644 index 0000000..0fd8e81 --- /dev/null +++ b/Server/Test/fixtures/park_mods/two maps/parks/bam.park.json @@ -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 + ] + } + ] + } +} diff --git a/Server/Test/fixtures/park_mods/two maps/parks/mpr.park.json b/Server/Test/fixtures/park_mods/two maps/parks/mpr.park.json new file mode 100644 index 0000000..ac7c0e1 --- /dev/null +++ b/Server/Test/fixtures/park_mods/two maps/parks/mpr.park.json @@ -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 + ] + } + ] + } +} diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 38595fe..35a6ccf 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -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"; diff --git a/Server/Test/server_park_tests.cpp b/Server/Test/server_park_tests.cpp new file mode 100644 index 0000000..3fa5770 --- /dev/null +++ b/Server/Test/server_park_tests.cpp @@ -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 +#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 +#include +#include +#include +#include +#include +#include + +namespace dingosdk::multiplayer { +namespace { +// What the fake transport holds: what each player is sent, and what they send. +struct Wire { + TransportStatus status; + std::vector inbound; + std::map>> sent; + std::map 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 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) {} +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 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 messages) { + for (auto &message : messages) message.sent = send(message.id, message.bytes, message.reliable, message.fresh, message.lane); +} +std::vector 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 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 &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 server; + ObjectState park; // the server's objects as this game holds them + std::vector 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{-583.f, 1948.35f, 1651.f} && + first.rotation == std::array{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 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 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 ") == 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 \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; + } +} diff --git a/Server/main.cpp b/Server/main.cpp index 0b3e580..3edc186 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -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); diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 8996ea4..7ee6384 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -28,6 +28,8 @@ constexpr std::string_view help_text = "announce | announce-to | announcements [list|add |remove |clear|interval |off]\n" "map-pool [add|remove |clear] | rotation [|off]\n" "park | park random | layer-sync on|off | layer default|on|off | tod \n" + "park-mod list | park-mod add [...] [minutes] | park-mod remove [...] | park-mod clear | park-mod reload [...]\n" + "park-mod config add | | [| ] | park-mod config remove \n" "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | afk-kick |off | speed-check off|warn|kick | board-check off|warn|kick\n" "score-check [off|warn|kick] | score-allow [|remove ] | words [reload | global on|off]\n" "reserved [slots | add|remove ] | roles [reload] | 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(); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 2c2b7e3..716e80a 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -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::vectoris_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 &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 {}; diff --git a/Server/server_config.h b/Server/server_config.h index 4da3023..e0c4bf7 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -62,6 +62,22 @@ struct CustomCommand { std::vector commands; bool admin{}; // only admins may use it }; +// A park built in the Park Editor and shipped as a mod: \\parks\.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 " 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 layers; + std::vector 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 bans; }; @@ -214,6 +234,13 @@ std::string custom_votes_error(const std::vector &votes); bool custom_command_name_free(std::string_view name) noexcept; std::string custom_commands_error(const std::vector &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); diff --git a/Server/server_engine.cpp b/Server/server_engine.cpp index 5e2d271..f8c07c9 100644 --- a/Server/server_engine.cpp +++ b/Server/server_engine.cpp @@ -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; diff --git a/Server/server_host.cpp b/Server/server_host.cpp index da544f7..94a8d44 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -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 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); diff --git a/Server/server_host.h b/Server/server_host.h index 1155a37..4b2ed01 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -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 objects; // with the server's own IDs + unsigned minutes{}; // 0: until removed + std::uint64_t expires{}; + bool warned{}; // told a minute before + }; + std::vector 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 diff --git a/Server/server_parks.cpp b/Server/server_parks.cpp new file mode 100644 index 0000000..6c73390 --- /dev/null +++ b/Server/server_parks.cpp @@ -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 +#include +#include +#include + +// 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(size), '\0'); + if (!in.read(text.data(), static_cast(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(); + } + 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 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/.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() != 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()); + if (scales->contains(id)) { + ok = scales->at(id).is_number(); + if (ok) object.scale = scales->at(id).get(); + } + } + // 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 &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 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 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 [...] [minutes] | remove [...] | clear | +// reload [...] | config add | | [| ] | config remove ". +// What it did is a "[parks]" line: the console and a vote log the reply, so it is logged here only +// for an admin in game. A vote's refusal also goes to chat, where the players who voted can read it. +std::string Host::park_mod_command(std::string_view argument, std::uint64_t admin) { + const auto [action, rest] = split(argument); + const auto verb = lower(action); + 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 &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 &skipped) { + std::string items; + for (std::size_t j = 0; j < skipped.size() && j < 8; ++j) items += (j ? ", " : "") + skipped[j]; + return "[parks] " + std::string(name) + ": skipped " + std::to_string(skipped.size()) + + " object(s) players' games would not show: " + items + (skipped.size() > 8 ? ", ..." : ""); + }; + const auto named_twice = [](const std::vector &names) -> std::optional { + for (std::size_t i = 1; i < names.size(); ++i) + if (std::find(names.begin(), names.begin() + static_cast(i), names[i]) != names.begin() + static_cast(i)) + return names[i]; + return {}; + }; + // Setting parks up while the server runs: saved to ReSkateServer.json like the map pool, so a + // park mod copied into Mods needs no restart. + if (verb == "config") { + const auto [what, fields] = split(rest); + const auto sub = lower(what); + if (sub == "add") { + std::vector parts; + for (auto left = fields;;) { + const auto bar = left.find('|'); + parts.emplace_back(trim(left.substr(0, bar))); + if (bar == std::string_view::npos) break; + left = left.substr(bar + 1); + } + if (parts.size() < 3 || parts.size() > 4 || parts[0].empty()) + return "park-mod config add | | [| ], e.g. park-mod config add street | " + "popular skate 2 street park | San Vansterdam | bam"; + ParkMod park{lower(parts[0]), parts[1], parts[2], parts.size() > 3 ? lower(parts[3]) : std::string{}}; + const auto refuse = [&](const std::string &why) { return done({"[parks] " + park.name + " could not be set up: " + why}); }; + if (configured(park.name)) return refuse(park.name + " is already set up (park-mod config remove " + park.name + " first)."); + if (const auto *level = find_level(park.map)) park.map = level->name; + auto next = config_; + next.park_mods.push_back(park); + if (const auto error = park_mods_error(next); !error.empty()) return refuse(error); + config_.park_mods.push_back(park); + save(); + std::error_code missing; + const bool there = std::filesystem::is_directory(config_.mods / std::filesystem::path(park.folder), missing); + return done({"[parks] " + park.name + " set up (\"" + park.folder + "\"" + (park.key.empty() ? "" : ", " + park.key) + " on " + + park.map + ") by " + who + (there ? "" : "; its folder is not in the Mods folder yet")}); + } + if (sub == "remove") { + const auto name = lower(trim(fields)); + if (name.empty()) return "park-mod config remove "; + const auto found = std::find_if(config_.park_mods.begin(), config_.park_mods.end(), [&](const auto &park) { return park.name == name; }); + if (found == config_.park_mods.end()) + return done({"[parks] " + name + " could not be removed from the setup: there is no park mod called \"" + name + "\" (park-mod list)."}); + std::vector lines; + if (const auto up = spawned(name); up != parks_spawned_.end()) { + parks_spawned_.erase(up); + publish_parks(); + send_chat("The " + name + " park was removed."); + lines.push_back("[parks] " + name + " removed (by " + who + ")"); + } + config_.park_mods.erase(found); + save(); + lines.push_back("[parks] " + name + " no longer set up (by " + who + ")"); + return done(lines); + } + return "park-mod config add | | [| ] | park-mod config remove "; + } + std::vector words; + for (auto left = rest; !left.empty();) { + const auto [word, more] = split(left); + 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 | | )."; + 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 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(value); + } + if (words.empty()) return "park-mod add [...] [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 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 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 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 [...]"; + 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 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 [...]"; + const auto names = listed(words); + const auto refuse = [&](const std::string &why) { + if (by_vote_) send_chat("The " + names + " park" + (words.size() > 1 ? "s" : "") + " could not be reloaded: " + why); + return done({"[parks] " + names + " could not be reloaded: " + why}); + }; + if (const auto twice = named_twice(words)) return refuse(*twice + " is named twice."); + std::vector loaded; + auto total = objects(); + for (const auto &name : words) { + const auto *park = configured(name); + if (!park) return refuse("there is no park mod called \"" + name + "\" (park-mod list)."); + const auto up = spawned(name); + if (up == parks_spawned_.end()) return refuse(name + " is not spawned (park-mod add " + name + ")."); + try { + loaded.push_back(load_park_mod(config_.mods, *park)); + } catch (const std::exception &e) { + return refuse(e.what()); + } + total = total - up->objects.size() + loaded.back().objects.size(); + } + if (total > config_.park_limits.objects) + return refuse("that makes " + std::to_string(total) + " park objects, and at most " + std::to_string(config_.park_limits.objects) + + " can be spawned at once."); + std::vector lines; + for (std::size_t i = 0; i < words.size(); ++i) { + auto &park = *spawned(words[i]); + park.objects = std::move(loaded[i].objects); + for (auto &object : park.objects) object.id = ++park_object_ids_; + lines.push_back("[parks] " + park.name + " reloaded (" + std::to_string(park.objects.size()) + " objects) by " + who); + if (!loaded[i].skipped.empty()) lines.push_back(skipped_line(park.name, loaded[i].skipped)); + } + publish_parks(); + send_chat(words.size() > 1 ? "The " + names + " parks were updated." : "The " + names + " park was updated."); + return done(lines); + } + return "park-mod list | add [...] [minutes] | remove [...] | clear | reload [...]\n" + "park-mod config add | | [| ] | park-mod config remove "; +} +} // namespace dingosdk::server diff --git a/Server/server_parks.h b/Server/server_parks.h new file mode 100644 index 0000000..dd1c4fe --- /dev/null +++ b/Server/server_parks.h @@ -0,0 +1,24 @@ +#pragma once +#include "server_config.h" +#include "Engine/Game/World/network_objects.h" +#include +#include +#include + +// Park mods on a dedicated server: parks built in the Park Editor and shipped as mods +// (\\parks\.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 objects; + // Objects players' games would not take (multiplayer::valid_network_object): not Build Kit + // items, or a broken position, rotation or scale. "# ", n counting from 1. + std::vector 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 diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index 8e2c0b2..3b7bb04 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -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); diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 575205b..073d75c 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -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)