From d6cf244c260119463f7a00514ffc96270f8f4131 Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Fri, 23 Oct 2026 15:38:40 +0200 Subject: [PATCH] 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);