mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 22:07:05 +02:00
Map downloader - A host or dedicated server now tells joiners which Thunderstore package its map comes from (the Mods folder name plus manifest.json). - A player without the map gets a card: icon, name, author, version and description, with DOWNLOAD and CANCEL. It works with mouse, keyboard and controller. - Only packages listed as Maps in the ReSkate community are offered, and never deprecated, rejected or NSFW ones. - On yes the package is downloaded, installed into Mods and applied as a live mod, then the session loads the map. A small card on the right shows the progress, so the player can keep skating. - The player stays connected while it downloads: the host gives a player who says they are fetching the map up to 45 minutes instead of the usual load time. - When the server changes map with the player in it, the card says so. - Protocol version 47: map package on map_offer and world_state, and a "fetching the map" flag on map_request and world_ready. Joining - The pause-menu server browser shows a CONNECTING card over the page while a join is under way, and a COULDN'T CONNECT card with the reason when it does not get in. - The session and voice tabs no longer show before the player is in. - Leaving without the map (declined or cancelled download) no longer counts towards the failed-join limit, on servers and player hosts. Input - A card that asks something holds the game's input until the buttons are released. - Steam's overlay hooks XInput in front of ReSkate and answers the game itself, so a controller reached the game behind menus. ReSkate now hooks that hook as well. Mod merging - Mods are read on several threads, at launch and for live merges. A live merge runs below the game's priority. - The paid-cosmetics check reads the game's files and the mods on several threads. - A live merge reuses the paid-cosmetics verdicts it already has and reports its progress and timings. Launcher - NSFW mods and their categories are not shown in the mod browser.
124 lines
6.2 KiB
C++
124 lines
6.2 KiB
C++
#include "thunderstore_package.h"
|
|
|
|
#include "Engine/Core/Json/json.h"
|
|
|
|
#include <algorithm>
|
|
|
|
namespace dingosdk::thunderstore_package {
|
|
namespace {
|
|
|
|
constexpr char site[] = "https://thunderstore.io";
|
|
|
|
bool word(std::string_view text) {
|
|
return !text.empty() && text.size() <= 64 && std::all_of(text.begin(), text.end(), [](char c) {
|
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
|
|
});
|
|
}
|
|
bool version(std::string_view text) {
|
|
unsigned numbers{};
|
|
for (std::size_t at = 0; at <= text.size();) {
|
|
const auto end = std::min(text.find('.', at), text.size());
|
|
const auto number = text.substr(at, end - at);
|
|
if (number.empty() || number.size() > 9 || !std::all_of(number.begin(), number.end(), [](char c) { return c >= '0' && c <= '9'; }))
|
|
return false;
|
|
++numbers;
|
|
at = end + 1;
|
|
}
|
|
return numbers == 3;
|
|
}
|
|
bool same(std::string_view a, std::string_view b) {
|
|
return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
|
|
const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c + 32) : c; };
|
|
return lower(x) == lower(y);
|
|
});
|
|
}
|
|
std::wstring wide(const std::string& text) { return std::wstring(text.begin(), text.end()); } // ASCII only here
|
|
std::string text_of(const Json& object, std::string_view key) {
|
|
return object.is_object() && object.contains(key) && object.at(key).is_string() ? object.at(key).string() : std::string();
|
|
}
|
|
bool flag(const Json& object, std::string_view key) {
|
|
return object.is_object() && object.contains(key) && object.at(key).is_boolean() && object.at(key).get<bool>();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::optional<Name> parse_name(std::string_view package) {
|
|
const auto first = package.find('-');
|
|
if (first == std::string_view::npos) return std::nullopt;
|
|
const auto rest = package.substr(first + 1);
|
|
const auto second = rest.find('-');
|
|
Name name{std::string(package.substr(0, first)), std::string(rest.substr(0, second)),
|
|
second == std::string_view::npos ? std::string() : std::string(rest.substr(second + 1))};
|
|
if (!word(name.owner) || !word(name.name) || (!name.version.empty() && !version(name.version))) return std::nullopt;
|
|
return name;
|
|
}
|
|
|
|
std::wstring details_url(const Name& name) {
|
|
return wide(std::string(site) + "/api/experimental/package/" + name.owner + '/' + name.name + '/');
|
|
}
|
|
|
|
std::wstring download_url(const Name& name, std::string_view wanted) {
|
|
return wide(std::string(site) + "/package/download/" + name.owner + '/' + name.name + '/' + std::string(wanted) + '/');
|
|
}
|
|
|
|
Choice choose(std::string_view json, const Name& wanted, std::string_view community) {
|
|
Choice choice;
|
|
try {
|
|
const auto root = Json::parse(json);
|
|
if (!root.is_object() || !same(text_of(root, "namespace"), wanted.owner) || !same(text_of(root, "name"), wanted.name)) {
|
|
choice.reason = "Thunderstore does not have that package.";
|
|
return choice;
|
|
}
|
|
if (flag(root, "is_deprecated")) {
|
|
choice.reason = "Its package on Thunderstore is marked deprecated.";
|
|
return choice;
|
|
}
|
|
bool listed{}, map{}, rejected{}, nsfw{};
|
|
if (root.contains("community_listings") && root.at("community_listings").is_array())
|
|
for (const auto& listing : root.at("community_listings")) {
|
|
if (!same(text_of(listing, "community"), community)) continue;
|
|
listed = true;
|
|
rejected = same(text_of(listing, "review_status"), "rejected");
|
|
nsfw = flag(listing, "has_nsfw_content");
|
|
if (listing.contains("categories") && listing.at("categories").is_array())
|
|
for (const auto& category : listing.at("categories"))
|
|
{
|
|
map = map || (category.is_string() && same(category.string(), "Maps"));
|
|
nsfw = nsfw || (category.is_string() && same(category.string(), "NSFW"));
|
|
}
|
|
}
|
|
if (!listed) { choice.reason = "Its package is not listed for ReSkate on Thunderstore."; return choice; }
|
|
if (rejected) { choice.reason = "Its package was rejected by Thunderstore's moderators."; return choice; }
|
|
if (nsfw) { choice.reason = "Its package is marked NSFW on Thunderstore."; return choice; }
|
|
if (!map) { choice.reason = "Its package is not listed as a map on Thunderstore."; return choice; }
|
|
const auto newest = root.contains("latest") ? text_of(root.at("latest"), "version_number") : std::string();
|
|
if (!version(newest)) { choice.reason = "Thunderstore gave no version of that package."; return choice; }
|
|
choice.newest = newest;
|
|
// Its own words about itself, kept to printable text of a card's length.
|
|
for (const unsigned char c : text_of(root.at("latest"), "description")) {
|
|
if (choice.description.size() >= 240) { choice.description += "..."; break; }
|
|
choice.description += c < 0x20 || c == 0x7f ? ' ' : static_cast<char>(c);
|
|
}
|
|
{
|
|
// https://<something>.thunderstore.io/...png, and nothing that is not a plain URL.
|
|
const auto icon = text_of(root.at("latest"), "icon");
|
|
constexpr std::string_view scheme = "https://";
|
|
const auto path = icon.find('/', scheme.size());
|
|
const auto host = icon.starts_with(scheme) && path != std::string::npos ? std::string_view(icon).substr(scheme.size(), path - scheme.size())
|
|
: std::string_view{};
|
|
const bool plain = icon.size() < 400 && std::all_of(icon.begin(), icon.end(), [](unsigned char c) {
|
|
return c > 0x20 && c < 0x7f && c != '"' && c != '<' && c != '>' && c != '\\' && c != '#' && c != '?' && c != '@';
|
|
});
|
|
if (plain && (host == "thunderstore.io" || host.ends_with(".thunderstore.io"))) choice.icon = icon;
|
|
}
|
|
choice.version = wanted.version.empty() ? newest : wanted.version;
|
|
choice.ok = true;
|
|
} catch (const std::exception&) {
|
|
choice = {};
|
|
choice.reason = "Thunderstore's answer could not be read.";
|
|
}
|
|
return choice;
|
|
}
|
|
|
|
} // namespace dingosdk::thunderstore_package
|