Multiplayer: dedicated servers require plugins, and players get them and the map on one card

A server lists Thunderstore packages in plugins.required ("Owner-Name", or "Owner-Name-1.2.3"
for at least that version, at most 16). It checks each on Thunderstore as it starts and leaves
out one not listed as a plugin, and sends the rest with its map offer (protocol 55).

A joining player's game checks them before the server's map loads. Those missing, switched off
or too old go on one card with the map when that is missing too, so the player is asked once.
GET ALL downloads and installs each, switches on any that were off, runs one live merge when
any brings game data, and joins; CANCEL leaves. The game fetches a package only when
Thunderstore lists it as a plugin and it installs as one (a reskate-plugin.json naming Luau
code), whatever a server asks; anything else is taken out again, so a cosmetic can never be
required.

map_download handles a list of packages instead of one map, and the Thunderstore chooser takes
the category it needs. A map alone keeps its card as it was.
This commit is contained in:
AyeZeeBB committed 2026-10-24 12:00:09 -05:00
1 parent e2d12a701d
commit 55a7342dc6
26 files changed
+1051 -255

No files matched your search

@@ -78,6 +78,18 @@ int main() {
refused(marked, "a package marked NSFW is refused"); refused(marked, "a package marked NSFW is refused");
} }
refused("{\"namespace\":\"Sandos\",\"name\":\"Vancouver_Plaza\"}", "an answer with no listing 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("not json", "an answer that is not JSON is refused");
refused("[]", "an answer that is not an object is refused"); refused("[]", "an answer that is not an object is refused");
} }
+9 -4
View File
@@ -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) + '/'); 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; Choice choice;
try { try {
const auto root = Json::parse(json); 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."; choice.reason = "Its package on Thunderstore is marked deprecated.";
return choice; 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()) if (root.contains("community_listings") && root.at("community_listings").is_array())
for (const auto& listing : root.at("community_listings")) { for (const auto& listing : root.at("community_listings")) {
if (!same(text_of(listing, "community"), community)) continue; 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()) if (listing.contains("categories") && listing.at("categories").is_array())
for (const auto& category : listing.at("categories")) 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")); 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 (!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 (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 (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(); 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; } if (!version(newest)) { choice.reason = "Thunderstore gave no version of that package."; return choice; }
choice.newest = newest; choice.newest = newest;
+7 -4
View File
@@ -5,7 +5,8 @@
#include <string_view> #include <string_view>
// One Thunderstore package looked up by name, for the game: the map a multiplayer host is on // 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(). // fetches details_url() and hands the answer to choose().
namespace dingosdk::thunderstore_package { namespace dingosdk::thunderstore_package {
@@ -35,9 +36,11 @@ struct Choice {
std::string icon; std::string icon;
std::string reason; // why not, for the player 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 // 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 // `community` as `kind`, is not deprecated or marked NSFW there and was not rejected by the
// not rejected by the community's moderators. // community's moderators.
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 = Kind::map);
} // namespace dingosdk::thunderstore_package } // namespace dingosdk::thunderstore_package
@@ -29,7 +29,7 @@ constexpr auto use_download_deadline = static_cast<std::uint64_t>(-1);
std::atomic<std::uint64_t> active_deadline{use_download_deadline}; std::atomic<std::uint64_t> active_deadline{use_download_deadline};
std::atomic<bool> downloading{}, installing{}, release_download{}, release_install{}, merging{}; std::atomic<bool> downloading{}, installing{}, release_download{}, release_install{}, merging{};
std::atomic<bool> fetching{true}, held{}, observed_cancel{}, installed_cancel{}; std::atomic<bool> fetching{true}, held{}, observed_cancel{}, installed_cancel{};
std::atomic<unsigned> downloads{}, installs{}, applies{}, stops{}, rejoins{}; std::atomic<unsigned> downloads{}, installs{}, applies{}, stops{}, rejoins{}, removals{};
std::atomic<std::uint64_t> queued_stop_deadline{}; std::atomic<std::uint64_t> queued_stop_deadline{};
bool block_download{}, block_install{}, slow{}, transfer_error{}, fallback{}; bool block_download{}, block_install{}, slow{}, transfer_error{}, fallback{};
dingosdk::MultiplayerModel session; dingosdk::MultiplayerModel session;
@@ -81,11 +81,17 @@ namespace dingosdk::https {
std::optional<std::uint64_t> content_length(std::wstring_view, std::uint32_t, const wchar_t*, Watch*) { std::optional<std::uint64_t> content_length(std::wstring_view, std::uint32_t, const wchar_t*, Watch*) {
return std::nullopt; return std::nullopt;
} }
std::optional<std::string> get_text(std::wstring_view, std::uint64_t, std::uint32_t, std::optional<std::string> get_text(std::wstring_view url, std::uint64_t, std::uint32_t,
const wchar_t*, Download* result, Watch* watch) { const wchar_t*, Download* result, Watch* watch) {
fixture::require(watch && watch->deadline_us.load() == map_download::deadline.load(), fixture::require(watch && watch->deadline_us.load() == map_download::deadline.load(),
"metadata shares the session deadline"); "metadata shares the session deadline");
if (result) *result = {true, 200, 0}; 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"]}], return R"({"namespace":"Team","name":"Map","community_listings":[{"community":"reskate","categories":["Maps"]}],
"latest":{"version_number":"2.0.0","description":"Test map"}})"; "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 { namespace dingosdk::mods {
std::filesystem::path engine_data_root() noexcept { return fixture::root; } std::filesystem::path engine_data_root() noexcept { return fixture::root; }
ModList scan_mods(const std::filesystem::path&) noexcept { return {}; } ModList scan_mods(const std::filesystem::path&) noexcept { return {}; }
Mod describe_mod(const std::filesystem::path& directory, std::string name, std::vector<std::string>&) {
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<ModEntry>&) {} void save_mod_order(const std::filesystem::path&, const std::vector<ModEntry>&) {}
} }
namespace dingosdk::launcher_mods { 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()); installed_cancel.store(cancel.load());
if (cancel.load()) throw std::runtime_error("cancelled"); if (cancel.load()) throw std::runtime_error("cancelled");
fs::create_directories(root / options.folder); 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; 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 { namespace dingosdk::live_mods {
bool only_maps(const std::filesystem::path&) { return true; } 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 { void hold_map_load(bool hold, std::string_view package) noexcept {
fixture::held.store(hold); 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) { bool queue_command(std::string_view action, std::string_view argument, std::string_view password) {
if (action == "stop") ++fixture::stops; if (action == "stop") ++fixture::stops;
@@ -228,6 +244,7 @@ void reset() {
downloads.store(0); downloads.store(0);
installs.store(0); installs.store(0);
applies.store(0); applies.store(0);
removals.store(0);
stops.store(0); stops.store(0);
queued_stop_deadline.store(0); queued_stop_deadline.store(0);
rejoins.store(0); rejoins.store(0);
@@ -624,6 +641,56 @@ void tests() {
rejoins.load() == 0 && !noticed("timed out"), rejoins.load() == 0 && !noticed("timed out"),
"join completed within session budget retires successfully even when next tick is after deadline"); "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(); reset();
download::tick(); download::tick();
require(!held.load() && downloads.load() == 0 && stops.load() == 0 && rejoins.load() == 0, require(!held.load() && downloads.load() == 0 && stops.load() == 0 && rejoins.load() == 0,
+480 -218
View File
@@ -32,6 +32,19 @@ constexpr std::uint64_t largest_download = 8ull << 30;
constexpr std::uint32_t download_seconds = 4 * 60 * 60; constexpr std::uint32_t download_seconds = 4 * 60 * 60;
namespace policy = multiplayer::map_fetch_policy; 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::mutex mutex;
std::atomic<Stage> stage{Stage::idle}; std::atomic<Stage> stage{Stage::idle};
std::atomic<unsigned> workers{}; std::atomic<unsigned> workers{};
@@ -42,13 +55,9 @@ std::atomic<std::uint64_t> deadline{}, ask_until{};
std::atomic<bool> detached{}; std::atomic<bool> detached{};
multiplayer::MapNeed need; // under mutex, while not idle multiplayer::MapNeed need; // under mutex, while not idle
std::optional<multiplayer::MapNeed> pending; // a fresh attempt waiting for cancelled work to drain std::optional<multiplayer::MapNeed> pending; // a fresh attempt waiting for cancelled work to drain
package::Name wanted; // under mutex std::vector<Item> items; // under mutex: the map first when it is needed, then the plugins
std::string version; // under mutex: the one to download std::atomic<std::size_t> current{}; // the item being fetched
std::string newest; // under mutex std::string last_failed; // under mutex: a need (need_key) not to offer again this run
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
https::Watch watch; https::Watch watch;
std::atomic<bool> stop_install{}; std::atomic<bool> stop_install{};
std::atomic<bool> apply_wanted{}, apply_started{}; std::atomic<bool> 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"; } 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<std::string>& 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::string need_package() {
std::lock_guard lock(mutex); std::lock_guard lock(mutex);
return need.package; return need.package;
@@ -76,17 +104,36 @@ bool need_server() {
return 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) { void leave_session(std::uint64_t expected = 0) {
const auto owned = expected ? expected : multiplayer::map_fetch_deadline(); const auto owned = expected ? expected : multiplayer::map_fetch_deadline();
if (multiplayer::fetching_map() && owned) if (multiplayer::fetching_map() && owned)
multiplayer::queue_command("stop-map-fetch", std::to_string(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. // Back to idle with the reason said, as the plain "map not installed" notice is.
void fail(std::string reason, bool retry = false) { void fail(std::string reason, bool retry = false) {
std::string text; std::string text, title;
bool update{};
std::uint64_t failed_deadline{}; std::uint64_t failed_deadline{};
{ {
std::lock_guard lock(mutex); std::lock_guard lock(mutex);
@@ -95,15 +142,13 @@ void fail(std::string reason, bool retry = false) {
watch.cancel.store(true); watch.cancel.store(true);
stop_install.store(true); stop_install.store(true);
apply_wanted.store(false); apply_wanted.store(false);
update = !need.have.empty(); text = needed_text(need) + reason;
text = "The " + host_word(need.server) + (need.moved ? " changed to " : " is on ") + need.map + title = needed_title(need);
(update ? ", at another version (" + std::string(multiplayer::map_package_version(need.package)) + ") than the one installed on this PC (" + need.have + "). " if (!retry) last_failed = need_key(need);
: ", which is not installed on this PC. ") + reason;
if (!retry) last_failed = need.package;
} }
if (multiplayer::map_fetch_deadline() == failed_deadline) leave_session(failed_deadline); if (multiplayer::map_fetch_deadline() == failed_deadline) leave_session(failed_deadline);
logging::log(logging::Level::warning, logging::Channel::assets, "Map download: {}", text); 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. // 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 // 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). // 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())) { 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 false;
} }
return !watch.cancel.load(); return !watch.cancel.load();
@@ -132,28 +177,38 @@ void ask() {
ask_until.store(std::min(deadline.load(), multiplayer::now_us() + policy::confirmation_us)); ask_until.store(std::min(deadline.load(), multiplayer::now_us() + policy::confirmation_us));
advance(Stage::checking, Stage::asking); advance(Stage::checking, Stage::asking);
} }
// The packages of the maps installed here, by level (lower case), read from the Mods folder in // The packages of the maps installed here, by level (lower case), and every plugin here, read
// one pass (package_of). Forgotten when a map is installed; read again in the background two // from the Mods folder in one pass (package_of, has_level, missing_plugins). Forgotten when
// minutes after it was read. It was read for each level asked, on the client thread, with every // anything is installed; read again in the background two minutes after it was read. It was
// map offer a host sends (a guest joining, a map change): tens of milliseconds, a second and more // read for each level asked, on the client thread, with every map offer a host sends (a guest
// with many mods (2026-10-15). // joining, a map change): tens of milliseconds, a second and more with many mods (2026-10-15).
struct KnownPackages { struct KnownPlugin {
std::string folder, title, author, version;
bool enabled{};
};
struct KnownTable {
std::map<std::string, std::string, std::less<>> by_level; std::map<std::string, std::string, std::less<>> by_level;
std::vector<KnownPlugin> plugins;
};
struct KnownPackages {
KnownTable table;
std::uint64_t read_at{}; std::uint64_t read_at{};
bool valid{}, reading{}; bool valid{}, reading{};
}; };
std::mutex known_mutex; std::mutex known_mutex;
KnownPackages known_packages; KnownPackages known_packages;
std::map<std::string, std::string, std::less<>> read_known_packages() { KnownTable read_known_packages() {
std::map<std::string, std::string, std::less<>> table; KnownTable table;
const auto list = mods::scan_mods(mods::engine_data_root()); const auto list = mods::scan_mods(mods::engine_data_root());
for (const auto& entry : list.entries) { 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; if (!entry.enabled || !entry.mod.provides_levels) continue;
const auto package = multiplayer::map_package_name(entry.mod.name, entry.mod.version); const auto package = multiplayer::map_package_name(entry.mod.name, entry.mod.version);
for (auto level : entry.mod.levels) { for (auto level : entry.mod.levels) {
std::transform(level.begin(), level.end(), level.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); }); std::transform(level.begin(), level.end(), level.begin(), [](unsigned char c) { return static_cast<char>(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; return table;
@@ -165,12 +220,12 @@ void refresh_known_packages(std::uint64_t now) {
known_packages.reading = true; known_packages.reading = true;
try { try {
std::thread([] { std::thread([] {
std::map<std::string, std::string, std::less<>> table; KnownTable table;
bool ok{}; bool ok{};
try { table = read_known_packages(); ok = true; } catch (...) {} try { table = read_known_packages(); ok = true; } catch (...) {}
std::lock_guard lock(known_mutex); std::lock_guard lock(known_mutex);
if (ok) { if (ok) {
known_packages.by_level = std::move(table); known_packages.table = std::move(table);
known_packages.read_at = GetTickCount64(); known_packages.read_at = GetTickCount64();
known_packages.valid = true; known_packages.valid = true;
} }
@@ -178,169 +233,327 @@ void refresh_known_packages(std::uint64_t now) {
}).detach(); }).detach();
} catch (...) { known_packages.reading = false; } } catch (...) { known_packages.reading = false; }
} }
// The table, read here when there is none (only just after something was installed). Takes
// The mod's row in mods.json when its folder is here: whether it is switched on, and what it // known_mutex; `use` runs under it.
// says of itself. template<class Use>
std::optional<bool> installed(const std::string& folder, std::string& about) { auto with_known(Use use) {
const auto list = mods::scan_mods(mods::engine_data_root()); const auto now = GetTickCount64();
for (const auto& entry : list.entries) std::unique_lock lock(known_mutex);
if (same(entry.mod.name, folder)) { if (!known_packages.valid) {
about = entry.mod.description; lock.unlock();
return entry.enabled; 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<mods::ModEntry> installed(const Item& item) {
const auto list = mods::scan_mods(mods::engine_data_root());
std::optional<mods::ModEntry> 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() { void check() {
package::Name name; std::vector<Item> list;
bool update{};
{ {
std::lock_guard lock(mutex); std::lock_guard lock(mutex);
name = wanted; list = items;
update = !need.have.empty();
} }
try { try {
std::string about; std::vector<Item> wanted;
// (At another version than the host's, it is fetched whether it is on or off.) for (auto& item : list) {
if (const auto on = update ? std::optional<bool>{} : installed(name.folder(), about)) { if (!continuing()) return;
if (*on) return fail("Its mod is installed, but the map did not load: update or reinstall it from the launcher."); const bool first = wanted.empty();
{ bool ready{};
std::lock_guard lock(mutex); if (!(item.plugin ? check_plugin(item, first, ready) : check_map(item, first))) return;
installed_off = true; if (!ready) wanted.push_back(std::move(item));
description = about.substr(0, 240); }
} if (wanted.empty()) {
// Its own icon, from its folder. // (Everything turned out to be here: the session sees so itself and goes on.)
try { auto from = Stage::checking;
const auto bytes = read_bytes(mods_root() / name.folder() / L"icon.png", 4 * 1024 * 1024); stage.compare_exchange_strong(from, Stage::idle);
overlay::set_map_download_icon(bytes);
} catch (const std::exception&) {}
ask();
return; 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); std::lock_guard lock(mutex);
version = choice.version; items = std::move(wanted);
newest = choice.newest;
description = choice.description;
package_bytes = length.value_or(0);
} }
// 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(); ask();
} catch (const std::exception& failure) { } catch (const std::exception& failure) {
fail(std::string("Looking for it failed: ") + failure.what()); 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<std::string> 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() { void fetch() {
package::Name name; std::vector<Item> list;
std::string get, fallback;
bool off{}, update{};
{ {
std::lock_guard lock(mutex); std::lock_guard lock(mutex);
name = wanted; list = items;
get = version;
fallback = newest;
off = installed_off;
update = !need.have.empty();
} }
const auto root = mods_root();
const auto folder = name.folder();
try { try {
if (!continuing()) return; bool any_merge{}, any_switch{};
if (!off) { for (std::size_t at = 0; at < list.size(); ++at) {
wchar_t temp[MAX_PATH + 1]{}; auto& item = list[at];
const auto length = GetTempPathW(MAX_PATH, temp); current.store(at);
const auto archive = fs::path(length ? std::wstring(temp, length) : L".") / if (!continuing()) return;
(L"ReSkate-map-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::wstring(folder.begin(), folder.end()) + L".zip"); // (Each after the first is downloaded as the first was.)
std::error_code error; if (at && !advance(Stage::installing, Stage::downloading)) return;
fs::remove(archive, error); if (item.off) {
watch.received.store(0); if (!advance(Stage::downloading, Stage::installing)) return;
watch.total.store(0); } else if (!fetch_one(item)) {
// (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);
return; 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; if (!continuing()) return;
{ if (!vetted(item)) return;
std::lock_guard known(known_mutex); any_merge |= item.merge;
known_packages.valid = false; any_switch |= item.off || !item.have.empty();
} }
} else { forget_known();
if (!advance(Stage::downloading, Stage::installing)) return; {
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; if (!continuing()) return;
// A map and nothing else: this way in is for maps. if (!any_merge) {
std::error_code error; // Plugins alone: the plugin host takes them from the Mods folder itself.
if (!fs::exists(root / folder / L"reskate-levels.json", error) || !live_mods::only_maps(root / folder)) { logging::log(logging::Level::info, logging::Channel::assets, "Map download: the plugins are in Mods.");
if (!off) { join_at.store(multiplayer::now_us());
try { launcher_mods::remove(root, folder); } catch (const std::exception&) {} advance(Stage::installing, Stage::joining);
} return;
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 (off || update) { // (a map at another version may have been switched off: it is wanted now) logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} is in Mods; applying it.", list.front().folder);
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);
apply_started.store(false); apply_started.store(false);
apply_wanted.store(true); apply_wanted.store(true);
if (!advance(Stage::installing, Stage::applying)) apply_wanted.store(false); 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.) // 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()) { if (stage.load() != Stage::idle && !detached.load() && asked.deadline_us && asked.deadline_us != deadline.load()) {
lock.unlock(); lock.unlock();
fail("Map download cancelled.", true); fail("Download cancelled.", true);
lock.lock(); lock.lock();
} }
// A cancelled worker/atomic live merge must drain before its globals or files are reused. // 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(); pending.reset();
auto idle = Stage::idle; 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.) // (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)) { 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. // being fetched is finished, and the session, which now waits on this other one, is left.
bool other{}; if (need_key(asked) != need_key(need)) {
other = asked.package != need.package;
if (other) {
overlay::notify(overlay::NoticeLevel::warning, "Map changed again", 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."); "The " + host_word(asked.server) + " moved on to " + asked.map + " while another map was downloading. Join again when it is done.");
leave_session(); leave_session();
@@ -399,21 +609,42 @@ void offer(const multiplayer::MapNeed& asked) {
} }
{ {
need = asked; need = asked;
if (!name || asked.package == last_failed) { std::vector<Item> 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.) // (Said once already, or nothing this can fetch: the plain notice.)
overlay::notify(overlay::NoticeLevel::warning, "Map not installed", overlay::notify(overlay::NoticeLevel::warning, needed_title(asked),
"The " + host_word(asked.server) + (asked.moved ? " changed to " : " is on ") + asked.map + needed_text(asked) + (asked.package.empty() ? "Install them and join again." : "Install its map mod and join again."));
", which is not installed on this PC. Install its map mod and join again.");
stage.store(Stage::idle); stage.store(Stage::idle);
leave_session(); leave_session();
return; return;
} }
wanted = *name; items = std::move(list);
version.clear(); current.store(0);
newest.clear();
description.clear();
package_bytes = 0;
installed_off = false;
} }
overlay::set_map_download_icon({}); overlay::set_map_download_icon({});
watch.cancel.store(false); watch.cancel.store(false);
@@ -430,7 +661,7 @@ void offer(const multiplayer::MapNeed& asked) {
start(check); start(check);
} catch (...) { } catch (...) {
lock.unlock(); 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; if (!continuing()) return;
const auto now = multiplayer::now_us(); const auto now = multiplayer::now_us();
if (stage.load() == Stage::asking && policy::expired(ask_until.load(), now)) { 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; return;
} }
if (!download) { if (!download) {
if (stage.load() == Stage::asking || stage.load() == Stage::downloading) if (stage.load() == Stage::asking || stage.load() == Stage::downloading)
fail("Map download cancelled.", true); fail("Download cancelled.", true);
} else { } else {
// Publish the stage and claim its worker together. Cancellation cannot allow // Publish the stage and claim its worker together. Cancellation cannot allow
// a fresh offer to reuse the globals before this thread starts its worker. // 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); if (!watch.cancel.load() && stage.compare_exchange_strong(asked, Stage::downloading)) start(fetch);
} }
} catch (...) { } 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(); out.stage = stage.load();
if (out.stage == Stage::idle || out.stage == Stage::checking) return out; if (out.stage == Stage::idle || out.stage == Stage::checking) return out;
std::lock_guard lock(mutex); std::lock_guard lock(mutex);
out.map = need.map; for (const auto& item : items) {
out.package = wanted.folder() + (version.empty() ? std::string() : " " + version); View::Item shown;
out.author = wanted.owner; shown.name = item.plugin ? plugin_label(item.name.folder()) : need.map;
out.version = version; shown.author = item.name.owner;
out.description = description; shown.version = item.version;
out.bytes = package_bytes; 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) { if (out.stage == Stage::applying) {
const auto now = live_mods::progress(); const auto now = live_mods::progress();
out.step = now.done; out.step = now.done;
@@ -486,8 +736,6 @@ View view() {
} }
out.server = need.server; out.server = need.server;
out.moved = need.moved; out.moved = need.moved;
out.installed = installed_off;
out.have = need.have;
out.choice = pick_download.load(std::memory_order_relaxed); out.choice = pick_download.load(std::memory_order_relaxed);
out.received = watch.received.load(); out.received = watch.received.load();
out.total = watch.total.load(); out.total = watch.total.load();
@@ -522,16 +770,16 @@ void tick() {
session.status.find("stopped waiting for the map download") != std::string::npos; 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. // 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) 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); }() + "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()) 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) 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 && if (continuing() && now == Stage::asking &&
policy::expired(ask_until.load(), multiplayer::now_us())) 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(); now = stage.load();
} }
// The session leaves the map alone until it has been applied (an update's level is one the // 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; if (!apply_started.load() || live_mods::busy()) return;
apply_started.store(false); apply_started.store(false);
std::string folder; std::vector<std::string> merged;
{ {
std::lock_guard lock(mutex); 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(); const auto applied = live_mods::applied_mods();
if (std::none_of(applied.begin(), applied.end(), [&](const std::string& name) { return same(name, folder); })) for (const auto& folder : merged)
return fail("It is installed, but was left out when the mods were applied (" + live_mods::status() + ")."); if (std::none_of(applied.begin(), applied.end(), [&](const std::string& name) { return same(name, folder); }))
// The level list takes the new map on the next ticks, and the session, which has been 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. // trying the map all along, loads it then.
join_at.store(multiplayer::now_us()); join_at.store(multiplayer::now_us());
advance(Stage::applying, Stage::joining); advance(Stage::applying, Stage::joining);
@@ -567,10 +817,12 @@ void tick() {
if (now == Stage::joining) { if (now == Stage::joining) {
const auto waited = multiplayer::now_us() - join_at.load(); const auto waited = multiplayer::now_us() - join_at.load();
if (waited < 2500000) return; 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(); const auto session = multiplayer::model();
if (multiplayer::fetching_map() || (session.active && !session.connected)) { 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; return;
} }
{ {
@@ -579,11 +831,12 @@ void tick() {
last_failed.clear(); last_failed.clear();
} }
if (!continuing() || !stage.compare_exchange_strong(now, Stage::idle)) return; 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. // while it would still have the player. Past its deadline it would not.
if (!multiplayer::model().active && if (!multiplayer::model().active &&
(policy::expired(deadline.load(), multiplayer::now_us()) || !multiplayer::queue_command("rejoin", "", ""))) (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 {}; if (asset.empty()) return {};
std::string key(asset); std::string key(asset);
std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); }); std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
const auto now = GetTickCount64(); return with_known([&](const KnownTable& table) {
std::unique_lock lock(known_mutex); const auto found = table.by_level.find(key);
// (Only with no table at all, just after a map was installed, is the Mods folder read here.) return found != table.by_level.end() ? found->second : std::string{};
if (!known_packages.valid) { });
lock.unlock(); }
auto table = read_known_packages();
lock.lock(); bool has_level(std::string_view asset) {
if (!known_packages.valid) { if (asset.empty()) return false;
known_packages.by_level = std::move(table); std::string key(asset);
known_packages.read_at = now; std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
known_packages.valid = true; return with_known([&](const KnownTable& table) { return table.by_level.contains(key); });
}
std::vector<std::string> missing_plugins(const std::vector<std::string>& required) {
return with_known([&](const KnownTable& table) {
std::vector<std::string> 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);
} }
} return missing;
refresh_known_packages(now); });
const auto found = known_packages.by_level.find(key);
return found != known_packages.by_level.end() ? found->second : std::string{};
} }
} }
+37 -16
View File
@@ -3,36 +3,40 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <vector>
// A map a multiplayer session needs and this PC does not have, fetched from Thunderstore // What a multiplayer session needs and this PC does not have, fetched from Thunderstore while
// while the game runs. The host says which package its map is from (session.h: MapNeed). The // the game runs: the map, when the host says which package its map is from (session.h:
// player is asked; on yes the package is downloaded, installed into Mods and applied as a live // MapNeed), and the plugins a dedicated server requires. The player is asked once, on one card,
// mod (live_mods.h). The session stays connected meanwhile and loads the map once it is there; // for all of it; on yes each package is downloaded and installed into Mods, and those with game
// on no, or when the map cannot be had, the session is left. // 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, // 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. // 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, // 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 // installs and is applied, and the player is told to join again. A cancelled worker drains
// before another attempt can begin. // before another attempt can begin.
// //
// Only maps come in this way. The package has to be listed as a map in ReSkate's Thunderstore // Only maps and plugins come in this way, whatever a host says. A map has to be listed as a map
// community, not deprecated and not rejected by its moderators; what is installed has to be a // in ReSkate's Thunderstore community and install as a map and nothing else (level bundles and
// map and nothing else (level bundles and their registration: no cosmetics, no settings, no // their registration: no cosmetics, no settings, no scripts). A plugin has to be listed as a
// scripts), or it is taken out again. Everything is downloaded from thunderstore.io, whatever // plugin there and install as one (a reskate-plugin.json that names Luau code to run); it may
// a host says. // 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 { namespace dingosdk::map_download {
enum class Stage { enum class Stage {
idle, 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 asking, // the card asks: download, or not
downloading, downloading,
installing, installing,
applying, // the live merge 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 { struct View {
Stage stage{}; 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 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. // applying: the live merge's steps done of all, and the one in hand.
std::size_t step{}, steps{}; 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 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 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) 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 bytes{}; // asking: what all of it downloads (Content-Length); 0 when no server said
std::uint64_t received{}, total{}; // downloading; total 0 when the size is not known 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 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<Item> 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); void offer(const multiplayer::MapNeed& need);
// Any thread. Yes downloads (when asking); no declines, or stops a download under way. // Any thread. Yes downloads (when asking); no declines, or stops a download under way.
void answer(bool download) noexcept; 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 // 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. // 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); 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<std::string> missing_plugins(const std::vector<std::string>& required);
// Reads the table package_of answers from, on a thread of its own, if it has not been (so the // 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. // first map offer does not wait on the Mods folder). Cheap to call each frame; tick() does.
void warm_packages() noexcept; void warm_packages() noexcept;
+31
View File
@@ -480,6 +480,8 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
throw std::invalid_argument("Invalid map name"); throw std::invalid_argument("Invalid map name");
if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_package(p.map_package)) if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_package(p.map_package))
throw std::invalid_argument("Invalid 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))) 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"); throw std::invalid_argument("Invalid map fetch notice");
if (p.kind == PacketKind::world_state && if (p.kind == PacketKind::world_state &&
@@ -530,6 +532,11 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end()); w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end());
w.integer(p.map_package.size(), 1); w.integer(p.map_package.size(), 1);
w.bytes.insert(w.bytes.end(), p.map_package.begin(), p.map_package.end()); 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::map_request) w.integer(p.map_fetching ? 1 : 0, 1);
if (p.kind == PacketKind::hello) { if (p.kind == PacketKind::hello) {
@@ -874,6 +881,14 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
r.at += static_cast<std::size_t>(length); r.at += static_cast<std::size_t>(length);
if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination) || !read_map_label(r, bytes, p)) if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination) || !read_map_label(r, bytes, p))
return {}; 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) { if (p.kind == PacketKind::map_request) {
const auto fetching = r.integer(1); 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; 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)); return second == std::string_view::npos || package_version(rest.substr(second + 1));
} }
bool valid_required_plugins(const std::vector<std::string> &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) { std::string map_package_name(std::string_view folder, std::string_view version) {
const auto dash = folder.find('-'); const auto dash = folder.find('-');
if (dash == std::string_view::npos || !package_word(folder.substr(0, dash))) return {}; if (dash == std::string_view::npos || !package_word(folder.substr(0, dash))) return {};
+8 -1
View File
@@ -27,7 +27,7 @@ namespace dingosdk::multiplayer {
constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_skater_bones = 512, max_board_bones = 64;
constexpr std::size_t max_packet = 24576; constexpr std::size_t max_packet = 24576;
constexpr std::size_t packet_header_size = 64; 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 // 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. // clears them after three seconds without input; this only covers a lost "stopped" packet.
constexpr std::uint64_t typing_hold_us = 4000000; 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. // maps and for a map mod that is not from Thunderstore.
inline constexpr std::size_t max_map_package = 96; inline constexpr std::size_t max_map_package = 96;
bool valid_map_package(std::string_view package) noexcept; 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<std::string> &packages) noexcept;
// That name for a mod folder and the version its manifest gives; empty when the folder is not // 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 // 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 // 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::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_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::string map_package; // map_offer, world_state: valid_map_package (may be empty)
std::vector<std::string> required_plugins; // map_offer: valid_required_plugins (a player's lobby: none)
std::vector<std::uint8_t> throwdown; // throwdown: one encoded message (1..max_throwdown_message bytes) std::vector<std::uint8_t> throwdown; // throwdown: one encoded message (1..max_throwdown_message bytes)
std::array<float, 3> teleport{}; // teleport: where the receiver goes (world position) std::array<float, 3> teleport{}; // teleport: where the receiver goes (world position)
std::vector<std::uint8_t> tuning; // physics_tuning: 0..max_physics_tuning bytes std::vector<std::uint8_t> tuning; // physics_tuning: 0..max_physics_tuning bytes
+45
View File
@@ -164,6 +164,8 @@ void stop(Session &s, std::string reason) {
s.join_destination.clear(); s.join_destination.clear();
s.map_label.clear(); s.map_label.clear();
s.map_package.clear(); s.map_package.clear();
s.required_plugins.clear();
s.plugins_ready = s.fetching_with_map = false;
s.fetching_since = 0; s.fetching_since = 0;
s.map_fetch_deadline = 0; s.map_fetch_deadline = 0;
map_fetch_deadline_flag.store(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::string take_removed_notice() { return std::exchange(session().removed_notice, {}); }
std::optional<MapNeed> take_map_need() { return std::exchange(session().map_need, std::nullopt); } std::optional<MapNeed> 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_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); } 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(); } std::uint64_t map_fetch_deadline() noexcept { return map_fetch_deadline_flag.load(); }
void hold_map_load(bool hold, std::string_view package) noexcept { 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 // 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. // installed one's place), and loads when the two agree.
const auto have = std::string(other_map_version(s.map_package, host_map_package(s.join_destination))); const auto 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<std::string> 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.) // (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 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; const auto result = have.empty() && !held ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing;
+12 -1
View File
@@ -41,7 +41,7 @@ std::string take_removed_notice();
// cannot be had. Should the session end meanwhile for another reason, // 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. // queue_command("rejoin", "", "") goes back to it, with the password it was joined with.
struct MapNeed { 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 std::string map; // its name for people
bool server{}; // a dedicated server's map, not a player's lobby 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) bool moved{}; // the session changed to this map with the player in it (else: joining)
@@ -50,6 +50,9 @@ struct MapNeed {
std::string have; std::string have;
// Local steady-clock deadline, shared with the downloader, never transmitted. // Local steady-clock deadline, shared with the downloader, never transmitted.
std::uint64_t deadline_us{}; 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<std::string> plugins;
}; };
std::optional<MapNeed> take_map_need(); std::optional<MapNeed> take_map_need();
// Whether a session is waiting on a map being fetched right now. Thread-safe. // Whether a session is waiting on a map being fetched right now. Thread-safe.
@@ -73,6 +76,14 @@ struct NextMapWanted {
std::vector<NextMapWanted> next_maps(); std::vector<NextMapWanted> next_maps();
unsigned next_maps_serial() noexcept; unsigned next_maps_serial() noexcept;
void set_map_package_lookup(MapPackageLookup lookup) 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<std::string> (*)(const std::vector<std::string> &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. // The session's chat lines for the overlay. Thread-safe and cheap: callable every frame.
MultiplayerChat chat(); MultiplayerChat chat();
// A chat line only this player sees, from a plugin: shown under the plugin's name with a // A chat line only this player sees, from a plugin: shown under the plugin's name with a
@@ -262,6 +262,10 @@ struct Session {
unsigned server_map_rotation{}; // minutes per map (0: off) 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_label; // the host's name for join_destination (may be empty)
std::string map_package; // the Thunderstore package it is from (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<std::string> 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 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). // The host is told with what is sent while waiting (Packet::map_fetching).
std::uint64_t fetching_since{}; 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. // Whether a vote the local player may answer is running: read by the game thread for the binds.
inline std::atomic<bool> server_vote_open_flag{}; inline std::atomic<bool> server_vote_open_flag{};
inline std::atomic<MapPackageLookup> map_package_lookup{}; inline std::atomic<MapPackageLookup> map_package_lookup{};
inline std::atomic<PluginCheck> plugin_check{};
inline std::atomic<LevelCheck> level_check{};
inline std::atomic<bool> fetching_map_flag{}; inline std::atomic<bool> fetching_map_flag{};
inline std::atomic<std::uint64_t> map_fetch_deadline_flag{}, map_load_held{}; inline std::atomic<std::uint64_t> map_fetch_deadline_flag{}, map_load_held{};
// A guest without the map is downloading it: the clock they are loading against starts over // A guest without the map is downloading it: the clock they are loading against starts over
@@ -774,6 +774,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
s.join_destination = p.destination; s.join_destination = p.destination;
s.map_label = p.map_label; s.map_label = p.map_label;
s.map_package = p.map_package; 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.world = p.world;
s.join_map_authorized |= p.map_authorized; s.join_map_authorized |= p.map_authorized;
link->last_packet = now; link->last_packet = now;
@@ -1185,6 +1185,28 @@ void dedicated_server_codec() {
const auto offer_arrived = decode_wire(encode_wire(offered)); const auto offer_arrived = decode_wire(encode_wire(offered));
check(offer_arrived && offer_arrived->map_package == "Sandos-Vancouver_Plaza" && offer_arrived->destination == offered.destination, check(offer_arrived && offer_arrived->map_package == "Sandos-Vancouver_Plaza" && offer_arrived->destination == offered.destination,
"The map's package lost from a map offer"); "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<std::vector<std::string>>{{""}, {"Filmer"}, {"Team-Filmer", "team-filmer-1.0.0"},
{"Team-A/B"}, std::vector<std::string>(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<std::string> 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", 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"}) { "Sandos-Pla/za", "Sandos-Plaza-1.2.3-4", "https://thunderstore.io/x-y"}) {
auto wrong = packaged; auto wrong = packaged;
+144 -5
View File
@@ -6,7 +6,9 @@
// The card for a map a multiplayer session needs and this PC does not have // 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, // (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. // 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, // 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 // 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) { std::string megabytes(std::uint64_t bytes) {
const auto mb = static_cast<double>(bytes) / (1024.0 * 1024.0); const auto mb = static_cast<double>(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 auto display = ImGui::GetIO().DisplaySize;
const float u = std::min(display.x / 3840.0f, display.y / 2160.0f); const float u = std::min(display.x / 3840.0f, display.y / 2160.0f);
const bool downloading = card.stage == 2, applying = card.stage == 4; 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)) 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.stage == 3 ? std::string("Putting it in your Mods folder...")
: card.step_name.empty() ? std::string("Adding it to the game...") : card.step_name + "..."; : 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); 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; const float x = left + icon + between, right = max.x - inset;
float y = top; 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; y += label_size + 12 * u;
draw->PushClipRect(ImVec2(x, y - name_size), ImVec2(right, y + name_size * 2), true); 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(); draw->PopClipRect();
y += name_size + 18 * u; y += name_size + 18 * u;
const float share = downloading ? (card.total ? static_cast<float>(card.received) / static_cast<float>(card.total) : -1.0f) const float share = downloading ? (card.total ? static_cast<float>(card.received) / static_cast<float>(card.total) : -1.0f)
@@ -185,14 +199,139 @@ void draw_progress(State& s) {
} }
void draw_asked_map(State& s); void draw_asked_map(State& s);
void draw_asked_list(State& s);
void draw_map_download() { void draw_map_download() {
if (card.stage) { if (card.stage) {
auto& s = state(); auto& s = state();
if (card.stage != 1) draw_progress(s); if (card.stage != 1) draw_progress(s);
else if (listed(card)) draw_asked_list(s);
else draw_asked_map(s); else draw_asked_map(s);
} }
draw_message(); // (over whatever else is up) 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::size_t>(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<Hint> 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<float>(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<std::size_t>(3, static_cast<std::size_t>((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<float>(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<unsigned>(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<unsigned>(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) { 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 // 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 // the game's 3840 by 2160), brushed heading, hand-cut tiles, the yellow note for what to
+10 -1
View File
@@ -398,7 +398,7 @@ struct CallbacksV2 {
using MultiplayerQueue = bool (*)(const char *action, const char *argument, const char *password, using MultiplayerQueue = bool (*)(const char *action, const char *argument, const char *password,
char *result, std::size_t size); char *result, std::size_t size);
void set_multiplayer_queue(MultiplayerQueue) noexcept; 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 { struct MapDownloadCard {
int stage{}; // 0 none, 1 asking, 2 downloading, 3 installing, 4 applying, 5 joining int stage{}; // 0 none, 1 asking, 2 downloading, 3 installing, 4 applying, 5 joining
std::string map, package, author, version, description; 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 bytes{}; // asking: the package's size; 0 when it is not known
std::uint64_t received{}, total{}; std::uint64_t received{}, total{};
std::uint32_t yes_bind{}, no_bind{}; 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<Item> items;
std::size_t current{}; // downloading, installing: the one in hand
}; };
using MapDownloadFeed = MapDownloadCard (*)(); using MapDownloadFeed = MapDownloadCard (*)();
void set_map_download_feed(MapDownloadFeed) noexcept; void set_map_download_feed(MapDownloadFeed) noexcept;
+5
View File
@@ -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 // 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). // 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); 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([]() { dingosdk::overlay::set_map_download_feed([]() {
const auto view = dingosdk::map_download::view(); const auto view = dingosdk::map_download::view();
using Stage = dingosdk::map_download::Stage; using Stage = dingosdk::map_download::Stage;
@@ -273,6 +275,9 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() {
card.total = view.total; card.total = view.total;
card.yes_bind = view.yes_bind; card.yes_bind = view.yes_bind;
card.no_bind = view.no_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; return card;
}); });
dingosdk::overlay::set_map_download_answer(dingosdk::map_download::answer); dingosdk::overlay::set_map_download_answer(dingosdk::map_download::answer);
+11
View File
@@ -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, park_limits How much may be spawned at once (default {"parks": 3,
"objects": 512}): parks 1-16, their objects in all 1-1024. "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. "players" - What players may do.
allow_noclip, allow_no_bail, allow_boosts allow_noclip, allow_no_bail, allow_boosts
Let players use noclip (and tp) / No Bail / the forward and up Let players use noclip (and tp) / No Bail / the forward and up
+17
View File
@@ -226,6 +226,23 @@ int run() {
"A config in sections did not read back as it was written"); "A config in sections did not read back as it was written");
std::filesystem::remove_all(folder / "data"); 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<std::vector<std::string>>{{"FPV_Drone"}, {""}, {"neekgate-FPV_Drone", "NEEKGATE-fpv_drone-1.0.0"},
{"neekgate-FPV Drone"}, std::vector<std::string>(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. // The away timer: off unless set, and no more than a day.
{ {
ServerConfig away; ServerConfig away;
+5
View File
@@ -528,6 +528,7 @@ int run(int argc, char **argv, bool skip_update) {
auto next_ban_check = next_advertise; auto next_ban_check = next_advertise;
bool bans_unread{}; bool bans_unread{};
MapUpdater map_updater; // "maps.latest_versions" and "maps update" (server_map_update.h) 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."); 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 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 // 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); for (const auto &line : maps.lines) write_log(line);
if (maps.changed) write_log(host.reload_maps()); 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 // (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.) // for them whether or not the bans are used.)
if (!ban_check.valid() && now_time >= next_ban_check) if (!ban_check.valid() && now_time >= next_ban_check)
+14
View File
@@ -114,6 +114,11 @@ Layout layout(const ServerConfig &c) {
park_limits["objects"] = c.park_limits.objects; park_limits["objects"] = c.park_limits.objects;
maps.set("park_limits", std::move(park_limits)); 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"); auto &players = root.section("players");
players.set("allow_boosts", c.boosts); players.set("allow_boosts", c.boosts);
players.set("allow_no_bail", c.no_bail); players.set("allow_no_bail", c.no_bail);
@@ -346,6 +351,9 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
c.rotation_announce = get("maps", "rotation_announce", c.rotation_announce); c.rotation_announce = get("maps", "rotation_announce", c.rotation_announce);
c.latest_versions = get("maps", "latest_versions", c.latest_versions); c.latest_versions = get("maps", "latest_versions", c.latest_versions);
c.latest_check_minutes = std::clamp(get("maps", "latest_check_minutes", c.latest_check_minutes), 5U, 1440U); c.latest_check_minutes = std::clamp(get("maps", "latest_check_minutes", c.latest_check_minutes), 5U, 1440U);
if (const auto *required = find("plugins", "required"); required && required->is_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()) 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]); 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); 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.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.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.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 (!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 + "."; if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + ".";
for (const auto id : c.reserved) for (const auto id : c.reserved)
+6
View File
@@ -113,6 +113,12 @@ struct ServerConfig {
// version (server_map_update.h), looked up at start and every so many minutes. // version (server_map_update.h), looked up at start and every so many minutes.
bool latest_versions = false; bool latest_versions = false;
unsigned latest_check_minutes = 60; 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<std::string> required_plugins;
unsigned max_players = 16; // players; the server itself is not one 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. // The most poses a second one player is sent (crowd_limits); 0: no limit.
unsigned crowd_budget = crowd_pose_budget; unsigned crowd_budget = crowd_pose_budget;
+5
View File
@@ -406,6 +406,7 @@ void ServerEngine::worker_loop() {
auto next_ban_check = std::chrono::steady_clock::now(); auto next_ban_check = std::chrono::steady_clock::now();
std::future<BanListCheck> ban_check; std::future<BanListCheck> ban_check;
MapUpdater map_updater; // "maps.latest_versions" and "maps update" (server_map_update.h) 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; bool bans_unread = true;
std::optional<bool> name_allowed; std::optional<bool> name_allowed;
const auto target_tick_interval = std::chrono::microseconds(1000000 / std::max(10U, config_.tps)); 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); for (const auto &line : maps.lines) log(line);
if (maps.changed) log(host_->reload_maps()); 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) { if (!ban_check.valid() && now_time >= next_ban_check) {
ban_check = std::async(std::launch::async, read_global_bans); ban_check = std::async(std::launch::async, read_global_bans);
} }
+9
View File
@@ -322,6 +322,14 @@ void Host::fetching(Guest &guest) {
else if (guest.travel_since) guest.travel_since = now_; else if (guest.travel_since) guest.travel_since = now_;
} }
std::string Host::invite() const { return format_invite({id_, secret_}); } std::string Host::invite() const { return format_invite({id_, secret_}); }
std::vector<std::string> Host::required_plugins() const {
std::vector<std::string> 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>{};
}
std::string Host::map_name() const { return map_label(config_.map); } std::string Host::map_name() const { return map_label(config_.map); }
std::string Host::map_level() const { std::string Host::map_level() const {
const auto destination = map_destination(config_.map); const auto destination = map_destination(config_.map);
@@ -1376,6 +1384,7 @@ void Host::receive(std::uint64_t peer, std::span<const std::uint8_t> bytes, std:
offer.destination = map_destination(config_.map); offer.destination = map_destination(config_.map);
offer.map_label = wire_map_label(); offer.map_label = wire_map_label();
offer.map_package = map_package(config_.map); offer.map_package = map_package(config_.map);
offer.required_plugins = required_plugins();
offer.challenge = link->password_challenge; offer.challenge = link->password_challenge;
offer.map_authorized = authorized; offer.map_authorized = authorized;
send_required(*link, encode_wire(offer)); send_required(*link, encode_wire(offer));
+5
View File
@@ -58,6 +58,10 @@ class Host {
// nothing; and whether "maps update" was typed since this was last asked. // nothing; and whether "maps update" was typed since this was last asked.
std::string map_folder_in_play() const; std::string map_folder_in_play() const;
bool take_map_update_request() { return std::exchange(map_update_requested_, false); } 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<std::string> required_plugins() const;
void refuse_plugins(std::set<std::string> refused) { refused_plugins_ = std::move(refused); }
unsigned players() const; unsigned players() const;
std::uint64_t secret() const { return secret_; } std::uint64_t secret() const { return secret_; }
// The UDP port players may connect straight to, or 0 (config connection). // The UDP port players may connect straight to, or 0 (config connection).
@@ -315,6 +319,7 @@ class Host {
SteamTransport &transport_; SteamTransport &transport_;
Log log_; Log log_;
bool map_update_requested_{}; bool map_update_requested_{};
std::set<std::string> refused_plugins_;
ActivityLog activity_; // what players do, for the console (config_.activity_log) 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. // 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). // A poll is one too: it has answers instead of yes and no, and runs nothing (but the console's poll-run).
+54
View File
@@ -1,6 +1,7 @@
#include "server_map_update.h" #include "server_map_update.h"
#include "Engine/Core/Json/json.h" #include "Engine/Core/Json/json.h"
#include "Engine/Vfs/thunderstore_package.h" #include "Engine/Vfs/thunderstore_package.h"
#include "Extension/Multiplayer/Net/protocol.h"
#ifdef _WIN32 #ifdef _WIN32
#include "Engine/Vfs/https_download.h" #include "Engine/Vfs/https_download.h"
#else #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<std::string> &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<MapUpdate> update_maps(const fs::path &mods, const std::string &in_play) { std::vector<MapUpdate> update_maps(const fs::path &mods, const std::string &in_play) {
std::vector<MapUpdate> result; std::vector<MapUpdate> result;
std::error_code error; std::error_code error;
+23
View File
@@ -32,6 +32,29 @@ std::vector<MapUpdate> update_maps(const std::filesystem::path &mods, const std:
std::string map_version(const std::filesystem::path &mod); std::string map_version(const std::filesystem::path &mod);
void set_map_version(const std::filesystem::path &mod, const std::string &version); 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<std::string> lines; // for the log
std::set<std::string> refused; // not plugins on Thunderstore: not asked of players
bool finished{};
};
Done tick(const std::vector<std::string> &required);
private:
std::future<Done> work_;
std::vector<std::string> checked_;
bool started_{};
};
// The run loop's side: a check when one is due or asked for, on a worker, and what it found. // The run loop's side: a check when one is due or asked for, on a worker, and what it found.
class MapUpdater { class MapUpdater {
public: public: