diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d820c35..457082ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -126,6 +126,14 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) target_include_directories(dingosdk_server_words_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_server_words_tests PRIVATE dingosdk_word_filter) add_test(NAME server_words COMMAND dingosdk_server_words_tests) + add_executable(dingosdk_server_map_update_tests Server/Test/server_map_update_tests.cpp Server/server_map_update.cpp + Engine/Vfs/thunderstore_package.cpp) + target_include_directories(dingosdk_server_map_update_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_server_map_update_tests PRIVATE dingosdk_json) + if(WIN32) + target_link_libraries(dingosdk_server_map_update_tests PRIVATE dingosdk_https) + endif() + add_test(NAME server_map_update COMMAND dingosdk_server_map_update_tests) add_executable(dingosdk_word_filter_tests Engine/Core/Text/Test/word_filter_tests.cpp) target_link_libraries(dingosdk_word_filter_tests PRIVATE dingosdk_word_filter) add_test(NAME word_filter COMMAND dingosdk_word_filter_tests) diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 43d1c462..27e45be5 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -102,7 +102,7 @@ struct MultiplayerChatLine { std::string name, text; bool local{}; // sent by this player // The sender's role, as their nametag shows it: its colour (IM_COL32 layout, 0 = none) - // and a tag shown in a box before the name ("Dev", "Staff", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend", "Server" or empty). + // and a tag shown in a box before the name ("Dev", "ReSkate Admin", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend", "Server" or empty). std::uint32_t color{}; std::string tag; // With the chat filter on, `text` is masked and this is the line as sent (same length), so @@ -206,7 +206,7 @@ struct MultiplayerModel { float chat_bubbles_distance{40.f}; float chat_bubbles_duration{5.f}; int chat_bubbles_history{3}; - // Local: the tag the ReSkate backend gives this player ("Dev", "Staff", "Content Creator", "Centrix" or "Homie"; empty + // Local: the tag the ReSkate backend gives this player ("Dev", "ReSkate Admin", "Content Creator", "Centrix" or "Homie"; empty // for most players) and its role colour, and whether they show it, and the animated items // that come with it, to everyone. // The local player is on the ReSkate team's developer, staff or homie list: the player list diff --git a/Extension/Assets/map_download.cpp b/Extension/Assets/map_download.cpp index 2e2636e6..ade729e5 100644 --- a/Extension/Assets/map_download.cpp +++ b/Extension/Assets/map_download.cpp @@ -346,6 +346,9 @@ View view() { void tick() { const auto now = stage.load(); + // The session leaves the map alone until it has been applied (an update's level is one the + // game already has, and would load from files that are being replaced). + multiplayer::hold_map_load(now == Stage::downloading || now == Stage::installing || now == Stage::applying); if (now == Stage::applying) { if (apply_wanted.load()) { // (A merge of the player's own may be running: wait for it and start this one.) diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index baa24da0..5f3710eb 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -36,7 +36,7 @@ inline constexpr std::uint32_t nametag_white = 0xffffffffU, nametag_developer = nametag_creator = 0xff6464ffU, // red nametag_homie = 0xff7ae2ffU, // gold nametag_centrix = 0xffff7b1fU, // blue (#1F7BFF) - nametag_staff = 0xff71cc2eU, // green (#2ECC71) + nametag_staff = 0xfffff9b2U, // pale cyan (#B2F9FF) nametag_admin = 0xffc674ffU, // pink nametag_host = 0xffffc85eU, // blue nametag_friend = 0xff8ae07bU, // green @@ -48,7 +48,7 @@ inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU, // #6E20FF nametag_creator_start = 0xff2e10c8U, // #C8102E nametag_homie_start = 0xff008ae0U, // #E08A00 nametag_centrix_start = 0xffffffffU, // white - nametag_staff_start = 0xff3a6b0bU; // #0B6B3A + nametag_staff_start = 0xffff0025U; // #2500FF // Client thread, once per rendered frame: everyone to label and the local skater's position // (for distances). Also reads whether the game is hiding its own nametags right now. diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index ce71a337..c807c9de 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -220,12 +220,33 @@ inline std::string_view map_package_version(std::string_view package) noexcept { const auto second = first == std::string_view::npos ? first : package.find('-', first + 1); return second == std::string_view::npos ? std::string_view{} : package.substr(second + 1); } -// The version of a map installed here, when the host's package is the same map at another -// one: players skate the host's version, so that is fetched first. Nothing when the versions -// agree, either names none, or they are different packages. +// Whether version `a` is a later one than `b`: their numbers compared in turn ("1.10.0" is +// later than "1.9.2"), a missing one counting as 0. False when either is not numbers and dots. +inline bool map_version_newer(std::string_view a, std::string_view b) noexcept { + const auto next = [](std::string_view &text, std::uint64_t &value) { + value = 0; + std::size_t i = 0; + for (; i < text.size() && text[i] >= '0' && text[i] <= '9' && i < 9; ++i) value = value * 10 + static_cast(text[i] - '0'); + if (!i || (i < text.size() && text[i] != '.')) return false; + text.remove_prefix(i < text.size() ? i + 1 : i); + return true; + }; + if (a.empty() || b.empty()) return false; + bool newer{}, decided{}; + while (!a.empty() || !b.empty()) { + std::uint64_t left{}, right{}; + if ((!a.empty() && !next(a, left)) || (!b.empty() && !next(b, right))) return false; + if (!decided && left != right) newer = left > right, decided = true; + } + return newer; +} +// The version of a map installed here, when the host's package is the same map at a later +// one: that is fetched first, so players skate at least the host's version. Nothing when the +// versions agree, the one here is the later (a player ahead of the host is left as they are), +// either names none, or they are different packages. inline std::string_view other_map_version(std::string_view host, std::string_view mine) noexcept { const auto wanted = map_package_version(host), have = map_package_version(mine); - if (wanted.empty() || have.empty() || wanted == have) return {}; + if (wanted.empty() || have.empty() || wanted == have || map_version_newer(have, wanted)) return {}; const auto host_name = host.substr(0, host.size() - wanted.size()), my_name = mine.substr(0, mine.size() - have.size()); if (host_name.size() != my_name.size()) return {}; for (std::size_t i = 0; i < host_name.size(); ++i) diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 5575a058..14b4ab69 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -706,6 +706,7 @@ std::string take_leave_notice() { return std::exchange(session().leave_notice, { std::optional take_map_need() { return std::exchange(session().map_need, std::nullopt); } void set_map_package_lookup(MapPackageLookup lookup) noexcept { map_package_lookup.store(lookup, std::memory_order_relaxed); } bool fetching_map() noexcept { return fetching_map_flag.load(std::memory_order_relaxed); } +void hold_map_load(bool hold) noexcept { map_load_held.store(hold, std::memory_order_relaxed); } MultiplayerChat chat() { auto &s = session(); std::lock_guard lock(s.mutex); @@ -804,7 +805,9 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade // fetched at the host's version as a missing one is (the downloader puts it in the // installed one's place), and loads when the two agree. const auto have = std::string(other_map_version(s.map_package, host_map_package(s.join_destination))); - const auto result = have.empty() ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing; + // (Nor while its files are being put in place and applied: hold_map_load.) + const bool held = map_load_held.load(std::memory_order_relaxed); + const auto result = have.empty() && !held ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing; if (result == MapLoadResult::missing) { const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label; const char *who = dedicated_host(s) ? "server" : "host"; diff --git a/Extension/Multiplayer/Session/session.h b/Extension/Multiplayer/Session/session.h index 7baecc28..9d7c3145 100644 --- a/Extension/Multiplayer/Session/session.h +++ b/Extension/Multiplayer/Session/session.h @@ -43,6 +43,11 @@ struct MapNeed { std::optional take_map_need(); // Whether a session is waiting on a map being fetched right now. Thread-safe. bool fetching_map() noexcept; +// Held while a map is being downloaded, installed or applied: the session does not load the +// host's map meanwhile, and waits as it does for one that is missing. A map that is being +// replaced by another version is one the game already knows, so without this it would load as +// soon as the new files are in place, before the mods are applied again. Thread-safe. +void hold_map_load(bool hold) noexcept; // How a host learns the package of the map it is on, to tell its guests: the level's asset in, // the package (or nothing) out. Called on the client thread, often; it should be quick. using MapPackageLookup = std::string (*)(std::string_view asset); diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 6f588b66..18413c8e 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -446,7 +446,7 @@ bool identity_link(Session &s, std::uint64_t other); void add_chat(Session &s, std::uint64_t sender, std::string name, std::string text, bool local = false, bool marks = true); // The colour and badge of one of the backend's categories. std::pair mark_role(IdentityList list); -// A player's role colour and badge ("Dev", "Staff", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or none), shown in chat +// A player's role colour and badge ("Dev", "ReSkate Admin", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or none), shown in chat // and on their nametag. `local`: the local player. std::pair player_role(Session &s, std::uint64_t id, bool local, bool marks = true); // Sends one line from this player; returns why not when it cannot. @@ -462,6 +462,7 @@ std::string answer_server_poll(Session &s, std::size_t answer); inline std::atomic server_vote_open_flag{}; inline std::atomic map_package_lookup{}; inline std::atomic fetching_map_flag{}; +inline std::atomic map_load_held{}; // A guest without the map is downloading it: the clock they are loading against starts over // each time they say so, for as long as map_fetch_limit_us from the first time. inline void guest_fetching(Peer &peer, std::uint64_t now) { diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 3ae3db95..1df386ba 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -666,7 +666,7 @@ std::pair mark_role(IdentityList list) { case IdentityList::developer: return {nametag_developer, "Dev"}; case IdentityList::content_creator: return {nametag_creator, "Content Creator"}; case IdentityList::centrix: return {nametag_centrix, "Centrix"}; - case IdentityList::staff: return {nametag_staff, "Staff"}; + case IdentityList::staff: return {nametag_staff, "ReSkate Admin"}; default: return {nametag_homie, "Homie"}; } } diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index c260ae5a..d070dd56 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -1172,6 +1172,13 @@ void dedicated_server_codec() { other_map_version("Sandos-Vancouver_Plaza-2.0.0", "Sandos-Vancouver_Plaza").empty() && other_map_version("Sandos-Vancouver_Plaza-2.0.0", "Other-Vancouver_Plaza-1.9.0").empty() && other_map_version("Sandos-Vancouver_Plaza-2.0.0", "").empty() && other_map_version("", "").empty() && + // (A player ahead of the host keeps their version.) + other_map_version("Sandos-Vancouver_Plaza-1.9.0", "Sandos-Vancouver_Plaza-2.0.0").empty() && + other_map_version("Sandos-Vancouver_Plaza-1.9.2", "Sandos-Vancouver_Plaza-1.10.0").empty() && + other_map_version("Sandos-Vancouver_Plaza-1.10.0", "Sandos-Vancouver_Plaza-1.9.2") == "1.9.2" && + map_version_newer("1.10.0", "1.9.2") && map_version_newer("2.0", "1.9.9") && map_version_newer("1.0.1", "1.0") && + !map_version_newer("1.0.0", "1.0.0") && !map_version_newer("1.0", "1.0.0") && !map_version_newer("1.9.2", "1.10.0") && + !map_version_newer("", "1.0.0") && !map_version_newer("1.0.0-beta", "0.9.0") && !map_version_newer("abc", "1.0.0") && map_package_version("Sandos-Vancouver_Plaza-2.0.0") == "2.0.0" && map_package_version("Sandos-Vancouver_Plaza").empty(), "A map's version is not compared with the host's as it should be"); check(map_package_name("Sandos-Vancouver_Plaza", "2.0.0") == "Sandos-Vancouver_Plaza-2.0.0" && diff --git a/Extension/UI/Overlay/chat_overlay.cpp b/Extension/UI/Overlay/chat_overlay.cpp index ba3fcc88..abf8b8d5 100644 --- a/Extension/UI/Overlay/chat_overlay.cpp +++ b/Extension/UI/Overlay/chat_overlay.cpp @@ -607,16 +607,23 @@ void draw_vote(const MultiplayerVote& vote, ImFont* heading, ImFont* body, ImVec draw->AddText(heading, size, ImVec2(left, y), theme::paper, question.c_str(), nullptr, inner); y += question_extent.y + gap; // The votes cast, yes in green from the left and no in red from the right: a vote is - // decided by those who vote. The mark is where yes has to reach to pass; nothing is cast - // yet, the bar is empty. + // decided by those who vote. The mark is in the middle whatever share the vote needs, and + // green reaches it when the vote would pass: up to the yes votes needed it grows over the + // left half, and past them over the right half. Nothing cast yet, the bar is empty. const unsigned cast = static_cast(vote.yes) + vote.no; draw->AddRectFilled(ImVec2(left, y), ImVec2(right, y + bar), skate_theme::tile_light); if (cast) { - const float split = std::floor(left + inner * static_cast(vote.yes) / static_cast(cast)); + float share = static_cast(vote.yes) / static_cast(cast); + if (const unsigned needed = vote.needed) { + const unsigned yes = vote.yes; + share = yes <= needed ? 0.5f * static_cast(yes) / static_cast(needed) + : 0.5f + 0.5f * static_cast(yes - needed) / static_cast(cast - needed); + } + const float split = std::floor(left + inner * std::clamp(share, 0.0f, 1.0f)); if (vote.yes) draw->AddRectFilled(ImVec2(left, y), ImVec2(split, y + bar), green); if (vote.no) draw->AddRectFilled(ImVec2(split, y), ImVec2(right, y + bar), red); - if (running && vote.needed && vote.needed <= cast) { - const float mark = std::floor(left + inner * static_cast(vote.needed) / static_cast(cast)); + if (running && vote.needed) { + const float mark = std::floor(left + inner * 0.5f); draw->AddRectFilled(ImVec2(mark - scale, y - 3.0f * scale), ImVec2(mark + scale, y + bar + 3.0f * scale), theme::paper); } } diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 5e3e83c8..f113705b 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -391,7 +391,7 @@ struct Nametag { std::array position{}; // above the skater's head, world space std::string name; std::uint32_t color{0xffffffffU}; // R, G, B, A bytes (IM_COL32) - std::string tag; // role badge before the name ("Dev", "Staff", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend") + std::string tag; // role badge before the name ("Dev", "ReSkate Admin", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend") float distance{}; // metres from the local skater bool talking{}; // Recent chat lines to show as bubbles above the head, oldest first ("" = none). diff --git a/Extension/UI/Overlay/role_badge.h b/Extension/UI/Overlay/role_badge.h index 1b9ce0ce..099e9cdd 100644 --- a/Extension/UI/Overlay/role_badge.h +++ b/Extension/UI/Overlay/role_badge.h @@ -4,7 +4,7 @@ #include #include -// The role badge ("Dev", "Staff", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend") before a player's name, in chat +// The role badge ("Dev", "ReSkate Admin", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend") before a player's name, in chat // and on nametags: a pill in the role's colour (animated for the roles in nametag_gradient.h) with dark text. namespace dingosdk::overlay::detail { inline float role_badge_width(ImFont *font, float size, const std::string &tag) { diff --git a/Server/GUI/server_gui.cpp b/Server/GUI/server_gui.cpp index 2e708a4b..943b0539 100644 --- a/Server/GUI/server_gui.cpp +++ b/Server/GUI/server_gui.cpp @@ -70,7 +70,7 @@ inline std::pair player_role(std::uint64_t id, bool case multiplayer::IdentityList::developer: return {nametag_developer, "Dev"}; case multiplayer::IdentityList::content_creator: return {nametag_creator, "Content Creator"}; case multiplayer::IdentityList::centrix: return {nametag_centrix, "Centrix"}; - case multiplayer::IdentityList::staff: return {nametag_staff, "Staff"}; + case multiplayer::IdentityList::staff: return {nametag_staff, "ReSkate Admin"}; default: return {nametag_homie, "Homie"}; } } diff --git a/Server/README.txt b/Server/README.txt index 61e8810b..055fcb6e 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -158,6 +158,22 @@ rotation_extend_minutes and "rotation extend [minutes]" adds time at once. rotation_announce Tell players on a card when the map has 10, 5 and 1 minutes left (default true). +latest_versions Keep this server's custom maps at Thunderstore's newest + version (default false). A map mod in Mods whose folder is a + Thunderstore package (Owner-Name) is looked up when the + server starts and every latest_check_minutes; when a newer + version is out, that version is written into the folder's + manifest.json and the maps are read again. Nothing is + downloaded: the server only needs to know the version, and + players, who are sent to the server's version of a map, + fetch it as they join. A player who already has a later + version than the server keeps it. The map being played is + left until nobody is on it. A folder that is not a + Thunderstore package is never touched. + The level list is not changed: when a new version adds, + removes or renames levels, copy its reskate-levels.json in. +latest_check_minutes + How often Thunderstore is asked, 5-1440 (default 60). 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". @@ -540,6 +556,11 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu. maps The maps this server knows. maps reload Read the Mods folder again: a map mod copied in while the server runs can be switched to at once. + maps update Ask Thunderstore now for the newest version of + each of this server's maps and take it (see + maps.latest_versions); the log says what it did. + maps latest on|off Turn maps.latest_versions on or off. + maps latest every How often Thunderstore is asked. map-pool [add|remove |clear] The maps players vote between and the rotation uses (see maps.pool). rotation [|off] Change the map on a timer (see maps.rotation_minutes). diff --git a/Server/Test/server_map_update_tests.cpp b/Server/Test/server_map_update_tests.cpp new file mode 100644 index 00000000..846153ba --- /dev/null +++ b/Server/Test/server_map_update_tests.cpp @@ -0,0 +1,99 @@ +// Writing a map mod's newest version into its manifest.json (server_map_update.h), without the +// network. +#include "Server/server_map_update.h" +#include "Engine/Core/Json/json.h" +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace dingosdk; +using namespace dingosdk::server; + +namespace { +int failures{}; +void check(bool ok, const std::string &what) { + if (ok) return; + ++failures; + std::cerr << "FAIL: " << what << '\n'; +} +std::string read(const fs::path &file) { + std::ifstream in(file, std::ios::binary); + std::stringstream text; + text << in.rdbuf(); + return text.str(); +} +void write(const fs::path &file, const std::string &text) { + fs::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary) << text; +} + +// With a Thunderstore package's folder name as its argument: asks Thunderstore about it and +// says what it would do, changing nothing (the map counts as being played). By hand only. +int look_up(const std::string &folder) { + const auto mods = fs::temp_directory_path() / "reskate-server-map-lookup" / "Mods"; + std::error_code ignored; + fs::remove_all(mods.parent_path(), ignored); + write(mods / folder / "manifest.json", "{\"version_number\": \"0.0.0\"}"); + write(mods / folder / "reskate-levels.json", "{\"levels\": []}"); + for (const auto &update : update_maps(mods, folder)) + std::cout << update.folder << ": installed " << update.from << ", newest " << (update.to.empty() ? "?" : update.to) + << (update.waiting ? " (would be written)" : "") << (update.problem.empty() ? "" : " - " + update.problem) << std::endl; + fs::remove_all(mods.parent_path(), ignored); + return 0; +} +} // namespace + +int main(int argc, char **argv) { + if (argc > 1) return look_up(argv[1]); + const auto mods = fs::temp_directory_path() / "reskate-server-map-update-tests" / "Mods"; + std::error_code ignored; + fs::remove_all(mods.parent_path(), ignored); + try { + // The version changes and the rest of the manifest is kept. + const auto map = mods / "Team-Map"; + write(map / "manifest.json", "{\"name\": \"Map\", \"version_number\": \"1.0.0\", \"dependencies\": [\"A-B-1.0.0\"], \"website_url\": \"\"}"); + write(map / "reskate-levels.json", "{\"levels\": [1]}"); + check(map_version(map) == "1.0.0", "A manifest's version was not read"); + set_map_version(map, "1.2.0"); + const auto after = Json::parse(read(map / "manifest.json")); + check(map_version(map) == "1.2.0", "The new version is not in the manifest"); + check(after.value("name", "") == "Map" && after.contains("dependencies") && after.at("dependencies").size() == 1 && after.contains("website_url"), + "Writing the version lost something else of the manifest"); + check(read(map / "reskate-levels.json") == "{\"levels\": [1]}", "Writing the version touched the level list"); + check(!fs::exists(map / "manifest.json.new"), "The manifest's working file was left behind"); + + // A folder with only a level list gets a manifest with the version alone. + const auto lite = mods / "Team-Lite"; + write(lite / "reskate-levels.json", "{\"levels\": []}"); + check(map_version(lite).empty(), "A folder without a manifest has a version"); + set_map_version(lite, "3.0.0"); + check(map_version(lite) == "3.0.0", "A folder without a manifest did not get one with its version"); + + // A manifest that is not what it should be is left as it is. + const auto odd = mods / "Team-Odd"; + write(odd / "manifest.json", "[1, 2, 3]"); + bool threw{}; + try { + set_map_version(odd, "9.9.9"); + } catch (const std::exception &) { + threw = true; + } + check(threw && read(odd / "manifest.json") == "[1, 2, 3]", "A manifest that is not an object was written over"); + write(odd / "manifest.json", "{ not json"); + threw = false; + try { + set_map_version(odd, "9.9.9"); + } catch (const std::exception &) { + threw = true; + } + check(threw && read(odd / "manifest.json") == "{ not json", "A manifest that cannot be read was written over"); + } catch (const std::exception &e) { + check(false, std::string("The test stopped: ") + e.what()); + } + fs::remove_all(mods.parent_path(), ignored); + if (!failures) std::cout << "Server map update tests passed.\n"; + return failures ? 1 : 0; +} diff --git a/Server/main.cpp b/Server/main.cpp index a30398ba..66375cdd 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -3,6 +3,7 @@ // the Steam client files (steamclient64.dll, tier0_s64.dll, vstdlib_s64.dll). // On Linux the Steam files are libsteam_api.so and steamclient.so. #include "global_bans.h" +#include "server_map_update.h" #include "server_config.h" #include "server_host.h" #include "server_update.h" @@ -512,6 +513,7 @@ int run(int argc, char **argv, bool skip_update) { std::future ban_check; auto next_ban_check = next_advertise; bool bans_unread{}; + MapUpdater map_updater; // "maps.latest_versions" and "maps update" (server_map_update.h) if (!config.global_bans) write_log("Global bans are off (\"use_global_bans\": false): only this server's own bans apply."); write_log(host.command("words")); // reads this server's own chat words // Signed in to Steam, as last logged, and when it is looked at again. Players already on stay @@ -583,6 +585,12 @@ int run(int argc, char **argv, bool skip_update) { } update_now = false; } + if (const auto maps = map_updater.tick(config.latest_versions, config.latest_check_minutes, config.mods, host.map_folder_in_play(), + host.take_map_update_request()); + !maps.lines.empty() || maps.changed) { + for (const auto &line : maps.lines) write_log(line); + if (maps.changed) write_log(host.reload_maps()); + } // (The chat word lists come in the same answer, and hold on every server: it is read // for them whether or not the bans are used.) if (!ban_check.valid() && now_time >= next_ban_check) diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 971bc9b2..f671e73b 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -183,22 +183,34 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { if (name == "maps" && lower(argument) == "reload") { // A map mod copied into Mods while the server runs: its maps can be switched to at once. if (!console && !can(admin, "map")) return "Your role cannot reload the maps."; - std::vector before; - for (const auto &level : levels()) before.push_back(level.asset); - const auto problems = load_levels(config_.mods); - resend_maps(); // whoever picks maps is sent the list again - std::string added; - std::size_t gone{}; - for (const auto &level : levels()) - if (std::find(before.begin(), before.end(), level.asset) == before.end()) added += (added.empty() ? "" : ", ") + level.name; - for (const auto &asset : before) - if (!find_level(asset)) ++gone; - std::string text = "Maps read again from Mods: " + std::to_string(levels().size()) + " maps" + - (added.empty() ? ", none new" : ", new: " + added) + (gone ? ", " + std::to_string(gone) + " no longer there" : "") + "."; - for (const auto &problem : problems) text += "\nMods: skipped " + problem; + const auto text = reload_maps(); if (!console) log_(text); return text; } + if (name == "maps" && lower(split(argument).first) == "update") { + // Now, whatever "maps latest" is: Thunderstore is asked about each map mod (server_map_update.h). + if (!console && !can(admin, "map")) return "Your role cannot update the maps."; + map_update_requested_ = true; + return std::string("Asking Thunderstore for the newest version of each of this server's maps; what it finds is in the log."); + } + if (name == "maps" && lower(split(argument).first) == "latest") { // maps latest [on|off | every ] + if (!console && !can(admin, "map")) return "Your role cannot change how the maps are updated."; + const auto [word, wanted] = split(split(argument).second); + const auto now_text = std::string("maps latest on|off | maps latest every <5-1440 minutes> (now ") + (config_.latest_versions ? "on" : "off") + + ", every " + std::to_string(config_.latest_check_minutes) + " min)"; + if (lower(word) == "every") { + const auto minutes = number(wanted); + if (!minutes || *minutes < 5 || *minutes > 1440) return now_text; + config_.latest_check_minutes = static_cast(*minutes); + return changed("Thunderstore is asked about this server's maps every " + std::to_string(*minutes) + " minutes."); + } + const auto on = on_off(word); + if (!on) return now_text; + config_.latest_versions = *on; + if (*on) map_update_requested_ = true; + return changed(*on ? "This server keeps its maps at Thunderstore's newest version, and players are held to it." + : "This server's maps stay at the versions in its Mods folder."); + } if (name == "maps") { std::string text = std::to_string(levels().size()) + " maps (custom maps come from Mods next to the server)"; for (const auto &level : levels()) diff --git a/Server/server_config.cpp b/Server/server_config.cpp index da9b413b..1a40047f 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -90,6 +90,8 @@ Layout layout(const ServerConfig &c) { maps.set("rotation_vote_seconds", c.rotation_vote_seconds); maps.set("rotation_extend_minutes", c.rotation_extend); maps.set("rotation_announce", c.rotation_announce); + maps.set("latest_versions", c.latest_versions); + maps.set("latest_check_minutes", c.latest_check_minutes); auto parks = Json::object(); for (unsigned lot = 0; lot < park_lots.size(); ++lot) parks[park_lots[lot].key] = c.parks[lot]; maps.set("parks", std::move(parks)); @@ -336,6 +338,8 @@ ServerConfig load_config(const std::filesystem::path &file, std::vectoris_object()) for (unsigned lot = 0; lot < park_lots.size(); ++lot) c.parks[lot] = parks->value(park_lots[lot].key, c.parks[lot]); c.world_layer_sync = get("maps", "world_layer_sync", c.world_layer_sync); diff --git a/Server/server_config.h b/Server/server_config.h index 8dd45551..f5726021 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -100,6 +100,10 @@ struct ServerConfig { unsigned rotation_extend = 15; // minutes, 0 to 240 // Players are told on a card when the map has 10, 5 and 1 minutes left. bool rotation_announce = true; + // The custom maps in Mods that are Thunderstore packages are kept at Thunderstore's newest + // version (server_map_update.h), looked up at start and every so many minutes. + bool latest_versions = false; + unsigned latest_check_minutes = 60; unsigned max_players = 16; // players; the server itself is not one // The most poses a second one player is sent (crowd_limits); 0: no limit. unsigned crowd_budget = crowd_pose_budget; diff --git a/Server/server_engine.cpp b/Server/server_engine.cpp index f8c07c9c..fb64f0d7 100644 --- a/Server/server_engine.cpp +++ b/Server/server_engine.cpp @@ -1,5 +1,6 @@ #include "server_engine.h" #include "global_bans.h" +#include "server_map_update.h" #include "server_update.h" #include "Extension/Multiplayer/Session/monotonic_clock.h" #include "Engine/Core/Text/word_filter.h" @@ -395,6 +396,7 @@ void ServerEngine::worker_loop() { auto next_update_check = std::chrono::steady_clock::now() + std::chrono::minutes(15); auto next_ban_check = std::chrono::steady_clock::now(); std::future ban_check; + MapUpdater map_updater; // "maps.latest_versions" and "maps update" (server_map_update.h) bool bans_unread = true; std::optional name_allowed; const auto target_tick_interval = std::chrono::microseconds(1000000 / std::max(10U, config_.tps)); @@ -416,6 +418,12 @@ void ServerEngine::worker_loop() { // Live ReSkate Identity Sync (Centrix, Dev, Staff, Homie, Content Creator, Global Bans) const auto now_time = std::chrono::steady_clock::now(); + if (const auto maps = map_updater.tick(config_.latest_versions, config_.latest_check_minutes, config_.mods, host_->map_folder_in_play(), + host_->take_map_update_request()); + !maps.lines.empty() || maps.changed) { + for (const auto &line : maps.lines) log(line); + if (maps.changed) log(host_->reload_maps()); + } if (!ban_check.valid() && now_time >= next_ban_check) { ban_check = std::async(std::launch::async, read_global_bans); } diff --git a/Server/server_host.h b/Server/server_host.h index 2a8919c9..ac8182cc 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -46,6 +46,12 @@ class Host { std::string invite() const; std::string map_name() const; + // "maps reload": reads Mods again and says what changed. Also after a map was updated. + std::string reload_maps(); + // The mod folder of the map in play while anyone is on (an update leaves it alone), or + // nothing; and whether "maps update" was typed since this was last asked. + std::string map_folder_in_play() const; + bool take_map_update_request() { return std::exchange(map_update_requested_, false); } unsigned players() const; std::uint64_t secret() const { return secret_; } // The UDP port players may connect straight to, or 0 (config connection). @@ -224,6 +230,7 @@ class Host { ServerConfig &config_; SteamTransport &transport_; Log log_; + bool map_update_requested_{}; ActivityLog activity_; // what players do, for the console (config_.activity_log) // The running player vote (server_votes.cpp), and when each player may start another. // A poll is one too: it has answers instead of yes and no, and runs nothing (but the console's poll-run). diff --git a/Server/server_map_update.cpp b/Server/server_map_update.cpp new file mode 100644 index 00000000..fb146dd6 --- /dev/null +++ b/Server/server_map_update.cpp @@ -0,0 +1,181 @@ +#include "server_map_update.h" +#include "Engine/Core/Json/json.h" +#include "Engine/Vfs/thunderstore_package.h" +#ifdef _WIN32 +#include "Engine/Vfs/https_download.h" +#else +#include +#endif +#include +#include +#include +#include + +namespace dingosdk::server { +namespace { +namespace fs = std::filesystem; +namespace package = thunderstore_package; + +constexpr char community[] = "reskate"; +constexpr std::uint64_t largest_answer = 1024 * 1024; + +#ifdef _WIN32 +std::string get_text(const std::wstring &url) { + https::Download result; + auto answer = https::get_text(url, largest_answer, 20, L"ReSkateServer/1", &result); + if (!answer) throw std::runtime_error("HTTP " + std::to_string(result.http_status) + ", error " + std::to_string(result.error)); + return std::move(*answer); +} +#else +// curl does the TLS, as for the team's lists (global_bans.cpp). The address is made here from a +// package's name, which is letters, digits, "_", "-" and "." only; anything else is refused +// before it reaches a command line. +std::string get_text(const std::wstring &url) { + std::string address; + for (const auto wide : url) { + const char c = wide < 0x80 ? static_cast(wide) : '?'; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == '/' || c == ':')) + throw std::runtime_error("the package's address is not one to ask for"); + address += c; + } + const auto command = "curl --silent --show-error --fail --location --max-time 20 --max-filesize " + std::to_string(largest_answer) + + " --proto =https --proto-redir =https --user-agent ReSkateServer/1 " + address + " 2>&1"; + auto *pipe = popen(command.c_str(), "r"); + if (!pipe) throw std::runtime_error("curl could not be started"); + std::string output; + char buffer[4096]; + for (std::size_t count; (count = fread(buffer, 1, sizeof(buffer), pipe)) > 0;) + if (output.size() < largest_answer) output.append(buffer, count); + if (pclose(pipe) != 0) { + while (!output.empty() && (output.back() == '\n' || output.back() == '\r')) output.pop_back(); + std::replace(output.begin(), output.end(), '\n', ' '); + throw std::runtime_error(output.empty() ? "curl failed" : output); + } + return output; +} +#endif + +std::string read(const fs::path &file) { + std::ifstream in(file, std::ios::binary); + std::stringstream text; + text << in.rdbuf(); + return in ? text.str() : std::string(); +} +bool hidden(const fs::path &folder) { + const auto name = folder.filename().string(); + return name.empty() || name.front() == '.'; +} +} // namespace + +std::string map_version(const fs::path &mod) { + try { + const auto text = read(mod / "manifest.json"); + return text.empty() ? std::string() : Json::parse(text).value("version_number", ""); + } catch (const std::exception &) { + return {}; + } +} + +void set_map_version(const fs::path &mod, const std::string &version) { + const auto file = mod / "manifest.json"; + auto root = Json::object(); + // (A manifest that cannot be read is not written over: its mod is left as it is.) + if (const auto text = read(file); !text.empty()) { + root = Json::parse(text); + if (!root.is_object()) throw std::runtime_error("its manifest.json is not an object"); + } + root["version_number"] = version; + // Written beside it and moved over it, so a manifest is never half written. + auto fresh = file; + fresh += ".new"; + { + std::ofstream out(fresh, std::ios::binary | std::ios::trunc); + out << root.dump(2) << '\n'; + if (!out.flush()) throw std::runtime_error("its manifest.json could not be written"); + } + std::error_code moved; + fs::rename(fresh, file, moved); + if (moved) { + std::error_code ignored; + fs::remove(fresh, ignored); + throw std::runtime_error("its manifest.json could not be replaced (" + moved.message() + ")"); + } +} + +std::vector update_maps(const fs::path &mods, const std::string &in_play) { + std::vector result; + std::error_code error; + std::vector folders; + for (const auto &entry : fs::directory_iterator(mods, error)) + if (entry.is_directory(error) && !hidden(entry.path()) && fs::exists(entry.path() / "reskate-levels.json", error)) + folders.push_back(entry.path()); + for (const auto &path : folders) { + const auto folder = path.filename().string(); + const auto name = package::parse_name(folder); + if (!name || !name->version.empty()) continue; // not a Thunderstore package's folder + MapUpdate update; + update.folder = folder; + update.from = map_version(path); + try { + const auto choice = package::choose(get_text(package::details_url(*name)), *name, community); + if (!choice.ok) { + update.problem = choice.reason.empty() ? "Thunderstore does not list it as a map" : choice.reason; + result.push_back(std::move(update)); + continue; + } + update.to = choice.version; + if (update.to == update.from) continue; // already the newest: nothing to say + if (folder == in_play) { + update.waiting = true; + result.push_back(std::move(update)); + continue; + } + set_map_version(path, update.to); + update.updated = true; + } catch (const std::exception &e) { + update.problem = e.what(); + // (A map copied in by hand, or one its author took down.) + if (update.problem.find("404") != std::string::npos) update.problem = "it is not on Thunderstore under that name"; + } + result.push_back(std::move(update)); + } + return result; +} + +MapUpdater::Done MapUpdater::tick(bool enabled, unsigned minutes, const fs::path &mods, const std::string &in_play, bool asked) { + Done done; + const auto now = std::chrono::steady_clock::now(); + asked_ = asked_ || asked; + if (work_.valid() && work_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + bool again_soon{}; + std::vector found; + try { + found = work_.get(); + } catch (const std::exception &e) { + done.lines.push_back(std::string("Map versions: the check failed (") + e.what() + ")."); + again_soon = true; + } + for (const auto &update : found) { + if (update.updated) { + done.changed = true; + done.lines.push_back("Map versions: " + update.folder + " is now at " + update.to + (update.from.empty() ? "" : " (was " + update.from + ")") + + ", Thunderstore's newest; players are sent there."); + said_.erase(update.folder); + } else if (update.waiting) { + again_soon = true; + if (said_.insert(update.folder + " waits for " + update.to).second) + done.lines.push_back("Map versions: " + update.folder + " " + update.to + " is out, and waits until its map is not being played."); + } else if (!update.problem.empty() && said_.insert(update.folder + ": " + update.problem).second) { + done.lines.push_back("Map versions: " + update.folder + " was left as it is: " + update.problem); + } + } + next_ = now + (again_soon ? std::chrono::minutes(5) : std::chrono::minutes(std::max(minutes, 5U))); + } + if (!work_.valid() && ((enabled && now >= next_) || asked_)) { + asked_ = false; + next_ = now + std::chrono::minutes(std::max(minutes, 5U)); + work_ = std::async(std::launch::async, update_maps, mods, in_play); + } + return done; +} +} // namespace dingosdk::server diff --git a/Server/server_map_update.h b/Server/server_map_update.h new file mode 100644 index 00000000..e991bd67 --- /dev/null +++ b/Server/server_map_update.h @@ -0,0 +1,51 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Keeping the server's custom maps at their newest Thunderstore version ("maps.latest_versions"). +// The server reads two files of a map mod: reskate-levels.json (its levels) and manifest.json +// (its version, which players are held to: protocol.h: map_package_name). So nothing is +// downloaded: a map mod in Mods whose folder is a Thunderstore package (Owner-Name) is asked +// about, and when Thunderstore's newest version is not the one in its manifest.json, that +// version is written there. Players then fetch the newest as they join. +// +// The level list is left as it is. A new version that adds, removes or renames levels needs its +// reskate-levels.json copied in by the server's owner, as when the map was first set up. +namespace dingosdk::server { +struct MapUpdate { + std::string folder, from, to; // the mod folder, its version before and Thunderstore's newest + bool updated{}; // the newest version is in its manifest.json + bool waiting{}; // left for later: its map is being played + std::string problem; // why it could not be looked up or written, or empty +}; +// Looks every map mod in `mods` up and writes the newest version where it differs. `in_play`: a +// mod folder to leave as it is this time. Asks Thunderstore, so it can take a few seconds a +// map; made for a worker thread. +std::vector update_maps(const std::filesystem::path &mods, const std::string &in_play); +// The version a mod folder's manifest.json gives (nothing when it has none), and writing one +// there: everything else in the file is kept, and a folder without one gets a file with the +// version alone. Throws when the file cannot be written. +std::string map_version(const std::filesystem::path &mod); +void set_map_version(const std::filesystem::path &mod, const std::string &version); + +// The run loop's side: a check when one is due or asked for, on a worker, and what it found. +class MapUpdater { + public: + struct Done { + std::vector lines; // for the log + bool changed{}; // a map's version changed: read Mods again + }; + // Every turn of the loop. `enabled` and `minutes`: the config's; `asked`: "maps update". + Done tick(bool enabled, unsigned minutes, const std::filesystem::path &mods, const std::string &in_play, bool asked); + + private: + std::future> work_; + std::chrono::steady_clock::time_point next_{}; + bool asked_{}; + std::set said_; // problems said once, not at every check +}; +} // namespace dingosdk::server diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index 67886f8a..f60ef932 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -741,6 +741,30 @@ std::string Host::rotation_text() const { void Host::resend_maps() { for (auto &[id, guest] : guests_) guest->maps_sent = false; } +std::string Host::reload_maps() { + std::vector before; + for (const auto &level : levels()) before.push_back(level.asset); + const auto problems = load_levels(config_.mods); + resend_maps(); // whoever picks maps is sent the list again + std::string added; + std::size_t gone{}; + for (const auto &level : levels()) + if (std::find(before.begin(), before.end(), level.asset) == before.end()) added += (added.empty() ? "" : ", ") + level.name; + for (const auto &asset : before) + if (!find_level(asset)) ++gone; + std::string text = "Maps read again from Mods: " + std::to_string(levels().size()) + " maps" + + (added.empty() ? ", none new" : ", new: " + added) + (gone ? ", " + std::to_string(gone) + " no longer there" : "") + "."; + for (const auto &problem : problems) text += "\nMods: skipped " + problem; + return text; +} +std::string Host::map_folder_in_play() const { + if (!players()) return {}; + // The package is the folder and the version (protocol.h: map_package_name). + const auto package = map_package(config_.map); + const auto version = multiplayer::map_package_version(package); + if (package.empty()) return {}; + return version.empty() ? package : package.substr(0, package.size() - version.size() - 1); +} // The rotation's vote: three maps of the pool other than this one, picked at random, then any // map of the pool, then staying. Asked as a poll, so every game can answer it (/1, /2...); games // that know next_map_vote show the maps to pick from. diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 073d75cb..85c0675d 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -180,6 +180,7 @@ if(WIN32) Server/server_engine.cpp Server/server_host.cpp Server/server_party.cpp Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/steam_server.cpp Server/server_update.cpp Server/server_release.cpp Launcher/updater.cpp + Server/server_map_update.cpp Engine/Vfs/thunderstore_package.cpp Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp @@ -196,7 +197,7 @@ else() find_package(OpenSSL REQUIRED) add_executable(dingosdk_server Server/main.cpp Server/server_engine.cpp Server/server_host.cpp Server/server_party.cpp Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/steam_server.cpp - Server/server_update.cpp Server/server_release.cpp + Server/server_update.cpp Server/server_release.cpp Server/server_map_update.cpp Engine/Vfs/thunderstore_package.cpp Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp