From 55a7342dc6e7e7be724a17cfc5733b7e265de8e1 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sat, 24 Oct 2026 12:00:09 -0500 Subject: [PATCH] Multiplayer: dedicated servers require plugins, and players get them and the map on one card A server lists Thunderstore packages in plugins.required ("Owner-Name", or "Owner-Name-1.2.3" for at least that version, at most 16). It checks each on Thunderstore as it starts and leaves out one not listed as a plugin, and sends the rest with its map offer (protocol 55). A joining player's game checks them before the server's map loads. Those missing, switched off or too old go on one card with the map when that is missing too, so the player is asked once. GET ALL downloads and installs each, switches on any that were off, runs one live merge when any brings game data, and joins; CANCEL leaves. The game fetches a package only when Thunderstore lists it as a plugin and it installs as one (a reskate-plugin.json naming Luau code), whatever a server asks; anything else is taken out again, so a cosmetic can never be required. map_download handles a list of packages instead of one map, and the Thunderstore chooser takes the category it needs. A map alone keeps its card as it was. --- .../Vfs/Test/thunderstore_package_tests.cpp | 12 + Engine/Vfs/thunderstore_package.cpp | 13 +- Engine/Vfs/thunderstore_package.h | 11 +- .../Test/map_download_timeout_tests.cpp | 77 +- Extension/Assets/map_download.cpp | 698 ++++++++++++------ Extension/Assets/map_download.h | 53 +- Extension/Multiplayer/Net/protocol.cpp | 31 + Extension/Multiplayer/Net/protocol.h | 9 +- Extension/Multiplayer/Session/session.cpp | 45 ++ Extension/Multiplayer/Session/session.h | 13 +- .../Multiplayer/Session/session_internal.h | 6 + .../Multiplayer/Session/session_receive.cpp | 2 + Extension/Multiplayer/Test/protocol_tests.cpp | 22 + Extension/UI/Overlay/map_download_card.cpp | 149 +++- Extension/UI/Overlay/overlay.h | 11 +- Runtime/runtime_init.cpp | 5 + Server/README.txt | 11 + Server/Test/server_config_tests.cpp | 17 + Server/main.cpp | 5 + Server/server_config.cpp | 14 + Server/server_config.h | 6 + Server/server_engine.cpp | 5 + Server/server_host.cpp | 9 + Server/server_host.h | 5 + Server/server_map_update.cpp | 54 ++ Server/server_map_update.h | 23 + 26 files changed, 1051 insertions(+), 255 deletions(-) diff --git a/Engine/Vfs/Test/thunderstore_package_tests.cpp b/Engine/Vfs/Test/thunderstore_package_tests.cpp index c5081d46..9ef32999 100644 --- a/Engine/Vfs/Test/thunderstore_package_tests.cpp +++ b/Engine/Vfs/Test/thunderstore_package_tests.cpp @@ -78,6 +78,18 @@ int main() { refused(marked, "a package marked NSFW is refused"); } refused("{\"namespace\":\"Sandos\",\"name\":\"Vancouver_Plaza\"}", "an answer with no listing is refused"); + // A dedicated server's required plugin: taken only when it is listed as a plugin. + { + const auto plugin = answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Plugins", "unreviewed"); + check(package::choose(plugin, newest, "reskate", package::Kind::plugin).ok, "a plugin listed as one is refused"); + check(!package::choose(plugin, newest, "reskate").ok, "a plugin is taken as a map"); + const auto cosmetic = package::choose(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Cosmetics", "unreviewed"), + newest, "reskate", package::Kind::plugin); + check(!cosmetic.ok && cosmetic.reason.find("plugin") != std::string::npos, "a cosmetic is taken as a plugin"); + check(!package::choose(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "unreviewed"), newest, "reskate", + package::Kind::plugin).ok, + "a map is taken as a plugin"); + } refused("not json", "an answer that is not JSON is refused"); refused("[]", "an answer that is not an object is refused"); } diff --git a/Engine/Vfs/thunderstore_package.cpp b/Engine/Vfs/thunderstore_package.cpp index e7adc20a..ae37d203 100644 --- a/Engine/Vfs/thunderstore_package.cpp +++ b/Engine/Vfs/thunderstore_package.cpp @@ -61,7 +61,7 @@ std::wstring download_url(const Name& name, std::string_view wanted) { return wide(std::string(site) + "/package/download/" + name.owner + '/' + name.name + '/' + std::string(wanted) + '/'); } -Choice choose(std::string_view json, const Name& wanted, std::string_view community) { +Choice choose(std::string_view json, const Name& wanted, std::string_view community, Kind kind) { Choice choice; try { const auto root = Json::parse(json); @@ -73,7 +73,8 @@ Choice choose(std::string_view json, const Name& wanted, std::string_view commun choice.reason = "Its package on Thunderstore is marked deprecated."; return choice; } - bool listed{}, map{}, rejected{}, nsfw{}; + const std::string_view category_wanted = kind == Kind::plugin ? "Plugins" : "Maps"; + bool listed{}, fits{}, rejected{}, nsfw{}; if (root.contains("community_listings") && root.at("community_listings").is_array()) for (const auto& listing : root.at("community_listings")) { if (!same(text_of(listing, "community"), community)) continue; @@ -83,14 +84,18 @@ Choice choose(std::string_view json, const Name& wanted, std::string_view commun if (listing.contains("categories") && listing.at("categories").is_array()) for (const auto& category : listing.at("categories")) { - map = map || (category.is_string() && same(category.string(), "Maps")); + fits = fits || (category.is_string() && same(category.string(), category_wanted)); nsfw = nsfw || (category.is_string() && same(category.string(), "NSFW")); } } if (!listed) { choice.reason = "Its package is not listed for ReSkate on Thunderstore."; return choice; } if (rejected) { choice.reason = "Its package was rejected by Thunderstore's moderators."; return choice; } if (nsfw) { choice.reason = "Its package is marked NSFW on Thunderstore."; return choice; } - if (!map) { choice.reason = "Its package is not listed as a map on Thunderstore."; return choice; } + if (!fits) { + choice.reason = kind == Kind::plugin ? "Its package is not listed as a plugin on Thunderstore." + : "Its package is not listed as a map on Thunderstore."; + return choice; + } const auto newest = root.contains("latest") ? text_of(root.at("latest"), "version_number") : std::string(); if (!version(newest)) { choice.reason = "Thunderstore gave no version of that package."; return choice; } choice.newest = newest; diff --git a/Engine/Vfs/thunderstore_package.h b/Engine/Vfs/thunderstore_package.h index 63fbeb38..3af20cf5 100644 --- a/Engine/Vfs/thunderstore_package.h +++ b/Engine/Vfs/thunderstore_package.h @@ -5,7 +5,8 @@ #include // One Thunderstore package looked up by name, for the game: the map a multiplayer host is on -// and this player does not have (Extension/Assets/map_download.h). No network here; the caller +// and this player does not have, and the plugins a dedicated server needs +// (Extension/Assets/map_download.h). No network here; the caller // fetches details_url() and hands the answer to choose(). namespace dingosdk::thunderstore_package { @@ -35,9 +36,11 @@ struct Choice { std::string icon; std::string reason; // why not, for the player }; +// What the package has to be listed as: a map (the "Maps" category) or a plugin ("Plugins"). +enum class Kind { map, plugin }; // Reads that answer. The package is taken only when it is the one asked for, is listed in -// `community` as a map (the "Maps" category), is not deprecated or marked NSFW there and was -// not rejected by the community's moderators. -Choice choose(std::string_view json, const Name& wanted, std::string_view community); +// `community` as `kind`, is not deprecated or marked NSFW there and was not rejected by the +// community's moderators. +Choice choose(std::string_view json, const Name& wanted, std::string_view community, Kind kind = Kind::map); } // namespace dingosdk::thunderstore_package diff --git a/Extension/Assets/Test/map_download_timeout_tests.cpp b/Extension/Assets/Test/map_download_timeout_tests.cpp index c3856e64..02383471 100644 --- a/Extension/Assets/Test/map_download_timeout_tests.cpp +++ b/Extension/Assets/Test/map_download_timeout_tests.cpp @@ -29,7 +29,7 @@ constexpr auto use_download_deadline = static_cast(-1); std::atomic active_deadline{use_download_deadline}; std::atomic downloading{}, installing{}, release_download{}, release_install{}, merging{}; std::atomic fetching{true}, held{}, observed_cancel{}, installed_cancel{}; -std::atomic downloads{}, installs{}, applies{}, stops{}, rejoins{}; +std::atomic downloads{}, installs{}, applies{}, stops{}, rejoins{}, removals{}; std::atomic queued_stop_deadline{}; bool block_download{}, block_install{}, slow{}, transfer_error{}, fallback{}; dingosdk::MultiplayerModel session; @@ -81,11 +81,17 @@ namespace dingosdk::https { std::optional content_length(std::wstring_view, std::uint32_t, const wchar_t*, Watch*) { return std::nullopt; } -std::optional get_text(std::wstring_view, std::uint64_t, std::uint32_t, +std::optional get_text(std::wstring_view url, std::uint64_t, std::uint32_t, const wchar_t*, Download* result, Watch* watch) { fixture::require(watch && watch->deadline_us.load() == map_download::deadline.load(), "metadata shares the session deadline"); if (result) *result = {true, 200, 0}; + for (const auto* name : {"Plug", "Fake", "Cosmetic"}) + if (url.find(L"/" + std::wstring(name, name + std::strlen(name)) + L"/") != std::wstring_view::npos) { + const std::string narrow(name); + return R"({"namespace":"Team","name":")" + narrow + R"(","community_listings":[{"community":"reskate","categories":[")" + + (narrow == "Cosmetic" ? "Cosmetics" : "Plugins") + R"("]}],"latest":{"version_number":"1.2.0","description":"Test plugin"}})"; + } return R"({"namespace":"Team","name":"Map","community_listings":[{"community":"reskate","categories":["Maps"]}], "latest":{"version_number":"2.0.0","description":"Test map"}})"; } @@ -125,6 +131,12 @@ Download get(std::wstring_view, const std::filesystem::path& destination, namespace dingosdk::mods { std::filesystem::path engine_data_root() noexcept { return fixture::root; } ModList scan_mods(const std::filesystem::path&) noexcept { return {}; } +Mod describe_mod(const std::filesystem::path& directory, std::string name, std::vector&) { + Mod mod; + mod.name = std::move(name); + mod.provides_plugin = std::filesystem::exists(directory / "reskate-plugin.json"); + return mod; +} void save_mod_order(const std::filesystem::path&, const std::vector&) {} } namespace dingosdk::launcher_mods { @@ -142,10 +154,14 @@ std::string install(const std::filesystem::path& root, const std::filesystem::pa installed_cancel.store(cancel.load()); if (cancel.load()) throw std::runtime_error("cancelled"); fs::create_directories(root / options.folder); - std::ofstream(root / options.folder / "reskate-levels.json") << "{}"; + if (options.folder == "Team-Plug") std::ofstream(root / options.folder / "reskate-plugin.json") << R"({"entry":"main.lua"})"; + else if (options.folder != "Team-Fake") std::ofstream(root / options.folder / "reskate-levels.json") << "{}"; return options.folder; } -void remove(const std::filesystem::path&, const std::string&) {} +void remove(const std::filesystem::path& root, const std::string& folder) { + ++fixture::removals; + std::filesystem::remove_all(root / folder); +} } namespace dingosdk::live_mods { bool only_maps(const std::filesystem::path&) { return true; } @@ -169,7 +185,7 @@ std::uint64_t map_fetch_deadline() noexcept { } void hold_map_load(bool hold, std::string_view package) noexcept { fixture::held.store(hold); - if (hold && package != "Team-Map-1.0.0") std::abort(); + if (hold && !package.empty() && package != "Team-Map-1.0.0") std::abort(); } bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { if (action == "stop") ++fixture::stops; @@ -228,6 +244,7 @@ void reset() { downloads.store(0); installs.store(0); applies.store(0); + removals.store(0); stops.store(0); queued_stop_deadline.store(0); rejoins.store(0); @@ -624,6 +641,56 @@ void tests() { rejoins.load() == 0 && !noticed("timed out"), "join completed within session budget retires successfully even when next tick is after deadline"); + // A dedicated server's required plugins alone: one card, fetched without a live merge (the + // plugin host takes them from Mods), and the session goes on. + reset(); + download::offer({"", "Test map", true, false, "", 0, {"Team-Plug-1.0.0"}}); + wait([] { return download::asking(); }, "plugin need did not reach confirmation"); + drain(); + { + const auto shown = download::view(); + require(shown.items.size() == 1 && shown.items[0].plugin && shown.items[0].name == "Plug" && shown.items[0].version == "1.2.0" && + shown.map.empty(), "the card lists the plugin, at Thunderstore's newest"); + } + download::answer(true); + wait([] { return download::view().stage == Stage::joining; }, "plugin install did not reach joining"); + drain(); + require(downloads.load() == 1 && installs.load() == 1 && applies.load() == 0, "a plugin alone needs no live merge"); + fetching.store(false); + session.connected = true; + clock.fetch_add(2500000); + download::tick(); + require(download::view().stage == Stage::idle && stops.load() == 0, "the session goes on once the plugin is in"); + + // The map and a plugin: asked once, on one card, both fetched, the map applied. + reset(); + download::offer({"Team-Map-1.0.0", "Test map", true, false, "", 0, {"Team-Plug"}}); + wait([] { return download::asking(); }, "map and plugin need did not reach confirmation"); + drain(); + require(download::view().items.size() == 2 && !download::view().items[0].plugin && download::view().items[1].plugin, + "the card lists the map first, then the plugin"); + applying(); + require(downloads.load() == 2 && installs.load() == 2, "both are downloaded and installed"); + joined(); + + // A package that is not a plugin on Thunderstore (a cosmetic) is never fetched for a server. + reset(); + download::offer({"", "Test map", true, false, "", 0, {"Team-Cosmetic"}}); + wait([] { return download::view().stage == Stage::idle; }, "a cosmetic was not refused"); + drain(); + require(downloads.load() == 0 && noticed("not listed as a plugin") && stops.load() == 1, "a cosmetic is refused before any download"); + + // Listed as a plugin, but what it installs is none: taken out again. + reset(); + download::offer({"", "Test map", true, false, "", 0, {"Team-Fake"}}); + wait([] { return download::asking(); }, "fake plugin did not reach confirmation"); + drain(); + download::answer(true); + wait([] { return download::view().stage == Stage::idle; }, "a package that is not a plugin was kept"); + drain(); + require(removals.load() == 1 && !fs::exists(root / "Mods" / "Team-Fake") && noticed("is not a plugin"), + "a package that installs as no plugin is removed"); + reset(); download::tick(); require(!held.load() && downloads.load() == 0 && stops.load() == 0 && rejoins.load() == 0, diff --git a/Extension/Assets/map_download.cpp b/Extension/Assets/map_download.cpp index e1db384a..ba3df0ab 100644 --- a/Extension/Assets/map_download.cpp +++ b/Extension/Assets/map_download.cpp @@ -32,6 +32,19 @@ constexpr std::uint64_t largest_download = 8ull << 30; constexpr std::uint32_t download_seconds = 4 * 60 * 60; namespace policy = multiplayer::map_fetch_policy; +// One package the session needs: the map, or one of the plugins. +struct Item { + package::Name name; // the map's with the host's version; a plugin's without one + bool plugin{}; + std::string at_least; // a plugin: the version the server named, the least it takes (empty: any) + std::string have; // the version here, when the one fetched replaces it + std::string folder; // its folder in Mods: an installed one's when it is here, else the package's + std::string version, newest, description; + std::uint64_t bytes{}; // the zip's Content-Length (0: not known) + bool off{}; // here already, switched off: nothing is downloaded + bool merge{}; // once it is in: it has game data, which the live merge applies +}; + std::mutex mutex; std::atomic stage{Stage::idle}; std::atomic workers{}; @@ -42,13 +55,9 @@ std::atomic deadline{}, ask_until{}; std::atomic detached{}; multiplayer::MapNeed need; // under mutex, while not idle std::optional pending; // a fresh attempt waiting for cancelled work to drain -package::Name wanted; // under mutex -std::string version; // under mutex: the one to download -std::string newest; // under mutex -std::string description; // under mutex: Thunderstore's, or the installed mod's own -std::uint64_t package_bytes{}; // under mutex: the zip's Content-Length -bool installed_off{}; // under mutex: here already, switched off -std::string last_failed; // under mutex: a package not to offer again this run +std::vector items; // under mutex: the map first when it is needed, then the plugins +std::atomic current{}; // the item being fetched +std::string last_failed; // under mutex: a need (need_key) not to offer again this run https::Watch watch; std::atomic stop_install{}; std::atomic apply_wanted{}, apply_started{}; @@ -67,6 +76,25 @@ std::string read_bytes(const fs::path& path, std::uintmax_t most) { } std::string host_word(bool server) { return server ? "server" : "host"; } +// What a need is, to know it again: its map and its plugins. +std::string need_key(const multiplayer::MapNeed& of) { + auto key = of.package; + for (const auto& plugin : of.plugins) key += '|' + plugin; + return key; +} +// A plugin's name for a player: its package's name with spaces ("FPV Drone"). +std::string plugin_label(std::string_view package) { + const auto name = package::parse_name(package); + auto label = name ? name->name : std::string(package); + std::replace(label.begin(), label.end(), '_', ' '); + return label; +} +std::string plugin_list(const std::vector& packages) { + std::string out; + for (std::size_t at = 0; at < packages.size(); ++at) + out += (at == 0 ? "" : at + 1 == packages.size() ? " and " : ", ") + plugin_label(packages[at]); + return out; +} std::string need_package() { std::lock_guard lock(mutex); return need.package; @@ -76,17 +104,36 @@ bool need_server() { return need.server; } -// The session that is waiting on the map is left: there is no map to wait for. +// The session that is waiting is left: what it waits for will not come. void leave_session(std::uint64_t expected = 0) { const auto owned = expected ? expected : multiplayer::map_fetch_deadline(); if (multiplayer::fetching_map() && owned) multiplayer::queue_command("stop-map-fetch", std::to_string(owned), ""); } +// What the session needs and this PC does not have, said for a player: "The server is on +// Vancouver Plaza, which is not installed on this PC." Under mutex. +std::string needed_text(const multiplayer::MapNeed& of) { + const auto who = host_word(of.server); + std::string text; + if (!of.package.empty()) + text = "The " + who + (of.moved ? " changed to " : " is on ") + of.map + + (!of.have.empty() ? ", at another version (" + std::string(multiplayer::map_package_version(of.package)) + + ") than the one installed on this PC (" + of.have + ")" + : ", which is not installed on this PC"); + if (!of.plugins.empty()) + text += (text.empty() ? "The " + who + " needs " : ", and needs ") + std::string(of.plugins.size() == 1 ? "the plugin " : "the plugins ") + + plugin_list(of.plugins) + ", which this PC does not have ready"; + return text + ". "; +} +std::string needed_title(const multiplayer::MapNeed& of) { + if (of.package.empty()) return "Plugins needed"; + return of.have.empty() ? "Map not installed" : "Map version differs"; +} + // Back to idle with the reason said, as the plain "map not installed" notice is. void fail(std::string reason, bool retry = false) { - std::string text; - bool update{}; + std::string text, title; std::uint64_t failed_deadline{}; { std::lock_guard lock(mutex); @@ -95,15 +142,13 @@ void fail(std::string reason, bool retry = false) { watch.cancel.store(true); stop_install.store(true); apply_wanted.store(false); - update = !need.have.empty(); - text = "The " + host_word(need.server) + (need.moved ? " changed to " : " is on ") + need.map + - (update ? ", at another version (" + std::string(multiplayer::map_package_version(need.package)) + ") than the one installed on this PC (" + need.have + "). " - : ", which is not installed on this PC. ") + reason; - if (!retry) last_failed = need.package; + text = needed_text(need) + reason; + title = needed_title(need); + if (!retry) last_failed = need_key(need); } if (multiplayer::map_fetch_deadline() == failed_deadline) leave_session(failed_deadline); logging::log(logging::Level::warning, logging::Channel::assets, "Map download: {}", text); - overlay::notify(overlay::NoticeLevel::warning, update ? "Map version differs" : "Map not installed", std::move(text)); + overlay::notify(overlay::NoticeLevel::warning, std::move(title), std::move(text)); } // Whether the download has begun (or is done): from then on it goes to the end on its own time. @@ -117,7 +162,7 @@ bool continuing() { // The session's deadline ends a check or a question it outlived; a download under way is // not thrown away with it (a large map on a slow line would never come at all). if (!under_way(now) && policy::expired(deadline.load(), multiplayer::now_us())) { - fail("Map download timed out. Please try joining the server again.", true); + fail("The download timed out. Please try joining the server again.", true); return false; } return !watch.cancel.load(); @@ -132,28 +177,38 @@ void ask() { ask_until.store(std::min(deadline.load(), multiplayer::now_us() + policy::confirmation_us)); advance(Stage::checking, Stage::asking); } -// The packages of the maps installed here, by level (lower case), read from the Mods folder in -// one pass (package_of). Forgotten when a map is installed; read again in the background two -// minutes after it was read. It was read for each level asked, on the client thread, with every -// map offer a host sends (a guest joining, a map change): tens of milliseconds, a second and more -// with many mods (2026-10-15). -struct KnownPackages { +// The packages of the maps installed here, by level (lower case), and every plugin here, read +// from the Mods folder in one pass (package_of, has_level, missing_plugins). Forgotten when +// anything is installed; read again in the background two minutes after it was read. It was +// read for each level asked, on the client thread, with every map offer a host sends (a guest +// joining, a map change): tens of milliseconds, a second and more with many mods (2026-10-15). +struct KnownPlugin { + std::string folder, title, author, version; + bool enabled{}; +}; +struct KnownTable { std::map> by_level; + std::vector plugins; +}; +struct KnownPackages { + KnownTable table; std::uint64_t read_at{}; bool valid{}, reading{}; }; std::mutex known_mutex; KnownPackages known_packages; -std::map> read_known_packages() { - std::map> table; +KnownTable read_known_packages() { + KnownTable table; const auto list = mods::scan_mods(mods::engine_data_root()); for (const auto& entry : list.entries) { + if (entry.mod.provides_plugin) + table.plugins.push_back({entry.mod.name, entry.mod.title, entry.mod.author, entry.mod.version, entry.enabled}); if (!entry.enabled || !entry.mod.provides_levels) continue; const auto package = multiplayer::map_package_name(entry.mod.name, entry.mod.version); for (auto level : entry.mod.levels) { std::transform(level.begin(), level.end(), level.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - table.emplace(std::move(level), package); // (the first mod that has a level is the one) + table.by_level.emplace(std::move(level), package); // (the first mod that has a level is the one) } } return table; @@ -165,12 +220,12 @@ void refresh_known_packages(std::uint64_t now) { known_packages.reading = true; try { std::thread([] { - std::map> table; + KnownTable table; bool ok{}; try { table = read_known_packages(); ok = true; } catch (...) {} std::lock_guard lock(known_mutex); if (ok) { - known_packages.by_level = std::move(table); + known_packages.table = std::move(table); known_packages.read_at = GetTickCount64(); known_packages.valid = true; } @@ -178,169 +233,327 @@ void refresh_known_packages(std::uint64_t now) { }).detach(); } catch (...) { known_packages.reading = false; } } - -// The mod's row in mods.json when its folder is here: whether it is switched on, and what it -// says of itself. -std::optional installed(const std::string& folder, std::string& about) { - const auto list = mods::scan_mods(mods::engine_data_root()); - for (const auto& entry : list.entries) - if (same(entry.mod.name, folder)) { - about = entry.mod.description; - return entry.enabled; +// The table, read here when there is none (only just after something was installed). Takes +// known_mutex; `use` runs under it. +template +auto with_known(Use use) { + const auto now = GetTickCount64(); + std::unique_lock lock(known_mutex); + if (!known_packages.valid) { + lock.unlock(); + auto table = read_known_packages(); + lock.lock(); + if (!known_packages.valid) { + known_packages.table = std::move(table); + known_packages.read_at = now; + known_packages.valid = true; } - return std::nullopt; + } + refresh_known_packages(now); + return use(known_packages.table); +} +void forget_known() { + std::lock_guard known(known_mutex); + known_packages.valid = false; +} +// Whether a plugin here is the package: in the package's folder, or with its name and author. +bool is_package(const package::Name& name, std::string_view folder, std::string_view title, std::string_view author) { + return same(folder, name.folder()) || (same(title, name.name) && same(author, name.owner)); +} +// Whether `have` is at least `at_least` (any version is, when none is asked). +bool new_enough(std::string_view have, std::string_view at_least) { + return at_least.empty() || (!have.empty() && !multiplayer::map_version_newer(at_least, have)); } -// Stage::checking: is it here but off, or what does Thunderstore say of it. +// The mod's row in mods.json when it is here: the map's by its package folder, a plugin's by +// is_package (an enabled one first). Its entry, and whether it was found. +std::optional installed(const Item& item) { + const auto list = mods::scan_mods(mods::engine_data_root()); + std::optional found; + for (const auto& entry : list.entries) { + const bool match = item.plugin ? is_package(item.name, entry.mod.name, entry.mod.title, entry.mod.author) + : same(entry.mod.name, item.name.folder()); + if (match && (!found || (entry.enabled && !found->enabled))) found = entry; + } + return found; +} + +// Stage::checking for the map: is it here but off, or what does Thunderstore say of it. False +// when it failed (and fail() has said why). +bool check_map(Item& item, bool first) { + const bool update = !item.have.empty(); + // (At another version than the host's, it is fetched whether it is on or off.) + if (const auto here = update ? std::nullopt : installed(item)) { + if (here->enabled) { + fail("Its mod is installed, but the map did not load: update or reinstall it from the launcher."); + return false; + } + item.off = true; + item.description = here->mod.description.substr(0, 240); + // Its own icon, from its folder. + if (first) + try { + overlay::set_map_download_icon(read_bytes(mods_root() / item.folder / L"icon.png", 4 * 1024 * 1024)); + } catch (const std::exception&) {} + return true; + } + https::Download result; + const auto answer = https::get_text(package::details_url(item.name), 512 * 1024, 20, agent, &result, &watch); + if (!continuing()) return false; + if (!answer) { + fail(result.http_status == 404 ? "Its mod is not on Thunderstore. Install its map mod yourself and join again." + : "Thunderstore could not be reached to look for it."); + return false; + } + const auto choice = package::choose(*answer, item.name, community, package::Kind::map); + if (!choice.ok) { + fail(choice.reason + " Install its map mod yourself and join again."); + return false; + } + // Another version is only worth fetching when it is the host's: any other leaves the two apart still. + if (update && choice.version != multiplayer::map_package_version(need_package())) { + fail("That version is not on Thunderstore any more. Ask the " + host_word(need_server()) + " to update the map, or install its version yourself."); + return false; + } + // The package page names the version and not its size. The download's Content-Length is + // what the card shows; the body is not fetched until the player says yes. + const auto length = https::content_length(package::download_url(item.name, choice.version), 8, agent, &watch); + item.version = choice.version; + item.newest = choice.newest; + item.description = choice.description; + item.bytes = length.value_or(0); + // Its icon for the card: small, and not worth holding the question up for long. + if (first && !choice.icon.empty()) + if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent, nullptr, &watch)) + overlay::set_map_download_icon(*png); + return true; +} + +// Stage::checking for a plugin. False when it failed; `ready` when it turns out to be here +// after all (switched on since the session looked). +bool check_plugin(Item& item, bool first, bool& ready) { + const auto label = plugin_label(item.name.folder()); + if (const auto here = installed(item)) { + item.folder = here->mod.name; + if (here->mod.provides_plugin && new_enough(here->mod.version, item.at_least)) { + if (here->enabled) { + ready = true; + return true; + } + item.off = true; + item.version = here->mod.version; + item.description = here->mod.description.substr(0, 240); + if (first) + try { + overlay::set_map_download_icon(read_bytes(mods_root() / item.folder / L"icon.png", 4 * 1024 * 1024)); + } catch (const std::exception&) {} + return true; + } + // Here at an older version (or not as a plugin): the new one goes in its place. + item.have = here->mod.version.empty() ? std::string("an older version") : here->mod.version; + } + https::Download result; + const auto answer = https::get_text(package::details_url(item.name), 512 * 1024, 20, agent, &result, &watch); + if (!continuing()) return false; + if (!answer) { + fail(result.http_status == 404 ? label + " is not on Thunderstore. Install it yourself and join again." + : "Thunderstore could not be reached to look for " + label + "."); + return false; + } + const auto choice = package::choose(*answer, item.name, community, package::Kind::plugin); + if (!choice.ok) { + fail(label + ": " + choice.reason + " It is not installed from here."); + return false; + } + // (The server names the least it takes; the newest is fetched, which is at least that.) + if (!new_enough(choice.newest, item.at_least)) { + fail("Thunderstore has version " + choice.newest + " of " + label + ", older than the " + item.at_least + " the " + + host_word(need_server()) + " needs."); + return false; + } + const auto length = https::content_length(package::download_url(item.name, choice.newest), 8, agent, &watch); + item.version = choice.newest; + item.newest = choice.newest; + item.description = choice.description; + item.bytes = length.value_or(0); + if (first && !choice.icon.empty()) + if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent, nullptr, &watch)) + overlay::set_map_download_icon(*png); + return true; +} + +// Stage::checking: what each package is and where it comes from. void check() { - package::Name name; - bool update{}; + std::vector list; { std::lock_guard lock(mutex); - name = wanted; - update = !need.have.empty(); + list = items; } try { - std::string about; - // (At another version than the host's, it is fetched whether it is on or off.) - if (const auto on = update ? std::optional{} : installed(name.folder(), about)) { - if (*on) return fail("Its mod is installed, but the map did not load: update or reinstall it from the launcher."); - { - std::lock_guard lock(mutex); - installed_off = true; - description = about.substr(0, 240); - } - // Its own icon, from its folder. - try { - const auto bytes = read_bytes(mods_root() / name.folder() / L"icon.png", 4 * 1024 * 1024); - overlay::set_map_download_icon(bytes); - } catch (const std::exception&) {} - ask(); + std::vector wanted; + for (auto& item : list) { + if (!continuing()) return; + const bool first = wanted.empty(); + bool ready{}; + if (!(item.plugin ? check_plugin(item, first, ready) : check_map(item, first))) return; + if (!ready) wanted.push_back(std::move(item)); + } + if (wanted.empty()) { + // (Everything turned out to be here: the session sees so itself and goes on.) + auto from = Stage::checking; + stage.compare_exchange_strong(from, Stage::idle); return; } - https::Download result; - const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result, &watch); - if (!continuing()) return; - if (!answer) - return fail(result.http_status == 404 ? "Its mod is not on Thunderstore. Install its map mod yourself and join again." - : "Thunderstore could not be reached to look for it."); - const auto choice = package::choose(*answer, name, community); - if (!choice.ok) return fail(choice.reason + " Install its map mod yourself and join again."); - // Another version is only worth fetching when it is the host's: any other leaves the two apart still. - if (update && choice.version != multiplayer::map_package_version(need_package())) - return fail("That version is not on Thunderstore any more. Ask the " + host_word(need_server()) + " to update the map, or install its version yourself."); - // The package page names the version and not its size. The download's Content-Length is - // what the card shows; the body is not fetched until the player says yes. - const auto length = https::content_length(package::download_url(name, choice.version), 8, agent, &watch); { std::lock_guard lock(mutex); - version = choice.version; - newest = choice.newest; - description = choice.description; - package_bytes = length.value_or(0); + items = std::move(wanted); } - // Its icon for the card: small, and not worth holding the question up for long. - if (!choice.icon.empty()) - if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent, nullptr, &watch)) - overlay::set_map_download_icon(*png); ask(); } catch (const std::exception& failure) { fail(std::string("Looking for it failed: ") + failure.what()); } } -// Stage::downloading and Stage::installing, then Stage::applying is the client thread's. +// Downloads one package into its folder in Mods (Stage::downloading, then Stage::installing). +// False when it failed or was stopped. +bool fetch_one(const Item& item) { + const auto root = mods_root(); + const bool update = !item.have.empty(); + wchar_t temp[MAX_PATH + 1]{}; + const auto length = GetTempPathW(MAX_PATH, temp); + const auto archive = fs::path(length ? std::wstring(temp, length) : L".") / + (L"ReSkate-map-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::wstring(item.folder.begin(), item.folder.end()) + L".zip"); + std::error_code error; + fs::remove(archive, error); + watch.received.store(0); + watch.total.store(0); + // (The session's deadline is not the download's: only its own time limit stops it.) + const auto download = [&](const std::string& selected) { + watch.deadline_us.store(0); + return https::get(package::download_url(item.name, selected), archive, largest_download, download_seconds, agent, &watch); + }; + auto result = download(item.version); + // The host's version of a map can be gone from Thunderstore: the newest is the same map. + if (!item.plugin && !update && !result.ok && result.http_status == 404 && item.version != item.newest && !watch.cancel.load()) { + fs::remove(archive, error); + logging::log(logging::Level::info, logging::Channel::assets, + "Map download: {} {} is not on Thunderstore any more; getting {}.", item.folder, item.version, item.newest); + result = download(item.newest); + } + if (!result.ok) { + fs::remove(archive, error); + if (!continuing()) return false; + if (result.error == ERROR_TIMEOUT || result.error == ERROR_WINHTTP_TIMEOUT) { + fail("The download timed out. Please try joining the server again.", true); + return false; + } + fail(result.http_status && result.http_status != 200 + ? "The download failed (Thunderstore answered " + std::to_string(result.http_status) + ")." + : "The download failed (Windows error " + std::to_string(result.error) + ")."); + return false; + } + if (!advance(Stage::downloading, Stage::installing)) { + fs::remove(archive, error); + return false; + } + try { + // (In the installed one's place when it is the same package at another version. The + // game may hold the old one's files open when it was used this run: then it cannot be + // replaced until the game is closed.) + launcher_mods::install(root, archive, update, [](float) { continuing(); }, stop_install, {item.folder, item.name.owner, true}); + } catch (const std::exception& failure) { + fs::remove(archive, error); + if (!continuing()) return false; + fail(update ? std::string("It could not be replaced while the game runs (") + failure.what() + + "). Close the game, update it from the launcher and join again." + : std::string("It could not be installed: ") + failure.what()); + return false; + } + fs::remove(archive, error); + return continuing(); +} + +// Whether what is in the folder is what it was let in as: a map and nothing else, or a plugin +// (with what game data it brings, which is then merged). False when it is not, and it is taken +// out again unless it was here before. +bool vetted(Item& item) { + const auto root = mods_root(); + const auto folder = root / item.folder; + std::error_code error; + if (!item.plugin) { + if (fs::exists(folder / L"reskate-levels.json", error) && live_mods::only_maps(folder)) { + item.merge = true; + return true; + } + if (!item.off) + try { launcher_mods::remove(root, item.folder); } catch (const std::exception&) {} + fail("Its package holds more than a map, so it is not installed from here. Install it from the launcher if you trust it."); + return false; + } + std::vector notes; + mods::Mod mod; + try { mod = mods::describe_mod(folder, item.folder, notes); } catch (const std::exception&) {} + if (mod.provides_plugin) { + item.merge = mod.provides_layout || mod.provides_levels; + return true; + } + if (!item.off && item.have.empty()) + try { launcher_mods::remove(root, item.folder); } catch (const std::exception&) {} + fail(plugin_label(item.name.folder()) + " is not a plugin, so it is not installed from here. Install it from the launcher if you trust it."); + return false; +} + +// Stage::downloading through Stage::installing for every package in turn, then Stage::applying +// (the client thread's) when any has game data, else Stage::joining. void fetch() { - package::Name name; - std::string get, fallback; - bool off{}, update{}; + std::vector list; { std::lock_guard lock(mutex); - name = wanted; - get = version; - fallback = newest; - off = installed_off; - update = !need.have.empty(); + list = items; } - const auto root = mods_root(); - const auto folder = name.folder(); try { - if (!continuing()) return; - if (!off) { - wchar_t temp[MAX_PATH + 1]{}; - const auto length = GetTempPathW(MAX_PATH, temp); - const auto archive = fs::path(length ? std::wstring(temp, length) : L".") / - (L"ReSkate-map-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::wstring(folder.begin(), folder.end()) + L".zip"); - std::error_code error; - fs::remove(archive, error); - watch.received.store(0); - watch.total.store(0); - // (The session's deadline is not the download's: only its own time limit stops it.) - const auto download = [&](const std::string& selected) { - watch.deadline_us.store(0); - return https::get(package::download_url(name, selected), archive, largest_download, download_seconds, agent, &watch); - }; - auto result = download(get); - // The host's version can be gone from Thunderstore: the newest is the same map. - if (!update && !result.ok && result.http_status == 404 && get != fallback && !watch.cancel.load()) { - fs::remove(archive, error); - logging::log(logging::Level::info, logging::Channel::assets, - "Map download: {} {} is not on Thunderstore any more; getting {}.", folder, get, fallback); - result = download(fallback); - } - if (!result.ok) { - fs::remove(archive, error); - if (!continuing()) return; - if (result.error == ERROR_TIMEOUT || result.error == ERROR_WINHTTP_TIMEOUT) - return fail("The download timed out. Please try joining the server again.", true); - return fail(result.http_status && result.http_status != 200 - ? "The download failed (Thunderstore answered " + std::to_string(result.http_status) + ")." - : "The download failed (Windows error " + std::to_string(result.error) + ")."); - } - if (!advance(Stage::downloading, Stage::installing)) { - fs::remove(archive, error); + bool any_merge{}, any_switch{}; + for (std::size_t at = 0; at < list.size(); ++at) { + auto& item = list[at]; + current.store(at); + if (!continuing()) return; + // (Each after the first is downloaded as the first was.) + if (at && !advance(Stage::installing, Stage::downloading)) return; + if (item.off) { + if (!advance(Stage::downloading, Stage::installing)) return; + } else if (!fetch_one(item)) { return; } - try { - // (In the installed one's place when it is the same map at another version. The - // game may hold the old one's files open when it was skated this run: then it - // cannot be replaced until the game is closed.) - launcher_mods::install(root, archive, update, [](float) { continuing(); }, stop_install, {folder, name.owner, true}); - } catch (const std::exception& failure) { - fs::remove(archive, error); - if (!continuing()) return; - return fail(update ? std::string("It could not be replaced while the game runs (") + failure.what() + - "). Close the game, update it from the launcher and join again." - : std::string("It could not be installed: ") + failure.what()); - } - fs::remove(archive, error); if (!continuing()) return; - { - std::lock_guard known(known_mutex); - known_packages.valid = false; - } - } else { - if (!advance(Stage::downloading, Stage::installing)) return; + if (!vetted(item)) return; + any_merge |= item.merge; + any_switch |= item.off || !item.have.empty(); + } + forget_known(); + { + std::lock_guard lock(mutex); + items = list; + } + // One that was here switched off, or at another version, is wanted on now. + if (any_switch) { + if (!continuing()) return; + auto mods_list = mods::scan_mods(mods::engine_data_root()); + if (!mods_list.issue.empty()) return fail("mods.json could not be read: " + mods_list.issue); + for (auto& entry : mods_list.entries) + for (const auto& item : list) + if ((item.off || !item.have.empty()) && same(entry.mod.name, item.folder)) entry.enabled = true; + mods::save_mod_order(mods_list.root, mods_list.entries); + forget_known(); } if (!continuing()) return; - // A map and nothing else: this way in is for maps. - std::error_code error; - if (!fs::exists(root / folder / L"reskate-levels.json", error) || !live_mods::only_maps(root / folder)) { - if (!off) { - try { launcher_mods::remove(root, folder); } catch (const std::exception&) {} - } - return fail("Its package holds more than a map, so it is not installed from here. Install it from the launcher if you trust it."); + if (!any_merge) { + // Plugins alone: the plugin host takes them from the Mods folder itself. + logging::log(logging::Level::info, logging::Channel::assets, "Map download: the plugins are in Mods."); + join_at.store(multiplayer::now_us()); + advance(Stage::installing, Stage::joining); + return; } - if (off || update) { // (a map at another version may have been switched off: it is wanted now) - if (!continuing()) return; - auto list = mods::scan_mods(mods::engine_data_root()); - if (!list.issue.empty()) return fail("mods.json could not be read: " + list.issue); - for (auto& entry : list.entries) - if (same(entry.mod.name, folder)) entry.enabled = true; - mods::save_mod_order(list.root, list.entries); - } - if (!continuing()) return; - logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} is in Mods; applying it.", folder); + logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} is in Mods; applying it.", list.front().folder); apply_started.store(false); apply_wanted.store(true); if (!advance(Stage::installing, Stage::applying)) apply_wanted.store(false); @@ -371,7 +584,7 @@ void offer(const multiplayer::MapNeed& asked) { // on it already: then it finishes, and the new need is turned away below.) if (stage.load() != Stage::idle && !detached.load() && asked.deadline_us && asked.deadline_us != deadline.load()) { lock.unlock(); - fail("Map download cancelled.", true); + fail("Download cancelled.", true); lock.lock(); } // A cancelled worker/atomic live merge must drain before its globals or files are reused. @@ -383,14 +596,11 @@ void offer(const multiplayer::MapNeed& asked) { } pending.reset(); auto idle = Stage::idle; - const auto name = package::parse_name(asked.package); // (Taken before anything is set up, so a second need while this one is in hand is dropped.) if (!stage.compare_exchange_strong(idle, Stage::checking)) { - // Another map is in hand (the session moved on while it came). One at a time: the one + // Another need is in hand (the session moved on while it came). One at a time: the one // being fetched is finished, and the session, which now waits on this other one, is left. - bool other{}; - other = asked.package != need.package; - if (other) { + if (need_key(asked) != need_key(need)) { overlay::notify(overlay::NoticeLevel::warning, "Map changed again", "The " + host_word(asked.server) + " moved on to " + asked.map + " while another map was downloading. Join again when it is done."); leave_session(); @@ -399,21 +609,42 @@ void offer(const multiplayer::MapNeed& asked) { } { need = asked; - if (!name || asked.package == last_failed) { + std::vector list; + bool fetchable = !asked.package.empty() || !asked.plugins.empty(); + if (!asked.package.empty()) { + if (const auto name = package::parse_name(asked.package)) { + Item map; + map.name = *name; + map.have = asked.have; + map.folder = name->folder(); + list.push_back(std::move(map)); + } else { + fetchable = false; + } + } + for (const auto& plugin : asked.plugins) { + auto name = package::parse_name(plugin); + if (!name) { + fetchable = false; + continue; + } + Item item; + item.plugin = true; + item.at_least = std::exchange(name->version, {}); + item.name = std::move(*name); + item.folder = item.name.folder(); + list.push_back(std::move(item)); + } + if (!fetchable || need_key(asked) == last_failed) { // (Said once already, or nothing this can fetch: the plain notice.) - overlay::notify(overlay::NoticeLevel::warning, "Map not installed", - "The " + host_word(asked.server) + (asked.moved ? " changed to " : " is on ") + asked.map + - ", which is not installed on this PC. Install its map mod and join again."); + overlay::notify(overlay::NoticeLevel::warning, needed_title(asked), + needed_text(asked) + (asked.package.empty() ? "Install them and join again." : "Install its map mod and join again.")); stage.store(Stage::idle); leave_session(); return; } - wanted = *name; - version.clear(); - newest.clear(); - description.clear(); - package_bytes = 0; - installed_off = false; + items = std::move(list); + current.store(0); } overlay::set_map_download_icon({}); watch.cancel.store(false); @@ -430,7 +661,7 @@ void offer(const multiplayer::MapNeed& asked) { start(check); } catch (...) { lock.unlock(); - fail("The map download could not be started. Please try joining again.", true); + fail("The download could not be started. Please try joining again.", true); } } @@ -440,12 +671,12 @@ void answer(bool download) noexcept { if (!continuing()) return; const auto now = multiplayer::now_us(); if (stage.load() == Stage::asking && policy::expired(ask_until.load(), now)) { - fail("The map download was not confirmed within two minutes. Please try joining again.", true); + fail("The download was not confirmed within two minutes. Please try joining again.", true); return; } if (!download) { if (stage.load() == Stage::asking || stage.load() == Stage::downloading) - fail("Map download cancelled.", true); + fail("Download cancelled.", true); } else { // Publish the stage and claim its worker together. Cancellation cannot allow // a fresh offer to reuse the globals before this thread starts its worker. @@ -454,7 +685,7 @@ void answer(bool download) noexcept { if (!watch.cancel.load() && stage.compare_exchange_strong(asked, Stage::downloading)) start(fetch); } } catch (...) { - fail("The map download could not be started. Please try joining again.", true); + fail("The download could not be started. Please try joining again.", true); } } @@ -472,12 +703,31 @@ View view() { out.stage = stage.load(); if (out.stage == Stage::idle || out.stage == Stage::checking) return out; std::lock_guard lock(mutex); - out.map = need.map; - out.package = wanted.folder() + (version.empty() ? std::string() : " " + version); - out.author = wanted.owner; - out.version = version; - out.description = description; - out.bytes = package_bytes; + for (const auto& item : items) { + View::Item shown; + shown.name = item.plugin ? plugin_label(item.name.folder()) : need.map; + shown.author = item.name.owner; + shown.version = item.version; + shown.description = item.description; + shown.have = item.have; + shown.plugin = item.plugin; + shown.installed = item.off; + shown.bytes = item.off ? 0 : item.bytes; + out.bytes += shown.bytes; + out.items.push_back(std::move(shown)); + } + out.current = std::min(current.load(), out.items.empty() ? std::size_t{} : out.items.size() - 1); + // The map's own facts, for the card that asks for a map alone. + if (!items.empty() && !items.front().plugin) { + const auto& map = items.front(); + out.map = need.map; + out.package = map.name.folder() + (map.version.empty() ? std::string() : " " + map.version); + out.author = map.name.owner; + out.version = map.version; + out.description = map.description; + out.installed = map.off; + out.have = need.have; + } if (out.stage == Stage::applying) { const auto now = live_mods::progress(); out.step = now.done; @@ -486,8 +736,6 @@ View view() { } out.server = need.server; out.moved = need.moved; - out.installed = installed_off; - out.have = need.have; out.choice = pick_download.load(std::memory_order_relaxed); out.received = watch.received.load(); out.total = watch.total.load(); @@ -522,16 +770,16 @@ void tick() { session.status.find("stopped waiting for the map download") != std::string::npos; // The session stopped waiting while the download was under way: it finishes all the same. if (!session.active && under_way(now) && !detached.exchange(true) && gave_up) - overlay::notify(overlay::NoticeLevel::warning, "Map still downloading", + overlay::notify(overlay::NoticeLevel::warning, need_package().empty() ? "Plugins still downloading" : "Map still downloading", "The " + [] { std::lock_guard lock(mutex); return host_word(need.server); }() + - " stopped waiting for the map. It goes on downloading and installs when it is done; join again then."); + " stopped waiting for the download. It goes on and installs when it is done; join again then."); if (session.active && !detached.load() && now != Stage::joining && multiplayer::map_fetch_deadline() != deadline.load()) - fail("Map download cancelled.", true); + fail("Download cancelled.", true); if (!session.active && !under_way(now) && gave_up) - fail("The server stopped waiting for the map download.", true); + fail("The server stopped waiting for the download.", true); if (continuing() && now == Stage::asking && policy::expired(ask_until.load(), multiplayer::now_us())) - fail("The map download was not confirmed within two minutes. Please try joining again.", true); + fail("The download was not confirmed within two minutes. Please try joining again.", true); now = stage.load(); } // The session leaves the map alone until it has been applied (an update's level is one the @@ -550,15 +798,17 @@ void tick() { } if (!apply_started.load() || live_mods::busy()) return; apply_started.store(false); - std::string folder; + std::vector merged; { std::lock_guard lock(mutex); - folder = wanted.folder(); + for (const auto& item : items) + if (item.merge) merged.push_back(item.folder); } const auto applied = live_mods::applied_mods(); - if (std::none_of(applied.begin(), applied.end(), [&](const std::string& name) { return same(name, folder); })) - return fail("It is installed, but was left out when the mods were applied (" + live_mods::status() + ")."); - // The level list takes the new map on the next ticks, and the session, which has been + for (const auto& folder : merged) + if (std::none_of(applied.begin(), applied.end(), [&](const std::string& name) { return same(name, folder); })) + return fail(folder + " is installed, but was left out when the mods were applied (" + live_mods::status() + ")."); + // The level list takes a new map on the next ticks, and the session, which has been // trying the map all along, loads it then. join_at.store(multiplayer::now_us()); advance(Stage::applying, Stage::joining); @@ -567,10 +817,12 @@ void tick() { if (now == Stage::joining) { const auto waited = multiplayer::now_us() - join_at.load(); if (waited < 2500000) return; - // Still waiting on it: the game has the mod and cannot find the map in it. + // Still waiting on it: the game has the mods and the session still cannot go on. const auto session = multiplayer::model(); if (multiplayer::fetching_map() || (session.active && !session.connected)) { - if (waited >= policy::installed_load_us) fail("It is installed, but the game still cannot load the map. Restart the game and join again."); + if (waited >= policy::installed_load_us) + fail(need_package().empty() ? "They are installed, but the session still does not see them. Restart the game and join again." + : "It is installed, but the game still cannot load the map. Restart the game and join again."); return; } { @@ -579,11 +831,12 @@ void tick() { last_failed.clear(); } if (!continuing() || !stage.compare_exchange_strong(now, Stage::idle)) return; - // The session ended while the map came (the host left, the connection dropped): back to it, + // The session ended while it came (the host left, the connection dropped): back to it, // while it would still have the player. Past its deadline it would not. if (!multiplayer::model().active && (policy::expired(deadline.load(), multiplayer::now_us()) || !multiplayer::queue_command("rejoin", "", ""))) - overlay::notify(overlay::NoticeLevel::info, "Map installed", "Join the session again from the server browser."); + overlay::notify(overlay::NoticeLevel::info, need_package().empty() ? "Plugins installed" : "Map installed", + "Join the session again from the server browser."); } } @@ -598,21 +851,30 @@ std::string package_of(std::string_view asset) { if (asset.empty()) return {}; std::string key(asset); std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - const auto now = GetTickCount64(); - std::unique_lock lock(known_mutex); - // (Only with no table at all, just after a map was installed, is the Mods folder read here.) - if (!known_packages.valid) { - lock.unlock(); - auto table = read_known_packages(); - lock.lock(); - if (!known_packages.valid) { - known_packages.by_level = std::move(table); - known_packages.read_at = now; - known_packages.valid = true; + return with_known([&](const KnownTable& table) { + const auto found = table.by_level.find(key); + return found != table.by_level.end() ? found->second : std::string{}; + }); +} + +bool has_level(std::string_view asset) { + if (asset.empty()) return false; + std::string key(asset); + std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return with_known([&](const KnownTable& table) { return table.by_level.contains(key); }); +} + +std::vector missing_plugins(const std::vector& required) { + return with_known([&](const KnownTable& table) { + std::vector missing; + for (const auto& wanted : required) { + const auto name = package::parse_name(wanted); + const bool ready = name && std::any_of(table.plugins.begin(), table.plugins.end(), [&](const KnownPlugin& plugin) { + return plugin.enabled && is_package(*name, plugin.folder, plugin.title, plugin.author) && new_enough(plugin.version, name->version); + }); + if (!ready) missing.push_back(wanted); } - } - refresh_known_packages(now); - const auto found = known_packages.by_level.find(key); - return found != known_packages.by_level.end() ? found->second : std::string{}; + return missing; + }); } } diff --git a/Extension/Assets/map_download.h b/Extension/Assets/map_download.h index 6f60a7a0..e5b3f397 100644 --- a/Extension/Assets/map_download.h +++ b/Extension/Assets/map_download.h @@ -3,36 +3,40 @@ #include #include #include +#include -// A map a multiplayer session needs and this PC does not have, fetched from Thunderstore -// while the game runs. The host says which package its map is from (session.h: MapNeed). The -// player is asked; on yes the package is downloaded, installed into Mods and applied as a live -// mod (live_mods.h). The session stays connected meanwhile and loads the map once it is there; -// on no, or when the map cannot be had, the session is left. +// What a multiplayer session needs and this PC does not have, fetched from Thunderstore while +// the game runs: the map, when the host says which package its map is from (session.h: +// MapNeed), and the plugins a dedicated server requires. The player is asked once, on one card, +// for all of it; on yes each package is downloaded and installed into Mods, and those with game +// data are applied as a live mod (live_mods.h). The session stays connected meanwhile and goes +// on once everything is there; on no, or when anything cannot be had, the session is left. // The session waits 30 minutes on its local monotonic clock for the whole of it (checking, // confirmation, download, installation, applying and joining); confirmation gets two of them. // A download that has begun is not cut short when the session stops waiting: it finishes, // installs and is applied, and the player is told to join again. A cancelled worker drains // before another attempt can begin. // -// Only maps come in this way. The package has to be listed as a map in ReSkate's Thunderstore -// community, not deprecated and not rejected by its moderators; what is installed has to be a -// map and nothing else (level bundles and their registration: no cosmetics, no settings, no -// scripts), or it is taken out again. Everything is downloaded from thunderstore.io, whatever -// a host says. +// Only maps and plugins come in this way, whatever a host says. A map has to be listed as a map +// in ReSkate's Thunderstore community and install as a map and nothing else (level bundles and +// their registration: no cosmetics, no settings, no scripts). A plugin has to be listed as a +// plugin there and install as one (a reskate-plugin.json that names Luau code to run); it may +// bring the game data it uses, which the live merge checks as it checks any mod's. A package +// that is deprecated or was rejected by the community's moderators is not fetched, and one that +// installs as anything else is taken out again. Everything is downloaded from thunderstore.io. namespace dingosdk::map_download { enum class Stage { idle, - checking, // asking Thunderstore about the package; nothing is shown yet + checking, // asking Thunderstore about the packages; nothing is shown yet asking, // the card asks: download, or not downloading, installing, applying, // the live merge - joining, // the map is in the game: the session is loading it (or is joined again) + joining, // everything is in the game: the session is going on (or is joined again) }; struct View { Stage stage{}; - std::string map, package; // "Vancouver Plaza", "Sandos-Vancouver_Plaza 2.0.0" + std::string map, package; // "Vancouver Plaza", "Sandos-Vancouver_Plaza 2.0.0"; empty when only plugins are needed std::string author, version, description; // "Sandos", "2.0.0", and what its Thunderstore page says of it // applying: the live merge's steps done of all, and the one in hand. std::size_t step{}, steps{}; @@ -42,11 +46,21 @@ struct View { bool installed{}; // asking: the mod is here but switched off, so nothing is downloaded std::string have; // the version installed here, when the host's is another and replaces it bool choice{true}; // asking: the answer a controller has picked (true: download) - std::uint64_t bytes{}; // asking: the zip's Content-Length; 0 when the server did not say - std::uint64_t received{}, total{}; // downloading; total 0 when the size is not known + std::uint64_t bytes{}; // asking: what all of it downloads (Content-Length); 0 when no server said + std::uint64_t received{}, total{}; // downloading the one in hand; total 0 when the size is not known std::uint32_t yes_bind{}, no_bind{}; // the vote binds, which answer the card + // Everything asked for, the map first when it is needed: what the card lists. + struct Item { + std::string name; // the map's name, or the plugin's ("FPV Drone") + std::string author, version, description; + std::string have; // the version installed here, when it is replaced + bool plugin{}, installed{}; + std::uint64_t bytes{}; // 0 when not known, or nothing is downloaded + }; + std::vector items; + std::size_t current{}; // downloading, installing: the one in hand, an index into items }; -// Client thread: a session just ended over this map. Ignored while another is in hand. +// Client thread: a session is waiting on what it needs. Ignored while another is in hand. void offer(const multiplayer::MapNeed& need); // Any thread. Yes downloads (when asking); no declines, or stops a download under way. void answer(bool download) noexcept; @@ -61,6 +75,13 @@ void tick(); // The Thunderstore package an enabled mod's level comes from (its folder and manifest), for a // host to tell its guests; empty when the level is the game's own or the mod is not a package. std::string package_of(std::string_view asset); +// Whether an enabled mod here has the level, from a package or not (session.h: LevelCheck). +bool has_level(std::string_view asset); +// Which of a dedicated server's required plugins are not ready here: not installed, switched +// off, not a plugin, or older than the version named (session.h: PluginCheck). A plugin counts +// as the package when its folder is the package's (Owner-Name), or its manifest's name and +// author are the package's. +std::vector missing_plugins(const std::vector& required); // Reads the table package_of answers from, on a thread of its own, if it has not been (so the // first map offer does not wait on the Mods folder). Cheap to call each frame; tick() does. void warm_packages() noexcept; diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 8d1197fb..26873e9b 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -480,6 +480,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 throw std::invalid_argument("Invalid map name"); if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_package(p.map_package)) throw std::invalid_argument("Invalid map package"); + if (!p.required_plugins.empty() && (p.kind != PacketKind::map_offer || !valid_required_plugins(p.required_plugins))) + throw std::invalid_argument("Invalid required plugins"); if (p.map_fetching && (p.world_ready || (p.kind != PacketKind::map_request && p.kind != PacketKind::world_ready))) throw std::invalid_argument("Invalid map fetch notice"); if (p.kind == PacketKind::world_state && @@ -530,6 +532,11 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end()); w.integer(p.map_package.size(), 1); w.bytes.insert(w.bytes.end(), p.map_package.begin(), p.map_package.end()); + w.integer(p.required_plugins.size(), 1); + for (const auto &plugin : p.required_plugins) { + w.integer(plugin.size(), 1); + w.bytes.insert(w.bytes.end(), plugin.begin(), plugin.end()); + } } if (p.kind == PacketKind::map_request) w.integer(p.map_fetching ? 1 : 0, 1); if (p.kind == PacketKind::hello) { @@ -874,6 +881,14 @@ std::optional decode(std::span bytes) noexcept { r.at += static_cast(length); if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination) || !read_map_label(r, bytes, p)) return {}; + const auto plugins = r.integer(1); + if (plugins > max_required_plugins) return {}; + for (std::uint64_t i = 0; i < plugins; ++i) { + auto plugin = read_text(r, bytes, 1, max_map_package); + if (!plugin) return {}; + p.required_plugins.push_back(std::move(*plugin)); + } + if (!valid_required_plugins(p.required_plugins)) return {}; } if (p.kind == PacketKind::map_request) { const auto fetching = r.integer(1); @@ -1422,6 +1437,22 @@ bool valid_map_package(std::string_view package) noexcept { if (!package_word(package.substr(0, first)) || !package_word(rest.substr(0, second))) return false; return second == std::string_view::npos || package_version(rest.substr(second + 1)); } +bool valid_required_plugins(const std::vector &packages) noexcept { + if (packages.size() > max_required_plugins) return false; + for (std::size_t i = 0; i < packages.size(); ++i) { + if (packages[i].empty() || !valid_map_package(packages[i])) return false; + // (Twice is twice whatever the version or the case: "A-B" and "a-b-1.0.0" are one package.) + const auto name = packages[i].substr(0, packages[i].size() - map_package_version(packages[i]).size()); + for (std::size_t k = 0; k < i; ++k) { + const auto other = packages[k].substr(0, packages[k].size() - map_package_version(packages[k]).size()); + const auto bare = [](std::string_view text) { return text.ends_with('-') ? text.substr(0, text.size() - 1) : text; }; + const auto a = bare(name), b = bare(other); + if (a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { return (x | 0x20) == (y | 0x20); })) + return false; + } + } + return true; +} std::string map_package_name(std::string_view folder, std::string_view version) { const auto dash = folder.find('-'); if (dash == std::string_view::npos || !package_word(folder.substr(0, dash))) return {}; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 0c17643c..e3d9572d 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -27,7 +27,7 @@ namespace dingosdk::multiplayer { constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_packet = 24576; constexpr std::size_t packet_header_size = 64; -constexpr std::uint16_t protocol_version = 54; +constexpr std::uint16_t protocol_version = 55; // How long a receiver keeps typing dots after the last "still typing" notice. The sender // clears them after three seconds without input; this only covers a lost "stopped" packet. constexpr std::uint64_t typing_hold_us = 4000000; @@ -224,6 +224,12 @@ inline constexpr std::uint64_t map_fetch_limit_us = map_fetch_policy::session_us // maps and for a map mod that is not from Thunderstore. inline constexpr std::size_t max_map_package = 96; bool valid_map_package(std::string_view package) noexcept; +// The plugins a dedicated server needs its players to have, named as map packages are +// ("Owner-Name", or "Owner-Name-1.2.3" for at least that version): each a valid_map_package, +// none empty, none twice. A player's game fetches only what Thunderstore lists as a plugin and +// what runs Luau code, whatever a server names (Extension/Assets/map_download.h). +inline constexpr std::size_t max_required_plugins = 16; +bool valid_required_plugins(const std::vector &packages) noexcept; // That name for a mod folder and the version its manifest gives; empty when the folder is not // named as a package: "Owner-Name", as the launcher installs one, or "Owner-Name-1.2.3", as its // zip unpacks by hand. The manifest's version comes before the folder's; one that is not three @@ -373,6 +379,7 @@ struct Packet { std::uint16_t map_rotation{}; // maps: minutes per map (0: off) std::string map_label; // map_offer, world_state: the map's name for people (may be empty) std::string map_package; // map_offer, world_state: valid_map_package (may be empty) + std::vector required_plugins; // map_offer: valid_required_plugins (a player's lobby: none) std::vector throwdown; // throwdown: one encoded message (1..max_throwdown_message bytes) std::array teleport{}; // teleport: where the receiver goes (world position) std::vector tuning; // physics_tuning: 0..max_physics_tuning bytes diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 6cdf3cad..51cf868f 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -164,6 +164,8 @@ void stop(Session &s, std::string reason) { s.join_destination.clear(); s.map_label.clear(); s.map_package.clear(); + s.required_plugins.clear(); + s.plugins_ready = s.fetching_with_map = false; s.fetching_since = 0; s.map_fetch_deadline = 0; map_fetch_deadline_flag.store(0); @@ -837,6 +839,10 @@ std::string take_leave_notice() { return std::exchange(session().leave_notice, { std::string take_removed_notice() { return std::exchange(session().removed_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); } +void set_plugin_check(PluginCheck plugins, LevelCheck levels) noexcept { + plugin_check.store(plugins, std::memory_order_relaxed); + level_check.store(levels, std::memory_order_relaxed); +} bool fetching_map() noexcept { return fetching_map_flag.load(std::memory_order_relaxed); } std::uint64_t map_fetch_deadline() noexcept { return map_fetch_deadline_flag.load(); } void hold_map_load(bool hold, std::string_view package) noexcept { @@ -957,6 +963,45 @@ 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))); + // A dedicated server's required plugins come before its map, which does not load until + // they are here. The ones missing are fetched as a missing map is, on one card with the + // map when that is missing too, so the player is asked once. + if (!s.plugins_ready && !s.required_plugins.empty()) { + std::vector missing = s.required_plugins; + try { + if (const auto check = plugin_check.load(std::memory_order_relaxed)) missing = check(s.required_plugins); + } catch (...) {} + if (missing.empty()) { + s.plugins_ready = true; + // (A fetch of plugins alone is over: the map, which is here, loads as usual.) + if (s.fetching_since && !s.fetching_with_map) { + s.fetching_since = 0; + fetching_map_flag.store(false, std::memory_order_relaxed); + } + } else { + if (!s.fetching_since) { + // The map goes on the card too when it is a package that no enabled mod here has. + const auto levels = level_check.load(std::memory_order_relaxed); + bool map_here = s.map_package.empty(); + try { + map_here = map_here || (have.empty() && levels && levels(world_destination_asset(s.join_destination))); + } catch (...) {} + const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label; + s.fetching_since = now; + s.fetching_with_map = !map_here; + if (!s.map_fetch_deadline) s.map_fetch_deadline = now + map_fetch_limit_us; + map_fetch_deadline_flag.store(s.map_fetch_deadline); + fetching_map_flag.store(true, std::memory_order_relaxed); + s.map_need = MapNeed{map_here ? std::string() : s.map_package, name, dedicated_host(s), s.travelling, + map_here ? std::string() : have, s.map_fetch_deadline, std::move(missing)}; + s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password, s.map_fetch_deadline}; + s.last_map_request = s.last_world_ready = 0; // say so at once + } + s.join_started = now; + s.status = "Waiting for the server's plugins to download..."; + return false; + } + } // (Nor while its files are being put in place and applied: hold_map_load.) const bool held = !s.map_package.empty() && map_load_held.load() == map_hash(s.map_package); const auto result = have.empty() && !held ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing; diff --git a/Extension/Multiplayer/Session/session.h b/Extension/Multiplayer/Session/session.h index f862e486..e36565d0 100644 --- a/Extension/Multiplayer/Session/session.h +++ b/Extension/Multiplayer/Session/session.h @@ -41,7 +41,7 @@ std::string take_removed_notice(); // cannot be had. Should the session end meanwhile for another reason, // queue_command("rejoin", "", "") goes back to it, with the password it was joined with. struct MapNeed { - std::string package; // protocol.h: valid_map_package, never empty + std::string package; // protocol.h: valid_map_package; empty when only plugins are needed std::string map; // its name for people bool server{}; // a dedicated server's map, not a player's lobby bool moved{}; // the session changed to this map with the player in it (else: joining) @@ -50,6 +50,9 @@ struct MapNeed { std::string have; // Local steady-clock deadline, shared with the downloader, never transmitted. std::uint64_t deadline_us{}; + // The plugins a dedicated server needs that this PC does not have ready (protocol.h: + // valid_required_plugins), asked for on the same card as the map; empty when only the map is. + std::vector plugins; }; std::optional take_map_need(); // Whether a session is waiting on a map being fetched right now. Thread-safe. @@ -73,6 +76,14 @@ struct NextMapWanted { std::vector next_maps(); unsigned next_maps_serial() noexcept; void set_map_package_lookup(MapPackageLookup lookup) noexcept; +// A dedicated server's required plugins (Packet::required_plugins) are checked before its map +// loads. PluginCheck answers which of them this PC does not have ready: not installed, switched +// off, or older than the version named. LevelCheck answers whether an enabled mod here has a +// level (its asset), from a package or not, so a missing map goes on the same card. Both are +// called on the client thread every half second while joining; they should be quick. +using PluginCheck = std::vector (*)(const std::vector &required); +using LevelCheck = bool (*)(std::string_view asset); +void set_plugin_check(PluginCheck plugins, LevelCheck levels) noexcept; // The session's chat lines for the overlay. Thread-safe and cheap: callable every frame. MultiplayerChat chat(); // A chat line only this player sees, from a plugin: shown under the plugin's name with a diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 98df947b..50bc7fa6 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -262,6 +262,10 @@ struct Session { unsigned server_map_rotation{}; // minutes per map (0: off) std::string map_label; // the host's name for join_destination (may be empty) std::string map_package; // the Thunderstore package it is from (may be empty) + // A dedicated server's required plugins (map offer), and whether this PC has them all ready. + std::vector required_plugins; + bool plugins_ready{}; + bool fetching_with_map{}; // the fetch under way brings the map too // The map is not installed and is being fetched (take_map_need): since when, 0 for not. // The host is told with what is sent while waiting (Packet::map_fetching). std::uint64_t fetching_since{}; @@ -481,6 +485,8 @@ std::string answer_server_poll(Session &s, std::size_t answer); // Whether a vote the local player may answer is running: read by the game thread for the binds. inline std::atomic server_vote_open_flag{}; inline std::atomic map_package_lookup{}; +inline std::atomic plugin_check{}; +inline std::atomic level_check{}; inline std::atomic fetching_map_flag{}; inline std::atomic map_fetch_deadline_flag{}, map_load_held{}; // A guest without the map is downloading it: the clock they are loading against starts over diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 09451897..04e04e56 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -774,6 +774,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { s.join_destination = p.destination; s.map_label = p.map_label; s.map_package = p.map_package; + // (Only a dedicated server needs plugins of its players.) + if (dedicated_host(s)) s.required_plugins = p.required_plugins; s.world = p.world; s.join_map_authorized |= p.map_authorized; link->last_packet = now; diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 96408b19..f593d6dc 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -1185,6 +1185,28 @@ void dedicated_server_codec() { const auto offer_arrived = decode_wire(encode_wire(offered)); check(offer_arrived && offer_arrived->map_package == "Sandos-Vancouver_Plaza" && offer_arrived->destination == offered.destination, "The map's package lost from a map offer"); + // A dedicated server's required plugins ride on its map offer. + check(offer_arrived && offer_arrived->required_plugins.empty(), "A map offer without plugins arrived with some"); + auto requiring = offered; + requiring.required_plugins = {"neekgate-FPV_Drone", "Team-Filmer-1.2.0"}; + const auto required_arrived = decode_wire(encode_wire(requiring)); + check(required_arrived && required_arrived->required_plugins == requiring.required_plugins && + required_arrived->map_package == "Sandos-Vancouver_Plaza", + "The required plugins lost from a map offer"); + for (const auto &bad : std::vector>{{""}, {"Filmer"}, {"Team-Filmer", "team-filmer-1.0.0"}, + {"Team-A/B"}, std::vector(max_required_plugins + 1, "Team-X")}) { + auto wrong = offered; + wrong.required_plugins = bad; + check(reject(wrong) && !valid_required_plugins(bad), "A required plugin list that is not one encoded"); + } + { + std::vector sixteen; + for (std::size_t i = 0; i < max_required_plugins; ++i) sixteen.push_back("Team-Plugin_" + std::to_string(i)); + check(valid_required_plugins(sixteen) && valid_required_plugins({}), "A full required plugin list refused"); + } + auto wrong_kind = packaged; + wrong_kind.required_plugins = {"Team-Filmer"}; + check(reject(wrong_kind), "Required plugins encoded on something other than a map offer"); for (const char *bad : {"Sandos", "Sandos-", "-Plaza", "San dos-Plaza", "Sandos-Plaza-2.0", "Sandos-Plaza-2.0.0.1", "../x-y", "Sandos-Pla/za", "Sandos-Plaza-1.2.3-4", "https://thunderstore.io/x-y"}) { auto wrong = packaged; diff --git a/Extension/UI/Overlay/map_download_card.cpp b/Extension/UI/Overlay/map_download_card.cpp index 05743772..12bf7adb 100644 --- a/Extension/UI/Overlay/map_download_card.cpp +++ b/Extension/UI/Overlay/map_download_card.cpp @@ -6,7 +6,9 @@ // The card for a map a multiplayer session needs and this PC does not have // (Extension/Assets/map_download.h), laid out as the mod's page is: its icon, name, author, -// version, size and description, then what is being asked or done. It asks whether to download it, +// version, size and description, then what is being asked or done. When a dedicated server +// needs plugins too, or plugins alone, one card lists everything instead (draw_asked_list), so +// the player is asked once. It asks whether to download it, // in the middle of the screen with the controls under it as the game's own menus have them. // Once the player has said yes it becomes a small card at the right edge, with the download, // the install and the apply, so they can skate on while they wait; it goes when the map is in @@ -119,7 +121,12 @@ ImU32 faded(ImU32 colour, float alpha) { } std::string megabytes(std::uint64_t bytes) { const auto mb = static_cast(bytes) / (1024.0 * 1024.0); - return mb >= 1024.0 ? std::format("{:.2f} GB", mb / 1024.0) : std::format("{:.0f} MB", mb); + return mb >= 1024.0 ? std::format("{:.2f} GB", mb / 1024.0) : bytes && mb < 1.0 ? std::string("1 MB") : std::format("{:.0f} MB", mb); +} +// Whether the card lists what is asked for: a map with plugins, or plugins alone. A map alone +// has its own card, as it always had. +bool listed(const MapDownloadCard& of) { + return of.items.size() > 1 || (of.items.size() == 1 && of.items.front().plugin); } } @@ -143,7 +150,14 @@ void draw_progress(State& s) { const auto display = ImGui::GetIO().DisplaySize; const float u = std::min(display.x / 3840.0f, display.y / 2160.0f); const bool downloading = card.stage == 2, applying = card.stage == 4; - const char* title = downloading ? "DOWNLOADING MAP" : card.stage == 3 ? "INSTALLING MAP" : "APPLYING MAP"; + // Several packages: which of them is in hand, and its name. + const bool several = listed(card); + const auto count = several && !applying ? std::format(" {} OF {}", card.current + 1, card.items.size()) : std::string(); + const std::string title = downloading ? (several ? "DOWNLOADING" + count : "DOWNLOADING MAP") + : card.stage == 3 ? (several ? "INSTALLING" + count : "INSTALLING MAP") + : several ? "APPLYING MODS" : "APPLYING MAP"; + const std::string name = several && !applying && card.current < card.items.size() ? card.items[card.current].name + : card.map.empty() && !card.items.empty() ? card.items.front().name : card.map; const std::string status = downloading ? (card.total ? megabytes(card.received) + " of " + megabytes(card.total) : megabytes(card.received)) : card.stage == 3 ? std::string("Putting it in your Mods folder...") : card.step_name.empty() ? std::string("Adding it to the game...") : card.step_name + "..."; @@ -161,10 +175,10 @@ void draw_progress(State& s) { else skate_theme::rough_rect(draw, icon_min, icon_max, skate_theme::tile_light, 0x1c1, u * 2); const float x = left + icon + between, right = max.x - inset; float y = top; - draw->AddText(bold, label_size, ImVec2(x, y), theme::blue, title); + draw->AddText(bold, label_size, ImVec2(x, y), theme::blue, title.c_str()); y += label_size + 12 * u; draw->PushClipRect(ImVec2(x, y - name_size), ImVec2(right, y + name_size * 2), true); - draw->AddText(bold, name_size, ImVec2(x, y), theme::paper, card.map.c_str()); + draw->AddText(bold, name_size, ImVec2(x, y), theme::paper, name.c_str()); draw->PopClipRect(); y += name_size + 18 * u; const float share = downloading ? (card.total ? static_cast(card.received) / static_cast(card.total) : -1.0f) @@ -185,14 +199,139 @@ void draw_progress(State& s) { } void draw_asked_map(State& s); +void draw_asked_list(State& s); void draw_map_download() { if (card.stage) { auto& s = state(); if (card.stage != 1) draw_progress(s); + else if (listed(card)) draw_asked_list(s); else draw_asked_map(s); } draw_message(); // (over whatever else is up) } + +// A dedicated server's plugins, with its map when that is missing too: one card that lists all +// of it, asked once. Laid out as the map's card is (the pause menu's look), a row for each +// package: what it is, its name and author, and what getting it takes. Answered the same ways. +void draw_asked_list(State& s) { + auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont(); + auto* body = s.menu.body ? s.menu.body : ImGui::GetFont(); + auto* heading = s.menu.heading ? s.menu.heading : bold; + const auto display = ImGui::GetIO().DisplaySize; + const float u = std::min(display.x / 3840.0f, display.y / 2160.0f); + constexpr ImU32 sticky = IM_COL32(236, 215, 108, 255), sticky_dim = IM_COL32(150, 140, 86, 255); + const char* host = card.server ? "server" : "host"; + const auto width_of = [](ImFont* font, float size, const std::string& text) { return font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()).x; }; + const bool with_map = !card.items.front().plugin; + const bool all_here = std::all_of(card.items.begin(), card.items.end(), [](const MapDownloadCard::Item& item) { return item.installed; }); + + const char* title = with_map ? (card.moved ? "MAP CHANGED" : "MAP AND PLUGINS NEEDED") : "PLUGINS NEEDED"; + const auto plugins = static_cast(std::count_if(card.items.begin(), card.items.end(), + [](const MapDownloadCard::Item& item) { return item.plugin; })); + const std::string headline = with_map ? std::format("{} and {} {}", card.items.front().name, plugins, plugins == 1 ? "plugin" : "plugins") + : std::format("{} {}", plugins, plugins == 1 ? "plugin" : "plugins"); + const std::string why = std::format("The {} needs {} to join. Plugins and maps are the only mods a {} can ask for, and they come from Thunderstore.", + host, card.items.size() == 1 ? "this" : "these", host); + const std::string total = card.bytes ? "Downloads " + megabytes(card.bytes) + " in all" : std::string(); + + dingosdk::ControllerInput pad; + DingoSDKOverlayReadControllerInput(&pad, true); + struct Hint { std::string key; const char* what; }; + const std::vector hints{ + {pad.available ? std::string("D-pad") : std::string("< >"), "Pick"}, + {pad.available ? dingosdk::controller_combo_label(0x1000, pad.style) : std::string("Enter"), "Select"}, + {pad.available ? dingosdk::controller_combo_label(0x2000, pad.style) : std::string("Esc"), "Cancel"}}; + const float key_scale = u * 2, hint_gap = 36 * u; + float hints_width{}; + for (const auto& hint : hints) + hints_width += width_of(bold, 12 * key_scale, hint.key) + 20 * key_scale + width_of(bold, 14 * key_scale, hint.what); + hints_width += hint_gap * static_cast(hints.size() - 1); + + const float width = 1500 * u, inset = 44 * u, tall = 110 * u, row = 104 * u, row_gap = 12 * u; + const float inner = width - inset * 2; + const float label_size = 28 * u, name_size = 64 * u, row_name = 36 * u, row_text = 28 * u, text_size = 32 * u; + const auto why_extent = body->CalcTextSizeA(text_size, FLT_MAX, inner, why.c_str()); + // (A long list is cut short to what fits the screen: the rest are said in a line.) + const std::size_t most = std::max(3, static_cast((display.y * 0.45f) / (row + row_gap))); + const std::size_t shown = std::min(card.items.size(), most); + float height = inset + label_size + 20 * u + name_size + 24 * u + why_extent.y + 30 * u + + static_cast(shown) * (row + row_gap) + inset + tall + inset; + if (shown < card.items.size()) height += row_text + 12 * u; + if (!total.empty()) height += row_text + 18 * u; + const float under = 34 * u + 20 * key_scale; + const ImVec2 min(std::floor((display.x - width) * 0.5f), std::floor((display.y - height - under) * 0.5f)), max(min.x + width, min.y + height); + auto* draw = ImGui::GetForegroundDrawList(); + draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(0, 0, 0, s.hub_pointer ? 170 : 110)); + skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 250), 0x4d4, u * 2); + const float left = min.x + inset, right = max.x - inset; + float y = min.y + inset; + draw->AddText(bold, label_size, ImVec2(left, y), theme::blue, title); + y += label_size + 20 * u; + draw->PushClipRect(ImVec2(left, y - name_size), ImVec2(right, y + name_size * 2), true); + draw->AddText(heading, name_size, ImVec2(left, y), theme::paper, headline.c_str()); + draw->PopClipRect(); + y += name_size + 24 * u; + draw->AddText(body, text_size, ImVec2(left, y), theme::muted, why.c_str(), nullptr, inner); + y += why_extent.y + 30 * u; + + // A row for each: MAP or PLUGIN, its name and who made it, and on the right what it takes. + for (std::size_t at = 0; at < shown; ++at) { + const auto& item = card.items[at]; + const ImVec2 a(left, y), b(right, y + row); + skate_theme::rough_rect(draw, a, b, skate_theme::tile, 0x700 + static_cast(at), u * 2); + const std::string kind = item.plugin ? "PLUGIN" : "MAP"; + const float tag_size = 24 * u, tag_pad = 14 * u, tag_width = 150 * u; + const ImVec2 tag_min(a.x + 24 * u, a.y + (row - tag_size - tag_pad * 2) * 0.5f), + tag_max(tag_min.x + tag_width, tag_min.y + tag_size + tag_pad * 2); + skate_theme::rough_rect(draw, tag_min, tag_max, item.plugin ? theme::blue : sticky, 0x720 + static_cast(at), u * 2); + draw->AddText(bold, tag_size, ImVec2(tag_min.x + (tag_width - width_of(bold, tag_size, kind)) * 0.5f, tag_min.y + tag_pad), + skate_theme::black, kind.c_str()); + const std::string what = item.installed ? "Here, switched off" + : !item.have.empty() ? "Update " + item.have + " \xe2\x86\x92 " + item.version + : item.bytes ? megabytes(item.bytes) : std::string("Download"); + const float what_width = width_of(bold, row_text, what); + const float text_x = tag_max.x + 28 * u, text_right = b.x - 24 * u - what_width - 28 * u; + draw->PushClipRect(ImVec2(text_x, a.y), ImVec2(text_right, b.y), true); + draw->AddText(bold, row_name, ImVec2(text_x, a.y + 14 * u), theme::paper, item.name.c_str()); + const auto by = "by " + item.author + (item.version.empty() || !item.have.empty() ? std::string() : " v" + item.version); + draw->AddText(body, row_text, ImVec2(text_x, a.y + 14 * u + row_name + 8 * u), theme::muted, by.c_str()); + draw->PopClipRect(); + draw->AddText(bold, row_text, ImVec2(b.x - 24 * u - what_width, a.y + (row - row_text) * 0.5f), + item.installed || item.have.empty() ? theme::paper : theme::blue, what.c_str()); + y += row + row_gap; + } + if (shown < card.items.size()) { + const auto more = std::format("and {} more", card.items.size() - shown); + draw->AddText(body, row_text, ImVec2(left, y), theme::muted, more.c_str()); + y += row_text + 12 * u; + } + if (!total.empty()) { + draw->AddText(body, row_text, ImVec2(left, y + 6 * u), theme::muted, total.c_str()); + y += row_text + 18 * u; + } + y += inset - row_gap; + const float button_width = (inner - 24 * u) * 0.5f; + const auto button = [&](float at, bool yes, unsigned seed) { + const ImVec2 a(at, y), b(at + button_width, y + tall); + const bool picked = card.choice == yes; + const bool hovered = ImGui::IsMouseHoveringRect(a, b, false); + const bool lit = picked || hovered; + const ImU32 fill = yes ? (lit ? sticky : sticky_dim) : (lit ? theme::blue : skate_theme::tile_light); + skate_theme::rough_rect(draw, a, b, fill, seed, u * 2); + if (yes && picked) draw->AddRect(a, b, theme::blue, 0, 0, 6 * u); + const std::string label = yes ? (all_here ? "SWITCH ON" : card.items.size() == 1 ? "DOWNLOAD" : "GET ALL") : "CANCEL"; + const float size = 40 * u; + draw->AddText(bold, size, ImVec2(a.x + (button_width - width_of(bold, size, label)) * 0.5f, a.y + (tall - size) * 0.5f), + yes || lit ? skate_theme::black : theme::paper, label.c_str()); + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) + if (const auto reply = answer.load()) reply(yes); + }; + button(left, true, 0x53); + button(left + button_width + 24 * u, false, 0x54); + float x = min.x + (width - hints_width) * 0.5f; + const float hint_y = max.y + 34 * u; + for (const auto& hint : hints) x += theme::keycap(draw, bold, key_scale, ImVec2(x, hint_y), hint.key.c_str(), hint.what) + hint_gap; +} void draw_asked_map(State& s) { // Drawn as the pages of the game's pause menu are (hub_page.cpp): the same unit (one of // the game's 3840 by 2160), brushed heading, hand-cut tiles, the yellow note for what to diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 53eda85d..0f8c1b3f 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -398,7 +398,7 @@ struct CallbacksV2 { using MultiplayerQueue = bool (*)(const char *action, const char *argument, const char *password, char *result, std::size_t size); void set_multiplayer_queue(MultiplayerQueue) noexcept; -// A map the session needs being fetched (Extension/Assets/map_download.h), for its card. +// What the session needs being fetched, a map and plugins (Extension/Assets/map_download.h), for its card. struct MapDownloadCard { int stage{}; // 0 none, 1 asking, 2 downloading, 3 installing, 4 applying, 5 joining std::string map, package, author, version, description; @@ -409,6 +409,15 @@ struct MapDownloadCard { std::uint64_t bytes{}; // asking: the package's size; 0 when it is not known std::uint64_t received{}, total{}; std::uint32_t yes_bind{}, no_bind{}; + // Everything asked for, the map first when it is needed, then a dedicated server's plugins. + // One map alone has the map's own card; anything more is listed. + struct Item { + std::string name, author, version, have; + bool plugin{}, installed{}; + std::uint64_t bytes{}; + }; + std::vector items; + std::size_t current{}; // downloading, installing: the one in hand }; using MapDownloadFeed = MapDownloadCard (*)(); void set_map_download_feed(MapDownloadFeed) noexcept; diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 0ca7a966..d0a0e760 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -249,6 +249,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { // A map a session needs: hosts say which Thunderstore package theirs is from, and a // guest without it is offered it on a card (Extension/Assets/map_download.h). dingosdk::multiplayer::set_map_package_lookup(dingosdk::map_download::package_of); + // And a dedicated server's required plugins, offered on the same card. + dingosdk::multiplayer::set_plugin_check(dingosdk::map_download::missing_plugins, dingosdk::map_download::has_level); dingosdk::overlay::set_map_download_feed([]() { const auto view = dingosdk::map_download::view(); using Stage = dingosdk::map_download::Stage; @@ -273,6 +275,9 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { card.total = view.total; card.yes_bind = view.yes_bind; card.no_bind = view.no_bind; + for (const auto& item : view.items) + card.items.push_back({item.name, item.author, item.version, item.have, item.plugin, item.installed, item.bytes}); + card.current = view.current; return card; }); dingosdk::overlay::set_map_download_answer(dingosdk::map_download::answer); diff --git a/Server/README.txt b/Server/README.txt index 8de10e54..3d014a19 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -192,6 +192,17 @@ park_mods Parks built in the game's Park Editor and shared as mods, park_limits How much may be spawned at once (default {"parks": 3, "objects": 512}): parks 1-16, their objects in all 1-1024. +"plugins" - What players must have to join. +required Thunderstore plugins every player must have (default []), + such as "neekgate-FPV_Drone", or "neekgate-FPV_Drone-1.0.0" + for at least that version; at most 16. A player without + them is asked once, on the card that offers a missing map, + and their game downloads, installs and applies them and + joins. Only plugins: the server checks each on Thunderstore + as it starts and leaves out one not listed as a plugin, and + players' games fetch nothing that is not a plugin, whatever + a server asks for. + "players" - What players may do. allow_noclip, allow_no_bail, allow_boosts Let players use noclip (and tp) / No Bail / the forward and up diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 4df8a2d0..a29ba72d 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -226,6 +226,23 @@ int run() { "A config in sections did not read back as it was written"); std::filesystem::remove_all(folder / "data"); } + // Required plugins: none unless set, kept as written, and only Thunderstore package names. + { + ServerConfig plugins; + check(plugins.required_plugins.empty() && config_error(plugins).find("plugins.required") == std::string::npos, + "Required plugins are not off by default"); + plugins.file = folder / "plugins.json"; + plugins.required_plugins = {"neekgate-FPV_Drone", "Team-Filmer-1.2.0"}; + save_config(plugins); + check(load_config(plugins.file).required_plugins == plugins.required_plugins && + text(plugins.file).find("\"required\"") != std::string::npos, + "Required plugins were not kept under plugins.required"); + for (const auto &bad : std::vector>{{"FPV_Drone"}, {""}, {"neekgate-FPV_Drone", "NEEKGATE-fpv_drone-1.0.0"}, + {"neekgate-FPV Drone"}, std::vector(17, "Team-X")}) { + plugins.required_plugins = bad; + check(config_error(plugins).find("plugins.required") != std::string::npos, "A required plugin that is not a package was accepted"); + } + } // The away timer: off unless set, and no more than a day. { ServerConfig away; diff --git a/Server/main.cpp b/Server/main.cpp index 13e78ae6..2b7c10a4 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -528,6 +528,7 @@ int run(int argc, char **argv, bool skip_update) { auto next_ban_check = next_advertise; bool bans_unread{}; MapUpdater map_updater; // "maps.latest_versions" and "maps update" (server_map_update.h) + RequiredPluginCheck plugin_check; // "plugins.required" (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 @@ -605,6 +606,10 @@ int run(int argc, char **argv, bool skip_update) { for (const auto &line : maps.lines) write_log(line); if (maps.changed) write_log(host.reload_maps()); } + if (auto plugins = plugin_check.tick(config.required_plugins); plugins.finished) { + for (const auto &line : plugins.lines) write_log(line); + host.refuse_plugins(std::move(plugins.refused)); + } // (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_config.cpp b/Server/server_config.cpp index 2a7ccc34..ebea71fb 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -114,6 +114,11 @@ Layout layout(const ServerConfig &c) { park_limits["objects"] = c.park_limits.objects; maps.set("park_limits", std::move(park_limits)); + auto &plugins = root.section("plugins"); + auto required = Json::array(); + for (const auto &plugin : c.required_plugins) required.push_back(plugin); + plugins.set("required", std::move(required)); + auto &players = root.section("players"); players.set("allow_boosts", c.boosts); players.set("allow_no_bail", c.no_bail); @@ -346,6 +351,9 @@ ServerConfig load_config(const std::filesystem::path &file, std::vectoris_array()) + for (const auto &plugin : *required) + if (plugin.is_string()) c.required_plugins.push_back(plugin.string()); if (const auto *parks = find("maps", "parks"); parks && parks->is_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); @@ -873,6 +881,12 @@ std::string config_error(const ServerConfig &c) { if (c.kick_minutes > 1440) return "kick_minutes must be 0 (until the server restarts) to 1440."; if (c.votes.kick_minutes > 1440) return "votes.kick_minutes must be 0 (until the server restarts) to 1440."; if (c.word_warnings > 10) return "word_warnings must be 0 (words are not checked) to 10."; + if (c.required_plugins.size() > max_required_plugins) + return "plugins.required holds at most " + std::to_string(max_required_plugins) + " plugins."; + for (const auto &plugin : c.required_plugins) + if (plugin.empty() || !valid_map_package(plugin)) + return "plugins.required: \"" + plugin + "\" is not a Thunderstore package. Write it as Owner-Name, or Owner-Name-1.2.3 for at least that version."; + if (!valid_required_plugins(c.required_plugins)) return "plugins.required names a plugin twice."; if (!parse_colour(c.chat_text_color)) return "chat_text_color must be a colour like #D9C8FF."; if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + "."; for (const auto id : c.reserved) diff --git a/Server/server_config.h b/Server/server_config.h index 65e47662..039c70c2 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -113,6 +113,12 @@ struct ServerConfig { // version (server_map_update.h), looked up at start and every so many minutes. bool latest_versions = false; unsigned latest_check_minutes = 60; + // Thunderstore packages every player must have to join ("plugins.required"): "Owner-Name", + // or "Owner-Name-1.2.3" for at least that version. A player without them is offered them on + // the card that offers a missing map, and fetches only what Thunderstore lists as a plugin + // and what runs Luau code. The server checks each on Thunderstore as it starts and leaves + // out one that is not a plugin there (RequiredPluginCheck, server_map_update.h). + std::vector required_plugins; 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 a95fc4c0..1ed9bc1a 100644 --- a/Server/server_engine.cpp +++ b/Server/server_engine.cpp @@ -406,6 +406,7 @@ void ServerEngine::worker_loop() { 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) + RequiredPluginCheck plugin_check; // "plugins.required" (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)); @@ -433,6 +434,10 @@ void ServerEngine::worker_loop() { for (const auto &line : maps.lines) log(line); if (maps.changed) log(host_->reload_maps()); } + if (auto plugins = plugin_check.tick(config_.required_plugins); plugins.finished) { + for (const auto &line : plugins.lines) log(line); + host_->refuse_plugins(std::move(plugins.refused)); + } 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.cpp b/Server/server_host.cpp index 396086f8..9170a2cd 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -322,6 +322,14 @@ void Host::fetching(Guest &guest) { else if (guest.travel_since) guest.travel_since = now_; } std::string Host::invite() const { return format_invite({id_, secret_}); } +std::vector Host::required_plugins() const { + std::vector out; + for (const auto &plugin : config_.required_plugins) + if (!refused_plugins_.contains(plugin)) out.push_back(plugin); + // (The config is checked as it is read; one edited in since is sent only when it is valid.) + return multiplayer::valid_required_plugins(out) ? out : std::vector{}; +} + std::string Host::map_name() const { return map_label(config_.map); } std::string Host::map_level() const { const auto destination = map_destination(config_.map); @@ -1376,6 +1384,7 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: offer.destination = map_destination(config_.map); offer.map_label = wire_map_label(); offer.map_package = map_package(config_.map); + offer.required_plugins = required_plugins(); offer.challenge = link->password_challenge; offer.map_authorized = authorized; send_required(*link, encode_wire(offer)); diff --git a/Server/server_host.h b/Server/server_host.h index f28b9314..c94b3bf0 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -58,6 +58,10 @@ class Host { // 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); } + // The plugins players are told to have ("plugins.required"): the config's, less those a + // check on Thunderstore found are not plugins (refuse_plugins). + std::vector required_plugins() const; + void refuse_plugins(std::set refused) { refused_plugins_ = std::move(refused); } unsigned players() const; std::uint64_t secret() const { return secret_; } // The UDP port players may connect straight to, or 0 (config connection). @@ -315,6 +319,7 @@ class Host { SteamTransport &transport_; Log log_; bool map_update_requested_{}; + std::set refused_plugins_; 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 index fb146dd6..5a17646e 100644 --- a/Server/server_map_update.cpp +++ b/Server/server_map_update.cpp @@ -1,6 +1,7 @@ #include "server_map_update.h" #include "Engine/Core/Json/json.h" #include "Engine/Vfs/thunderstore_package.h" +#include "Extension/Multiplayer/Net/protocol.h" #ifdef _WIN32 #include "Engine/Vfs/https_download.h" #else @@ -102,6 +103,59 @@ void set_map_version(const fs::path &mod, const std::string &version) { } } +std::string required_plugin_problem(const std::string &package_name, bool &reached) { + reached = false; + const auto name = package::parse_name(package_name); + if (!name) return "it is not a Thunderstore package (Owner-Name, or Owner-Name-1.2.3)"; + auto bare = *name; + bare.version.clear(); + std::string answer; + try { + answer = get_text(package::details_url(bare)); + } catch (const std::exception &failure) { + return std::string("Thunderstore could not be asked about it (") + failure.what() + ")"; + } + reached = true; + const auto choice = package::choose(answer, bare, community, package::Kind::plugin); + if (!choice.ok) return choice.reason.empty() ? "Thunderstore does not list it as a plugin" : choice.reason; + if (!name->version.empty() && multiplayer::map_version_newer(name->version, choice.newest)) + return "Thunderstore's newest version is " + choice.newest + ", older than " + name->version; + return {}; +} + +RequiredPluginCheck::Done RequiredPluginCheck::tick(const std::vector &required) { + Done done; + if (work_.valid()) { + if (work_.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return done; + done = work_.get(); + done.finished = true; + } + if (!work_.valid() && (!started_ || required != checked_)) { + started_ = true; + checked_ = required; + if (required.empty()) return done; + work_ = std::async(std::launch::async, [required] { + Done result; + for (const auto &plugin : required) { + bool reached{}; + auto problem = required_plugin_problem(plugin, reached); + if (problem.empty()) continue; + if (problem.ends_with('.')) problem.pop_back(); + if (reached) { + result.refused.insert(plugin); + result.lines.push_back("Required plugin " + plugin + " is left out: " + problem + ". Players are only asked for plugins."); + } else { + result.lines.push_back("Required plugin " + plugin + " could not be checked: " + problem + ". Players' games check it themselves."); + } + } + if (result.lines.empty()) + result.lines.push_back("Required plugins: " + std::to_string(required.size()) + " checked on Thunderstore; players without them are offered them as they join."); + return result; + }); + } + return done; +} + std::vector update_maps(const fs::path &mods, const std::string &in_play) { std::vector result; std::error_code error; diff --git a/Server/server_map_update.h b/Server/server_map_update.h index e991bd67..6d001cd0 100644 --- a/Server/server_map_update.h +++ b/Server/server_map_update.h @@ -32,6 +32,29 @@ std::vector update_maps(const std::filesystem::path &mods, const std: std::string map_version(const std::filesystem::path &mod); void set_map_version(const std::filesystem::path &mod, const std::string &version); +// Whether a required plugin ("plugins.required") is one players can be asked for: listed as a +// plugin in ReSkate's Thunderstore community, not deprecated, not rejected. Empty when it is, +// else why not. `reached` is false when Thunderstore could not be asked (it is then kept: the +// players' games check it again themselves). Asks Thunderstore; for a worker thread. +std::string required_plugin_problem(const std::string &package, bool &reached); + +// The run loop's side of that: the list checked once it changes (and at start), on a worker, +// and the packages to leave out of what players are told. +class RequiredPluginCheck { + public: + struct Done { + std::vector lines; // for the log + std::set refused; // not plugins on Thunderstore: not asked of players + bool finished{}; + }; + Done tick(const std::vector &required); + + private: + std::future work_; + std::vector checked_; + bool started_{}; +}; + // The run loop's side: a check when one is due or asked for, on a worker, and what it found. class MapUpdater { public: