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 5ea156d1..56fe1075 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..f1be9c11 100644 --- a/Server/Test/server_park_tests.cpp +++ b/Server/Test/server_park_tests.cpp @@ -517,6 +517,41 @@ 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. 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); + 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(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(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); + 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 bbef2e72..fa501a93 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -1318,8 +1318,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 b7e76a63..ae9edd04 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -472,6 +472,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 ac8d18a3..d92fc45b 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 {}; @@ -408,6 +404,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."; @@ -496,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;