From 07cd31b8aa7045e124b92198d5e9494148f8e045 Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Sun, 18 Oct 2026 02:02:56 +0200 Subject: [PATCH 1/2] Votes: the same answer in the next poll is no longer turned away as a repeat Chat's flood control refuses a line identical to the one before it within 15 s, which also caught /2 in a poll right after /2 in the last one. An answer to the running vote or poll is now exempt from that one rule, on the server and in the game; the pace still holds, and the vote still counts one answer per player. Co-Authored-By: Claude Opus 5.5 --- Engine/Game/Multiplayer/chat_rate.h | 8 +++-- .../Multiplayer/Session/session_send.cpp | 26 +++++++++------- Server/Test/server_activity_tests.cpp | 5 ++++ Server/Test/server_park_tests.cpp | 30 +++++++++++++++++++ Server/server_host.cpp | 4 ++- Server/server_host.h | 2 ++ Server/server_votes.cpp | 11 +++++++ 7 files changed, 71 insertions(+), 15 deletions(-) diff --git a/Engine/Game/Multiplayer/chat_rate.h b/Engine/Game/Multiplayer/chat_rate.h index e3b32d94..266b9132 100644 --- a/Engine/Game/Multiplayer/chat_rate.h +++ b/Engine/Game/Multiplayer/chat_rate.h @@ -7,7 +7,9 @@ namespace dingosdk { // Chat flood control, the same for the sender, the host, a dedicated server and every // receiver: a few lines at once, then one every 1.5 s, and never the same line twice within -// 15 s. Receivers allow one line of slack for lines that arrive bunched up. +// 15 s. Receivers allow one line of slack for lines that arrive bunched up. An answer to the +// running vote or poll may repeat the line before it (`answer`): the same /2 is a new answer +// in the next poll, and the vote itself counts one answer per player. The pace still holds. struct ChatRate { static constexpr double burst = 4, interval_us = 1500000; static constexpr std::uint64_t repeat_us = 15000000; @@ -15,7 +17,7 @@ struct ChatRate { double tokens = burst; std::uint64_t refilled{}, previous_at{}; std::string previous; - Verdict accept(std::uint64_t now, std::string_view text, double slack = 0) { + Verdict accept(std::uint64_t now, std::string_view text, double slack = 0, bool answer = false) { const double limit = burst + slack; if (!refilled || now < refilled) { tokens = limit; @@ -23,7 +25,7 @@ struct ChatRate { tokens = std::min(limit, tokens + static_cast(now - refilled) / interval_us); } refilled = now; - if (!previous.empty() && text == previous && now >= previous_at && now - previous_at < repeat_us) + if (!answer && !previous.empty() && text == previous && now >= previous_at && now - previous_at < repeat_us) return Verdict::repeated; if (tokens < 1) return Verdict::too_fast; tokens -= 1; diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 9fa1dc71..930c45f4 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -217,8 +217,21 @@ std::string send_chat_command(Session &s, std::string_view typed) { if (!dedicated_host(s)) return "That command needs a dedicated server."; auto text = clean_chat_text(typed); if (text.size() < 2 || text.front() != '/') return "Type a command after the /."; + // An answer to the running vote: it shows on the vote card like one given there, and may + // repeat the line before it (the same /2 in the next poll; the server counts one per player). + std::uint8_t answer{}; + if (s.vote.id && s.vote.outcome == vote_running) { + const std::string_view line = text; + if (s.vote.kind == server_vote_poll) { + // "/2" or "/vote 2" + const auto number = line.starts_with("/vote ") ? line.substr(6) : line.substr(1); + if (number.size() == 1 && number[0] >= '1' && static_cast(number[0] - '0') <= s.vote.answers.size()) + answer = static_cast(number[0] - '0'); + } else if (line == "/yes" || line == "/y" || line == "/vote yes" || line == "/vote y") answer = 1; + else if (line == "/no" || line == "/n" || line == "/vote no" || line == "/vote n") answer = 2; + } const auto now = now_us(); - switch (s.local_chat_rate.accept(now, text)) { + switch (s.local_chat_rate.accept(now, text, 0, answer != 0)) { case ChatRate::Verdict::repeated: return "You just sent that."; case ChatRate::Verdict::too_fast: return "Slow down: one message every couple of seconds."; case ChatRate::Verdict::accepted: break; @@ -226,18 +239,9 @@ std::string send_chat_command(Session &s, std::string_view typed) { auto message = packet(s, PacketKind::chat, now); message.text = std::move(text); if (!send_packet(s, s.host_id, message, true, false)) return "Could not reach the server."; + if (answer) s.vote_mine = answer; // Party chat: the server relays it to the rest of the party, not back to us. 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 (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) { const auto local = s.transport.status().local_id; add_chat(s, local, s.transport.name(local), "[Party] " + std::string(sent.substr(3)), true); diff --git a/Server/Test/server_activity_tests.cpp b/Server/Test/server_activity_tests.cpp index 65c3c404..a8a8cb19 100644 --- a/Server/Test/server_activity_tests.cpp +++ b/Server/Test/server_activity_tests.cpp @@ -126,6 +126,11 @@ int main() { check(rate.accept(2'600'000, "line 5") == V::accepted, "one more line is allowed after 1.5 s"); check(rate.accept(2'700'000, "line 5") == V::repeated, "the same line straight after is refused"); check(rate.accept(30'000'000, "line 5") == V::accepted, "the same line later is allowed"); + // An answer to a vote may repeat the line before it (the same /2 in the next poll), at the same pace. + ChatRate answers; + for (int i = 0; i < 4; ++i) check(answers.accept(1'000'000, "/2", 0, true) == V::accepted, "an answer to a vote may repeat the line before it"); + check(answers.accept(1'000'000, "/3", 0, true) == V::too_fast, "an answer is still held to the pace"); + check(answers.accept(2'600'000, "/2") == V::repeated, "the same line, not an answer, is still refused"); ChatRate relayed; for (int i = 0; i < 5; ++i) check(relayed.accept(1'000'000, std::to_string(i), 1) == V::accepted, "receivers allow one line of slack"); diff --git a/Server/Test/server_park_tests.cpp b/Server/Test/server_park_tests.cpp index 687accba..db69d5d8 100644 --- a/Server/Test/server_park_tests.cpp +++ b/Server/Test/server_park_tests.cpp @@ -517,6 +517,36 @@ int run(const std::filesystem::path &fixtures) { run_for(1000000); check(said("passed (2 yes, 0 no, 2 needed of 2 votes)") == 1, "A vote nobody could change any more was not decided at once"); } + // Poll answers: the same /2 in two polls back to back is an answer in each (chat turns + // repeats away, answers not), twice in one poll it is still one; a chat line said twice is + // still turned away. + { + const auto said = [&](std::string_view text) { + return std::count_if(a.chat.begin(), a.chat.end(), [&](const std::string &line) { return line.find(text) != std::string::npos; }); + }; + auto &c = games.at(76561198000000003ULL); + command("poll First? | yes | no"); + a.say("/2", now); + c.say("/2", now); + run_for(1000000); + a.say("/2", now); + run_for(1000000); + check(said("You already answered no.") == 1, "A second answer in the same poll was not answered as one"); + command("poll end"); + run_for(1000000); + check(said("Poll \"First?\" ended: yes 0, no 2 (no wins).") == 1, "A poll counted an answer given twice twice"); + command("poll Second? | yes | no"); + a.say("/2", now); + run_for(1000000); + command("poll end"); + run_for(1000000); + check(said("Poll \"Second?\" ended: yes 0, no 1 (no wins).") == 1, "The same answer in the next poll was turned away"); + c.say("hello again", now); + run_for(1000000); + c.say("hello again", now); + run_for(1000000); + check(said("hello again") == 1, "The same chat line twice in a row was not turned away"); + } // The rotation: the minutes left are announced, the vote's maps are drawn a minute ahead, // and the vote decides: staying adds time, a map changes to it. { diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 76b974ff..fa85384b 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -1247,8 +1247,10 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: if (p.kind == PacketKind::physics_tuning || p.kind == PacketKind::physics_extras) return; if (p.kind == PacketKind::chat) { active(*link); + // An answer to the running vote may repeat the line before it: the same /2 in the next poll. + const bool answer = p.text.starts_with('/') && answers_vote(*link, std::string_view(p.text).substr(1)); if (!routed_source(p, link->member, peer, true, id_) || - link->chat_rate.accept(now_, p.text, 1) != ChatRate::Verdict::accepted) + link->chat_rate.accept(now_, p.text, 1, answer) != ChatRate::Verdict::accepted) return; link->last_packet = now_; if (!allowed_words(*link, p.text)) return; diff --git a/Server/server_host.h b/Server/server_host.h index 312b1dfc..8d8da118 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -433,6 +433,8 @@ class Host { std::string start_poll(Guest *starter, std::string_view text, std::string run = {}); void cast_vote(Guest &, bool yes); void answer_poll(Guest &, std::size_t answer); + // Whether a chat command (without its "/") answers the running vote or poll for this player. + bool answers_vote(const Guest &, std::string_view line) const; std::string end_poll(Guest *by); void check_vote(bool expired); void cancel_vote(const std::string &why); diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index a569acd7..62c73292 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -408,6 +408,17 @@ void Host::answer_poll(Guest &guest, std::size_t answer) { check_vote(false); } +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 (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"); +} + // `by` nullptr: the server console, which may always end it. std::string Host::end_poll(Guest *by) { if (!vote_ || vote_->kind != VoteKind::poll) return "No poll is running."; From bd21a9d8cad512ebcec6474cb5b69b1dad39100d Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Mon, 19 Oct 2026 21:25:50 +0200 Subject: [PATCH 2/2] Polls: chat no longer repeats the question and the result the card shows A poll put its question and answers in chat when it started, and its result when it ended, while the poll card already shows both. Chat now says neither; the result still goes to the log, and a cancelled poll still says why in chat. Co-Authored-By: Claude Opus 5.5 --- Server/Test/server_park_tests.cpp | 11 ++++++++--- Server/server_votes.cpp | 11 +++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Server/Test/server_park_tests.cpp b/Server/Test/server_park_tests.cpp index db69d5d8..f1be9c11 100644 --- a/Server/Test/server_park_tests.cpp +++ b/Server/Test/server_park_tests.cpp @@ -519,11 +519,15 @@ int run(const std::filesystem::path &fixtures) { } // Poll answers: the same /2 in two polls back to back is an answer in each (chat turns // repeats away, answers not), twice in one poll it is still one; a chat line said twice is - // still turned away. + // still turned away. The card shows the question and the result, so chat names neither; the + // log has the result. { const auto said = [&](std::string_view text) { return std::count_if(a.chat.begin(), a.chat.end(), [&](const std::string &line) { return line.find(text) != std::string::npos; }); }; + const auto logged = [&](std::string_view text) { + return std::count_if(log.begin(), log.end(), [&](const std::string &line) { return line.find(text) != std::string::npos; }); + }; auto &c = games.at(76561198000000003ULL); command("poll First? | yes | no"); a.say("/2", now); @@ -534,13 +538,14 @@ int run(const std::filesystem::path &fixtures) { check(said("You already answered no.") == 1, "A second answer in the same poll was not answered as one"); command("poll end"); run_for(1000000); - check(said("Poll \"First?\" ended: yes 0, no 2 (no wins).") == 1, "A poll counted an answer given twice twice"); + check(logged("Poll \"First?\" ended: yes 0, no 2 (no wins).") == 1, "A poll counted an answer given twice twice"); + check(!said("First?"), "A poll's question or result was repeated in chat"); command("poll Second? | yes | no"); a.say("/2", now); run_for(1000000); command("poll end"); run_for(1000000); - check(said("Poll \"Second?\" ended: yes 0, no 1 (no wins).") == 1, "The same answer in the next poll was turned away"); + check(logged("Poll \"Second?\" ended: yes 0, no 1 (no wins).") == 1, "The same answer in the next poll was turned away"); c.say("hello again", now); run_for(1000000); c.say("hello again", now); diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index 62c73292..3a845b8d 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -372,13 +372,9 @@ std::string Host::start_poll(Guest *starter, std::string_view text, std::string poll.id = vote_ids_; poll.ends = now_ + microseconds(config_.votes.poll_seconds); if (!admin) vote_cooldowns_[starter->member.id] = now_ + microseconds(config_.votes.cooldown); - std::string choices; - for (std::size_t i = 0; i < poll.answers.size(); ++i) choices += (i ? " " : "") + std::string("/") + std::to_string(i + 1) + " " + poll.answers[i]; vote_ = std::move(poll); + // The card shows the question and its answers; chat does not repeat them. const auto who = starter ? guest_name(*starter) : std::string("The server"); - send_chat(who + " asks: " + vote_->label + " (" + std::to_string(config_.votes.poll_seconds) + - " s). Answer on the card at the right of your screen, or type:"); - send_chat(choices); log_("[poll] " + who + " started a poll: " + vote_->label + " " + joined(vote_->answers, " | ")); check_vote(false); return {}; @@ -507,9 +503,8 @@ void Host::check_vote(bool expired) { if (top && count[i] == top) leaders.push_back(done.answers[i]); const auto outcome = !top ? std::string("nobody answered") : leaders.size() > 1 ? "a tie: " + joined(leaders, ", ") : leaders.front() + " wins"; - const auto line = "Poll \"" + done.label + "\" ended: " + answer_count(done.answers, count) + " (" + outcome + ")."; - send_chat(line); - log_("[poll] " + line); + // The card shows the result for a while; chat does not repeat it. + log_("[poll] Poll \"" + done.label + "\" ended: " + answer_count(done.answers, count) + " (" + outcome + ")."); // A console poll-run: the winning answer's command, as the console. A tie or no answer runs nothing. if (!done.run.empty() && top && leaders.size() == 1) log_(command(fill(done.run, "{answer}", leaders.front()))); return;