Files
ReSkate/Engine/Vfs/thunderstore_package.cpp
AyeZeeBB 0ae0e71f0f Map downloads on join, faster mod merging, connect cards in the server browser
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.
2026-10-10 13:15:04 -05:00

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