mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 17:27:19 +02:00
The Mods page has a third tab, PROFILES. A profile is a saved list of which mods are on, in what order and at what version; every mod stays installed once, in the one Mods folder. Loading a profile rewrites mods.json, switches off what it does not name, and downloads from Thunderstore what it names that is missing. Profiles live in Mods/profiles.json. A profile travels the way r2modman's do, so the two managers read each other's: export.r2x inside a .r2z file, and a code on Thunderstore's profile service. Config files in an r2modman profile are ignored. Installing or updating a Thunderstore package now brings the packages it depends on, needed ones first, leaving out any already installed at a new enough version. A mod pack is a package that is nothing else. A kick, by an admin or by a passed vote, used to keep the player out until the server restarted. "kick_minutes" in the players section of the config (1-1440; 0, the default, is still until the restart) and the console command "kick-time <minutes>|restart" now set how long it lasts. The player is told how long when they are kicked, and how many minutes are left if they try to join early. A kick keeps the time it was given, so changing the setting is for later kicks. Bans are unchanged.
223 lines
14 KiB
C++
223 lines
14 KiB
C++
// Thunderstore listing: parsing, gzip chunks, versions and the folder a package installs into.
|
|
#include "Launcher/thunderstore.h"
|
|
|
|
#include <miniz.h>
|
|
|
|
#include <iostream>
|
|
#include <string>
|
|
|
|
using namespace dingosdk;
|
|
|
|
namespace {
|
|
|
|
int failures = 0;
|
|
|
|
void check(bool condition, const char* what) {
|
|
if (condition) return;
|
|
std::cerr << "FAIL: " << what << '\n';
|
|
++failures;
|
|
}
|
|
|
|
template<class F> std::string error_of(F&& run) {
|
|
try { run(); } catch (const std::exception& failure) { return failure.what(); }
|
|
return {};
|
|
}
|
|
|
|
// A gzip member holding `text` in one stored (uncompressed) deflate block.
|
|
std::string gzip(std::string_view text, bool with_name = false) {
|
|
std::string out{'\x1f', '\x8b', '\x08', static_cast<char>(with_name ? 0x08 : 0), 0, 0, 0, 0, 0, '\xff'};
|
|
if (with_name) out += std::string("listing.json") + '\0';
|
|
const auto length = static_cast<unsigned>(text.size());
|
|
out += '\x01';
|
|
out += static_cast<char>(length & 0xff);
|
|
out += static_cast<char>(length >> 8 & 0xff);
|
|
out += static_cast<char>(~length & 0xff);
|
|
out += static_cast<char>(~length >> 8 & 0xff);
|
|
out += text;
|
|
const auto crc = mz_crc32(MZ_CRC32_INIT, reinterpret_cast<const unsigned char*>(text.data()), text.size());
|
|
for (int i = 0; i < 4; ++i) out += static_cast<char>(crc >> (8 * i) & 0xff);
|
|
for (int i = 0; i < 4; ++i) out += static_cast<char>(length >> (8 * i) & 0xff);
|
|
return out;
|
|
}
|
|
|
|
constexpr char listing[] = R"([
|
|
{"name": "Desert_Springs", "full_name": "Zee-Desert_Springs", "owner": "Zee",
|
|
"package_url": "https://thunderstore.io/c/reskate/p/Zee/Desert_Springs/",
|
|
"date_created": "2026-10-01T10:00:00Z", "date_updated": "2026-10-02T09:00:00Z",
|
|
"rating_score": 4, "is_pinned": false, "is_deprecated": false, "has_nsfw_content": false,
|
|
"categories": ["Mods", "Maps"],
|
|
"versions": [
|
|
{"name": "Desert_Springs", "full_name": "Zee-Desert_Springs-1.9.0", "description": "Old",
|
|
"icon": "https://gcdn.thunderstore.io/live/repository/icons/Zee-Desert_Springs-1.9.0.png",
|
|
"version_number": "1.9.0", "dependencies": [], "downloads": 10,
|
|
"download_url": "https://thunderstore.io/package/download/Zee/Desert_Springs/1.9.0/",
|
|
"date_created": "2026-10-01T10:00:00Z", "website_url": "", "is_active": true, "file_size": 700000000},
|
|
{"name": "Desert_Springs", "full_name": "Zee-Desert_Springs-1.10.0", "description": "A desert park.",
|
|
"icon": "https://gcdn.thunderstore.io/live/repository/icons/Zee-Desert_Springs-1.10.0.png",
|
|
"version_number": "1.10.0", "dependencies": [], "downloads": 5,
|
|
"download_url": "https://thunderstore.io/package/download/Zee/Desert_Springs/1.10.0/",
|
|
"date_created": "2026-10-02T09:00:00Z", "website_url": "https://example.com", "is_active": true,
|
|
"file_size": 5368709120},
|
|
{"name": "Desert_Springs", "full_name": "Zee-Desert_Springs-2.0.0", "description": "Pulled",
|
|
"version_number": "2.0.0", "download_url": "https://thunderstore.io/package/download/Zee/Desert_Springs/2.0.0/",
|
|
"is_active": false}
|
|
]},
|
|
{"name": "Bad Name", "full_name": "Zee-Bad Name", "owner": "Zee", "versions": []},
|
|
{"name": "No_Versions", "full_name": "Zee-No_Versions", "owner": "Zee", "versions": []},
|
|
{"name": "Plain", "full_name": "Other-Plain", "owner": "Other",
|
|
"versions": [{"version_number": "1.0.0", "download_url": "http://insecure.example/plain.zip"}]},
|
|
"not a package",
|
|
{"name": "Low_Cam", "full_name": "SunJay-Low_Cam", "owner": "SunJay", "is_deprecated": true,
|
|
"versions": [{"version_number": "0.1.0", "description": "Low camera",
|
|
"download_url": "https://thunderstore.io/package/download/SunJay/Low_Cam/0.1.0/",
|
|
"icon": "http://not-https/icon.png", "downloads": 3}]}
|
|
])";
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
// ------------------------------------------------ listing
|
|
const auto packages = thunderstore::parse_listing(listing);
|
|
check(packages.size() == 2, "Malformed rows, packages without versions and insecure downloads are skipped");
|
|
const auto& desert = packages.at(0);
|
|
check(desert.full_name == "Zee-Desert_Springs" && desert.owner == "Zee" && desert.title() == "Desert Springs",
|
|
"Names are read and titles show underscores as spaces");
|
|
check(desert.versions.size() == 2 && desert.latest().number == "1.10.0",
|
|
"Inactive versions are dropped and the newest version comes first");
|
|
check(desert.latest().file_size == 5368709120ull, "Sizes over 4 GB survive");
|
|
check(desert.downloads == 15 && desert.in_category("Maps") && !desert.in_category("Audio"),
|
|
"Downloads add up across versions and categories are listed");
|
|
const auto& cam = packages.at(1);
|
|
check(cam.deprecated && cam.latest().icon.empty(), "Flags are read and non-HTTPS icons are dropped");
|
|
check(!error_of([] { thunderstore::parse_listing(R"({"error": "No cache available"})"); }).empty(),
|
|
"A listing that is not an array is an error");
|
|
check(thunderstore::parse_listing("[]").empty(), "An empty community lists nothing");
|
|
|
|
// ------------------------------------------------ index and gzip
|
|
const auto chunk = gzip(listing, true);
|
|
check(thunderstore::gunzip(chunk, 1 << 20) == listing, "A gzip chunk (with a file name) unpacks");
|
|
check(thunderstore::gunzip("[]", 16) == "[]", "Plain JSON passes through");
|
|
check(!error_of([&] { thunderstore::gunzip(chunk, 100); }).empty(), "Output past the limit is refused");
|
|
auto damaged = chunk;
|
|
damaged[damaged.size() - 9] ^= 0x20;
|
|
check(!error_of([&] { thunderstore::gunzip(damaged, 1 << 20); }).empty(), "A damaged chunk fails its CRC check");
|
|
check(!error_of([&] { thunderstore::gunzip(chunk.substr(0, 12), 1 << 20); }).empty(), "A truncated chunk is refused");
|
|
const auto index = thunderstore::parse_index(thunderstore::gunzip(gzip(
|
|
R"(["https://ccdn.thunderstore.io/live/blob-storage/sha256/abc.blob"])"), 1024));
|
|
check(index.size() == 1 && index[0] == L"https://ccdn.thunderstore.io/live/blob-storage/sha256/abc.blob",
|
|
"The index lists chunk URLs");
|
|
check(!error_of([] { thunderstore::parse_index(R"(["http://insecure/blob"])"); }).empty(),
|
|
"Non-HTTPS chunks are refused");
|
|
|
|
// ------------------------------------------------ versions
|
|
check(thunderstore::compare_versions("1.10.0", "1.9.0") > 0, "Version parts compare as numbers");
|
|
check(thunderstore::compare_versions("1.0.0", "1.0") == 0, "Missing parts count as zero");
|
|
check(thunderstore::compare_versions("01.2.3", "1.2.3") == 0, "Leading zeros do not matter");
|
|
check(thunderstore::compare_versions("2.0.0", "10.0.0") < 0, "A shorter number is smaller");
|
|
check(thunderstore::compare_versions("", "0.0.1") < 0, "No version is older than any version");
|
|
|
|
// ------------------------------------------------ installed copies
|
|
check(thunderstore::folder_for("Zee-Desert_Springs") == "Zee-Desert_Springs", "Packages install as Namespace-Name");
|
|
check(thunderstore::folder_for(std::string(100, 'a')).size() == 64, "Folder names keep to the Mods name limit");
|
|
thunderstore::Installed installed{{"Zee-Desert_Springs", "1.9.0"}, {"SunJay-Low_Cam", "0.1.0"}};
|
|
check(thunderstore::update_available(desert, installed), "An older installed copy has an update");
|
|
check(!thunderstore::update_available(cam, installed), "The same version has none");
|
|
installed["Zee-Desert_Springs"] = "";
|
|
check(!thunderstore::update_available(desert, installed), "An installed copy without a version is left alone");
|
|
installed.erase("Zee-Desert_Springs");
|
|
check(!thunderstore::update_available(desert, installed), "A package that is not installed has no update");
|
|
|
|
// ------------------------------------------------ README
|
|
check(thunderstore::readme_url(desert) == L"https://thunderstore.io/api/experimental/package/" +
|
|
std::wstring(desert.owner.begin(), desert.owner.end()) + L"/" + std::wstring(desert.name.begin(), desert.name.end()) + L"/" +
|
|
std::wstring(desert.latest().number.begin(), desert.latest().number.end()) + L"/readme/",
|
|
"The README URL names the owner, the package and its newest version");
|
|
check(thunderstore::parse_readme(R"({"markdown": "# Hi\nthere"})") == "# Hi\nthere", "The README's markdown is read");
|
|
check(thunderstore::parse_readme(R"({"markdown": null})").empty() && thunderstore::parse_readme("{}").empty(),
|
|
"A package without a README has an empty one");
|
|
check(!error_of([] { (void)thunderstore::parse_readme("[]"); }).empty(), "A README answer that is not an object is refused");
|
|
{
|
|
using Kind = thunderstore::ReadmeLine::Kind;
|
|
const auto readme = thunderstore::readme_lines(
|
|
"\n\n# Desert **Springs** #\r\n"
|
|
"\n"
|
|
"A [big map](https://example.com) with `ramps`, *bowls* and a snake_run.\n\n\n"
|
|
"- first\n* second <b>bold</b>\n"
|
|
"```\nmp host --now\n```\n"
|
|
"---\n"
|
|
"> quoted & kept\n"
|
|
"| Key | Does |\n|-----|------|\n| F1 | Yes |\n"
|
|
"<img src=\"x.png\">\n"
|
|
"1. numbered stays as written\n");
|
|
const std::vector<thunderstore::ReadmeLine> expected{
|
|
{Kind::heading, "Desert Springs"},
|
|
{Kind::text, "A big map with ramps, bowls and a snake_run."},
|
|
{Kind::gap, ""},
|
|
{Kind::bullet, "first"},
|
|
{Kind::bullet, "second bold"},
|
|
{Kind::code, "mp host --now"},
|
|
{Kind::rule, ""},
|
|
{Kind::text, "quoted & kept"},
|
|
{Kind::text, "Key Does"},
|
|
{Kind::text, "F1 Yes"},
|
|
{Kind::text, "1. numbered stays as written"},
|
|
};
|
|
check(readme.lines == expected && !readme.cut, "A README's markdown is laid out as plain lines");
|
|
std::string many;
|
|
for (int i = 0; i < 50; ++i) many += "line " + std::to_string(i) + "\n";
|
|
const auto cut = thunderstore::readme_lines(many, 10);
|
|
check(cut.lines.size() == 10 && cut.cut && cut.lines.back().text == "line 9", "A long README is cut at the limit and says so");
|
|
check(thunderstore::readme_lines("").lines.empty() && thunderstore::readme_lines("\n \n\n").lines.empty(),
|
|
"A README with nothing to show has no lines");
|
|
const auto wide = thunderstore::readme_lines(std::string(1999, 'a') + "\xC3\xA9\xC3\xA9");
|
|
check(wide.lines.size() == 1 && wide.lines[0].text == std::string(1999, 'a'), "An overlong line is cut between characters");
|
|
}
|
|
|
|
// ------------------------------------------------ dependencies and mod packs
|
|
{
|
|
const auto version = [](const char* owner, const char* name, const char* number, const char* needs) {
|
|
return std::string(R"({"name":")") + name + R"(","full_name":")" + owner + "-" + name + R"(","owner":")" + owner +
|
|
R"(","versions":[{"version_number":")" + number + R"(","download_url":"https://thunderstore.io/d/)" + name +
|
|
R"(/","dependencies":[)" + needs + R"(]},{"version_number":"0.9.0","download_url":"https://thunderstore.io/d/old/)" +
|
|
name + R"(/"}]})";
|
|
};
|
|
const auto shop = thunderstore::parse_listing("[" +
|
|
version("Zee", "Pack", "1.0.0", R"("Zee-Map-1.2.0","Kit-Rails-2.0.0","Gone-Mod-1.0.0")") + "," +
|
|
version("Zee", "Map", "1.2.0", R"("Kit-Rails-1.0.0","Kit-Core-1.0.0")") + "," +
|
|
version("Kit", "Rails", "2.0.0", R"("Kit-Core-1.0.0","Zee-Map-1.0.0")") + "," +
|
|
version("Kit", "Core", "1.0.0", "") + "]");
|
|
check(shop.size() == 4 && shop[0].latest().dependencies.size() == 3, "A version's dependencies are read");
|
|
check(thunderstore::split_dependency("Zee-Desert_Springs-1.10.0") == std::pair<std::string, std::string>{"Zee-Desert_Springs", "1.10.0"} &&
|
|
thunderstore::split_dependency("Zee-Map") == std::pair<std::string, std::string>{"Zee-Map", ""},
|
|
"A dependency splits into its package and version");
|
|
const auto names = [](const std::vector<thunderstore::Package>& packages) {
|
|
std::string text;
|
|
for (const auto& package : packages) text += package.full_name + " ";
|
|
return text;
|
|
};
|
|
std::vector<std::string> unknown;
|
|
check(names(thunderstore::with_dependencies(shop[0], shop, {}, &unknown)) == "Kit-Core Kit-Rails Zee-Map Zee-Pack ",
|
|
"A pack's mods are gathered once each, needed ones first, through a cycle");
|
|
check(unknown == std::vector<std::string>{"Gone-Mod"}, "A dependency the listing lacks is reported");
|
|
check(names(thunderstore::with_dependencies(shop[0], shop, {{"Kit-Core", "1.0.0"}, {"Kit-Rails", "1.5.0"}, {"Zee-Map", "1.2.0"}})) ==
|
|
"Kit-Rails Zee-Pack ", "Dependencies installed at a new enough version are left out, and an old one is updated");
|
|
check(names(thunderstore::with_dependencies(shop[3], shop, {})) == "Kit-Core ", "A package that needs nothing comes alone");
|
|
check(shop[1].at_version("0.9.0").latest().number == "0.9.0" && shop[1].at_version("7.7.7").latest().number == "1.2.0" &&
|
|
shop[1].at_version("0.9.0").versions.size() == 2, "A package is taken at an older version when it has it");
|
|
check(thunderstore::find_package(shop, "Kit-Rails") == &shop[2] && !thunderstore::find_package(shop, "kit-rails-2.0.0"),
|
|
"A package is found by its full name");
|
|
}
|
|
|
|
// ------------------------------------------------ community
|
|
check(thunderstore::listing_index_url("reskate") == L"https://thunderstore.io/c/reskate/api/v1/package-listing-index/",
|
|
"The listing index URL");
|
|
check(thunderstore::community_page("reskate") == L"https://thunderstore.io/c/reskate/", "The community page URL");
|
|
|
|
if (failures) {
|
|
std::cerr << failures << " Thunderstore check(s) failed\n";
|
|
return 1;
|
|
}
|
|
std::cout << "Thunderstore checks passed.\n";
|
|
return 0;
|
|
}
|