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: