Merge pull request #310 from moelrobi/fix/poll-answer-repeat

Polls: answers no longer refused as repeats, and less chat noise
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-21 18:45:38 -05:00
commit c745d605ae
7 files changed
+79 -23

No files matched your search

+5 -3
View File
@@ -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<double>(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;
+15 -11
View File
@@ -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<std::size_t>(number[0] - '0') <= s.vote.answers.size())
answer = static_cast<std::uint8_t>(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<std::size_t>(number[0] - '0') <= s.vote.answers.size())
s.vote_mine = static_cast<std::uint8_t>(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);
+5
View File
@@ -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");
+35
View File
@@ -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.
{
+3 -1
View File
@@ -1318,8 +1318,10 @@ void Host::receive(std::uint64_t peer, std::span<const std::uint8_t> 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;
+2
View File
@@ -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);
+14 -8
View File
@@ -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<std::size_t>(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;