From f76379273250e10f82ce982d2965a3905a5a888b Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Fri, 23 Oct 2026 15:17:12 +0200 Subject: [PATCH 1/3] Plugins: world_to_screen gives nil for points the drawing functions would refuse A point just in front of the camera and off to the side projects to millions of pixels. world_to_screen handed that back, and drawing it (d:poly, d:line, ...) raised "must be a finite position", so a client plugin that drew a world marker near the camera was switched off after five errors. world_to_screen now returns nil when x, y or the depth is not finite or beyond 1e6, the same bound the drawing functions use, as it already did for a point behind the camera. The draw test now also checks that its handler ran to the end; before, a failing assert inside it went unnoticed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SYabE7FCPgj3SaWeF92EML --- Extension/Plugins/Test/plugin_tests.cpp | 7 +++++++ Extension/Plugins/plugin_api_ui.cpp | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 0391a85e..ebae3f1a 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -1741,11 +1741,17 @@ void plugins_draw_on_the_overlay() { local on = d:world_to_screen(vector.create(1, 0, -10)) assert(on and math.abs(on.x - 1060) < 0.01 and math.abs(on.z - 10) < 0.01, "world_to_screen") assert(d:world_to_screen(vector.create(0, 0, 5)) == nil, "behind the camera") + -- Just in front of the camera and off to the side the point is millions of pixels + -- out, which d:poly would refuse. + assert(d:world_to_screen(vector.create(1000, 0, -0.06)) == nil, "far off the screen") + assert(d:world_to_screen(vector.create(0, 1000, -0.06)) == nil, "far above the screen") + assert(d:world_to_screen(vector.create(1, 0, -0.06)), "off the screen but drawable") assert(d:size().x == 1920 and d:size().y == 1080) assert(not pcall(d.text, d, vector.create(0, 0, 0), "x", {colour = "red"}), "a colour by name") assert(not pcall(d.text, d, vector.create(0 / 0, 0, 0), "x"), "NaN") assert(not pcall(d.poly, d, {vector.create(0, 0, 0)}), "a polygon of one point") assert(not pcall(d.text, d, vector.create(0, 0, 0), string.rep("x", 600)), "a long text") + reskate.log("drawn") end) reskate.console.command("outside", "x", function() local ok, err = pcall(saved.text, saved, vector.create(0, 0, 0), "x") @@ -1769,6 +1775,7 @@ void plugins_draw_on_the_overlay() { running(host, "painter", environment); check(environment.layers.size() == 1 && environment.layers[0].plugin == "painter" && environment.layers[0].shapes.size() == 8, "Its shapes are handed to the overlay each tick\n" + environment.all()); + check(environment.logged("drawn"), "and the draw handler ran to its end\n" + environment.all()); const auto& first = environment.layers[0].shapes[0]; check(first.kind == plugin_ui::Shape::Kind::text && first.text == "hello" && first.style.colour == 0xff0080ff && first.style.size == 24 && first.style.align == 1, "with their style (colours as ImGui keeps them)"); diff --git a/Extension/Plugins/plugin_api_ui.cpp b/Extension/Plugins/plugin_api_ui.cpp index 3935a9e9..1b6fb99a 100644 --- a/Extension/Plugins/plugin_api_ui.cpp +++ b/Extension/Plugins/plugin_api_ui.cpp @@ -282,7 +282,11 @@ struct UiApi { auto& plugin = plugin_of(L); need(L, plugin, "overlay", "world_to_screen"); const auto on = plugin.services_.world_to_screen(point(L, 2, "the world position")); - if (on) lua_pushvector(L, (*on)[0], (*on)[1], (*on)[2]); + // A point just in front of the camera and off to the side lands millions of pixels out, + // where the drawing functions refuse it; nil here, as for a point behind the camera, so + // a plugin that draws what it gets back keeps running. + const auto drawable = [](float v) { return std::isfinite(v) && std::fabs(v) <= 1e6f; }; + if (on && drawable((*on)[0]) && drawable((*on)[1]) && drawable((*on)[2])) lua_pushvector(L, (*on)[0], (*on)[1], (*on)[2]); else lua_pushnil(L); return 1; } From 0706730e792b228e555ebf26e8196b32ffb32ee3 Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Fri, 23 Oct 2026 15:22:30 +0200 Subject: [PATCH 2/3] Plugins: mods.content and read_content read the server's Mods on a dedicated server A server plugin with mods.read could call reskate.mods.content, but the server's game had no content of its own, so it got nil and "not available". A mode that keeps its data in mods (a race reading start and finish from a park mod) could only read it in the players' games. The server now answers from every mod folder in its Mods, by name, as it finds its maps there; it has no load order or mods.json. A hidden folder (.reskate_plugin_data) and names a plugin could not ask for are left out, and a mod's title is its manifest.json's "name". The listing and reading moved from plugin_game_runtime.cpp into plugin_content.cpp, which the game and the server share, so both keep the same limits: the folders the manifest's "content" names, 1000 files, four folders deep, 1 MB a file, nothing outside the mod's folder. The game's side is unchanged apart from calling it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SYabE7FCPgj3SaWeF92EML --- Extension/Plugins/Test/plugin_tests.cpp | 50 +++++++++++ Extension/Plugins/plugin_content.cpp | 104 ++++++++++++++++++++++ Extension/Plugins/plugin_content.h | 28 ++++++ Extension/Plugins/plugin_game_runtime.cpp | 59 ++---------- Server/Test/server_plugin_tests.cpp | 34 +++++++ Server/server_plugins.cpp | 21 +++++ Server/server_plugins.h | 3 +- cmake/Core.cmake | 1 + 8 files changed, 246 insertions(+), 54 deletions(-) create mode 100644 Extension/Plugins/plugin_content.cpp create mode 100644 Extension/Plugins/plugin_content.h diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index ebae3f1a..4d1059e7 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -1,5 +1,6 @@ // The plugin host apart from the game: manifests, the reskate API, storage, modules, hot reload, // and the sandbox against plugins written to break it (loops, memory, error floods, escapes). +#include "Extension/Plugins/plugin_content.h" #include "Extension/Plugins/plugin_discovery.h" #include "Extension/Plugins/plugin_host.h" #include "Extension/Plugins/plugin_manifest.h" @@ -2893,6 +2894,54 @@ void a_plugin_reads_the_content_folder_it_names() { check(environment.printed("content ok") && game.called("content list parks"), "The plugin reads its content folder\n" + environment.all()); } +// What the game and a dedicated server read for mods.content (plugin_content.h), from real folders: +// every mod folder in a Mods folder by name, each one's files under the folder asked for, and the +// limits a plugin cannot get past. +void content_is_read_from_the_mod_folders() { + namespace content = dingosdk::plugins::content; + const auto mods = scratch / "content mods"; + const auto write = [](const std::filesystem::path& file, const std::string& text) { + std::filesystem::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary) << text; + }; + write(mods / "b_track" / "parks" / "start.json", "{}"); + write(mods / "b_track" / "parks" / "deep" / "a" / "b" / "c" / "level4.json", "{}"); + write(mods / "b_track" / "parks" / "deep" / "a" / "b" / "c" / "d" / "level5.json", "{}"); + write(mods / "b_track" / "parks" / "half.tmp", ""); + write(mods / "b_track" / "parks" / "big.json", std::string((1u << 20) + 1, ' ')); + write(mods / "b_track" / "manifest.json", R"({"name": "B Track", "version_number": "1.0.0"})"); + write(mods / "A_Park" / "parks" / "z.json", "zz"); + write(mods / "A_Park" / "reskate-levels.json", "{}"); + write(mods / "no parks" / "manifest.json", "not JSON"); + write(mods / ".reskate_plugin_data" / "parks" / "secret.json", "{}"); + write(mods / "loose.json", "{}"); + + const auto found = content::mod_folders(mods); + check(found.size() == 3 && found[0].name == "A_Park" && found[1].name == "b_track" && found[2].name == "no parks", + "Every mod folder, by name, none hidden"); + check(found[0].title == "A_Park" && found[1].title == "B Track" && found[2].title == "no parks", "titled as their manifest names them"); + check(content::mod_folders(scratch / "no such folder").empty(), "No Mods folder, no mods"); + + std::vector files; + content::list(found, "parks", files); + std::string listed; + for (const auto& file : files) listed += std::format("{}:{}:{} ", file.mod, file.path, file.size); + check(listed == std::format("A_Park:z.json:2 b_track:big.json:{} b_track:deep/a/b/c/level4.json:2 b_track:start.json:2 ", (1u << 20) + 1), + "Mod by mod, sorted, four folders deep at most, no .tmp: " + listed); + + std::string text; + check(content::read(found, "B_TRACK", "parks", "start.json", text).empty() && text == "{}", "A file is read, the mod named in any case"); + check(content::read(found, "b_track", "parks", "missing.json", text) == "not found", "A missing file"); + check(content::read(found, "b_track", "parks", "big.json", text) == "the file is larger than 1 MB", "A file over 1 MB"); + check(content::read(found, "nobody", "parks", "start.json", text) == "no such mod is on", "A mod that is not there"); + check(content::read(found, ".reskate_plugin_data", "parks", "secret.json", text) == "no such mod is on", "A hidden folder is no mod"); + + for (int i = 0; i < 1005; ++i) write(mods / "c_many" / "parks" / std::format("{:04}.json", i), ""); + files.clear(); + content::list(content::mod_folders(mods), "parks", files); + check(files.size() == content::most_files, std::format("At most {} files, {} listed", content::most_files, files.size())); +} + void board_break_snaps_the_board_on_a_slam() { TestEnvironment environment; std::filesystem::remove_all(scratch / "storage" / "board-break"); @@ -3896,6 +3945,7 @@ int main() { run(dual_stick_edits_the_companions_settings); run(rock_to_fakie_relabels_and_records_the_stall); run(a_plugin_reads_the_content_folder_it_names); + run(content_is_read_from_the_mod_folders); run(board_break_snaps_the_board_on_a_slam); run(fpv_drone_flies_and_follows); run(hall_of_meat_scores_a_bail); diff --git a/Extension/Plugins/plugin_content.cpp b/Extension/Plugins/plugin_content.cpp new file mode 100644 index 00000000..a2db86c5 --- /dev/null +++ b/Extension/Plugins/plugin_content.cpp @@ -0,0 +1,104 @@ +#include "plugin_content.h" +#include "Engine/Core/Json/json.h" +#include +#include +#include + +namespace dingosdk::plugins::content { +namespace { +// The names mod_list.h's scan_mods takes for a mod folder, as long as read_content takes. +bool mod_name(std::string_view name) { + if (name.empty() || name.size() > 64 || name.front() == '.') return false; + return std::all_of(name.begin(), name.end(), [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == ' '; + }); +} +bool same_name(std::string_view a, std::string_view b) { + return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) { + const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast(c + 32) : c; }; + return lower(x) == lower(y); + }); +} +// manifest.json's "name", 1 to 64 characters, or empty when there is none to read. +std::string manifest_name(const std::filesystem::path& directory) { + const auto file = directory / "manifest.json"; + std::error_code error; + if (!std::filesystem::is_regular_file(file, error) || std::filesystem::file_size(file, error) > 32 * 1024 || error) return {}; + try { + std::ifstream in(file, std::ios::binary); + std::stringstream text; + text << in.rdbuf(); + const auto root = Json::parse(text.str()); + if (!root.is_object() || !root.contains("name") || !root.at("name").is_string()) return {}; + const auto& name = root.at("name").string(); + return name.size() <= 64 ? name : std::string{}; + } catch (const std::exception&) { + return {}; + } +} +} // namespace + +std::vector mod_folders(const std::filesystem::path& mods_root) { + std::vector mods; + std::error_code error; + for (std::filesystem::directory_iterator it(mods_root, error), end; !error && it != end; it.increment(error)) { + std::error_code entry; + if (!it->is_directory(entry)) continue; + const auto utf8 = it->path().filename().u8string(); + std::string name(utf8.begin(), utf8.end()); + if (!mod_name(name)) continue; + auto title = manifest_name(it->path()); + mods.push_back({name, title.empty() ? name : std::move(title), it->path()}); + } + std::sort(mods.begin(), mods.end(), [](const Mod& a, const Mod& b) { return a.name < b.name; }); + return mods; +} + +void list(const std::vector& mods, std::string_view folder, std::vector& files) { + for (const auto& mod : mods) { + const auto root = mod.directory / std::filesystem::path(std::string(folder)); + std::error_code error; + if (!std::filesystem::is_directory(root, error)) continue; + std::vector found; + for (std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::none, error), end; + !error && it != end; it.increment(error)) { + if (it.depth() >= 4) it.disable_recursion_pending(); + if (!it->is_regular_file(error)) continue; + const auto relative = it->path().lexically_relative(root).generic_wstring(); + std::string path; + bool plain = true; + for (const auto c : relative) { + if (c < 0x20 || c > 0x7e) plain = false; + path.push_back(static_cast(c)); + } + if (!plain || path.ends_with(".tmp")) continue; // a name a plugin could not ask for again + found.push_back({mod.name, mod.title, std::move(path), static_cast(it->file_size(error))}); + if (files.size() + found.size() >= most_files) break; + } + std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.path < b.path; }); + for (auto& file : found) files.push_back(std::move(file)); + if (files.size() >= most_files) break; + } +} + +std::string read(const std::vector& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text) { + const auto found = std::find_if(mods.begin(), mods.end(), [&](const Mod& each) { return same_name(each.name, mod); }); + if (found == mods.end()) return "no such mod is on"; + const auto root = found->directory / std::filesystem::path(std::string(folder)); + const auto file = root / std::filesystem::path(std::string(path)); + std::error_code error; + const auto real_root = std::filesystem::weakly_canonical(root, error); + const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error); + if (error) return "not found"; + const auto relative = real_file.lexically_relative(real_root); + if (relative.empty() || *relative.begin() == "..") return "outside the mod's folder"; + if (!std::filesystem::is_regular_file(file, error)) return "not found"; + const auto size = std::filesystem::file_size(file, error); + if (error) return "the file cannot be read"; + if (size > most_bytes) return "the file is larger than 1 MB"; + std::ifstream in(file, std::ios::binary); + text.resize(static_cast(size)); + if (!in || !in.read(text.data(), static_cast(size))) return "the file cannot be read"; + return {}; +} +} // namespace dingosdk::plugins::content diff --git a/Extension/Plugins/plugin_content.h b/Extension/Plugins/plugin_content.h new file mode 100644 index 00000000..7ead29e9 --- /dev/null +++ b/Extension/Plugins/plugin_content.h @@ -0,0 +1,28 @@ +#pragma once +#include "plugin_game.h" +#include +#include +#include +#include +#include + +// mods.content and read_content, from the mods' folders on disk: the game reads its enabled mods +// (plugin_game_runtime.cpp), a dedicated server every mod folder in its Mods (server_plugins.cpp). +// Both keep the same limits, so a plugin reads the same files on either. +namespace dingosdk::plugins::content { +struct Mod { + std::string name, title; // the folder's name and the mod's own name + std::filesystem::path directory; +}; +inline constexpr std::size_t most_files = 1000, most_bytes = 1u << 20; + +// Every folder in `mods_root` a mod can be, by name: not hidden (".reskate_plugin_data"), and a +// name a plugin can ask for again. Titled by its manifest.json's "name", else its folder's name. +std::vector mod_folders(const std::filesystem::path& mods_root); +// The files under // of each of `mods` in turn, sorted within a mod, four folders deep +// at most and most_files in all; `folder` is checked against the manifest already. +void list(const std::vector& mods, std::string_view folder, std::vector& files); +// One of them, at most most_bytes; `path` is checked already (safe_relative_path). Empty when read, +// else why not. +std::string read(const std::vector& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text); +} // namespace dingosdk::plugins::content diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 70409d7c..ecc370b6 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -1,5 +1,6 @@ #include "plugin_game_runtime.h" #include "plugin_session_feed.h" +#include "plugin_content.h" #include "Runtime/runtime_internal.h" #include "plugin_ui.h" #include "Engine/Game/World/park_rotation.h" @@ -1442,13 +1443,9 @@ public: tricks::prepare_trick_gestures(); return tricks::trick_gesture(trick); } - // ---- content: // of every enabled mod --------------------------------------------- + // ---- content: // of every enabled mod (plugin_content.h) -------------------------- // The enabled mods, read again at most every 2 s: a page may list them every frame. - struct EnabledMod { - std::string name, title; - std::filesystem::path directory; - }; - std::vector enabled_mods() { + std::vector enabled_mods() { const auto now = GetTickCount64(); if (enabled_read_at_ && now - enabled_read_at_ < 2000) return enabled_mods_; enabled_read_at_ = now; @@ -1459,56 +1456,12 @@ public: if (entry.enabled) enabled_mods_.push_back({entry.mod.name, entry.mod.title.empty() ? entry.mod.name : entry.mod.title, entry.mod.directory}); return enabled_mods_; } - static constexpr std::size_t most_content_files = 1000, most_content_bytes = 1u << 20; std::string content_list(std::string_view folder, std::vector& files) override { - for (const auto& mod : enabled_mods()) { - const auto root = mod.directory / std::filesystem::path(std::string(folder)); - std::error_code error; - if (!std::filesystem::is_directory(root, error)) continue; - std::vector found; - for (std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::none, error), end; - !error && it != end; it.increment(error)) { - if (it.depth() >= 4) it.disable_recursion_pending(); - if (!it->is_regular_file(error)) continue; - const auto relative = it->path().lexically_relative(root).generic_wstring(); - std::string path; - bool plain = true; - for (const auto c : relative) { - if (c < 0x20 || c > 0x7e) plain = false; - path.push_back(static_cast(c)); - } - if (!plain || path.ends_with(".tmp")) continue; // a name a plugin could not ask for again - found.push_back({mod.name, mod.title, std::move(path), static_cast(it->file_size(error))}); - if (files.size() + found.size() >= most_content_files) break; - } - std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.path < b.path; }); - for (auto& file : found) files.push_back(std::move(file)); - if (files.size() >= most_content_files) break; - } + content::list(enabled_mods(), folder, files); return {}; } std::string content_read(std::string_view mod, std::string_view folder, std::string_view path, std::string& text) override { - const auto mods_now = enabled_mods(); - const auto found = std::find_if(mods_now.begin(), mods_now.end(), [&](const EnabledMod& each) { - return each.name.size() == mod.size() && _strnicmp(each.name.c_str(), mod.data(), mod.size()) == 0; - }); - if (found == mods_now.end()) return "no such mod is on"; - const auto root = found->directory / std::filesystem::path(std::string(folder)); - const auto file = root / std::filesystem::path(std::string(path)); - std::error_code error; - const auto real_root = std::filesystem::weakly_canonical(root, error); - const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error); - if (error) return "not found"; - const auto relative = real_file.lexically_relative(real_root); - if (relative.empty() || *relative.begin() == "..") return "outside the mod's folder"; - if (!std::filesystem::is_regular_file(file, error)) return "not found"; - const auto size = std::filesystem::file_size(file, error); - if (error) return "the file cannot be read"; - if (size > most_content_bytes) return "the file is larger than 1 MB"; - std::ifstream in(file, std::ios::binary); - text.resize(static_cast(size)); - if (!in || !in.read(text.data(), static_cast(size))) return "the file cannot be read"; - return {}; + return content::read(enabled_mods(), mod, folder, path, text); } std::string trick_label(std::string_view plugin, std::string_view from, std::string_view to) override { return trick_rewrite::label(plugin, from, to); @@ -1741,7 +1694,7 @@ private: std::vector installed; }; std::map> variant_reads_; - std::vector enabled_mods_; + std::vector enabled_mods_; std::string reacher_; // the plugin holding the skater's arm (skater_reach) std::string poser_; // the plugin turning the skater's joints (skater_pose) std::string carrier_; // the plugin having the skater carry something (skater_carry) diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp index 4a682735..6999f1c7 100644 --- a/Server/Test/server_plugin_tests.cpp +++ b/Server/Test/server_plugin_tests.cpp @@ -211,6 +211,10 @@ int run(const std::filesystem::path &fixtures) { local ok, why = pcall(reskate.server.parks) reskate.console.print(ok and "blind listed parks" or "blind parks refused: " .. tostring(why)) end) + reskate.console.command("blind content", "mod content without mods.read", function() + local ok, why = pcall(reskate.mods.content, "parks") + reskate.console.print(ok and "blind listed content" or "blind content refused: " .. tostring(why)) + end) )"); // One that says what the level is, and when it changes. @@ -251,6 +255,22 @@ int run(const std::filesystem::path &fixtures) { end) )"); + // One that reads the parks folder of the mods the server has, as a game's plugin reads its + // enabled mods'. + write(root / "Plugins" / "reader" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "mods.read"], "content": ["parks"]})"); + write(root / "Plugins" / "reader" / "plugin" / "main.lua", R"( + reskate.console.command("reader", "mod content, for the test", function() + for _, f in ipairs(assert(reskate.mods.content("parks"))) do + reskate.console.print(string.format("content %s (%s) %s %d", f.mod, f.title, f.path, f.size)) + end + local text = assert(reskate.mods.read_content("mixed park", "parks", "grom.park.json")) + reskate.console.print("read " .. #text .. " " .. tostring(text:find('"reskate-park"', 1, true) ~= nil)) + local missing, why = reskate.mods.read_content("mixed park", "parks", "nothing.json") + reskate.console.print("missing " .. tostring(missing) .. " " .. why) + end) + )"); + std::vector log; SteamTransport transport; Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); @@ -350,6 +370,20 @@ int run(const std::filesystem::path &fixtures) { plugin_command("blind parks"); check(has(log, "blind parks refused: ") && has(log, "server.parks"), "A plugin without server.parks listed the parks"); + // The mods the server has, read through mods.content as a game reads its enabled mods. + const auto size_of = [&](const char *mod, const char *file) { + return std::to_string(std::filesystem::file_size(root / "Mods" / mod / "parks" / file)); + }; + plugin_command("reader"); + check(has(log, "content mixed park (mixed park) grom.park.json " + size_of("mixed park", "grom.park.json")) && + has(log, "content popular skate 2 street park (popular skate 2 street park) bam.park.json " + + size_of("popular skate 2 street park", "bam.park.json")), + "The server's mods' parks folders were not listed"); + check(has(log, "read " + size_of("mixed park", "grom.park.json") + " true"), "A park mod's file was not read"); + check(has(log, "missing nil not found"), "A missing file was not \"not found\""); + plugin_command("blind content"); + check(has(log, "blind content refused: ") && has(log, "mods.read"), "A plugin without mods.read listed the mods' content"); + // The level, as players' games name it, and its change heard when the server changes it. const auto *first = find_level(config.map); const auto other = std::find_if(levels().begin(), levels().end(), [&](const auto &l) { return l.asset != first->asset; }); diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index 5eeb453e..a0c33501 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -1,5 +1,6 @@ #include "server_plugins.h" #include "server_host.h" +#include "Extension/Plugins/plugin_content.h" #include "Extension/Plugins/plugin_host.h" #include "Extension/Plugins/plugin_manifest.h" #include "Extension/Plugins/plugin_wire.h" @@ -101,6 +102,15 @@ class ServerGame final : public Game { } return result; } + // mods.content: every mod folder in the server's Mods, as a game reads its enabled mods. The + // server has no load order or mods.json of its own; the folders come by name, as for its maps. + std::string content_list(std::string_view folder, std::vector &files) override { + content::list(mod_folders(), folder, files); + return {}; + } + std::string content_read(std::string_view mod, std::string_view folder, std::string_view path, std::string &text) override { + return content::read(mod_folders(), mod, folder, path, text); + } // Through the console's own ban and unban, so they keep its checks and save the bans file. std::string ban(std::uint64_t player) override { const auto answer = host_.command("ban " + std::to_string(player)); @@ -116,6 +126,15 @@ class ServerGame final : public Game { } private: + // Read again at most every 2 s: a plugin may list them every tick. + const std::vector &mod_folders() { + const auto now = std::chrono::steady_clock::now(); + if (!mods_read_ || now - *mods_read_ >= std::chrono::seconds(2)) { + mods_read_ = now; + mods_ = content::mod_folders(host_.config().mods); + } + return mods_; + } bool present(std::uint64_t player) const { for (const auto &p : host_.player_list()) if (p.id == player) return true; @@ -123,6 +142,8 @@ class ServerGame final : public Game { } Host &host_; ServerPlugins::Log log_; + std::vector mods_; + std::optional mods_read_; }; class ServerEnvironment final : public Environment { diff --git a/Server/server_plugins.h b/Server/server_plugins.h index 402dfe56..144a3d06 100644 --- a/Server/server_plugins.h +++ b/Server/server_plugins.h @@ -12,7 +12,8 @@ // a game mode's rules run. The operator installs them in Plugins beside the server, a folder a // plugin (with its reskate-plugin.json, "side" "server" or "both"); they load, reload and unload as // the folder changes, checked every 2 s. The server has no game: what a plugin may do here is the -// session's messages, its players, the server API (kick, say, tell, move) and its storage. +// session's messages, its players, the server API (kick, say, tell, move), its storage and reading +// the content folders of the mods in Mods (mods.content). // Everything here runs on the server's tick thread. namespace dingosdk::server { class Host; diff --git a/cmake/Core.cmake b/cmake/Core.cmake index 9e928e3b..d4507b7d 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -147,6 +147,7 @@ add_library(dingosdk_plugins STATIC Extension/Plugins/plugin_api_services.cpp Extension/Plugins/plugin_api_throwdowns.cpp Extension/Plugins/plugin_api_capture.cpp + Extension/Plugins/plugin_content.cpp # The throwdown kit's store and the plugins' modes, which the plugin host and the game's # native UI share (kit::flag is the game's: Extension/Throwdowns/native_flag_session.cpp). Extension/Throwdowns/throwdown_kit.cpp From d6cf244c260119463f7a00514ffc96270f8f4131 Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Fri, 23 Oct 2026 15:38:40 +0200 Subject: [PATCH 3/3] Plugins: a dedicated server's plugins show players cards and ask them polls A server plugin could only reach players through chat lines (say, tell). A mode that asks players something (which track next, join the next round) had to read chat commands, and could not show a card. reskate.server.card(who, text, seconds) and reskate.server.poll(who, question, answers, {seconds}) put a card or a poll on the screens of a player, a list of players or "all", in the slot the roster already has for the server's announcement and vote, so games need nothing new and the protocol is unchanged. Each player's is their own and shows over the server's until it ends, then the server's shows again. A poll has one id for everyone it asks; a player's answer ("/2", as the game sends it) closes it for them and comes back to the plugin that asked as a poll_answer event; a poll that runs out, is replaced by a newer one for that player, is closed with reskate.server.poll_close, or whose player leaves comes back as poll_closed with why. The plugin counts the answers. Both need the "server" permission and a server copy on a dedicated server; a lobby's host has no roster slot for them. Answering a plugin's poll is not a server vote, so it does not ask the player's role for "votes". A plugin that stops takes its cards and polls with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SYabE7FCPgj3SaWeF92EML --- Extension/Plugins/Test/plugin_tests.cpp | 89 ++++++++++++++ Extension/Plugins/plugin_api.cpp | 3 +- Extension/Plugins/plugin_api_world.cpp | 79 ++++++++++++- Extension/Plugins/plugin_game.h | 26 +++++ Extension/Plugins/plugin_host.cpp | 14 +++ Server/Test/server_plugin_tests.cpp | 75 ++++++++++++ Server/server_host.cpp | 15 ++- Server/server_host.h | 48 ++++++++ Server/server_plugins.cpp | 15 +++ Server/server_votes.cpp | 149 +++++++++++++++++++++++- 10 files changed, 507 insertions(+), 6 deletions(-) diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 4d1059e7..800bc8d5 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -556,6 +556,31 @@ struct FakeGame final : Game { calls.push_back(std::format("park remove {} {}", plugin, name)); return {}; } + static std::string players_text(const std::vector& players) { + std::string text; + for (const auto id : players) text += (text.empty() ? "" : ",") + std::to_string(id); + return text.empty() ? "all" : text; + } + std::string server_card(std::string_view plugin, const std::vector& players, std::string_view text, unsigned seconds) override { + calls.push_back(std::format("card {} {} {} {}", plugin, players_text(players), text, seconds)); + return {}; + } + std::uint32_t next_poll = 41; + std::string server_poll(std::string_view plugin, const std::vector& players, std::string_view question, + const std::vector& answers, unsigned seconds, std::uint32_t& id) override { + std::string listed; + for (const auto& answer : answers) listed += (listed.empty() ? "" : "|") + answer; + calls.push_back(std::format("poll {} {} {} {} {}", plugin, players_text(players), question, listed, seconds)); + if (players == std::vector{9}) return "that player is not on the server"; + id = next_poll++; + return {}; + } + std::string server_poll_close(std::string_view plugin, std::uint32_t id) override { + calls.push_back(std::format("poll close {} {}", plugin, id)); + return {}; + } + std::vector poll_news; + std::vector take_poll_news() override { return std::exchange(poll_news, {}); } std::optional camera_view() override { return CameraView{{1, 2, 3}, {0, 0, 1}, 60}; } std::string set_camera(std::string_view plugin, const std::array& pose, float fov) override { calls.push_back(std::format("camera {} {:.1f} {:.1f} {:.1f} back {:.2f} {:.2f} {:.2f} fov {:.0f}", plugin, pose[12], pose[13], pose[14], @@ -2116,6 +2141,69 @@ void plugins_server_copies_spawn_park_mods() { "A plugin without server.parks used the park mods\n" + blind.all()); } +// Cards and polls on players' screens, for a server copy with "server": for a player, a list of +// them or "all", with checked text, and each answer or close coming back as an event to the plugin +// that asked. Not for a copy without the permission, nor for a player's copy. +void plugins_server_copies_show_cards_and_polls() { + TestEnvironment server, player, blind; + const std::string main = R"( + local heard = pcall(reskate.on, "poll_answer", function(e) + reskate.log(string.format("answer %d %s %d %s", e.id, e.player, e.answer, e.text)) + end) + pcall(reskate.on, "poll_closed", function(e) reskate.log(string.format("closed %d %s %s", e.id, e.player, e.why)) end) + if not heard then reskate.log("no poll events") end + reskate.console.command("cards", "x", function() + assert(reskate.server.card("all", "Race in 30 s")) + assert(reskate.server.card({"7", {id = "8"}}, "Ready?", 5)) + assert(reskate.server.card({id = "7"}, "Go")) + local id = assert(reskate.server.poll("all", "Next track?", {"Docks", "Hill"}, {seconds = 20})) + assert(id == 41, tostring(id)) + assert(reskate.server.poll("7", "Again?", {"Yes", "No", "Later"}) == 42) + local none, why = reskate.server.poll("9", "Anyone?", {"a", "b"}) + assert(none == nil and why == "that player is not on the server", tostring(why)) + assert(reskate.server.poll_close(41)) + assert(not pcall(reskate.server.card, "all", ""), "a card says something") + assert(not pcall(reskate.server.card, "all", string.rep("x", 121)), "120 characters at most") + assert(not pcall(reskate.server.card, "all", "x", 0), "1 to 60 s") + assert(not pcall(reskate.server.card, "all", "x", 61), "1 to 60 s") + assert(not pcall(reskate.server.card, "everyone", "x"), "a player, a list of them or all") + assert(not pcall(reskate.server.card, {}, "x"), "at least one player") + assert(not pcall(reskate.server.poll, "all", "", {"a", "b"}), "a question") + assert(not pcall(reskate.server.poll, "all", "q?", {"only one"}), "2 to 6 answers") + assert(not pcall(reskate.server.poll, "all", "q?", {"1", "2", "3", "4", "5", "6", "7"}), "2 to 6 answers") + assert(not pcall(reskate.server.poll, "all", "q?", {"a", string.rep("x", 49)}), "48 characters an answer at most") + assert(not pcall(reskate.server.poll, "all", "q?", {"a", ""}), "an answer says something") + assert(not pcall(reskate.server.poll, "all", "q?", {"a", "b"}, {seconds = 4}), "5 to 300 s") + assert(not pcall(reskate.server.poll_close, "x"), "a poll's id") + reskate.log("cards done") + end) + )"; + const auto cards = [&](const char* id, const char* permissions, const char* side) { + return package(id, {{manifest_file, manifest(permissions, side)}, {"plugin/main.lua", main}}); + }; + Host server_host(server, Side::server), player_host(player, Side::client), blind_host(blind, Side::server); + server_host.apply({cards("cards-s", R"("console", "server")", "server")}); + player_host.apply({cards("cards-p", R"("console", "server")", "client")}); + blind_host.apply({cards("cards-b", R"("console")", "server")}); + for (auto* host : {&server_host, &player_host, &blind_host}) host->run_console("cards"); + check(server.logged("[cards-s] cards done"), "A server copy could not show cards and polls\n" + server.all()); + for (const auto* call : {"card cards-s all Race in 30 s 0", "card cards-s 7,8 Ready? 5", "card cards-s 7 Go 0", + "poll cards-s all Next track? Docks|Hill 20", "poll cards-s 7 Again? Yes|No|Later 0", "poll close cards-s 41"}) + check(server.fake.called(call), std::string("The server was asked: ") + call + "\n" + server.all()); + check(!server.fake.called("card cards-s all 0") && !server.fake.called("poll cards-s all q? only one 0"), "A refused call reached the server"); + // Answers and closes, each to the plugin that asked. + server.fake.poll_news = {{"cards-s", 41, 7, 2, "Hill", ""}, {"cards-s", 42, 7, 0, "", "timeout"}, {"someone", 50, 8, 1, "Docks", ""}}; + ticks(server_host, server, 1); + check(server.logged("[cards-s] answer 41 7 2 Hill") && server.logged("[cards-s] closed 42 7 timeout"), + "The plugin hears its polls answered and closed\n" + server.all()); + check(!server.logged("answer 50"), "and only its own"); + check(!player.logged("cards done") && player.all().find("server copy") != std::string::npos, + "A player's copy showed cards\n" + player.all()); + check(!blind.logged("cards done") && blind.all().find("\"server\"") != std::string::npos && blind.logged("no poll events"), + "A plugin without server showed cards or heard polls\n" + blind.all()); + running(server_host, "cards-s", server); +} + // Clips on the skater's body: from the package or recorded, played, replacing a trick. void plugins_animate_the_skater() { TestEnvironment environment; @@ -3926,6 +4014,7 @@ int main() { run(animator_studio_records_and_saves); run(plugins_server_copies_are_the_authority); run(plugins_server_copies_spawn_park_mods); + run(plugins_server_copies_show_cards_and_polls); run(plugins_animate_the_skater); run(plugins_play_their_own_sounds); run(plugins_have_menu_panels); diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index b2d618ab..edbd05d8 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -17,7 +17,7 @@ namespace { struct Event { std::string_view name, permission; }; -constexpr std::array events{{ +constexpr std::array events{{ {"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}}, {"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"}, {"draw", "overlay"}, {"player_joined", "session.read"}, {"player_left", "session.read"}, {"message", "net.session"}, @@ -26,6 +26,7 @@ constexpr std::array events{{ {"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"}, {"physics_step", "events.physics"}, {"launch", "events.skater"}, {"trick_notes", "events.tricks"}, {"throwdown_ui", "throwdowns"}, {"touch", "session.skaters"}, {"touch_end", "session.skaters"}, {"camera_cycle", "camera"}, + {"poll_answer", "server"}, {"poll_closed", "server"}, }}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 40f72379..e6391346 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -2063,6 +2063,83 @@ struct WorldApi { auto& plugin = authority(L, "reskate.server.unban"); return result(L, game(plugin).unban(player_of(L, 1))); } + // ---- cards and polls on players' screens --------------------------------------------------------- + // Who a card or poll is for: "all" (empty: everyone on the server), one player (an id or a player + // table) or a list of them, 1 to 128. + static std::vector players_of(lua_State* L, int index) { + if (lua_isstring(L, index) && std::string_view(lua_tostring(L, index)) == "all") return {}; + if (!lua_istable(L, index)) return {player_of(L, index)}; + lua_getfield(L, index, "id"); + const bool one = !lua_isnil(L, -1); + lua_pop(L, 1); + if (one) return {player_of(L, index)}; + const int count = lua_objlen(L, index); + if (count < 1 || count > 128) luaL_argerror(L, index, "a player, a list of 1 to 128 players, or \"all\""); + std::vector players; + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, index, i); + const auto id = player_of(L, lua_gettop(L)); + if (std::find(players.begin(), players.end(), id) == players.end()) players.push_back(id); + lua_pop(L, 1); + } + return players; + } + // As the roster carries an announcement and a vote (Extension/Multiplayer/Net/protocol.h: + // max_vote_label, max_vote_answers, max_vote_answer). + static constexpr std::size_t card_text = 120, poll_answers = 6, poll_answer = 48; + static std::string line_of(lua_State* L, int index, std::size_t most, const char* what) { + auto text = checked_text(L, index, most, what); + if (text.empty()) luaL_error(L, "%s is empty", what); + return text; + } + // server.card(players, text, seconds?): up for 1 to 60 s (none: long enough to read). + static int server_card(lua_State* L) { + auto& plugin = authority(L, "reskate.server.card"); + const auto players = players_of(L, 1); + const auto text = line_of(L, 2, card_text, "a card's text"); + const auto seconds = lua_isnoneornil(L, 3) ? 0 : luaL_checkinteger(L, 3); + if (!lua_isnoneornil(L, 3) && (seconds < 1 || seconds > 60)) luaL_argerror(L, 3, "seconds, 1 to 60 (none: long enough to read)"); + return result(L, game(plugin).server_card(plugin.id(), players, text, static_cast(seconds))); + } + // server.poll(players, question, answers, {seconds}?): the poll's id, or nil and why. 2 to 6 + // answers; it runs 5 to 300 s (none: the server's poll time). + static int server_poll(lua_State* L) { + auto& plugin = authority(L, "reskate.server.poll"); + const auto players = players_of(L, 1); + const auto question = line_of(L, 2, card_text, "a poll's question"); + luaL_checktype(L, 3, LUA_TTABLE); + const int count = lua_objlen(L, 3); + if (count < 2 || count > static_cast(poll_answers)) luaL_argerror(L, 3, "2 to 6 answers"); + std::vector answers; + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, 3, i); + answers.push_back(line_of(L, lua_gettop(L), poll_answer, "a poll's answer")); + lua_pop(L, 1); + } + lua_Integer seconds = 0; + if (!lua_isnoneornil(L, 4)) { + luaL_checktype(L, 4, LUA_TTABLE); + if (lua_getfield(L, 4, "seconds") != LUA_TNIL) { + seconds = luaL_checkinteger(L, -1); + if (seconds < 5 || seconds > 300) luaL_argerror(L, 4, "seconds, 5 to 300 (none: the server's poll time)"); + } + lua_pop(L, 1); + } + std::uint32_t id = 0; + if (const auto why = game(plugin).server_poll(plugin.id(), players, question, answers, static_cast(seconds), id); !why.empty()) { + lua_pushnil(L); + lua_pushlstring(L, why.data(), why.size()); + return 2; + } + lua_pushnumber(L, static_cast(id)); + return 1; + } + static int server_poll_close(lua_State* L) { + auto& plugin = authority(L, "reskate.server.poll_close"); + const double id = luaL_checknumber(L, 1); + if (!(id >= 1 && id <= 4294967295.0) || id != std::floor(id)) luaL_argerror(L, 1, "a poll's id"); + return result(L, game(plugin).server_poll_close(plugin.id(), static_cast(id))); + } // ---- server.parks: a dedicated server's park mods ----------------------------------------------- // parks(): {name, map, here (fits the map running now), spawned, objects, minutes_left}[]; // park_spawn(name, minutes?): for that long (1-1440), or until removed; park_remove(name). @@ -2799,7 +2876,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "audio", {{"play", audio_play}, {"set", audio_set}, {"stop", audio_stop}, {"playing", audio_playing}}); add_functions(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move}, {"ban", server_ban}, {"unban", server_unban}, {"parks", server_parks}, {"park_spawn", server_park_spawn}, - {"park_remove", server_park_remove}}); + {"park_remove", server_park_remove}, {"card", server_card}, {"poll", server_poll}, {"poll_close", server_poll_close}}); add_functions(L, "throwdowns", {{"state", throwdown_state}, {"host", throwdown_host}, {"cancel", throwdown_cancel}, {"hosting", throwdown_hosting}, {"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}}); add_functions(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous}, diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 6ea2fa9f..1e86bfa0 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -608,6 +608,32 @@ public: virtual std::vector server_parks() { return {}; } virtual std::string spawn_park(std::string_view /*plugin*/, std::string_view /*name*/, unsigned /*minutes*/) { return unavailable; } virtual std::string remove_park(std::string_view /*plugin*/, std::string_view /*name*/) { return unavailable; } + // Cards and polls on players' screens (a dedicated server's only): each player's own, shown over + // the server's announcement or vote until it ends, then the server's again. `players` empty is + // everyone on the server now. A card is up for `seconds` (0: long enough to read). A poll has + // 2 to max_vote_answers answers and runs `seconds` (0: the server's poll time); it is one card + // with one `id` for all its players, and closes for each when they answer. A newer poll for a + // player replaces theirs. What each player did comes back from take_poll_news. + virtual std::string server_card(std::string_view /*plugin*/, const std::vector& /*players*/, std::string_view /*text*/, + unsigned /*seconds*/) { + return unavailable; + } + virtual std::string server_poll(std::string_view /*plugin*/, const std::vector& /*players*/, std::string_view /*question*/, + const std::vector& /*answers*/, unsigned /*seconds*/, std::uint32_t& /*id*/) { + return unavailable; + } + // Closes a poll of the plugin's for everyone it is still open for. + virtual std::string server_poll_close(std::string_view /*plugin*/, std::uint32_t /*id*/) { return unavailable; } + // A player answered a plugin's poll (`answer` from 1, its text), or it closed for them unanswered + // (`answer` 0, and why: "timeout", "left", "replaced" or "closed"). + struct PollNews { + std::string plugin; + std::uint32_t id{}; + std::uint64_t player{}; + std::size_t answer{}; + std::string text, why; + }; + virtual std::vector take_poll_news() { return {}; } // ---- skater.animation: clips on the local skater's body (Extension/Skater/skater_animation.h) ------ // One plugin plays at a time; none while a throwdown runs. A replacement plays when the game does // `trick` ("kickflip") with that pop ("" any, "low", "mid", "high"); a null clip takes it away. diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index 4a0cdf2e..33697e12 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -383,6 +383,20 @@ void Host::follow_game() { each([&](Plugin& plugin, const std::string&) { plugin.throwdown_phase(active ? "started" : "ended"); }); } } + // Answers to a server copy's polls (server.poll), and polls that closed unanswered, to the + // plugin that asked. + for (const auto& news : game.take_poll_news()) { + auto data = Json::object(); + data["id"] = static_cast(news.id); + data["player"] = std::to_string(news.player); + if (news.answer) { + data["answer"] = static_cast(news.answer); + data["text"] = news.text; + } else data["why"] = news.why; + each([&](Plugin& plugin, const std::string&) { + if (plugin.id() == news.plugin) plugin.game_event(news.answer ? "poll_answer" : "poll_closed", "server", data); + }); + } for (const auto& [plugin, action] : game.take_presses()) if (auto* entry = find(plugin); entry && entry->plugin) { entry->plugin->action(action); diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp index 6999f1c7..7cbac670 100644 --- a/Server/Test/server_plugin_tests.cpp +++ b/Server/Test/server_plugin_tests.cpp @@ -137,6 +137,18 @@ struct Player { p.world_ready = true; wire().inbound.push_back({id, encode_wire(p), now}); } + void chat(std::uint64_t now, const std::string &text) { + auto p = header(PacketKind::chat, now); + p.text = text; + wire().inbound.push_back({id, encode_wire(p), now}); + } + // The latest roster the server sent this player (and forgets what else it sent). + std::optional roster() { + std::optional latest; + for (const auto &bytes : std::exchange(wire().sent[id], {})) + if (auto p = decode_wire(bytes); p && p->kind == PacketKind::roster) latest = std::move(p); + return latest; + } void pose(std::uint64_t now) { for (const auto &bytes : std::exchange(wire().sent[id], {})) if (const auto read = pose_batch::Ack::read(bytes)) upload.ack(*read); @@ -271,6 +283,24 @@ int run(const std::filesystem::path &fixtures) { end) )"); + // One that asks players and shows them cards, and says what came back. + write(root / "Plugins" / "asker" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "server"]})"); + write(root / "Plugins" / "asker" / "plugin" / "main.lua", R"( + reskate.on("poll_answer", function(e) reskate.console.print(string.format("answer %s %d %s", e.player, e.answer, e.text)) end) + reskate.on("poll_closed", function(e) reskate.console.print(string.format("closed %s %s", e.player, e.why)) end) + reskate.console.command("asker", "cards and polls, for the test", function(args) + local who = args[2] == "all" and "all" or args[2] + if args[1] == "poll" then + local id, why = reskate.server.poll(who, "Next track?", {"Docks", "Hill"}, {seconds = tonumber(args[3]) or 30}) + reskate.console.print("poll " .. tostring(id or why)) + elseif args[1] == "card" then + local ok, why = reskate.server.card(who, "Race in 30 s", 5) + reskate.console.print("card " .. tostring(ok or why)) + end + end) + )"); + std::vector log; SteamTransport transport; Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); @@ -384,6 +414,46 @@ int run(const std::filesystem::path &fixtures) { plugin_command("blind content"); check(has(log, "blind content refused: ") && has(log, "mods.read"), "A plugin without mods.read listed the mods' content"); + // Cards and polls: each player's own roster carries them, over the server's; a player's answer + // (as the game sends it, "/2") comes back to the plugin, and so does a poll that ran out. + const auto first_id = std::to_string(players[0].id), second_id = std::to_string(players[1].id); + for (auto &p : players) p.roster(); + plugin_command("asker card " + first_id); + check(has(log, "card true"), "The plugin could not show a card"); + plugin_command("asker poll all 10"); + check(has(log, "poll ") && !has(log, "poll nil"), "The plugin could not ask everyone"); + run_for(200000); + auto first_roster = players[0].roster(), second_roster = players[1].roster(); + check(first_roster && first_roster->announcement.text == "Race in 30 s" && first_roster->announcement.seconds == 5, + "The first player's roster has no card"); + check(second_roster && second_roster->announcement.text.empty(), "The second player was shown the first player's card"); + for (auto *r : {&first_roster, &second_roster}) + check(*r && (*r)->vote.label == "Next track?" && (*r)->vote.answers == std::vector{"Docks", "Hill"} && + (*r)->vote.kind == server_vote_poll && (*r)->vote.outcome == vote_running && (*r)->vote.seconds == 10, + "A player's roster has no poll"); + const auto poll_id = first_roster ? first_roster->vote.id : 0; + check(poll_id && second_roster && second_roster->vote.id == poll_id, "The poll is not one for everyone"); + log.clear(); + players[0].chat(now, "/2"); + run_for(200000); + check(has(log, "answer " + first_id + " 2 Hill"), "The plugin did not hear the answer"); + first_roster = players[0].roster(); + check(first_roster && first_roster->vote.id != poll_id, "The answered poll stayed on the player's screen"); + players[0].chat(now, "/1"); + run_for(200000); + check(!has(log, "answer " + first_id + " 1"), "A poll was answered twice"); + log.clear(); + run_for(10000000); + check(has(log, "closed " + second_id + " timeout") && !has(log, "closed " + first_id), "The unanswered poll did not run out"); + second_roster = players[1].roster(); + check(second_roster && second_roster->vote.id != poll_id && second_roster->announcement.text.empty(), "The poll stayed after it ran out"); + plugin_command("asker poll " + second_id); + plugin_command("asker poll " + second_id); + run_for(200000); + check(has(log, "closed " + second_id + " replaced"), "A new poll did not replace the player's earlier one"); + plugin_command("asker poll 76561198000000099"); + check(has(log, "poll that player is not on the server"), "A poll for nobody here was asked"); + // The level, as players' games name it, and its change heard when the server changes it. const auto *first = find_level(config.map); const auto other = std::find_if(levels().begin(), levels().end(), [&](const auto &l) { return l.asset != first->asset; }); @@ -397,6 +467,11 @@ int run(const std::filesystem::path &fixtures) { check(has(log, "unloading " + first->asset) && has(log, "loaded " + other->asset), "The plugin did not hear the map change"); check(has(log, "level " + other->asset) && !has(log, "level " + first->asset), "level.current() did not follow the map change"); check(!has(log, "skaters 2"), "Skaters stayed on the old map's places after the map changed"); + // A player who leaves with a poll open: the plugin hears it closed. + log.clear(); + host.drop(players[1].id, "testing"); + run_for(200000); + check(has(log, "closed " + second_id + " left"), "A poll stayed open for a player who left"); if (failures) for (const auto &line : log) std::fprintf(stderr, "log: %s\n", line.c_str()); return failures ? 1 : 0; diff --git a/Server/server_host.cpp b/Server/server_host.cpp index b4cc006b..309ceace 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -823,11 +823,13 @@ void Host::send_roster() { if (m.party && std::none_of(p.members.begin(), p.members.end(), [&](const Member &o) { return o.party == m.party && o.party_leader; })) m.party_leader = true; // the first listed member of a party missing its leader - // An announcement for one player: everyone else's roster goes without it. And a player with - // locked tools is told those are off for them. Everyone else gets the same roster; players - // whose roster differs in the same way share one. + // An announcement for one player: everyone else's roster goes without it. A player with + // locked tools is told those are off for them, and one with a plugin's card or poll is shown + // it (by its id) over the server's. Everyone else gets the same roster; players whose roster + // differs in the same way share one. struct Variant { bool noclip{}, no_bail{}, boosts{}, ollies{}, enforce_tuning{}, announcement{}; + std::uint32_t plugin_card{}, plugin_poll{}; auto operator<=>(const Variant &) const = default; }; const Variant common{config_.noclip, config_.no_bail, config_.boosts, true, config_.enforce_tuning, !announcement_for_}; @@ -837,6 +839,8 @@ void Host::send_roster() { if (!guest->handshaken) continue; auto variant = common; variant.announcement = !announcement_for_ || id == announcement_for_; + if (const auto card = plugin_cards_.find(id); card != plugin_cards_.end()) variant.plugin_card = card->second.card.id; + if (const auto poll = plugin_polls_.find(id); poll != plugin_polls_.end()) variant.plugin_poll = poll->second.id; if (const auto locks = locks_of(id)) { // Without "movement" (send_permissions), the tools their role gives are said here instead. const bool movement = (locks & lock_movement) && has(id, multiplayer::role_movement); @@ -853,6 +857,7 @@ void Host::send_roster() { others.insert(id); } const auto shown = p.announcement; + const auto vote = p.vote; if (announcement_for_) p.announcement = {}; broadcast(p, true, false, 0, nullptr, others.empty() ? nullptr : &others); for (const auto &[variant, players] : variants) { @@ -862,6 +867,9 @@ void Host::send_roster() { p.guest_ollies = variant.ollies; p.enforce_tuning = variant.enforce_tuning; p.announcement = variant.announcement ? shown : multiplayer::ServerAnnouncement{}; + p.vote = vote; + // The same card and poll ids: the same card and poll, so any of these players' is theirs. + if (variant.plugin_card || variant.plugin_poll) plugin_screens_roster(*players.begin(), p.announcement, p.vote); broadcast(p, true, false, 0, &players); } roster_dirty_ = false; @@ -1031,6 +1039,7 @@ void Host::drop(std::uint64_t id, const std::string &reason, const std::string & guest->sound_sender.forget(id); } party_left(id, name); + plugin_screens_left(id); // Their vote cooldown stays (tick() drops it once it has run out): leaving and coming // back does not let a player start another vote sooner. // One voter fewer, or the player a kick vote was about. Counted in tick(): a passing kick diff --git a/Server/server_host.h b/Server/server_host.h index 2ad1843c..dd4c90b2 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -110,6 +111,26 @@ class Host { std::string plugin_park(std::string_view plugin, bool spawn, std::string_view name, unsigned minutes); // A plugin message from the server's plugins (server_plugins.h) to one player, or everyone (0). void send_plugin_message(std::uint64_t to, const std::vector &bytes); + // Cards and polls for the server's plugins (server_votes.cpp), on the players' screens: each + // player's own, shown over the server's announcement and vote until it ends, then those again. + // `players` empty is everyone on now. A card is up `seconds` (0: as long as an announcement); + // a poll runs `seconds` (0: votes.poll_seconds), one id for all its players, closed for each + // as they answer ("/2"); a player's newer poll replaces theirs. "" when shown, else why not. + std::string plugin_card(std::string_view plugin, const std::vector &players, std::string_view text, unsigned seconds); + std::string plugin_poll(std::string_view plugin, const std::vector &players, std::string_view question, + const std::vector &answers, unsigned seconds, std::uint32_t &id); + std::string plugin_poll_close(std::string_view plugin, std::uint32_t id); + void plugin_screens_release(std::string_view plugin); // a plugin that stopped: its cards and polls go + // A player's answer (from 1) to a plugin's poll, or (answer 0) the poll closed for them and why: + // "timeout", "left", "replaced" or "closed". + struct PollNews { + std::string plugin; + std::uint32_t id{}; + std::uint64_t player{}; + std::size_t answer{}; + std::string text, why; + }; + std::vector take_poll_news() { return std::exchange(poll_news_, {}); } enum class VoteKind { map, kick, time, custom, poll, extend }; @@ -293,6 +314,22 @@ class Host { std::uint64_t announcement_until_{}; std::uint64_t announcement_for_{}; // the one player shown it, or 0: everyone std::uint32_t announcement_ids_{}; + // The plugins' cards and polls, by the player they are shown to (plugin_card, plugin_poll). + struct PluginCard { + std::string plugin; + multiplayer::ServerAnnouncement card; + std::uint64_t until{}; + }; + struct PluginPoll { + std::string plugin; + std::uint32_t id{}; + std::string question; + std::vector answers; + std::uint64_t ends{}; + }; + std::map plugin_cards_; + std::map plugin_polls_; + std::vector poll_news_; std::uint64_t announced_at_{}; // the last timed announcement, or while nobody is on std::size_t next_announcement_{}; void active(Guest &guest) { @@ -517,6 +554,17 @@ class Host { // The announcement card on every player's screen, or only on player `to`'s (not in chat). void announce(std::string_view text, std::uint64_t to = 0); void tick_announcements(); // the owner's messages in turn, on their timer + // The plugins' cards and polls (plugin_card, plugin_poll): a poll closed for one player, with + // why; a number they typed, true when it was about their plugin poll (answered or refused); + // what ran out; a player who left; and the card and vote player `id`'s roster carries instead + // of `card` and `vote`. + // The players a plugin named who are on now (everyone for none); empty, and why, when none is. + std::vector plugin_screen_players(const std::vector &players, std::string &why); + void close_plugin_poll(std::uint64_t player, std::string_view why); + bool answer_plugin_poll(Guest &, std::size_t answer); + void tick_plugin_screens(); + void plugin_screens_left(std::uint64_t player); + void plugin_screens_roster(std::uint64_t id, multiplayer::ServerAnnouncement &card, multiplayer::ServerVote &vote) const; void reply(Guest &, std::string_view text, unsigned max_lines = 12); Guest *match_player(std::string_view text); // Sends each mover to a ring of `radius` around `at`, 1 m up, so nobody lands inside anyone; how many were sent. diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index a0c33501..242ac441 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -90,6 +90,21 @@ class ServerGame final : public Game { return host_.plugin_park(plugin, true, name, minutes); } std::string remove_park(std::string_view plugin, std::string_view name) override { return host_.plugin_park(plugin, false, name, 0); } + // Cards and polls on the players' screens, as the roster carries the server's own. + std::string server_card(std::string_view plugin, const std::vector &players, std::string_view text, unsigned seconds) override { + return host_.plugin_card(plugin, players, text, seconds); + } + std::string server_poll(std::string_view plugin, const std::vector &players, std::string_view question, + const std::vector &answers, unsigned seconds, std::uint32_t &id) override { + return host_.plugin_poll(plugin, players, question, answers, seconds, id); + } + std::string server_poll_close(std::string_view plugin, std::uint32_t id) override { return host_.plugin_poll_close(plugin, id); } + std::vector take_poll_news() override { + std::vector news; + for (auto &n : host_.take_poll_news()) news.push_back({std::move(n.plugin), n.id, n.player, n.answer, std::move(n.text), std::move(n.why)}); + return news; + } + void release(std::string_view plugin) override { host_.plugin_screens_release(plugin); } // The skaters of the players in the world, as their latest poses put them. The server has // no skater of its own, so this is every player's. std::vector peer_skaters() override { diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index d92fc45b..67fdd4bc 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -155,6 +155,12 @@ void Host::chat_command(Guest &guest, std::string_view line) { const std::string whisper = "/w : send a private message"; return reply(guest, (text.empty() ? "This server has no player votes. Type /tp to teleport.\n" : text) + whisper); } + // A number from a player with a plugin's poll open answers that one, not the server's: it is the + // plugin's question, so their role's "votes" is not asked. + if (plugin_polls_.contains(guest.member.id)) { + const auto word = verb == "vote" ? lower(split(rest).first) : verb; + if (word.size() == 1 && word[0] >= '1' && word[0] <= '9' && answer_plugin_poll(guest, static_cast(word[0] - '1'))) return; + } if ((verb == "party" || verb == "p") && !may(guest.member.id, multiplayer::role_parties)) return reply(guest, role_refusal(guest.member.id, "use parties")); if ((verb == "w" || verb == "whisper" || verb == "tell") && !may(guest.member.id, multiplayer::role_whisper)) @@ -405,11 +411,14 @@ void Host::answer_poll(Guest &guest, std::size_t answer) { } bool Host::answers_vote(const Guest &guest, std::string_view line) const { - if (!vote_) return false; // "/2", "/yes", or the same after "/vote", as chat_command takes them. const auto [first, rest] = split(line); auto verb = lower(first); if (verb == "vote") verb = lower(split(rest).first); + if (const auto poll = plugin_polls_.find(guest.member.id); + poll != plugin_polls_.end() && verb.size() == 1 && verb[0] >= '1' && static_cast(verb[0] - '0') <= poll->second.answers.size()) + return true; + if (!vote_) return false; if (vote_->kind == VoteKind::poll) return verb.size() == 1 && verb[0] >= '1' && static_cast(verb[0] - '0') <= vote_->answers.size(); return guest.member.id != vote_->target && (verb == "yes" || verb == "y" || verb == "no" || verb == "n"); @@ -684,6 +693,7 @@ void Host::announce(std::string_view text, std::uint64_t to) { roster_dirty_ = true; } void Host::tick_announcements() { // the timer waits while nobody is on + tick_plugin_screens(); if (announcement_.id && now_ >= announcement_until_) { announcement_ = {}; announcement_for_ = 0; @@ -699,6 +709,143 @@ void Host::tick_announcements() { // the timer waits while nobody is on next_announcement_ %= a.messages.size(); announce(a.messages[next_announcement_++]); } +// ---- The plugins' cards and polls ------------------------------------------------------------------ +std::vector Host::plugin_screen_players(const std::vector &players, std::string &why) { + std::vector found; + for (auto &[id, guest] : guests_) + if (guest->handshaken && (players.empty() || std::find(players.begin(), players.end(), id) != players.end())) found.push_back(guest.get()); + if (found.empty()) + why = players.empty() ? "nobody is on the server" : players.size() == 1 ? "that player is not on the server" : "none of those players is on the server"; + return found; +} + +std::string Host::plugin_card(std::string_view plugin, const std::vector &players, std::string_view text, unsigned seconds) { + const auto line = clean_chat_text(text); + if (line.empty()) return "the card has nothing to show"; + std::string why; + const auto shown = plugin_screen_players(players, why); + if (shown.empty()) return why; + // As long as an announcement of the same text, unless the plugin says. + if (!seconds) seconds = static_cast(std::clamp(5 + line.size() / 20, 6, 15)); + if (!++announcement_ids_) ++announcement_ids_; + std::vector names; + for (const auto *guest : shown) { + plugin_cards_[guest->member.id] = {std::string(plugin), {announcement_ids_, static_cast(seconds), line}, now_ + microseconds(seconds)}; + names.push_back(guest_name(*guest)); + } + log_("[card by plugin " + std::string(plugin) + " -> " + (players.empty() ? std::string("everyone") : joined(names, ", ")) + "] " + line); + roster_dirty_ = true; + return {}; +} + +std::string Host::plugin_poll(std::string_view plugin, const std::vector &players, std::string_view question, + const std::vector &answers, unsigned seconds, std::uint32_t &id) { + auto label = clean_chat_text(question); + cut_text(label, multiplayer::max_vote_label); + std::vector choices; + for (const auto &answer : answers) { + auto choice = clean_chat_text(answer); + cut_text(choice, multiplayer::max_vote_answer); + if (choice.empty()) return "an answer has nothing to show"; + choices.push_back(std::move(choice)); + } + if (label.empty()) return "the question has nothing to show"; + if (choices.size() < 2 || choices.size() > multiplayer::max_vote_answers) + return "a poll has 2 to " + std::to_string(multiplayer::max_vote_answers) + " answers"; + std::string why; + const auto shown = plugin_screen_players(players, why); + if (shown.empty()) return why; + // From the server's own count of votes: no game takes one poll for another. + if (!++vote_ids_) ++vote_ids_; + id = vote_ids_; + const auto ends = now_ + microseconds(seconds ? seconds : config_.votes.poll_seconds); + std::vector names; + for (const auto *guest : shown) { + if (plugin_polls_.contains(guest->member.id)) close_plugin_poll(guest->member.id, "replaced"); + plugin_polls_[guest->member.id] = {std::string(plugin), id, label, choices, ends}; + names.push_back(guest_name(*guest)); + } + log_("[poll by plugin " + std::string(plugin) + " #" + std::to_string(id) + " -> " + + (players.empty() ? std::string("everyone") : joined(names, ", ")) + "] " + label + " | " + joined(choices, " | ")); + roster_dirty_ = true; + return {}; +} + +std::string Host::plugin_poll_close(std::string_view plugin, std::uint32_t id) { + std::vector open; + for (const auto &[player, poll] : plugin_polls_) + if (poll.id == id && poll.plugin == plugin) open.push_back(player); + if (open.empty()) return "no poll of the plugin's with that id is open"; + for (const auto player : open) close_plugin_poll(player, "closed"); + return {}; +} + +void Host::plugin_screens_release(std::string_view plugin) { + const auto before = plugin_cards_.size() + plugin_polls_.size(); + std::erase_if(plugin_cards_, [&](const auto &entry) { return entry.second.plugin == plugin; }); + std::erase_if(plugin_polls_, [&](const auto &entry) { return entry.second.plugin == plugin; }); + if (plugin_cards_.size() + plugin_polls_.size() != before) roster_dirty_ = true; +} + +void Host::close_plugin_poll(std::uint64_t player, std::string_view why) { + const auto found = plugin_polls_.find(player); + if (found == plugin_polls_.end()) return; + if (poll_news_.size() < 4096) poll_news_.push_back({found->second.plugin, found->second.id, player, 0, {}, std::string(why)}); + plugin_polls_.erase(found); + roster_dirty_ = true; +} + +bool Host::answer_plugin_poll(Guest &guest, std::size_t answer) { + const auto found = plugin_polls_.find(guest.member.id); + if (found == plugin_polls_.end()) return false; + const auto &poll = found->second; + if (answer >= poll.answers.size()) { + reply(guest, "Answer with /1 to /" + std::to_string(poll.answers.size()) + "."); + return true; + } + // One answer closes it for them: the card goes, and the server's vote or poll shows again if one runs. + log_("[poll by plugin " + poll.plugin + " #" + std::to_string(poll.id) + "] " + guest_name(guest) + " answered " + + std::to_string(answer + 1) + " " + poll.answers[answer]); + if (poll_news_.size() < 4096) poll_news_.push_back({poll.plugin, poll.id, guest.member.id, answer + 1, poll.answers[answer], {}}); + plugin_polls_.erase(found); + roster_dirty_ = true; + return true; +} + +void Host::tick_plugin_screens() { + if (std::erase_if(plugin_cards_, [&](const auto &entry) { return now_ >= entry.second.until; })) roster_dirty_ = true; + std::vector ended; + for (const auto &[player, poll] : plugin_polls_) + if (now_ >= poll.ends) ended.push_back(player); + for (const auto player : ended) close_plugin_poll(player, "timeout"); +} + +void Host::plugin_screens_left(std::uint64_t player) { + plugin_cards_.erase(player); + close_plugin_poll(player, "left"); +} + +void Host::plugin_screens_roster(std::uint64_t id, multiplayer::ServerAnnouncement &card, multiplayer::ServerVote &vote) const { + const auto left = [&](std::uint64_t until) { + return static_cast(std::min(until > now_ ? (until - now_ + 999999) / 1000000 : 0, 65535)); + }; + if (const auto found = plugin_cards_.find(id); found != plugin_cards_.end()) { + card = found->second.card; + card.seconds = left(found->second.until); + } + if (const auto found = plugin_polls_.find(id); found != plugin_polls_.end()) { + const auto &poll = found->second; + vote = {}; + vote.id = poll.id; + vote.kind = multiplayer::server_vote_poll; + vote.outcome = multiplayer::vote_running; + vote.label = poll.question; + vote.answers = poll.answers; + vote.counts.assign(poll.answers.size(), 0); + vote.seconds = left(poll.ends); + } +} + std::pair Host::announcements_command(std::string_view argument) { auto &a = config_.announcements; const auto [what_text, rest] = split(argument);