Files
ReSkate/Launcher/Test/mod_profiles_tests.cpp
AyeZeeBB 2e186b9839 Launcher: mod profiles, sharing them by code or file, and Thunderstore dependencies
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.
2026-10-13 00:55:29 -05:00

159 lines
8.8 KiB
C++

// Mod profiles: storage, applying one to the installed mods, and r2modman's export formats.
#include "Launcher/mod_profiles.h"
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include <tuple>
#include <vector>
using namespace dingosdk;
namespace profiles = launcher_profiles;
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 {};
}
mods::ModList installed(std::initializer_list<std::tuple<const char*, const char*, bool>> rows) {
mods::ModList list;
for (const auto& [name, version, enabled] : rows) {
mods::ModEntry entry;
entry.mod.name = name;
entry.mod.version = version;
entry.enabled = enabled;
list.entries.push_back(std::move(entry));
}
return list;
}
std::vector<std::string> order(const std::vector<mods::ModEntry>& entries) {
std::vector<std::string> names;
for (const auto& entry : entries) names.push_back(entry.mod.name + (entry.enabled ? "+" : "-"));
return names;
}
void names() {
check(profiles::valid_profile_name("Street maps"), "An ordinary name was refused");
for (const auto* name : {"", " lead", "trail ", "dot.", "a/b", "a\\b", "what?", "a:b", "tab\there"})
check(!profiles::valid_profile_name(name), "A name that cannot be a file name was accepted");
check(!profiles::valid_profile_name(std::string(49, 'a')), "An overlong name was accepted");
check(profiles::clean_profile_name(" My: <best> pack?. ") == "My best pack", "A name was not cleaned");
check(profiles::clean_profile_name("???") == "Profile", "A name with nothing usable had no fallback");
check(profiles::valid_profile_name(profiles::clean_profile_name(std::string(80, 'x'))), "A long name was not cut to fit");
profiles::Profiles set;
set.list = {{"Pack", {}}, {"Pack (2)", {}}};
check(set.free_name("Other") == "Other" && set.free_name("pack") == "pack (3)", "A free name was not found");
check(set.find("PACK") == &set.list[0], "Profiles are not found whatever the case");
}
void applying() {
const auto list = installed({{"Team-A", "1.0.0", true}, {"Team-B", "2.1.0", false}, {"Local Mod", "wip", true}});
const auto captured = profiles::capture("Now", list);
check(captured.mods.size() == 3 && captured.mods[1] == profiles::ProfileMod{"Team-B", "2.1.0", false} &&
captured.mods[2].version == "0.0.0", "The installed mods were not captured as they are");
check(profiles::in_use(captured, list), "A profile just captured is not in use");
const profiles::Profile profile{"Pack", {{"team-b", "2.1.0", true}, {"Team-C", "1.2.3", true}, {"Team-D", "1.0.0", false},
{"Team-A", "1.0.0", false}, {"Team-B", "2.1.0", false}}};
const auto applied = profiles::apply(profile, list);
check(order(applied.entries) == std::vector<std::string>{"Team-B+", "Team-A-", "Local Mod-"},
"A profile's order and switches were not applied, or a mod outside it stayed on");
check(applied.missing.size() == 1 && applied.missing[0].name == "Team-C" && applied.missing[0].version == "1.2.3",
"The mods to download are not the missing ones that are switched on");
check(!profiles::in_use(profile, list), "A profile with a mod to download counts as in use");
auto after = list;
after.entries = applied.entries;
const profiles::Profile have{"Have", {{"Team-B", "2.1.0", true}, {"Team-A", "1.0.0", false}}};
check(profiles::in_use(have, after) && !profiles::in_use(have, list), "In use does not follow which mods are on");
}
void storage(const std::filesystem::path& root) {
std::filesystem::remove_all(root);
std::filesystem::create_directories(root);
check(profiles::load(root).list.empty(), "A Mods folder with no profiles file has profiles");
profiles::Profiles set;
set.list = {{"Street", {{"Team-A", "1.0.0", true}, {"Team-B", "2.0.0", false}}}, {"Empty", {}}};
set.active = "Street";
profiles::save(root, set);
const auto read = profiles::load(root);
check(read.active == "Street" && read.list == set.list, "Profiles did not survive saving and loading");
std::ofstream(root / profiles::profiles_file) << R"({"active":"Gone","profiles":[{"name":"ok","mods":[{"name":"../x"},{"name":"Fine"}]},)"
R"({"name":"bad/name"},{"name":"ok"},7]})";
const auto loose = profiles::load(root);
check(loose.list.size() == 1 && loose.list[0].mods.size() == 1 && loose.list[0].mods[0].name == "Fine" &&
loose.list[0].mods[0].enabled && loose.active.empty(), "A damaged profiles file was not read loosely");
std::ofstream(root / profiles::profiles_file) << "not json";
check(profiles::load(root).list.empty(), "An unreadable profiles file was not treated as none");
}
void exporting(const std::filesystem::path& root) {
const profiles::Profile profile{"My \"best\" pack", {{"Team-Map_One", "1.10.3", true}, {"Local Mod", "0.0.0", false}}};
const auto text = profiles::export_text(profile);
check(text.find("profileName: \"My \\\"best\\\" pack\"") == 0 && text.find(" minor: 10\n") != std::string::npos &&
text.find(" enabled: false\n") != std::string::npos, "The export is not r2modman's layout");
// clean_profile_name drops the quotes, which a file name cannot hold.
auto expected = profile;
expected.name = "My best pack";
check(profiles::parse_export(text) == expected, "An export did not read back");
check(profiles::parse_archive(profiles::export_archive(profile)) == expected, "A .r2z did not read back");
// Left in the scratch folder, for opening with another zip reader.
const auto archive = profiles::export_archive(profile);
std::ofstream(root / "exported.r2z", std::ios::binary).write(archive.data(), static_cast<std::streamsize>(archive.size()));
check(profiles::parse_code_payload(profiles::code_payload(profile)) == expected, "A code payload did not read back");
check(profiles::code_payload(profile).starts_with("#r2modman\n"), "A code payload lacks r2modman's first line");
check(profiles::parse_export(profiles::export_text({"Empty", {}})) == profiles::Profile{"Empty", {}},
"A profile with no mods did not read back");
// As r2modman's own YAML writer lays one out: unquoted, list items not indented, CRLF.
const auto theirs = profiles::parse_export("profileName: Default\r\nmods:\r\n- name: BepInEx-BepInExPack\r\n version:\r\n"
" major: 5\r\n minor: 4\r\n patch: 2100\r\n enabled: true\r\n- name: 'Team-Other'\r\n version:\r\n"
" major: 1\r\n minor: 0\r\n patch: 0\r\n enabled: false\r\n- name: bad/name\r\n enabled: true\r\n");
check(theirs.name == "Default" && theirs.mods.size() == 2 &&
theirs.mods[0] == profiles::ProfileMod{"BepInEx-BepInExPack", "5.4.2100", true} &&
theirs.mods[1] == profiles::ProfileMod{"Team-Other", "1.0.0", false}, "r2modman's own export was misread");
check(!error_of([] { profiles::parse_export("just: text\n"); }).empty(), "Text that is not a profile was accepted");
check(!error_of([] { profiles::parse_archive("PK\x03\x04 not really a zip"); }).empty(), "A broken archive was accepted");
check(!error_of([] { profiles::parse_code_payload("<html>not found</html>"); }).empty(), "A page that is not a profile was accepted");
check(!error_of([] { profiles::parse_code_payload("#r2modman\n!!!!"); }).empty(), "A payload that is not base64 was accepted");
}
void codes() {
check(profiles::valid_code("0193a2b4-7c1d-4e5f-8a9b-0c1d2e3f4a5b") && !profiles::valid_code("short") &&
!profiles::valid_code("0193a2b4/../../etc") && !profiles::valid_code("0193a2b4 7c1d 4e5f"), "Profile codes are not checked");
check(profiles::parse_created_code(R"({"key":"0193a2b4-7c1d-4e5f-8a9b-0c1d2e3f4a5b"})") == "0193a2b4-7c1d-4e5f-8a9b-0c1d2e3f4a5b",
"The code in a create answer was not read");
check(!error_of([] { profiles::parse_created_code(R"({"detail":"throttled"})"); }).empty(), "An answer with no code was accepted");
check(profiles::code_get_url("abc-123") == L"https://thunderstore.io/api/experimental/legacyprofile/get/abc-123/",
"The address of a code is wrong");
}
} // namespace
int wmain(int argc, wchar_t** argv) {
if (argc != 2) {
std::cerr << "Pass a scratch folder\n";
return 2;
}
names();
applying();
storage(std::filesystem::path(argv[1]) / L"profiles");
exporting(std::filesystem::path(argv[1]) / L"profiles");
codes();
if (failures) return 1;
std::cout << "Mod profile names, applying, storage, exports and codes passed.\n";
return 0;
}