mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 15:07:06 +02:00
Map updates - A map the game already has, being replaced by the host's version, could load as soon as the new files were in Mods, while the mods were still being applied: it ended on "This map is not loading". The session now holds the load while a map is downloading, installing or applying, as it waits for one that is missing. - A player whose version of a map is later than the host's keeps it and loads. Only an older one is replaced by the host's. Versions are compared number by number (1.10.0 is later than 1.9.2). Dedicated servers - maps.latest_versions (off by default): the server asks Thunderstore for the newest version of each of its custom maps, at start and every maps.latest_check_minutes (5-1440, default 60), and writes it into the mod's manifest.json. Nothing is downloaded: the server reads only a map's level list and version, and players, who are held to the server's version, fetch it as they join. The map being played waits until nobody is on it. A folder that is not a Thunderstore package is not touched, and the level list is left as it is. - Console: maps update, maps latest on|off, maps latest every <minutes>. Overlay - Vote card: the pass mark is always in the middle of the bar. Green grows over the left half up to the yes votes needed and over the right half past them, whatever share the vote needs. - The "Staff" tag is "ReSkate Admin", shimmering from #2500FF to #B2F9FF. No protocol change. Tests cover the version comparison and the manifest write; the Thunderstore lookup was checked against the site. The map update flow, the vote bar and the tag were not seen in game, and the server setting has not run on a live server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
7.7 KiB
C++
182 lines
7.7 KiB
C++
#include "server_map_update.h"
|
|
#include "Engine/Core/Json/json.h"
|
|
#include "Engine/Vfs/thunderstore_package.h"
|
|
#ifdef _WIN32
|
|
#include "Engine/Vfs/https_download.h"
|
|
#else
|
|
#include <cstdio>
|
|
#endif
|
|
#include <algorithm>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include <stdexcept>
|
|
|
|
namespace dingosdk::server {
|
|
namespace {
|
|
namespace fs = std::filesystem;
|
|
namespace package = thunderstore_package;
|
|
|
|
constexpr char community[] = "reskate";
|
|
constexpr std::uint64_t largest_answer = 1024 * 1024;
|
|
|
|
#ifdef _WIN32
|
|
std::string get_text(const std::wstring &url) {
|
|
https::Download result;
|
|
auto answer = https::get_text(url, largest_answer, 20, L"ReSkateServer/1", &result);
|
|
if (!answer) throw std::runtime_error("HTTP " + std::to_string(result.http_status) + ", error " + std::to_string(result.error));
|
|
return std::move(*answer);
|
|
}
|
|
#else
|
|
// curl does the TLS, as for the team's lists (global_bans.cpp). The address is made here from a
|
|
// package's name, which is letters, digits, "_", "-" and "." only; anything else is refused
|
|
// before it reaches a command line.
|
|
std::string get_text(const std::wstring &url) {
|
|
std::string address;
|
|
for (const auto wide : url) {
|
|
const char c = wide < 0x80 ? static_cast<char>(wide) : '?';
|
|
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == '/' || c == ':'))
|
|
throw std::runtime_error("the package's address is not one to ask for");
|
|
address += c;
|
|
}
|
|
const auto command = "curl --silent --show-error --fail --location --max-time 20 --max-filesize " + std::to_string(largest_answer) +
|
|
" --proto =https --proto-redir =https --user-agent ReSkateServer/1 " + address + " 2>&1";
|
|
auto *pipe = popen(command.c_str(), "r");
|
|
if (!pipe) throw std::runtime_error("curl could not be started");
|
|
std::string output;
|
|
char buffer[4096];
|
|
for (std::size_t count; (count = fread(buffer, 1, sizeof(buffer), pipe)) > 0;)
|
|
if (output.size() < largest_answer) output.append(buffer, count);
|
|
if (pclose(pipe) != 0) {
|
|
while (!output.empty() && (output.back() == '\n' || output.back() == '\r')) output.pop_back();
|
|
std::replace(output.begin(), output.end(), '\n', ' ');
|
|
throw std::runtime_error(output.empty() ? "curl failed" : output);
|
|
}
|
|
return output;
|
|
}
|
|
#endif
|
|
|
|
std::string read(const fs::path &file) {
|
|
std::ifstream in(file, std::ios::binary);
|
|
std::stringstream text;
|
|
text << in.rdbuf();
|
|
return in ? text.str() : std::string();
|
|
}
|
|
bool hidden(const fs::path &folder) {
|
|
const auto name = folder.filename().string();
|
|
return name.empty() || name.front() == '.';
|
|
}
|
|
} // namespace
|
|
|
|
std::string map_version(const fs::path &mod) {
|
|
try {
|
|
const auto text = read(mod / "manifest.json");
|
|
return text.empty() ? std::string() : Json::parse(text).value("version_number", "");
|
|
} catch (const std::exception &) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
void set_map_version(const fs::path &mod, const std::string &version) {
|
|
const auto file = mod / "manifest.json";
|
|
auto root = Json::object();
|
|
// (A manifest that cannot be read is not written over: its mod is left as it is.)
|
|
if (const auto text = read(file); !text.empty()) {
|
|
root = Json::parse(text);
|
|
if (!root.is_object()) throw std::runtime_error("its manifest.json is not an object");
|
|
}
|
|
root["version_number"] = version;
|
|
// Written beside it and moved over it, so a manifest is never half written.
|
|
auto fresh = file;
|
|
fresh += ".new";
|
|
{
|
|
std::ofstream out(fresh, std::ios::binary | std::ios::trunc);
|
|
out << root.dump(2) << '\n';
|
|
if (!out.flush()) throw std::runtime_error("its manifest.json could not be written");
|
|
}
|
|
std::error_code moved;
|
|
fs::rename(fresh, file, moved);
|
|
if (moved) {
|
|
std::error_code ignored;
|
|
fs::remove(fresh, ignored);
|
|
throw std::runtime_error("its manifest.json could not be replaced (" + moved.message() + ")");
|
|
}
|
|
}
|
|
|
|
std::vector<MapUpdate> update_maps(const fs::path &mods, const std::string &in_play) {
|
|
std::vector<MapUpdate> result;
|
|
std::error_code error;
|
|
std::vector<fs::path> folders;
|
|
for (const auto &entry : fs::directory_iterator(mods, error))
|
|
if (entry.is_directory(error) && !hidden(entry.path()) && fs::exists(entry.path() / "reskate-levels.json", error))
|
|
folders.push_back(entry.path());
|
|
for (const auto &path : folders) {
|
|
const auto folder = path.filename().string();
|
|
const auto name = package::parse_name(folder);
|
|
if (!name || !name->version.empty()) continue; // not a Thunderstore package's folder
|
|
MapUpdate update;
|
|
update.folder = folder;
|
|
update.from = map_version(path);
|
|
try {
|
|
const auto choice = package::choose(get_text(package::details_url(*name)), *name, community);
|
|
if (!choice.ok) {
|
|
update.problem = choice.reason.empty() ? "Thunderstore does not list it as a map" : choice.reason;
|
|
result.push_back(std::move(update));
|
|
continue;
|
|
}
|
|
update.to = choice.version;
|
|
if (update.to == update.from) continue; // already the newest: nothing to say
|
|
if (folder == in_play) {
|
|
update.waiting = true;
|
|
result.push_back(std::move(update));
|
|
continue;
|
|
}
|
|
set_map_version(path, update.to);
|
|
update.updated = true;
|
|
} catch (const std::exception &e) {
|
|
update.problem = e.what();
|
|
// (A map copied in by hand, or one its author took down.)
|
|
if (update.problem.find("404") != std::string::npos) update.problem = "it is not on Thunderstore under that name";
|
|
}
|
|
result.push_back(std::move(update));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
MapUpdater::Done MapUpdater::tick(bool enabled, unsigned minutes, const fs::path &mods, const std::string &in_play, bool asked) {
|
|
Done done;
|
|
const auto now = std::chrono::steady_clock::now();
|
|
asked_ = asked_ || asked;
|
|
if (work_.valid() && work_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
|
|
bool again_soon{};
|
|
std::vector<MapUpdate> found;
|
|
try {
|
|
found = work_.get();
|
|
} catch (const std::exception &e) {
|
|
done.lines.push_back(std::string("Map versions: the check failed (") + e.what() + ").");
|
|
again_soon = true;
|
|
}
|
|
for (const auto &update : found) {
|
|
if (update.updated) {
|
|
done.changed = true;
|
|
done.lines.push_back("Map versions: " + update.folder + " is now at " + update.to + (update.from.empty() ? "" : " (was " + update.from + ")") +
|
|
", Thunderstore's newest; players are sent there.");
|
|
said_.erase(update.folder);
|
|
} else if (update.waiting) {
|
|
again_soon = true;
|
|
if (said_.insert(update.folder + " waits for " + update.to).second)
|
|
done.lines.push_back("Map versions: " + update.folder + " " + update.to + " is out, and waits until its map is not being played.");
|
|
} else if (!update.problem.empty() && said_.insert(update.folder + ": " + update.problem).second) {
|
|
done.lines.push_back("Map versions: " + update.folder + " was left as it is: " + update.problem);
|
|
}
|
|
}
|
|
next_ = now + (again_soon ? std::chrono::minutes(5) : std::chrono::minutes(std::max(minutes, 5U)));
|
|
}
|
|
if (!work_.valid() && ((enabled && now >= next_) || asked_)) {
|
|
asked_ = false;
|
|
next_ = now + std::chrono::minutes(std::max(minutes, 5U));
|
|
work_ = std::async(std::launch::async, update_maps, mods, in_play);
|
|
}
|
|
return done;
|
|
}
|
|
} // namespace dingosdk::server
|