diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index bf356d17..5edadca5 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" @@ -559,6 +560,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], @@ -1745,11 +1771,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") @@ -1773,6 +1805,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)"); @@ -2146,6 +2179,69 @@ void plugins_server_copies_see_roles() { "A plugin without server read the players' roles\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; @@ -2924,6 +3020,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"); @@ -3909,6 +4053,7 @@ int main() { run(plugins_server_copies_are_the_authority); run(plugins_server_copies_spawn_park_mods); run(plugins_server_copies_see_roles); + run(plugins_server_copies_show_cards_and_polls); run(plugins_animate_the_skater); run(plugins_play_their_own_sounds); run(plugins_have_menu_panels); @@ -3928,6 +4073,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_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_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; } diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index c30b0c13..8c021366 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). @@ -2815,7 +2892,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_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.h b/Extension/Plugins/plugin_game.h index 09374755..b419df59 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -617,6 +617,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_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index cac6e6f1..d51114b9 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" @@ -1443,13 +1444,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; @@ -1460,56 +1457,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); @@ -1742,7 +1695,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/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 2a791e0b..e5138856 100644 --- a/Server/Test/server_plugin_tests.cpp +++ b/Server/Test/server_plugin_tests.cpp @@ -138,6 +138,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); @@ -213,6 +225,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. @@ -279,6 +295,40 @@ 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) + )"); + + // 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); }); @@ -390,6 +440,60 @@ 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"); + + // 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; }); @@ -403,6 +507,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 ed87b6a9..5947fe31 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 608e8172..fbd0d98f 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -112,6 +113,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 }; @@ -296,6 +317,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) { @@ -520,6 +557,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 a6913d1e..b2d715fd 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" @@ -96,6 +97,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 { @@ -108,6 +124,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)); @@ -123,6 +148,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; @@ -130,6 +164,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/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); 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