From c996fbda13dc1168348154fcc6f1643576f03489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20M=C3=B6ller?= Date: Fri, 9 Oct 2026 21:01:40 +0000 Subject: [PATCH] Server votes: custom votes, polls, more vote settings and announcements Builds on the 2.0.0 vote card. Protocol 46: a server's vote can now be a poll, and the roster carries who may start polls, the owner's own votes and the current announcement. Custom votes (votes.custom): the server owner defines /vote [choice] in ReSkateServer.json. When one passes, the server runs its command as the console; {map} is the current map and {arg} the player's choice, which must be one of the vote's "choices", so players never put free text into a command. They show in /help, /vote list and the "/" menu. Polls: /poll | | ... with 2 to 6 answers. A poll runs nothing. Players answer by clicking a row on the vote card or with /1, /2...; the card shows each answer's count, then the result for a few seconds. votes.polls says who may start one (off, admins, everyone), poll_seconds how long it runs, and /poll end ends it early (its starter or an admin). More vote settings: each vote may have its own seconds, cooldown_seconds and min_players; starter_votes_yes says whether starting a vote counts as a yes. The "votes" admin command sets all of them. Announcements: "announce " posts a chat line and shows a card at the top of every player's screen. The new "announcements" section holds messages the server posts in turn every interval_minutes while players are on; "card": false keeps them to chat. Managed with the "announcements" command. Older configs gain the new settings with their defaults on first start. Server/README.txt documents them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WbSRfNf5T9wX4XMMQ5kwia --- Engine/Game/Multiplayer/session_model.h | 13 +- Extension/Multiplayer/Net/protocol.cpp | 95 +++- Extension/Multiplayer/Net/protocol.h | 40 +- Extension/Multiplayer/Session/session.cpp | 4 + .../Multiplayer/Session/session_commands.cpp | 7 +- .../Multiplayer/Session/session_internal.h | 9 +- .../Multiplayer/Session/session_receive.cpp | 16 +- .../Multiplayer/Session/session_send.cpp | 20 +- .../Multiplayer/Session/session_view.cpp | 30 +- Extension/Multiplayer/Test/protocol_tests.cpp | 46 ++ Extension/Multiplayer/console_commands.cpp | 2 +- Extension/UI/Overlay/chat_overlay.cpp | 84 ++- Server/README.txt | 37 ++ Server/Test/server_config_tests.cpp | 56 ++ Server/server_commands.cpp | 51 +- Server/server_config.cpp | 114 +++- Server/server_config.h | 31 ++ Server/server_host.cpp | 6 + Server/server_host.h | 38 +- Server/server_votes.cpp | 490 ++++++++++++++++-- 20 files changed, 1079 insertions(+), 110 deletions(-) diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 1b0107e..22e22b9 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -126,9 +126,19 @@ struct MultiplayerVote { std::string label; // "change the map to ..." unsigned yes{}, no{}, needed{}, seconds{}; // seconds: left of a running one std::uint8_t outcome{}; // 0 running, 1 passed, 2 failed, 3 cancelled - std::uint8_t mine{}; // this player's answer: 0 none yet, 1 yes, 2 no + std::uint8_t mine{}; // this player's answer: 0 none yet, 1 yes, 2 no; in a poll, 1 + the answer's index bool may_vote{}; // not the player a kick vote is about std::uint32_t yes_bind{}, no_bind{}; // the player's binds for Yes and No (controller_bindings.h), 0: none + // A poll: a question (`label`) with answers and a count for each, instead of yes and no. + bool poll{}; + std::vector answers; + std::vector counts; +}; +// A dedicated server's announcement, for its card. `id` 0: none showing. +struct MultiplayerAnnouncement { + std::uint32_t id{}; + std::string text; + unsigned seconds{}; // how long it still shows }; struct MultiplayerChat { bool available{}; // in a session that can carry chat @@ -137,6 +147,7 @@ struct MultiplayerChat { std::vector commands; // what "/" offers in this session std::vector players, maps; // what their arguments complete to MultiplayerVote vote; + MultiplayerAnnouncement announcement; }; // A player this PC's lobbies never admit, kept in the local profile. struct MultiplayerBan { diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 3750076..0ae2c75 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -616,9 +616,14 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(p.tps, 1); w.integer(p.chat_badge & 0xffffff, 3); // red, green, blue w.integer(p.chat_text & 0xffffff, 3); + // Short text: its length in `width` bytes, then the text. + const auto text = [&w](std::string_view value, unsigned width) { + w.integer(value.size(), width); + w.bytes.insert(w.bytes.end(), value.begin(), value.end()); + }; w.integer(p.vote.id, 4); if (p.vote.id) { - if (p.vote.label.size() > max_vote_label || p.vote.outcome > vote_cancelled) throw std::invalid_argument("Invalid server vote"); + if (!valid_server_vote(p.vote) || p.vote.outcome > vote_cancelled) throw std::invalid_argument("Invalid server vote"); w.integer(p.vote.kind, 1); w.integer(p.vote.outcome, 1); w.integer(p.vote.yes, 2); @@ -629,6 +634,11 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(p.vote.target, 8); w.integer(p.vote.label.size(), 1); w.bytes.insert(w.bytes.end(), p.vote.label.begin(), p.vote.label.end()); + w.integer(p.vote.answers.size(), 1); + for (std::size_t i = 0; i < p.vote.answers.size(); ++i) { + text(p.vote.answers[i], 1); + w.integer(p.vote.counts[i], 2); + } } w.integer(static_cast(p.object_placement), 1); if (!valid_object_limit(p.object_limit)) throw std::invalid_argument("Invalid object limit"); @@ -652,6 +662,23 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 if (p.server_votes > 7) throw std::invalid_argument("Invalid server votes"); w.integer((p.guest_noclip ? 1U : 0U) | (p.guest_no_bail ? 2U : 0U) | (p.guest_boosts ? 4U : 0U) | (static_cast(p.server_votes) << 3) | (p.enforce_tuning ? 64U : 0U), 1); + if (p.server_polls > static_cast(ServerPolls::everyone) || p.server_custom_votes.size() > server_custom_vote_limit || + !std::all_of(p.server_custom_votes.begin(), p.server_custom_votes.end(), [](const auto &v) { return valid_server_custom_vote(v); })) + throw std::invalid_argument("Invalid server votes"); + w.integer(p.server_polls, 1); + w.integer(p.server_custom_votes.size(), 1); + for (const auto &vote : p.server_custom_votes) { + text(vote.name, 1); + text(vote.description, 1); + w.integer(vote.choices.size(), 1); + for (const auto &choice : vote.choices) text(choice, 1); + } + w.integer(p.announcement.id, 4); + if (p.announcement.id) { + if (!valid_chat_text(p.announcement.text)) throw std::invalid_argument("Invalid server announcement"); + w.integer(p.announcement.seconds, 2); + text(p.announcement.text, 1); + } } if (p.kind == PacketKind::routes) { w.integer(p.members.size(), 1); @@ -686,6 +713,14 @@ static bool read_map_label(Reader &r, std::span bytes, Packe r.at += static_cast(length); return valid_map_label(p.map_label); } +// Short text written by encode's `text`: nothing when it is longer than `limit` or the packet. +static std::optional read_text(Reader &r, std::span bytes, unsigned width, std::size_t limit) { + const auto length = r.integer(width); + if (length > limit || length > bytes.size() - r.at) return {}; + std::string value(reinterpret_cast(bytes.data() + r.at), static_cast(length)); + r.at += static_cast(length); + return value; +} std::optional decode(std::span bytes) noexcept { try { if (bytes.size() < header_size || bytes.size() > max_packet) @@ -968,6 +1003,15 @@ std::optional decode(std::span bytes) noexcept { if (p.vote.outcome > vote_cancelled || length > max_vote_label || length > bytes.size() - r.at) return {}; p.vote.label.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at += static_cast(length); + const auto answers = r.integer(1); + if (answers > max_vote_answers) return {}; + for (std::uint64_t i = 0; i < answers; ++i) { + auto answer = read_text(r, bytes, 1, max_vote_answer); + if (!answer) return {}; + p.vote.answers.push_back(std::move(*answer)); + p.vote.counts.push_back(static_cast(r.integer(2))); + } + if (!valid_server_vote(p.vote)) return {}; } const auto placement = r.integer(1); if (!valid_object_placement(placement)) return {}; @@ -1006,6 +1050,33 @@ std::optional decode(std::span bytes) noexcept { p.guest_boosts = (tools & 4) != 0; p.server_votes = static_cast((tools >> 3) & 7); p.enforce_tuning = (tools & 64) != 0; + const auto polls = r.integer(1), custom = r.integer(1); + if (polls > static_cast(ServerPolls::everyone) || custom > server_custom_vote_limit) return {}; + p.server_polls = static_cast(polls); + for (std::uint64_t i = 0; i < custom; ++i) { + ServerCustomVote vote; + auto name = read_text(r, bytes, 1, server_vote_name_bytes); + auto description = name ? read_text(r, bytes, 1, server_vote_description_bytes) : std::nullopt; + if (!description) return {}; + vote.name = std::move(*name); + vote.description = std::move(*description); + const auto choices = r.integer(1); + if (choices > server_vote_max_choices) return {}; + for (std::uint64_t c = 0; c < choices; ++c) { + auto choice = read_text(r, bytes, 1, server_vote_name_bytes); + if (!choice) return {}; + vote.choices.push_back(std::move(*choice)); + } + if (!valid_server_custom_vote(vote)) return {}; + p.server_custom_votes.push_back(std::move(vote)); + } + p.announcement.id = static_cast(r.integer(4)); + if (p.announcement.id) { + p.announcement.seconds = static_cast(r.integer(2)); + auto line = read_text(r, bytes, 1, multiplayer_chat_max_bytes); + if (!line || !valid_chat_text(*line)) return {}; + p.announcement.text = std::move(*line); + } if (!valid_roster(p.members, p.capacity) || !p.distances.valid()) return {}; } else if (p.kind == PacketKind::routes) { @@ -1085,6 +1156,28 @@ bool valid_chat_text(std::string_view text) noexcept { } return visible; } +bool valid_server_vote_name(std::string_view name) noexcept { + return !name.empty() && name.size() <= server_vote_name_bytes && std::all_of(name.begin(), name.end(), [](char c) { + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'; + }); +} +bool valid_server_custom_vote(const ServerCustomVote &vote) noexcept { + if (!valid_server_vote_name(vote.name) || vote.choices.size() > server_vote_max_choices) return false; + if (!vote.description.empty() && + (vote.description.size() > server_vote_description_bytes || !valid_chat_text(vote.description))) + return false; + return std::all_of(vote.choices.begin(), vote.choices.end(), [](const std::string &c) { return valid_server_vote_name(c); }); +} +bool valid_server_vote(const ServerVote &vote) noexcept { + if (vote.label.size() > max_vote_label || vote.answers.size() > max_vote_answers || vote.answers.size() != vote.counts.size()) + return false; + // A poll has two answers at least; nothing else has any. + if ((vote.kind == server_vote_poll) != !vote.answers.empty() || (vote.kind == server_vote_poll && vote.answers.size() < 2)) + return false; + return std::all_of(vote.answers.begin(), vote.answers.end(), [](const std::string &answer) { + return answer.size() <= max_vote_answer && valid_chat_text(answer); + }); +} bool valid_member_name(std::string_view text) noexcept { if (text.size() > max_member_name) return false; for (std::size_t at = 0; at < text.size();) { diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 216936c..ef6d153 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -26,7 +26,7 @@ namespace dingosdk::multiplayer { constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_packet = 24576; constexpr std::size_t packet_header_size = 64; -constexpr std::uint16_t protocol_version = 45; +constexpr std::uint16_t protocol_version = 46; // A dedicated server's chat lines unless its owner says otherwise: violet (#8E5CFF) and lavender (#D9C8FF). inline constexpr std::uint32_t default_server_chat_badge = 0xffff5c8eU, default_server_chat_text = 0xffffc8d9U; constexpr std::size_t max_throwdown_message = 4096; @@ -34,19 +34,46 @@ constexpr std::size_t max_throwdown_message = 4096; constexpr std::size_t max_physics_tuning = 16384; // Votes a dedicated server runs (Packet::server_votes). constexpr std::uint8_t server_vote_map = 1, server_vote_kick = 2, server_vote_time = 4; +// What else a running vote can be (ServerVote::kind; never in Packet::server_votes): a poll, +// a question with answers that runs nothing, and a vote the server's owner defined. +constexpr std::uint8_t server_vote_poll = 8, server_vote_custom = 16; // The vote a dedicated server is running, or has just finished (Packet::vote): games show it // with its tally and let the player answer. `id` is 0 when there is none. `kind` is one of the -// server_vote_* bits; `seconds` is what is left of a running one. +// server_vote_* bits; `seconds` is what is left of a running one. A poll has its answers and a +// count for each instead of yes, no and needed. constexpr std::size_t max_vote_label = 120; +constexpr std::size_t max_vote_answers = 6, max_vote_answer = 48; constexpr std::uint8_t vote_running = 0, vote_passed = 1, vote_failed = 2, vote_cancelled = 3; struct ServerVote { std::uint32_t id{}; std::uint8_t kind{}, outcome{vote_running}; std::uint16_t yes{}, no{}, needed{}, seconds{}; std::uint64_t starter{}, target{}; // target: the player a kick vote is about, who has no vote in it - std::string label; // "change the map to ..." + std::string label; // "change the map to ...", or a poll's question + std::vector answers; // poll: 2 to max_vote_answers + std::vector counts; // poll: one per answer bool operator==(const ServerVote &) const = default; }; +// Who may start a poll on a dedicated server (Packet::server_polls). +enum class ServerPolls : std::uint8_t { off = 0, admins = 1, everyone = 2 }; +// A vote a dedicated server's owner defined: "/vote [choice]" (Packet::server_custom_votes). +// The name and each choice are valid_server_vote_name; the description is chat text or empty. +constexpr std::size_t server_custom_vote_limit = 16, server_vote_name_bytes = 16, server_vote_description_bytes = 80, + server_vote_max_choices = 8; +struct ServerCustomVote { + std::string name; // "restart" + std::string description; // "Reload the current map" + std::vector choices; // what the player picks from; empty: the vote takes no argument + bool operator==(const ServerCustomVote &) const = default; +}; +// A dedicated server's announcement, shown as a card for `seconds` (Packet::announcement). +// `id` tells one from the next; 0 is none. `text` is one chat line. +struct ServerAnnouncement { + std::uint32_t id{}; + std::uint16_t seconds{}; + std::string text; + bool operator==(const ServerAnnouncement &) const = default; +}; enum class PacketKind : std::uint16_t { hello = 1, welcome = 2, @@ -221,6 +248,9 @@ struct Packet { bool enforce_tuning = true; // roster: the votes a dedicated server lets players start (server_vote_* bits) std::uint8_t server_votes{}; + std::uint8_t server_polls{}; // roster: who may start a poll (ServerPolls) + std::vector server_custom_votes; // roster: the owner's own votes that are on + ServerAnnouncement announcement; // roster std::vector bans; // bans: newest first std::uint32_t ban_total{}; // bans: how many the server has in all std::vector maps; // maps: level assets @@ -249,6 +279,10 @@ bool valid_chat_text(std::string_view) noexcept; // A player name in a hello or roster: UTF-8 without control characters. bool valid_member_name(std::string_view) noexcept; bool valid_admin_text(std::string_view) noexcept; +// 1 to server_vote_name_bytes of a-z, 0-9, - and _. +bool valid_server_vote_name(std::string_view) noexcept; +bool valid_server_custom_vote(const ServerCustomVote &) noexcept; +bool valid_server_vote(const ServerVote &) noexcept; // its label and, for a poll, its answers // The message a player typed, made valid: control characters and broken UTF-8 // dropped, surrounding blanks trimmed, cut to the byte limit on a character boundary. std::string clean_chat_text(std::string_view); diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 4ca729f..2cf792d 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -102,6 +102,8 @@ void stop(Session &s, std::string reason) { set_session_tuning_enforced(false); set_host_physics_extras({}); s.server_votes = 0; + s.server_polls = 0; + s.server_custom_votes.clear(); set_session_tools_allowed(true, true, true); s.object_clears.reset(); s.clear_pending = false; @@ -164,6 +166,8 @@ void stop(Session &s, std::string reason) { s.vote = {}; s.vote_ends = 0; s.vote_mine = 0; + s.announcement = {}; + s.announcement_ends = 0; server_vote_open_flag.store(false, std::memory_order_relaxed); s.pose_streams.clear(); s.pose_ack = {}; diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index e497a3e..38c63b0 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -666,8 +666,11 @@ std::string command(std::string_view action, std::string_view argument, std::str return result; } if (action == "vote") { - if (argument != "yes" && argument != "no") return "vote yes|no"; - const auto result = cast_server_vote(s, argument == "yes"); + // yes or no, or the number of a poll's answer + const bool answer = argument.size() == 1 && argument[0] >= '1' && argument[0] <= '0' + static_cast(max_vote_answers); + if (argument != "yes" && argument != "no" && !answer) return "vote yes|no|"; + const auto result = answer ? answer_server_poll(s, static_cast(argument[0] - '1')) + : cast_server_vote(s, argument == "yes"); if (!result.empty()) add_chat(s, 0, "ReSkate", result); return result; } diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index db64437..1ff8bb8 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -190,6 +190,8 @@ struct Session { std::vector extras_packet; std::uint64_t next_extras_check{}; std::uint8_t server_votes{}; // guest of a dedicated server: the votes it runs + std::uint8_t server_polls{}; // ... who may start a poll there (ServerPolls) + std::vector server_custom_votes; // ... and its owner's own votes // Host: bumped per "delete all guest objects". Guest: the last value seen // (unset until the first roster) and whether a local wipe is outstanding. std::optional object_clears; @@ -222,10 +224,13 @@ struct Session { // The colours of the dedicated server's own chat lines, as its roster gives them. std::uint32_t server_chat_badge = default_server_chat_badge, server_chat_text = default_server_chat_text; // The dedicated server's vote as its roster last gave it, when it ends by this game's clock, - // and what this player answered in it (0 nothing yet, 1 yes, 2 no). + // and what this player answered in it (0 nothing yet, 1 yes, 2 no; in a poll 1 + the answer). ServerVote vote; std::uint64_t vote_ends{}; std::uint8_t vote_mine{}; + // The dedicated server's announcement as its roster last gave it, and when it goes by this game's clock. + ServerAnnouncement announcement; + std::uint64_t announcement_ends{}; // The server's ban list, as sent to us while we are one of its admins. std::vector server_bans; std::uint32_t server_ban_total{}; @@ -427,6 +432,8 @@ std::string send_chat(Session &s, std::string_view typed); std::string send_chat_command(Session &s, std::string_view typed); // Answers the dedicated server's running vote, as /yes or /no in chat does. std::string cast_server_vote(Session &s, bool yes); +// Answers the dedicated server's running poll (0: its first answer), as /1, /2... in chat does. +std::string answer_server_poll(Session &s, std::size_t answer); // Whether a vote the local player may answer is running: read by the game thread for the binds. inline std::atomic server_vote_open_flag{}; // "/p " in a lobby: one line for the local player's party only, relayed by the host. diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 53866a5..f7ae62a 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -279,6 +279,9 @@ void apply_roster(Session &s, const Packet &p, std::uint64_t now) { apply_guest_tools(s, p.guest_noclip, p.guest_no_bail, p.guest_boosts); s.enforce_tuning = p.enforce_tuning; s.server_votes = dedicated_host(s) ? p.server_votes : 0; + s.server_polls = dedicated_host(s) ? p.server_polls : 0; + if (dedicated_host(s)) s.server_custom_votes = p.server_custom_votes; + else s.server_custom_votes.clear(); publish_chat(s); // the "/" list follows the server's votes, its players and its maps if (s.object_clears && *s.object_clears != p.object_clears) s.clear_pending = true; s.object_clears = p.object_clears; @@ -312,13 +315,20 @@ void apply_roster(Session &s, const Packet &p, std::uint64_t now) { s.roster_voice_range = p.voice_range; s.server_chat_badge = p.chat_badge; s.server_chat_text = p.chat_text; - // The server's vote. A new one starts with no answer from this player, unless they started it. + // The server's vote. A new one starts with no answer from this player, unless they started + // it and the server counts that as a yes. A poll they started they still answer. if (dedicated_host(s)) { const auto local = s.transport.status().local_id; - if (p.vote.id != s.vote.id) s.vote_mine = p.vote.id && p.vote.starter == local ? 1 : 0; + const bool poll = p.vote.kind == server_vote_poll; + if (p.vote.id != s.vote.id) s.vote_mine = p.vote.id && !poll && p.vote.starter == local && p.vote.yes ? 1 : 0; s.vote = p.vote; s.vote_ends = now_us() + std::uint64_t{p.vote.seconds} * 1000000; - server_vote_open_flag.store(p.vote.id && p.vote.outcome == vote_running && p.vote.target != local, std::memory_order_relaxed); + // The Yes and No binds answer a yes/no vote; a poll is answered on its card or with /1, /2... + server_vote_open_flag.store(p.vote.id && !poll && p.vote.outcome == vote_running && p.vote.target != local, + std::memory_order_relaxed); + if (p.announcement.id != s.announcement.id) + s.announcement_ends = now_us() + std::uint64_t{p.announcement.seconds} * 1000000; + s.announcement = p.announcement; } ++s.party_revision; // anyone's party may have changed // A dedicated server knows players only by the name each sent in their hello. diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index bebf1fe..2fb5384 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -225,7 +225,12 @@ std::string send_chat_command(Session &s, std::string_view typed) { const std::string_view sent = message.text; // An answer to the vote typed in chat shows on the vote card like one given there. if (s.vote.id && s.vote.outcome == vote_running) { - if (sent == "/yes" || sent == "/y" || sent == "/vote yes" || sent == "/vote y") s.vote_mine = 1; + if (s.vote.kind == server_vote_poll) { + // "/2" or "/vote 2" + const auto number = sent.starts_with("/vote ") ? sent.substr(6) : sent.substr(1); + if (number.size() == 1 && number[0] >= '1' && static_cast(number[0] - '0') <= s.vote.answers.size()) + s.vote_mine = static_cast(number[0] - '0'); + } else if (sent == "/yes" || sent == "/y" || sent == "/vote yes" || sent == "/vote y") s.vote_mine = 1; else if (sent == "/no" || sent == "/n" || sent == "/vote no" || sent == "/vote n") s.vote_mine = 2; } if (sent.starts_with("/p ") && s.local_party) { @@ -234,8 +239,21 @@ std::string send_chat_command(Session &s, std::string_view typed) { } return {}; } +std::string answer_server_poll(Session &s, std::size_t answer) { + if (!dedicated_host(s) || !s.vote.id || s.vote.outcome != vote_running || s.vote.kind != server_vote_poll) + return "No poll is running."; + if (answer >= s.vote.answers.size()) return "The poll has " + std::to_string(s.vote.answers.size()) + " answers."; + if (s.vote_mine == answer + 1) return {}; + // Sent as the chat command, as an answer to a vote is. + auto message = packet(s, PacketKind::chat, now_us()); + message.text = "/" + std::to_string(answer + 1); + if (!send_packet(s, s.host_id, message, true, false)) return "Could not reach the server."; + s.vote_mine = static_cast(answer + 1); + return {}; +} std::string cast_server_vote(Session &s, bool yes) { if (!dedicated_host(s) || !s.vote.id || s.vote.outcome != vote_running) return "No vote is running."; + if (s.vote.kind == server_vote_poll) return "This is a poll: answer it on its card, or with /1, /2..."; if (s.vote.target == s.transport.status().local_id) return "You cannot vote on your own kick."; if (s.vote_mine == (yes ? 1 : 2)) return {}; // The server takes the answer as the chat command (server_votes.cpp); it is not a chat diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 1820147..230989e 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -470,10 +470,21 @@ std::vector chat_commands(const Session &s) { list.push_back({"/vote kick", "/vote kick ", "Start a vote to kick a player", "player"}); if (s.server_votes & server_vote_time) list.push_back({"/vote tod", "/vote tod