Files
ReSkate/Engine/Vfs/Test/thunderstore_package_tests.cpp
T
AyeZeeBB 55a7342dc6 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.
2026-10-24 12:00:09 -05:00

103 lines
6.8 KiB
C++

// One Thunderstore package looked up by name (Engine/Vfs/thunderstore_package.h): the name a
// host gives, and what Thunderstore's answer has to say before the game downloads it.
#include "Engine/Vfs/thunderstore_package.h"
#include <iostream>
#include <string>
using namespace dingosdk;
namespace package = thunderstore_package;
namespace {
int failures = 0;
void check(bool condition, const char* what) {
if (condition) return;
std::cerr << "FAIL: " << what << '\n';
++failures;
}
// Thunderstore's answer for a package, as /api/experimental/package/<owner>/<name>/ gives it.
std::string answer(std::string_view owner, std::string_view name, std::string_view version, std::string_view community,
std::string_view category, std::string_view review, bool deprecated = false) {
return std::string("{\"namespace\":\"") + std::string(owner) + "\",\"name\":\"" + std::string(name) +
"\",\"full_name\":\"x\",\"is_deprecated\":" + (deprecated ? "true" : "false") +
",\"latest\":{\"version_number\":\"" + std::string(version) +
"\",\"icon\":\"https://gcdn.thunderstore.io/live/repository/icons/x.png\",\"description\":\"A plaza\\nby the sea\",\"download_url\":\"https://elsewhere.example/steal.zip\",\"is_active\":true},\"community_listings\":[{\"has_nsfw_content\":false,"
"\"categories\":[\"" + std::string(category) + "\"],\"community\":\"" + std::string(community) +
"\",\"review_status\":\"" + std::string(review) + "\"}]}";
}
}
int main() {
{
const auto plain = package::parse_name("Sandos-Vancouver_Plaza");
check(plain && plain->owner == "Sandos" && plain->name == "Vancouver_Plaza" && plain->version.empty() &&
plain->folder() == "Sandos-Vancouver_Plaza", "a package name read");
const auto versioned = package::parse_name("Sandos-Vancouver_Plaza-2.0.0");
check(versioned && versioned->version == "2.0.0" && versioned->folder() == "Sandos-Vancouver_Plaza", "and with its version");
for (const char* bad : {"", "Sandos", "Sandos-", "-Plaza", "San dos-Plaza", "Sandos-Plaza-2.0", "Sandos-Plaza-2.0.0.1", "Sandos-Plaza-two",
"../x-y", "Sandos-Plaza/..", "Sandos-Pla.za", "a-b-1.2.3-4"})
check(!package::parse_name(bad), bad);
const auto name = *versioned;
check(package::details_url(name) == L"https://thunderstore.io/api/experimental/package/Sandos/Vancouver_Plaza/", "where it is asked about");
check(package::download_url(name, "2.0.0") == L"https://thunderstore.io/package/download/Sandos/Vancouver_Plaza/2.0.0/",
"where it is downloaded from");
}
{
const package::Name newest{"Sandos", "Vancouver_Plaza", ""}, pinned{"Sandos", "Vancouver_Plaza", "1.4.0"};
const auto listed = answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "unreviewed");
auto choice = package::choose(listed, newest, "reskate");
check(choice.ok && choice.version == "2.0.0" && choice.newest == "2.0.0", "a listed map is taken, its newest version");
check(choice.description == "A plaza by the sea", "with what it says of itself, on one line");
check(choice.icon == "https://gcdn.thunderstore.io/live/repository/icons/x.png", "and its icon, from Thunderstore's own host");
{
auto elsewhere = listed;
const std::string host = "gcdn.thunderstore.io";
elsewhere.replace(elsewhere.find(host), host.size(), "thunderstore.io.example.com");
const auto other = package::choose(elsewhere, newest, "reskate");
check(other.ok && other.icon.empty(), "an icon anywhere else is not fetched");
}
choice = package::choose(listed, pinned, "reskate");
check(choice.ok && choice.version == "1.4.0" && choice.newest == "2.0.0", "or the version the host has, with the newest to fall back on");
check(package::choose(answer("sandos", "vancouver_plaza", "2.0.0", "ReSkate", "maps", "approved"), newest, "reskate").ok,
"names compare whatever their case");
const auto refused = [&](const std::string& text, const char* what) {
const auto result = package::choose(text, newest, "reskate");
check(!result.ok && !result.reason.empty() && result.version.empty(), what);
};
refused(answer("Someone", "Else", "2.0.0", "reskate", "Maps", "unreviewed"), "another package's answer is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Cosmetics", "unreviewed"), "a package that is not a map is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "othergame", "Maps", "unreviewed"), "a package of another community is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "rejected"), "a package the moderators rejected is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "unreviewed", true), "a deprecated package is refused");
refused(answer("Sandos", "Vancouver_Plaza", "latest", "reskate", "Maps", "unreviewed"), "a version that is not one is refused");
{
auto marked = listed;
const std::string off = "\"has_nsfw_content\":false";
marked.replace(marked.find(off), off.size(), "\"has_nsfw_content\":true");
refused(marked, "a package marked NSFW is refused");
}
refused("{\"namespace\":\"Sandos\",\"name\":\"Vancouver_Plaza\"}", "an answer with no listing is refused");
// A dedicated server's required plugin: taken only when it is listed as a plugin.
{
const auto plugin = answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Plugins", "unreviewed");
check(package::choose(plugin, newest, "reskate", package::Kind::plugin).ok, "a plugin listed as one is refused");
check(!package::choose(plugin, newest, "reskate").ok, "a plugin is taken as a map");
const auto cosmetic = package::choose(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Cosmetics", "unreviewed"),
newest, "reskate", package::Kind::plugin);
check(!cosmetic.ok && cosmetic.reason.find("plugin") != std::string::npos, "a cosmetic is taken as a plugin");
check(!package::choose(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "unreviewed"), newest, "reskate",
package::Kind::plugin).ok,
"a map is taken as a plugin");
}
refused("not json", "an answer that is not JSON is refused");
refused("[]", "an answer that is not an object is refused");
}
if (failures) {
std::cerr << failures << " failure(s)\n";
return 1;
}
std::cout << "thunderstore package tests passed\n";
return 0;
}