Merge pull request #143 from moelrobi/claude/loving-goodall-98qb8j

Server votes: custom votes, polls, more vote settings and announcements
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-09 16:06:15 -05:00
commit b2b434e622
20 files changed
+1079 -110

No files matched your search

+12 -1
View File
@@ -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<std::string> answers;
std::vector<unsigned> 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<MultiplayerChatCommand> commands; // what "/" offers in this session
std::vector<std::string> 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 {
+94 -1
View File
@@ -616,9 +616,14 @@ std::vector<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t>(p.object_placement), 1);
if (!valid_object_limit(p.object_limit)) throw std::invalid_argument("Invalid object limit");
@@ -652,6 +662,23 @@ std::vector<std::uint8_t> 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<unsigned>(p.server_votes) << 3) | (p.enforce_tuning ? 64U : 0U), 1);
if (p.server_polls > static_cast<std::uint8_t>(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<const std::uint8_t> bytes, Packe
r.at += static_cast<std::size_t>(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<std::string> read_text(Reader &r, std::span<const std::uint8_t> 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<const char *>(bytes.data() + r.at), static_cast<std::size_t>(length));
r.at += static_cast<std::size_t>(length);
return value;
}
std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
try {
if (bytes.size() < header_size || bytes.size() > max_packet)
@@ -968,6 +1003,15 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
if (p.vote.outcome > vote_cancelled || length > max_vote_label || length > bytes.size() - r.at) return {};
p.vote.label.assign(reinterpret_cast<const char *>(bytes.data() + r.at), static_cast<std::size_t>(length));
r.at += static_cast<std::size_t>(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<std::uint16_t>(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<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
p.guest_boosts = (tools & 4) != 0;
p.server_votes = static_cast<std::uint8_t>((tools >> 3) & 7);
p.enforce_tuning = (tools & 64) != 0;
const auto polls = r.integer(1), custom = r.integer(1);
if (polls > static_cast<std::uint8_t>(ServerPolls::everyone) || custom > server_custom_vote_limit) return {};
p.server_polls = static_cast<std::uint8_t>(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<std::uint32_t>(r.integer(4));
if (p.announcement.id) {
p.announcement.seconds = static_cast<std::uint16_t>(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();) {
+37 -3
View File
@@ -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<std::string> answers; // poll: 2 to max_vote_answers
std::vector<std::uint16_t> 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 <name> [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<std::string> 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<ServerCustomVote> server_custom_votes; // roster: the owner's own votes that are on
ServerAnnouncement announcement; // roster
std::vector<MultiplayerBan> bans; // bans: newest first
std::uint32_t ban_total{}; // bans: how many the server has in all
std::vector<std::string> 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);
@@ -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 = {};
@@ -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<char>(max_vote_answers);
if (argument != "yes" && argument != "no" && !answer) return "vote yes|no|<answer number>";
const auto result = answer ? answer_server_poll(s, static_cast<std::size_t>(argument[0] - '1'))
: cast_server_vote(s, argument == "yes");
if (!result.empty()) add_chat(s, 0, "ReSkate", result);
return result;
}
@@ -190,6 +190,8 @@ struct Session {
std::vector<std::uint8_t> 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<ServerCustomVote> 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<std::uint32_t> 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<MultiplayerBan> 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<bool> server_vote_open_flag{};
// "/p <message>" in a lobby: one line for the local player's party only, relayed by the host.
@@ -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.
+19 -1
View File
@@ -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<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) {
@@ -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<std::uint8_t>(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
+28 -2
View File
@@ -470,10 +470,21 @@ std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
list.push_back({"/vote kick", "/vote kick <player>", "Start a vote to kick a player", "player"});
if (s.server_votes & server_vote_time)
list.push_back({"/vote tod", "/vote tod <time>", "Vote for a time of day: morning, noon, afternoon, evening, night...", "time"});
if (s.server_votes) {
for (const auto &vote : s.server_custom_votes) {
std::string choices;
for (const auto &choice : vote.choices) choices += (choices.empty() ? "" : "|") + choice;
list.push_back({"/vote " + vote.name, "/vote " + vote.name + (choices.empty() ? "" : " <" + choices + ">"),
vote.description.empty() ? "Start a vote this server defines" : "Start a vote: " + vote.description});
}
if (s.server_votes || !s.server_custom_votes.empty()) {
list.push_back({"/yes", "/yes", "Vote yes in the running vote"});
list.push_back({"/no", "/no", "Vote no in the running vote"});
}
if (!s.server_custom_votes.empty()) list.push_back({"/vote list", "/vote list", "This server's own votes, and what each does"});
const auto polls = static_cast<ServerPolls>(s.server_polls);
if (polls == ServerPolls::everyone || (polls == ServerPolls::admins && s.server_admin))
list.push_back({"/poll", "/poll <question> | <answer> | <answer>...", "Ask everyone a question, with up to six answers"});
if (polls != ServerPolls::off) list.push_back({"/1", "/1, /2...", "Answer the running poll"});
if (s.server_admin) {
list.push_back({"/msg", "/msg <player> <message>", "Admin: message a player privately", "player"});
list.push_back({"/msg-party", "/msg-party <player> <message>", "Admin: message everyone in a player's party", "player"});
@@ -484,7 +495,11 @@ std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
list.push_back({"/tpall", "/tpall [player]", "Admin: teleport everyone to you (or to a player)", "player"});
list.push_back({"/tphere", "/tphere <player>", "Admin: teleport a player to you", "player"});
list.push_back({"/tod", "/tod <time>", "Admin: set the time of day", "time"});
list.push_back({"/votes", "/votes [map|kick|tod on|off|<percent>]", "Admin: the server's vote settings"});
list.push_back({"/votes", "/votes [<vote> on|off|<percent>|seconds|cooldown|min-players <n>]", "Admin: the server's vote settings"});
list.push_back({"/announce", "/announce <text>", "Admin: announce something to everyone, on a card on their screen"});
list.push_back({"/announcements", "/announcements [list|add <text>|remove <n>|interval <minutes>|off]",
"Admin: the messages the server announces on a timer"});
list.push_back({"/poll end", "/poll end", "Admin: end the running poll now"});
list.push_back({"/vote-cancel", "/vote-cancel", "Admin: stop the running vote"});
list.push_back({"/map-pool", "/map-pool [add|remove <map>|clear]", "Admin: the maps players vote between and the rotation uses"});
list.push_back({"/rotation", "/rotation [<minutes>|off]", "Admin: change the map on a timer, through the map pool"});
@@ -535,6 +550,13 @@ void publish_chat(Session &s) {
signature.add(static_cast<std::uint64_t>(s.vote.id));
signature.add(static_cast<std::uint64_t>(s.vote.yes) << 32 | static_cast<std::uint64_t>(s.vote.no) << 16 | s.vote.needed);
signature.add(static_cast<std::uint64_t>(s.vote.outcome) << 40 | static_cast<std::uint64_t>(s.vote_mine) << 32 | vote_seconds);
for (const auto count : s.vote.counts) signature.add(static_cast<std::uint64_t>(count));
signature.add(static_cast<std::uint64_t>(s.server_polls));
for (const auto &vote : s.server_custom_votes) signature.add(vote.name);
// The announcement card, and the seconds it still shows.
const unsigned announcement_seconds = s.announcement.id && s.announcement_ends > now
? static_cast<unsigned>((s.announcement_ends - now + 999999) / 1000000) : 0;
signature.add(static_cast<std::uint64_t>(s.announcement.id) << 16 | announcement_seconds);
signature.add(static_cast<std::uint64_t>(s.chat.size()));
if (!s.chat.empty()) {
signature.add(s.chat.front().sequence);
@@ -567,12 +589,16 @@ void publish_chat(Session &s) {
vote.outcome = s.vote.outcome;
vote.mine = s.vote_mine;
vote.may_vote = s.vote.target != s.transport.status().local_id;
vote.poll = s.vote.kind == server_vote_poll;
for (const auto &answer : s.vote.answers) vote.answers.push_back(clean_chat_text(answer));
vote.counts.assign(s.vote.counts.begin(), s.vote.counts.end());
const auto binds = local_profile_controller_bindings();
if (binds.available) {
vote.yes_bind = binds.vote_yes_combo;
vote.no_bind = binds.vote_no_combo;
}
}
if (dedicated && announcement_seconds) view.announcement = {s.announcement.id, clean_chat_text(s.announcement.text), announcement_seconds};
view.lines.assign(s.chat.begin(), s.chat.end());
// The filter masks each line once; lines leave the cache with the log.
if (!s.chat_filter || s.chat.empty()) {
@@ -597,6 +597,52 @@ void object_codec() {
served.vote.id = 1;
served.vote.label.assign(max_vote_label + 1, 'a');
check(reject(served), "An overlong vote label was encoded");
// A poll on the card: its question and answers, a count for each, and no yes or no.
served.vote = {8, server_vote_poll, vote_running, 0, 0, 0, 40, 76561198000000002ULL, 0, "Next map?",
{"Grom", "San Vansterdam", "Stadium"}, {3, 1, 0}};
const auto polled = decode(encode(served));
check(polled && polled->vote == served.vote, "A server's poll failed to round-trip");
auto bad_poll = served;
bad_poll.vote.counts.pop_back();
check(reject(bad_poll), "A poll with a count missing was encoded");
bad_poll = served;
bad_poll.vote.answers = {"Only one"};
bad_poll.vote.counts = {1};
check(reject(bad_poll), "A poll with one answer was encoded");
bad_poll = served;
bad_poll.vote.answers[1].assign(max_vote_answer + 1, 'b');
check(reject(bad_poll), "An overlong poll answer was encoded");
bad_poll = served;
bad_poll.vote.answers.resize(max_vote_answers + 1, "x");
bad_poll.vote.counts.resize(max_vote_answers + 1);
check(reject(bad_poll), "A poll with seven answers was encoded");
bad_poll = served;
bad_poll.vote.kind = server_vote_map;
check(reject(bad_poll), "A yes/no vote with answers was encoded");
// Who may start polls, the owner's own votes, and an announcement ride on the roster too.
served.vote = {};
served.server_polls = static_cast<std::uint8_t>(ServerPolls::everyone);
served.server_custom_votes = {{"restart", "Reload the current map", {}}, {"noclip", "", {"on", "off"}}};
served.announcement = {4, 12, "Tournament starts in 10 minutes!"};
const auto extras = decode(encode(served));
check(extras && extras->server_polls == served.server_polls && extras->server_custom_votes == served.server_custom_votes &&
extras->announcement == served.announcement,
"Polls, custom votes or the announcement failed to round-trip");
auto bad_extras = served;
bad_extras.server_custom_votes[0].name = "Restart now";
check(reject(bad_extras), "A custom vote name with capitals and a space was encoded");
bad_extras = served;
bad_extras.server_custom_votes.resize(server_custom_vote_limit + 1, served.server_custom_votes[0]);
check(reject(bad_extras), "Too many custom votes were encoded");
bad_extras = served;
bad_extras.server_polls = 3;
check(reject(bad_extras), "An unknown poll setting was encoded");
bad_extras = served;
bad_extras.announcement.text = "two\nlines";
check(reject(bad_extras), "An announcement that is not one chat line was encoded");
const auto announced = encode(served);
for (std::size_t length = announced.size() - 40; length < announced.size(); ++length)
check(!decode(std::span(announced).first(length)), "A truncated roster with an announcement decoded");
}
void chat_codec() {
const auto reject = [](const Packet &packet) {
+1 -1
View File
@@ -33,7 +33,7 @@ void register_multiplayer_commands(Commands &registry) {
Command{"tphere", "Host or server admin: teleport one player to you"},
Command{"nametags", "Show or hide player nametags (on, off, toggle)"},
Command{"pose-dump", "Research: record your own poses for a number of seconds (1-600) to logs/poses-*.bin"},
Command{"vote", "Answer the vote a dedicated server is running (yes, no)"},
Command{"vote", "Answer the vote a dedicated server is running (yes, no), or its poll (1, 2...)"},
Command{"voice-chat", "Your own voice chat (on, off, toggle)"},
Command{"direct-connections", "Connect straight to servers that offer it, not through Steam's relays (on, off, toggle)"},
Command{"player-distance", "How far away another player still gets a skater, in metres (50-1000; 1000: every player). Past it: a nametag or dot"},
+83 -1
View File
@@ -305,7 +305,7 @@ bool chat_pending() {
}
if (s.chat_visible.load()) return true;
if (!c.feed.available) return false;
if (c.feed.vote.id) return true; // the vote card
if (c.feed.vote.id || c.feed.announcement.id) return true; // the vote and announcement cards
for (const auto& [line, at] : c.arrivals)
if (now - at < chat_hold + chat_fade) return true;
return false;
@@ -454,8 +454,88 @@ void draw_command_list(ChatState& c, ImFont* heading, ImFont* body, ImVec2 botto
// down its left). It says what is voted on, the tally and the time left, and has Yes and No. The
// player answers with their binds (the keycaps on the buttons; F1 and F2 unless changed), or by
// clicking while the cursor is free.
// A poll on the same card: its question, then a row for each answer with its count and a bar of
// its share. The player answers by clicking a row while the cursor is free, or with /1, /2... in
// chat; their answer is the blue row.
void draw_poll(const MultiplayerVote& poll, ImFont* heading, ImFont* body, ImVec2 display, float scale, bool clickable) {
const bool running = poll.outcome == 0;
const ImU32 accent = running ? theme::blue : poll.outcome == 3 ? theme::muted : skate_theme::good;
const char* title = running ? "POLL" : poll.outcome == 3 ? "POLL CANCELLED" : "POLL ENDED";
const float width = 360.0f * scale, margin = 24.0f * scale, strip = 5.0f * scale;
const float pad = 14.0f * scale, gap = 8.0f * scale, title_size = 13.0f * scale, size = 18.0f * scale, fine = 13.0f * scale;
const float row = 30.0f * scale, between = 4.0f * scale, inner = width - strip - pad * 2.0f;
const auto question_extent = heading->CalcTextSizeA(size, FLT_MAX, inner, poll.label.c_str());
const bool answers = running && poll.may_vote;
const auto rows = static_cast<float>(poll.answers.size());
const float height = pad + title_size + gap + question_extent.y + gap + rows * row + std::max(0.0f, rows - 1.0f) * between +
(answers ? gap + fine : 0.0f) + pad;
const ImVec2 min(display.x - margin - width, std::floor((display.y - height) * 0.5f)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.92f));
draw->AddRectFilled(min, ImVec2(min.x + strip, max.y), accent);
const float left = min.x + strip + pad, right = max.x - pad;
float y = min.y + pad;
draw->AddText(heading, title_size, ImVec2(left, y), accent, title);
if (running) {
const auto time = std::to_string(poll.seconds) + " s";
const auto extent = heading->CalcTextSizeA(title_size, FLT_MAX, 0.0f, time.c_str());
draw->AddText(heading, title_size, ImVec2(right - extent.x, y), theme::paper, time.c_str());
}
y += title_size + gap;
draw->AddText(heading, size, ImVec2(left, y), theme::paper, poll.label.c_str(), nullptr, inner);
y += question_extent.y + gap;
unsigned total{}, most{};
for (const auto count : poll.counts) total += count, most = std::max(most, count);
for (std::size_t i = 0; i < poll.answers.size(); ++i) {
const auto count = i < poll.counts.size() ? poll.counts[i] : 0U;
const ImVec2 a(left, y), b(right, y + row);
const bool chosen = poll.mine == i + 1;
const bool hovered = answers && clickable && ImGui::IsMouseHoveringRect(a, b, false);
draw->AddRectFilled(a, b, hovered ? skate_theme::tile_light : skate_theme::tile_grey);
// Its share of the answers as a bar along the bottom; once ended, the winner's is green.
const float share = total ? static_cast<float>(count) / static_cast<float>(total) : 0.0f;
const ImU32 fill = chosen ? theme::blue : !running && count && count == most ? skate_theme::good : theme::muted;
if (share > 0.0f) draw->AddRectFilled(ImVec2(a.x, b.y - 3.0f * scale), ImVec2(a.x + (b.x - a.x) * share, b.y), fill);
if (chosen) draw->AddRectFilled(a, ImVec2(a.x + 3.0f * scale, b.y), theme::blue);
const auto number = std::to_string(i + 1);
const float text_y = y + (row - fine) * 0.5f - 1.0f * scale;
draw->AddText(heading, fine, ImVec2(a.x + 10.0f * scale, text_y), chosen ? theme::blue : theme::muted, number.c_str());
const auto counted = std::to_string(count);
const float count_width = heading->CalcTextSizeA(fine, FLT_MAX, 0.0f, counted.c_str()).x;
draw->PushClipRect(ImVec2(a.x + 28.0f * scale, a.y), ImVec2(b.x - count_width - 18.0f * scale, b.y), true);
draw->AddText(body, fine + 1.0f * scale, ImVec2(a.x + 28.0f * scale, text_y), theme::paper, poll.answers[i].c_str());
draw->PopClipRect();
draw->AddText(heading, fine, ImVec2(b.x - 10.0f * scale - count_width, text_y), theme::paper, counted.c_str());
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) queue_multiplayer_action("vote", number);
y += row + between;
}
if (answers) {
const auto hint = "Click an answer, or type /1 to /" + std::to_string(poll.answers.size()) + " in chat.";
draw->AddText(body, fine, ImVec2(left, y - between + gap), theme::muted, hint.c_str());
}
}
// A dedicated server's announcement: a card at the top middle of the screen in the same look,
// blue down its left, for as long as the server shows it.
void draw_announcement(const MultiplayerAnnouncement& announcement, ImFont* heading, ImFont* body, ImVec2 display, float scale) {
if (!announcement.id || announcement.text.empty()) return;
const float width = std::min(520.0f * scale, display.x - 48.0f * scale), strip = 5.0f * scale;
const float pad = 14.0f * scale, gap = 6.0f * scale, title_size = 13.0f * scale, size = 18.0f * scale;
const float inner = width - strip - pad * 2.0f;
const auto text_extent = body->CalcTextSizeA(size, FLT_MAX, inner, announcement.text.c_str());
const float height = pad + title_size + gap + text_extent.y + pad;
const ImVec2 min(std::floor((display.x - width) * 0.5f), 24.0f * scale), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.92f));
draw->AddRectFilled(min, ImVec2(min.x + strip, max.y), theme::blue);
const float left = min.x + strip + pad;
draw->AddText(heading, title_size, ImVec2(left, min.y + pad), theme::blue, "ANNOUNCEMENT");
draw->AddText(body, size, ImVec2(left, min.y + pad + title_size + gap), theme::paper, announcement.text.c_str(), nullptr, inner);
}
void draw_vote(const MultiplayerVote& vote, ImFont* heading, ImFont* body, ImVec2 display, float scale, bool clickable) {
if (!vote.id) return;
if (vote.poll) return draw_poll(vote, heading, body, display, scale, clickable);
constexpr ImU32 green = skate_theme::good, red = skate_theme::danger;
const bool running = vote.outcome == 0;
const ImU32 accent = running ? theme::blue : vote.outcome == 1 ? green : vote.outcome == 2 ? red : theme::muted;
@@ -659,6 +739,7 @@ void draw_chat() {
const float above = panel_top - 6.0f * scale;
draw_command_list(c, heading, body, ImVec2(display.x - margin - width, above), width, scale);
emote_picker(c, heading, ImVec2(display.x - margin - width, above), width, scale);
draw_announcement(c.feed.announcement, heading, body, display, scale);
draw_vote(c.feed.vote, heading, body, display, scale, true); // the cursor is free while typing
return;
}
@@ -711,6 +792,7 @@ void draw_chat() {
bottom = top_left.y - gap;
}
// Clickable while the menu or the console has freed the cursor.
draw_announcement(c.feed.announcement, heading, body, display, scale);
draw_vote(c.feed.vote, heading, body, display, scale, s.visible.load() || s.console_visible.load());
}
} // namespace dingosdk::overlay::detail
+37
View File
@@ -249,6 +249,36 @@ votes Player votes, each off until turned on:
"cooldown_seconds" (default 60) how long a player waits before
starting another. Players vote with /yes and /no in chat;
admins cannot be vote-kicked.
Each vote may also have its own "seconds" and
"cooldown_seconds" (0: the ones above) and "min_players", the
players who must be on before anyone can start it (default 1).
starter_votes_yes Whoever starts a vote has voted yes (default true).
custom Votes of your own: a list, each running a server command
when it passes. {map} in the command is the current map,
{arg} the choice the player picked (one of "choices"; without
choices the vote takes no argument). For example:
{"name": "restart", "description": "Reload the current map",
"command": "map {map}", "percent": 60}
{"name": "noclip", "description": "Turn noclip on or off",
"command": "noclip {arg}", "choices": ["on", "off"]}
Players start them with /vote restart, /vote noclip off; /vote
list shows them. A name and each choice is 1-16 of a-z, 0-9,
- and _, and not one of the server's own (map, kick, tod,
list...). Each takes "enabled", "percent", "seconds",
"cooldown_seconds" and "min_players" as the others do. Up to 16.
polls Who may ask everyone a question with up to six answers:
"off", "admins" (default) or "everyone".
/poll Next map? | Grom | San Vansterdam | Stadium
Players answer on the card or with /1, /2... A poll runs
nothing; it ends after "poll_seconds" (default 60), or when
whoever started it (or an admin) types /poll end.
"announcements" - Messages from the server.
messages Lines the server posts in turn, one every "interval_minutes"
(0: off) while players are on. Each is one chat line.
card Also show each announcement as a card at the top of every
player's screen, not only in chat (default true).
Admins announce something once with: announce <text>.
Every change made from the console or by an admin is saved back to this file.
@@ -310,6 +340,13 @@ and change voice, distances, placement and kicks from the Multiplayer menu.
Time of day on every map (needs layer-sync on).
votes [map|kick|tod on|off|<percent>] The vote settings (see votes).
votes seconds <n> votes cooldown <n> vote-cancel
votes <vote> seconds|cooldown|min-players <n> One vote's own limits; <vote>
is map, kick, tod or a custom vote's name, which
also takes on|off|<percent>.
votes polls off|admins|everyone votes poll-seconds <n> votes starter-yes on|off
announce <text> Tell everyone, in chat and on a card.
announcements [list | add <text> | remove <n> | clear | interval <minutes>|off | card on|off]
The messages posted on a timer.
activity-log on|off Log player activity (see activity_log).
announce-throwdowns on|off Chat message when a throwdown is placed.
parties [on|off] List the parties, or allow them (off ends them all).
+56
View File
@@ -42,6 +42,10 @@ int run() {
// A setting under its old name is read, written back under its new one, and not called new.
check(!has("players.allow_boosts") && has("players.allow_noclip"), "A renamed setting was reported as new, or a new one was not");
check(!has("server.name") && !has("tps") && !has("votes.map"), "Settings the file had reported as new");
// Settings added after 2.0.0's votes: each vote's own limits, polls, custom votes, announcements.
check(has("votes.map.seconds") && has("votes.map.min_players") && has("votes.polls") && has("votes.custom") &&
has("announcements.messages") && has("announcements.interval_minutes"),
"The new vote and announcement settings were not reported");
const auto written = text(file);
check(written.find("\"enforce_tuning\"") != std::string::npos && written.find("\"seconds\"") != std::string::npos,
"New settings not written into the file");
@@ -315,6 +319,58 @@ int run() {
check(odd_pool.map_pool == std::vector<std::string>{"Isle of Grom"} && odd_pool.map_rotation == 1440,
"Bad pool entries kept, or rotation not capped");
// Each vote's own limits, polls, custom votes and announcements are kept on a rewrite.
check(config.votes.polls == "admins" && config.votes.poll_seconds == 60 && config.votes.starter_votes_yes &&
config.votes.map.min_players == 1 && config.votes.custom.empty() && config.announcements.card,
"The new vote settings do not start at their defaults");
{
auto voting = pool;
voting.votes.kick.seconds = 45;
voting.votes.kick.min_players = 4;
voting.votes.polls = "everyone";
voting.votes.custom = {{"restart", "Reload the current map", "map {map}", {}, {true, 70}},
{"noclip", "Allow noclip", "noclip {arg}", {"on", "off"}, {false, 55, 20, 0, 3}}};
voting.announcements = {{"Welcome to the server!", "Join our Discord"}, 15, false};
check(config_error(voting).empty(), "Valid custom votes and announcements refused");
save_config(voting);
const auto back = load_config(file);
check(back.votes.kick.seconds == 45 && back.votes.kick.min_players == 4 && back.votes.polls == "everyone",
"Vote settings lost on save");
check(back.votes.custom.size() == 2 && back.votes.custom[0].name == "restart" && back.votes.custom[0].command == "map {map}" &&
back.votes.custom[0].setting.percent == 70 && back.votes.custom[1].choices == std::vector<std::string>{"on", "off"} &&
!back.votes.custom[1].setting.enabled && back.votes.custom[1].setting.seconds == 20 &&
back.votes.custom[1].setting.min_players == 3,
"Custom votes lost on save");
check(back.announcements.messages == voting.announcements.messages && back.announcements.interval == 15 &&
!back.announcements.card,
"Announcements lost on save");
const auto refused = [&](auto change, const char *what) {
auto bad = voting;
change(bad);
check(!config_error(bad).empty(), what);
};
refused([](ServerConfig &c) { c.votes.custom[0].name = "map"; }, "A custom vote named like a built-in one accepted");
refused([](ServerConfig &c) { c.votes.custom[0].name = "Restart!"; }, "A custom vote name with capitals and marks accepted");
refused([](ServerConfig &c) { c.votes.custom[0].name = "list"; }, "A custom vote named like /vote list accepted");
refused([](ServerConfig &c) { c.votes.custom[0].name = "2"; }, "A custom vote named like a poll answer accepted");
refused([](ServerConfig &c) { c.votes.custom[1].name = "restart"; }, "Two custom votes with one name accepted");
refused([](ServerConfig &c) { c.votes.custom[0].command.clear(); }, "A custom vote without a command accepted");
refused([](ServerConfig &c) { c.votes.custom[1].choices.clear(); }, "{arg} without choices accepted");
refused([](ServerConfig &c) { c.votes.custom[0].choices = {"a"}; }, "Choices without {arg} accepted");
refused([](ServerConfig &c) { c.votes.custom[1].choices = {"on now"}; }, "A choice with a space accepted");
refused([](ServerConfig &c) { c.announcements.messages = {std::string(201, 'a')}; }, "An announcement over one chat line accepted");
refused([](ServerConfig &c) { c.votes.custom.resize(17, c.votes.custom[0]); }, "More than 16 custom votes accepted");
// A vote written in by hand needs only its name and command; nonsense is evened out.
std::ofstream(file, std::ios::binary) << R"({"votes": {"polls": "sometimes", "poll_seconds": 5000,
"custom": [{"name": "restart", "command": "map {map}", "percent": 500}]},
"announcements": {"messages": ["hi", "", 3], "interval_minutes": 99999}})";
const auto hand = load_config(file);
check(hand.votes.polls == "admins" && hand.votes.poll_seconds == 600 && hand.votes.custom.size() == 1 &&
hand.votes.custom[0].setting.enabled && hand.votes.custom[0].setting.percent == 100 &&
hand.announcements.messages == std::vector<std::string>{"hi"} && hand.announcements.interval == 1440,
"A hand-written custom vote or announcement list was not read sensibly");
}
std::filesystem::remove_all(folder);
if (failures) return 1;
std::cout << "server config: ok\n";
+14 -37
View File
@@ -17,7 +17,9 @@ constexpr std::string_view help_text =
"map <name, e.g. San Vansterdam> | maps | name <text> | password <text|off> | welcome <text|off> | listed on|off\n"
"voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low> | crowd <n>|off | rate <KB/s> | bone-scale <1-8>|off\n"
"placement everyone|admins|nobody | objects <number>|off | object-scaling on|off | effects on|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n"
"tpall [player] | tphere <player> | votes [map|kick|tod on|off|<percent>] | vote-cancel\n"
"tpall [player] | tphere <player> | votes [<vote> on|off|<percent>|seconds|cooldown|min-players <n>] | vote-cancel\n"
"votes polls off|admins|everyone | votes poll-seconds <n> | votes starter-yes on|off\n"
"announce <text> | announcements [list|add <text>|remove <n>|clear|interval <minutes>|off|card on|off]\n"
"map-pool [add|remove <map>|clear] | rotation [<minutes>|off]\n"
"park <lot> <layout> | park random | layer-sync on|off | layer <key> default|on|off | tod <time|default>\n"
"activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | afk-kick <minutes>|off | speed-check off|warn|kick\n"
@@ -467,42 +469,17 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
: "Players no longer see each other's skater effects. Ones already showing stay until that player's skater is shown again.");
}
if (name == "votes") {
// votes | votes <map|kick|tod> on|off|<percent> | votes seconds|cooldown <n>
const auto [what_text, value_text] = split(argument);
const auto what = lower(what_text);
const auto describe = [&](const char *label, const VoteSetting &v) {
return std::string(label) + ": " + (v.enabled ? "on, " + std::to_string(v.percent) + "% to pass" : "off");
};
if (what.empty())
return describe("map votes", config_.votes.map) + "\n" + describe("kick votes", config_.votes.kick) + "\n" +
describe("time of day votes", config_.votes.time) +
(config_.world_layer_sync ? "" : " (needs layer-sync on)") + "\nvotes last " +
std::to_string(config_.votes.seconds) + " s; a player waits " + std::to_string(config_.votes.cooldown) +
" s between votes" + (vote_ ? "\nrunning: a vote to " + vote_->label : std::string{});
const auto value = lower(value_text);
if (what == "seconds" || what == "cooldown") {
const auto n = number(value);
const bool seconds = what == "seconds";
if (!n || (seconds ? *n < 10 || *n > 300 : *n > 3600))
return seconds ? "votes seconds <10-300>" : "votes cooldown <0-3600>";
(seconds ? config_.votes.seconds : config_.votes.cooldown) = static_cast<unsigned>(*n);
return changed(seconds ? "Votes now last " + std::to_string(*n) + " s."
: "Players now wait " + std::to_string(*n) + " s between votes.");
}
VoteSetting *setting = what == "map" ? &config_.votes.map : what == "kick" ? &config_.votes.kick
: what == "tod" || what == "time" ? &config_.votes.time : nullptr;
if (!setting) return "votes [map|kick|tod on|off|<percent>] | votes seconds <n> | votes cooldown <n>";
const auto label = what == "map" ? std::string("Map votes") : what == "kick" ? std::string("Kick votes")
: std::string("Time of day votes");
if (const auto toggle = on_off(value)) {
setting->enabled = *toggle;
if (!*toggle && vote_ && vote_setting(vote_->kind).enabled == false) cancel_vote("that vote was switched off");
return changed(label + (*toggle ? " are on (" + std::to_string(setting->percent) + "% to pass)." : " are off."));
}
const auto percent = number(value.ends_with("%") ? std::string_view(value).substr(0, value.size() - 1) : std::string_view(value));
if (!percent || *percent < 1 || *percent > 100) return "votes " + what + " on|off|<1-100>";
setting->percent = static_cast<unsigned>(*percent);
return changed(label + " now need " + std::to_string(*percent) + "% to pass.");
const auto [text, did] = votes_command(argument);
return did ? changed(text) : text;
}
if (name == "announcements") {
const auto [text, did] = announcements_command(argument);
return did ? changed(text) : text;
}
if (name == "announce") {
if (argument.empty()) return "announce <text>";
announce(argument);
return "Announced.";
}
if (name == "vote-cancel") {
if (!vote_) return "No vote is running.";
+112 -2
View File
@@ -136,12 +136,42 @@ Layout layout(const ServerConfig &c) {
auto &item = votes.section(name);
item.set("enabled", v.enabled);
item.set("percent", v.percent);
item.set("seconds", v.seconds);
item.set("cooldown_seconds", v.cooldown);
item.set("min_players", v.min_players);
};
vote("map", c.votes.map);
vote("kick", c.votes.kick);
vote("time_of_day", c.votes.time);
votes.set("seconds", c.votes.seconds);
votes.set("cooldown_seconds", c.votes.cooldown);
votes.set("starter_votes_yes", c.votes.starter_votes_yes);
votes.set("polls", c.votes.polls);
votes.set("poll_seconds", c.votes.poll_seconds);
auto custom = Json::array();
for (const auto &v : c.votes.custom) {
auto item = Json::object();
item["name"] = v.name;
item["description"] = v.description;
item["command"] = v.command;
auto choices = Json::array();
for (const auto &choice : v.choices) choices.push_back(choice);
item["choices"] = std::move(choices);
item["enabled"] = v.setting.enabled;
item["percent"] = v.setting.percent;
item["seconds"] = v.setting.seconds;
item["cooldown_seconds"] = v.setting.cooldown;
item["min_players"] = v.setting.min_players;
custom.push_back(std::move(item));
}
votes.set("custom", std::move(custom));
auto &announcements = root.section("announcements");
auto messages = Json::array();
for (const auto &message : c.announcements.messages) messages.push_back(message);
announcements.set("messages", std::move(messages));
announcements.set("interval_minutes", c.announcements.interval);
announcements.set("card", c.announcements.card);
return root;
}
// Bans have a file of their own, beside the config.
@@ -293,19 +323,61 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
// Votes were a section from the start.
const Json no_votes = Json::object();
const auto &votes = root.contains("votes") && root.at("votes").is_object() ? root.at("votes") : no_votes;
// A vote's own run time, cooldown and player minimum; 0 for the first two is "the votes'".
const auto vote_limits = [](const Json &item, VoteSetting &v) {
v.seconds = item.value("seconds", v.seconds);
if (v.seconds) v.seconds = std::clamp(v.seconds, 10U, 300U);
v.cooldown = std::min(item.value("cooldown_seconds", v.cooldown), 3600U);
v.min_players = std::clamp(item.value("min_players", v.min_players), 1U, static_cast<unsigned>(multiplayer::max_players - 1));
};
const auto read_vote = [&](const char *key, VoteSetting &v) {
if (!votes.contains(key) || !votes.at(key).is_object()) return missing.push_back(std::string("votes.") + key);
const auto &item = votes.at(key);
for (const char *added : {"seconds", "cooldown_seconds", "min_players"})
if (!item.contains(added)) missing.push_back(std::string("votes.") + key + "." + added);
v.enabled = item.value("enabled", v.enabled);
v.percent = std::clamp(item.value("percent", v.percent), 1U, 100U);
vote_limits(item, v);
};
read_vote("map", c.votes.map);
read_vote("kick", c.votes.kick);
read_vote("time_of_day", c.votes.time);
if (!votes.contains("seconds")) missing.push_back("votes.seconds");
if (!votes.contains("cooldown_seconds")) missing.push_back("votes.cooldown_seconds");
for (const char *key : {"seconds", "cooldown_seconds", "starter_votes_yes", "polls", "poll_seconds", "custom"})
if (!votes.contains(key)) missing.push_back(std::string("votes.") + key);
c.votes.seconds = std::clamp(votes.value("seconds", c.votes.seconds), 10U, 300U);
c.votes.cooldown = std::clamp(votes.value("cooldown_seconds", c.votes.cooldown), 0U, 3600U);
c.votes.starter_votes_yes = votes.value("starter_votes_yes", c.votes.starter_votes_yes);
c.votes.polls = votes.value("polls", c.votes.polls);
if (c.votes.polls != "off" && c.votes.polls != "admins" && c.votes.polls != "everyone") c.votes.polls = "admins";
c.votes.poll_seconds = std::clamp(votes.value("poll_seconds", c.votes.poll_seconds), 10U, 600U);
if (votes.contains("custom") && votes.at("custom").is_array())
for (const auto &item : votes.at("custom")) {
if (!item.is_object()) throw std::runtime_error("votes.custom must be a list of votes, each a JSON object.");
// A vote the owner writes in needs no more than its name and command.
CustomVote v;
v.name = item.value("name", std::string{});
v.description = item.value("description", std::string{});
v.command = item.value("command", std::string{});
if (item.contains("choices") && item.at("choices").is_array())
for (const auto &choice : item.at("choices"))
if (choice.is_string()) v.choices.push_back(choice.string());
v.setting.enabled = item.value("enabled", v.setting.enabled);
v.setting.percent = std::clamp(item.value("percent", v.setting.percent), 1U, 100U);
vote_limits(item, v.setting);
c.votes.custom.push_back(std::move(v));
}
// Announcements came after the sections.
const Json no_announcements = Json::object();
const auto &announcements = root.contains("announcements") && root.at("announcements").is_object()
? root.at("announcements") : no_announcements;
for (const char *key : {"messages", "interval_minutes", "card"})
if (!announcements.contains(key)) missing.push_back(std::string("announcements.") + key);
if (announcements.contains("messages") && announcements.at("messages").is_array())
for (const auto &message : announcements.at("messages"))
if (message.is_string() && !message.string().empty()) c.announcements.messages.push_back(message.string());
c.announcements.interval = std::min(announcements.value("interval_minutes", c.announcements.interval), max_announcement_interval);
c.announcements.card = announcements.value("card", c.announcements.card);
const auto read_bans = [&](const Json &rows) {
if (!rows.is_array()) throw std::runtime_error("The bans must be a JSON list.");
@@ -392,6 +464,39 @@ std::optional<std::uint32_t> parse_colour(std::string_view text) noexcept {
// Written red first; held with red lowest and opaque.
return 0xff000000U | (rgb & 0xff) << 16 | (rgb & 0xff00) | rgb >> 16;
}
bool custom_vote_name_free(std::string_view name) noexcept {
// What "/vote <word>" already means.
for (const std::string_view taken : {"map", "kick", "tod", "time", "yes", "y", "no", "n", "poll", "list"})
if (name == taken) return false;
// A number is an answer to a poll ("/vote 2").
return !std::all_of(name.begin(), name.end(), [](char c) { return c >= '0' && c <= '9'; });
}
std::string custom_votes_error(const std::vector<CustomVote> &votes) {
using namespace multiplayer;
if (votes.size() > server_custom_vote_limit)
return "votes.custom: at most " + std::to_string(server_custom_vote_limit) + " votes.";
for (std::size_t i = 0; i < votes.size(); ++i) {
const auto &v = votes[i];
const auto where = "votes.custom \"" + v.name + "\": ";
if (!valid_server_vote_name(v.name))
return "votes.custom: each vote needs a name of 1 to 16 lowercase letters, digits, - or _ (\"" + v.name + "\" is not).";
if (!custom_vote_name_free(v.name)) return where + "that name is one of the server's own votes.";
for (std::size_t j = 0; j < i; ++j)
if (votes[j].name == v.name) return where + "two votes have that name.";
if (!v.description.empty() && (v.description.size() > server_vote_description_bytes || !valid_chat_text(v.description)))
return where + "description must be one chat line of at most " + std::to_string(server_vote_description_bytes) + " bytes.";
if (v.command.empty() || !valid_admin_text(v.command)) return where + "command must be one server command, e.g. \"map {map}\".";
if (v.choices.size() > server_vote_max_choices)
return where + "at most " + std::to_string(server_vote_max_choices) + " choices.";
for (const auto &choice : v.choices)
if (!valid_server_vote_name(choice))
return where + "each choice is 1 to 16 lowercase letters, digits, - or _ (\"" + choice + "\" is not).";
const bool takes_argument = v.command.find("{arg}") != std::string::npos;
if (takes_argument && v.choices.empty()) return where + "a command with {arg} needs a list of choices.";
if (!takes_argument && !v.choices.empty()) return where + "choices need {arg} in the command, where the choice goes.";
}
return {};
}
std::string config_error(const ServerConfig &c) {
using namespace multiplayer;
if (!parse_colour(c.chat_color)) return "chat_color must be a colour like #8E5CFF.";
@@ -423,6 +528,11 @@ std::string config_error(const ServerConfig &c) {
return "max_players must be 1 to " + std::to_string(max_players - 1) + ".";
if (c.password.size() > 64) return "password must be at most 64 characters.";
if (!c.welcome.empty() && !valid_chat_text(c.welcome)) return "welcome must be one chat line (at most 200 bytes).";
if (auto error = custom_votes_error(c.votes.custom); !error.empty()) return error;
if (c.announcements.messages.size() > max_announcements)
return "announcements.messages: at most " + std::to_string(max_announcements) + " messages.";
for (const auto &message : c.announcements.messages)
if (!valid_chat_text(message)) return "announcements.messages: each must be one chat line (at most 200 bytes).";
if (c.tps != dedicated_tps) return "tps is " + std::to_string(dedicated_tps) + " on dedicated servers.";
if (!valid_voice_range(c.voice_range)) return "voice_range must be 50 to 1000.";
if (!valid_object_limit(c.object_limit)) return "object_limit must be 0 (no limit) to " + std::to_string(max_object_limit) + ".";
+31
View File
@@ -19,11 +19,37 @@ namespace dingosdk::server {
struct VoteSetting {
bool enabled{};
unsigned percent = 60;
unsigned seconds{}; // how long it runs; 0: VoteSettings::seconds
unsigned cooldown{}; // seconds before its starter may start another vote; 0: VoteSettings::cooldown
unsigned min_players = 1; // players on before anyone may start it
};
// A vote the server's owner defines: "/vote <name> [choice]" runs `command` (any server
// command, as the console) when it passes. In the command {map} is the current map and {arg}
// the choice the starter picked, one of `choices`; without choices the vote takes no argument.
struct CustomVote {
std::string name; // 1-16 of a-z 0-9 - _
std::string description; // shown in /help and the "/" menu
std::string command; // "map {map}", "noclip {arg}"...
std::vector<std::string> choices;
VoteSetting setting{true, 60};
};
struct VoteSettings {
VoteSetting map, kick, time{false, 50};
std::vector<CustomVote> custom;
unsigned seconds = 30; // how long a vote runs
unsigned cooldown = 60; // seconds before the same player may start another
bool starter_votes_yes = true; // whoever starts a vote has voted yes
// Polls: questions with up to six answers that run nothing. "off", "admins" or "everyone".
std::string polls = "admins";
unsigned poll_seconds = 60;
};
inline constexpr std::size_t max_announcements = 32;
inline constexpr unsigned max_announcement_interval = 1440; // minutes
// Messages the server posts by itself, one every `interval` minutes in turn while players are on.
struct Announcements {
std::vector<std::string> messages;
unsigned interval{}; // minutes; 0: off
bool card = true; // also as a card on each player's screen, not only in chat
};
// ReSkateServer.json. Every setting an admin or the console changes is saved
// back, so a restart keeps it.
@@ -118,6 +144,7 @@ struct ServerConfig {
// Players skate with the game's own physics tuning, not copies they edited.
bool enforce_tuning = true;
VoteSettings votes; // all off until the owner turns them on
Announcements announcements;
ParkChoices parks{"skatepark_01", "megapark_05", "flumppark_08"};
// Forced on every player while world_layer_sync is on: layer key -> mode.
// Needs world-layers.json (the players' catalog) next to the server.
@@ -143,6 +170,10 @@ bool may_join(const ServerConfig &config, std::uint64_t id, std::size_t on) noex
std::optional<std::uint32_t> parse_colour(std::string_view text) noexcept;
// Why `config` cannot run, or empty.
std::string config_error(const ServerConfig &config);
// Why the owner's custom votes cannot run, or empty; and whether a custom vote may be called
// `name` (not a word "/vote" already takes: map, kick, tod, yes, poll...).
std::string custom_votes_error(const std::vector<CustomVote> &votes);
bool custom_vote_name_free(std::string_view name) noexcept;
// A scoring fingerprint as the config and console write it (16 hex digits), and read back
// (nothing for text that is not one, or for 0: the game's own scoring needs no entry).
std::string scoring_text(std::uint64_t fingerprint);
+6
View File
@@ -642,6 +642,11 @@ void Host::send_roster() {
p.guest_boosts = config_.boosts;
p.enforce_tuning = config_.enforce_tuning;
p.server_votes = enabled_votes();
p.server_polls = static_cast<std::uint8_t>(enabled_polls());
p.server_custom_votes = custom_votes();
p.announcement = announcement_;
if (announcement_.id)
p.announcement.seconds = static_cast<std::uint16_t>(announcement_until_ > now_ ? (announcement_until_ - now_ + 999999) / 1000000 : 0);
p.object_clears = object_clears_;
// Without the catalog (or with sync off) no layers are sent: every player keeps their own.
p.force_world_layers = config_.world_layer_sync && !world_layers().empty();
@@ -1683,6 +1688,7 @@ void Host::tick(std::uint64_t now) {
roster_dirty_ = true;
}
tick_rotation();
tick_announcements();
remove_away();
std::erase_if(vote_cooldowns_, [&](const auto &entry) { return now_ >= entry.second; });
join_backoff_.prune(now_);
+31 -7
View File
@@ -47,7 +47,7 @@ class Host {
// The UDP port players may connect straight to, or 0 (config connection).
std::uint16_t direct_port() const { return direct_port_; }
enum class VoteKind { map, kick, time };
enum class VoteKind { map, kick, time, custom, poll };
private:
struct ChatBudget {
@@ -191,14 +191,17 @@ class Host {
Log log_;
ActivityLog activity_; // what players do, for the console (config_.activity_log)
// The running player vote (server_votes.cpp), and when each player may start another.
// A poll is one too: it has answers instead of yes and no, and runs nothing.
struct Vote {
VoteKind kind{};
std::size_t custom{}; // custom: which of config_.votes.custom
std::uint32_t id{}; // told to games, so each can tell one vote from the next
std::uint64_t starter{}, target{}; // target: the player a kick vote is about
std::string value, label; // value: the map or time; label: "change the map to ..."
std::string value, label; // value: the map, time or command; label: "change the map to ..." or the question
std::set<std::uint64_t> yes, no;
std::vector<std::string> answers; // poll
std::map<std::uint64_t, std::size_t> chosen; // poll: each voter's answer
std::uint64_t ends{};
unsigned shown_yes{}, shown_no{}; // the tally last announced
};
std::optional<Vote> vote_;
// What games are shown of the vote (the roster carries it): the running one with its
@@ -206,7 +209,13 @@ class Host {
multiplayer::ServerVote vote_shown_;
std::uint64_t vote_shown_until_{};
std::uint32_t vote_ids_{};
void show_vote(const Vote &vote, std::uint8_t outcome, unsigned yes, unsigned no, unsigned needed);
void show_vote(const Vote &vote, std::uint8_t outcome);
// The announcement games show (the roster carries it), until announcement_until_.
multiplayer::ServerAnnouncement announcement_;
std::uint64_t announcement_until_{};
std::uint32_t announcement_ids_{};
std::uint64_t announced_at_{}; // the last timed announcement, or while nobody is on
std::size_t next_announcement_{};
void active(Guest &guest) {
guest.active_at = now_;
guest.away_warned = false;
@@ -323,13 +332,28 @@ class Host {
void apply_layers();
// Chat commands and votes (server_votes.cpp).
void chat_command(Guest &, std::string_view line);
void start_vote(Guest &, VoteKind, std::string_view argument);
void start_vote(Guest &, VoteKind, std::string_view argument, std::size_t custom = 0);
void start_poll(Guest &, std::string_view text);
void cast_vote(Guest &, bool yes);
void answer_poll(Guest &, std::size_t answer);
void end_poll(Guest &);
void check_vote(bool expired);
void cancel_vote(const std::string &why);
const VoteSetting &vote_setting(VoteKind) const;
const VoteSetting &vote_setting(VoteKind, std::size_t custom = 0) const;
std::uint8_t enabled_votes() const;
void reply(Guest &, std::string_view text);
multiplayer::ServerPolls enabled_polls() const;
std::vector<multiplayer::ServerCustomVote> custom_votes() const; // the owner's votes that are on, for the roster
std::set<std::uint64_t> vote_voters(const Vote &) const; // who may vote in it
unsigned votes_needed(const Vote &, unsigned voters) const;
std::vector<unsigned> poll_count(const Vote &) const;
std::string running_vote_text() const; // what is running, and how to answer it
// The "votes" and "announcements" commands (server_votes.cpp); the bool: a setting changed.
std::pair<std::string, bool> votes_command(std::string_view argument);
std::pair<std::string, bool> announcements_command(std::string_view argument);
// A line in chat, and the announcement card on every player's screen.
void announce(std::string_view text);
void tick_announcements(); // the owner's messages in turn, on their timer
void reply(Guest &, std::string_view text, unsigned max_lines = 12);
Guest *match_player(std::string_view text);
void tick_rotation();
std::string pool_text() const; // the map pool, one map a line
+442 -48
View File
@@ -4,19 +4,69 @@
#include <array>
#include <cmath>
// Player votes (map, kick, time of day) and the chat commands that run them. Each vote is
// switched on and given its pass percentage in ReSkateServer.json ("votes"); also the map pool and rotation.
// Player votes (map, kick, time of day, and the owner's own custom votes), polls,
// announcements, and the chat commands that run them. Each vote is switched on and given its
// pass percentage in ReSkateServer.json ("votes"); also the map pool and rotation. Games show
// the running vote or poll, and each announcement, as a card (the roster carries them).
namespace dingosdk::server {
namespace {
constexpr std::array<std::string_view, 8> times{"default", "morning", "noon", "afternoon",
"evening", "night", "weatherday", "weathernight"};
const char *vote_name(Host::VoteKind kind) {
return kind == Host::VoteKind::map ? "Map" : kind == Host::VoteKind::kick ? "Kick" : "Time of day";
switch (kind) {
case Host::VoteKind::map: return "Map";
case Host::VoteKind::kick: return "Kick";
case Host::VoteKind::time: return "Time of day";
case Host::VoteKind::custom: return "Custom";
case Host::VoteKind::poll: return "Poll";
}
return "";
}
std::uint8_t vote_bit(Host::VoteKind kind) {
switch (kind) {
case Host::VoteKind::map: return server_vote_map;
case Host::VoteKind::kick: return server_vote_kick;
case Host::VoteKind::time: return server_vote_time;
case Host::VoteKind::custom: return multiplayer::server_vote_custom;
case Host::VoteKind::poll: return multiplayer::server_vote_poll;
}
return 0;
}
std::uint64_t microseconds(unsigned seconds) { return static_cast<std::uint64_t>(seconds) * 1000000; }
// `text` with each `key` replaced by `value`.
std::string fill(std::string text, std::string_view key, std::string_view value) {
for (auto at = text.find(key); at != std::string::npos; at = text.find(key, at + value.size()))
text.replace(at, key.size(), value);
return text;
}
std::string joined(const std::vector<std::string> &items, std::string_view between) {
std::string text;
for (const auto &item : items) text += (text.empty() ? "" : std::string(between)) + item;
return text;
}
// "Grom 3, San Vansterdam 1"
std::string answer_count(const std::vector<std::string> &answers, const std::vector<unsigned> &count) {
std::string text;
for (std::size_t i = 0; i < answers.size() && i < count.size(); ++i)
text += (i ? ", " : "") + answers[i] + " " + std::to_string(count[i]);
return text;
}
// "The vote to change the map to Grom", "The poll \"Next map?\""
std::string vote_title(Host::VoteKind kind, const std::string &label) {
return kind == Host::VoteKind::poll ? "The poll \"" + label + "\"" : "The vote to " + label;
}
} // namespace
const VoteSetting &Host::vote_setting(VoteKind kind) const {
return kind == VoteKind::map ? config_.votes.map : kind == VoteKind::kick ? config_.votes.kick : config_.votes.time;
const VoteSetting &Host::vote_setting(VoteKind kind, std::size_t custom) const {
static const VoteSetting none;
switch (kind) {
case VoteKind::map: return config_.votes.map;
case VoteKind::kick: return config_.votes.kick;
case VoteKind::time: return config_.votes.time;
case VoteKind::custom: return custom < config_.votes.custom.size() ? config_.votes.custom[custom].setting : none;
case VoteKind::poll: break;
}
return none;
}
std::uint8_t Host::enabled_votes() const {
std::uint8_t bits{};
@@ -26,10 +76,21 @@ std::uint8_t Host::enabled_votes() const {
if (config_.votes.time.enabled && config_.world_layer_sync && !world_layers().empty()) bits |= server_vote_time;
return bits;
}
void Host::reply(Guest &guest, std::string_view text) {
multiplayer::ServerPolls Host::enabled_polls() const {
using multiplayer::ServerPolls;
return config_.votes.polls == "everyone" ? ServerPolls::everyone
: config_.votes.polls == "admins" ? ServerPolls::admins : ServerPolls::off;
}
std::vector<multiplayer::ServerCustomVote> Host::custom_votes() const {
std::vector<multiplayer::ServerCustomVote> list;
for (const auto &v : config_.votes.custom)
if (v.setting.enabled) list.push_back({v.name, v.description, v.choices});
return list;
}
void Host::reply(Guest &guest, std::string_view text, unsigned max_lines) {
// Chat lines are single lines: longer answers (admin commands) arrive a line at a time.
unsigned lines{};
for (std::size_t at = 0; at <= text.size() && lines < 12;) {
for (std::size_t at = 0; at <= text.size() && lines < max_lines;) {
const auto end = text.find('\n', at);
const auto line = text.substr(at, end == std::string_view::npos ? std::string_view::npos : end - at);
if (!trim(line).empty()) {
@@ -58,18 +119,30 @@ Host::Guest *Host::match_player(std::string_view text) {
}
void Host::chat_command(Guest &guest, std::string_view line) {
using multiplayer::ServerPolls;
const auto [first, rest] = split(line);
const auto verb = lower(first);
const auto custom = custom_votes();
const auto polls = enabled_polls();
if (verb.empty() || verb == "help" || verb == "?") {
std::string text;
const auto votes = enabled_votes();
if (votes & server_vote_map) text += "/vote map <map>: start a vote to change the map\n";
if (votes & server_vote_kick) text += "/vote kick <player>: start a vote to kick a player\n";
if (votes & server_vote_time) text += "/vote tod <time>: vote for a time of day (morning, noon, night...)\n";
if (votes) text += "/yes or /no: vote in the running vote\n";
if (!custom.empty()) {
std::vector<std::string> names;
for (const auto &v : custom) names.push_back(v.name);
text += "/vote <name>: this server's own votes (" + joined(names, ", ") + "); /vote list says what each does\n";
}
if (votes || !custom.empty()) text += "/yes or /no: vote in the running vote\n";
if (polls == ServerPolls::everyone || (polls == ServerPolls::admins && is_admin(guest.member.id)))
text += "/poll <question> | <answer> | <answer>...: ask everyone a question\n";
if (polls != ServerPolls::off) text += "/1, /2...: answer the running poll\n";
if (config_.map_rotation) text += "The map changes every " + std::to_string(config_.map_rotation) + " min.\n";
if (config_.parties) text += "/party: your party (invite, accept, leave...; /party help); /p <message>: party chat\n";
if (is_admin(guest.member.id)) text += "Admins: any server command as /<command>, e.g. /kick, /map, /tpall, /votes, /msg, /msg-party, /msg-admins\n";
if (is_admin(guest.member.id))
text += "Admins: any server command as /<command>, e.g. /kick, /map, /tpall, /votes, /announce, /msg, /msg-party, /msg-admins\n";
const std::string whisper = "/w <player> <message>: send a private message";
return reply(guest, (text.empty() ? "This server has no player votes. Type /tp <player> to teleport.\n" : text) + whisper);
}
@@ -88,6 +161,16 @@ void Host::chat_command(Guest &guest, std::string_view line) {
send_chat(dm_line(guest_name(guest), {}, text, multiplayer_chat_max_bytes), other);
return reply(guest, "[DM to " + guest_name(*other) + "] " + clean_chat_text(text));
}
// "/1".."/6": an answer to the running poll.
const auto answer = [](std::string_view word) -> std::optional<std::size_t> {
if (word.size() != 1 || word[0] < '1' || word[0] > '0' + static_cast<char>(multiplayer::max_vote_answers)) return {};
return static_cast<std::size_t>(word[0] - '1');
};
if (const auto chosen = answer(verb)) return answer_poll(guest, *chosen);
if (verb == "poll") {
if (lower(trim(rest)) == "end") return end_poll(guest);
return start_poll(guest, rest);
}
if (verb == "yes" || verb == "y") return cast_vote(guest, true);
if (verb == "no" || verb == "n") return cast_vote(guest, false);
if (verb == "vote") {
@@ -95,35 +178,58 @@ void Host::chat_command(Guest &guest, std::string_view line) {
const auto what = lower(what_text);
if (what == "yes" || what == "y") return cast_vote(guest, true);
if (what == "no" || what == "n") return cast_vote(guest, false);
if (const auto chosen = answer(what)) return answer_poll(guest, *chosen);
if (what == "map") return start_vote(guest, VoteKind::map, argument);
if (what == "kick") return start_vote(guest, VoteKind::kick, argument);
if (what == "tod" || what == "time") return start_vote(guest, VoteKind::time, argument);
if (vote_) return reply(guest, "Running: a vote to " + vote_->label + ". Type /yes or /no.");
return reply(guest, enabled_votes() ? "Start one with /vote map, /vote kick or /vote tod (see /help)."
: "This server has no player votes.");
if (what == "list") {
if (custom.empty()) return reply(guest, "This server has no votes of its own.");
std::string text;
for (const auto &v : custom)
text += "/vote " + v.name + (v.choices.empty() ? "" : " <" + joined(v.choices, "|") + ">") +
(v.description.empty() ? "" : ": " + v.description) + "\n";
return reply(guest, text, static_cast<unsigned>(multiplayer::server_custom_vote_limit));
}
for (std::size_t i = 0; i < config_.votes.custom.size(); ++i)
if (config_.votes.custom[i].name == what) return start_vote(guest, VoteKind::custom, argument, i);
if (vote_) return reply(guest, running_vote_text());
return reply(guest, enabled_votes() || !custom.empty() ? "Start one with /vote map, /vote kick, /vote tod or /vote list (see /help)."
: "This server has no player votes.");
}
// Admins run any server command from chat, as they do with "mp server". The caller
// has already logged the line with any password hidden, so it is not logged again here.
if (is_admin(guest.member.id)) {
const auto id = guest.member.id;
const auto answer = command(line, id);
if (auto *still = find(id)) reply(*still, answer.empty() ? std::string("Done.") : answer);
const auto answer_text = command(line, id);
if (auto *still = find(id)) reply(*still, answer_text.empty() ? std::string("Done.") : answer_text);
return;
}
reply(guest, "Unknown command /" + verb + ". Type /help for the list.");
}
void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument) {
const auto &setting = vote_setting(kind);
if (!(enabled_votes() & (kind == VoteKind::map ? server_vote_map : kind == VoteKind::kick ? server_vote_kick : server_vote_time)))
return reply(guest, kind == VoteKind::time && setting.enabled
? "Time of day votes need world layer sync on the server."
: std::string(vote_name(kind)) + " votes are off on this server.");
if (vote_) return reply(guest, "A vote is already running: " + vote_->label + ". Type /yes or /no.");
std::string Host::running_vote_text() const {
if (!vote_) return "No vote is running.";
if (vote_->kind == VoteKind::poll)
return "A poll is running: " + vote_->label + " Answer on the card at the right of your screen, or type /1 to /" +
std::to_string(vote_->answers.size()) + ".";
return "A vote is running: " + vote_->label + ". Type /yes or /no.";
}
void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument, std::size_t custom) {
const auto &setting = vote_setting(kind, custom);
const bool on = kind == VoteKind::custom ? setting.enabled : (enabled_votes() & vote_bit(kind)) != 0;
if (!on)
return reply(guest, kind == VoteKind::time && setting.enabled ? std::string("Time of day votes need world layer sync on the server.")
: kind == VoteKind::custom ? "That vote is off on this server."
: std::string(vote_name(kind)) + " votes are off on this server.");
if (vote_) return reply(guest, running_vote_text());
if (const auto wait = vote_cooldowns_.find(guest.member.id); wait != vote_cooldowns_.end() && now_ < wait->second)
return reply(guest, "Wait " + std::to_string((wait->second - now_) / 1000000 + 1) + " s before starting another vote.");
if (players() < setting.min_players)
return reply(guest, "That vote needs " + std::to_string(setting.min_players) + " players on (" + std::to_string(players()) + " now).");
Vote vote;
vote.kind = kind;
vote.custom = custom;
vote.starter = guest.member.id;
switch (kind) {
case VoteKind::map: {
@@ -155,23 +261,81 @@ void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument) {
vote.label = "set the time of day to " + wanted;
break;
}
case VoteKind::custom: {
// The owner wrote the command; a player only picks one of its choices, never free text.
const auto &v = config_.votes.custom[custom];
auto choice = lower(trim(argument));
if (v.choices.empty()) choice.clear();
else if (std::find(v.choices.begin(), v.choices.end(), choice) == v.choices.end())
return reply(guest, "/vote " + v.name + " <" + joined(v.choices, "|") + ">" + (v.description.empty() ? "" : ": " + v.description));
vote.value = fill(fill(v.command, "{map}", config_.map), "{arg}", choice);
vote.label = v.description.empty() ? v.name + (choice.empty() ? "" : " " + choice)
: v.description + (choice.empty() ? "" : ": " + choice);
// The card reads it as a question ("Reload the current map?"): it starts in lower case, as the others do.
if (!vote.label.empty() && vote.label.front() >= 'A' && vote.label.front() <= 'Z') vote.label.front() = static_cast<char>(vote.label.front() + 32);
break;
}
vote.yes.insert(guest.member.id); // the starter is for it
case VoteKind::poll: return start_poll(guest, argument);
}
if (config_.votes.starter_votes_yes) vote.yes.insert(guest.member.id);
if (!++vote_ids_) ++vote_ids_;
vote.id = vote_ids_;
vote.ends = now_ + static_cast<std::uint64_t>(config_.votes.seconds) * 1000000;
vote_cooldowns_[guest.member.id] = now_ + static_cast<std::uint64_t>(config_.votes.cooldown) * 1000000;
const auto seconds = setting.seconds ? setting.seconds : config_.votes.seconds;
vote.ends = now_ + microseconds(seconds);
vote_cooldowns_[guest.member.id] = now_ + microseconds(setting.cooldown ? setting.cooldown : config_.votes.cooldown);
const auto label = vote.label;
vote_ = std::move(vote);
send_chat(guest_name(guest) + " started a vote to " + label + " (" + std::to_string(setting.percent) + "% needed, " +
std::to_string(config_.votes.seconds) + " s). Vote on the card at the right of your screen, or type /yes or /no.");
std::to_string(seconds) + " s). Vote on the card at the right of your screen, or type /yes or /no.");
log_("[vote] " + guest_name(guest) + " started a vote to " + label + ".");
check_vote(false);
}
void Host::start_poll(Guest &guest, std::string_view text) {
using multiplayer::ServerPolls;
const auto polls = enabled_polls();
const bool admin = is_admin(guest.member.id);
if (polls == ServerPolls::off) return reply(guest, "Polls are off on this server.");
if (polls == ServerPolls::admins && !admin) return reply(guest, "Only admins can start a poll on this server.");
if (vote_) return reply(guest, running_vote_text());
if (const auto wait = vote_cooldowns_.find(guest.member.id); !admin && wait != vote_cooldowns_.end() && now_ < wait->second)
return reply(guest, "Wait " + std::to_string((wait->second - now_) / 1000000 + 1) + " s before starting a poll.");
// "<question> | <answer> | <answer>..."
std::vector<std::string> parts;
for (std::size_t at = 0; at <= text.size();) {
const auto bar = text.find('|', at);
auto part = clean_chat_text(trim(text.substr(at, bar == std::string_view::npos ? std::string_view::npos : bar - at)));
cut_text(part, parts.empty() ? multiplayer::max_vote_label : multiplayer::max_vote_answer);
if (!part.empty()) parts.push_back(std::move(part));
if (bar == std::string_view::npos) break;
at = bar + 1;
}
if (parts.size() < 3 || parts.size() > multiplayer::max_vote_answers + 1)
return reply(guest, "/poll <question> | <answer> | <answer>... (2 to " + std::to_string(multiplayer::max_vote_answers) +
" answers), e.g. /poll Next map? | Grom | San Vansterdam");
Vote poll;
poll.kind = VoteKind::poll;
poll.starter = guest.member.id;
poll.label = parts.front();
poll.answers.assign(parts.begin() + 1, parts.end());
if (!++vote_ids_) ++vote_ids_;
poll.id = vote_ids_;
poll.ends = now_ + microseconds(config_.votes.poll_seconds);
if (!admin) vote_cooldowns_[guest.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);
send_chat(guest_name(guest) + " 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] " + guest_name(guest) + " started a poll: " + vote_->label + " " + joined(vote_->answers, " | "));
check_vote(false);
}
void Host::cast_vote(Guest &guest, bool yes) {
if (!vote_) return reply(guest, "No vote is running.");
auto &vote = *vote_;
if (vote.kind == VoteKind::poll) return reply(guest, running_vote_text());
if (vote.kind == VoteKind::kick && guest.member.id == vote.target) return reply(guest, "You cannot vote on your own kick.");
const bool changed = !(yes ? vote.yes : vote.no).contains(guest.member.id);
vote.yes.erase(guest.member.id);
@@ -181,60 +345,115 @@ void Host::cast_vote(Guest &guest, bool yes) {
check_vote(false);
}
void Host::answer_poll(Guest &guest, std::size_t answer) {
if (!vote_) return reply(guest, "No poll is running.");
auto &poll = *vote_;
if (poll.kind != VoteKind::poll) return reply(guest, running_vote_text());
if (answer >= poll.answers.size()) return reply(guest, "Answer with /1 to /" + std::to_string(poll.answers.size()) + ".");
const auto [chosen, added] = poll.chosen.try_emplace(guest.member.id, answer);
if (!added && chosen->second == answer) return reply(guest, "You already answered " + poll.answers[answer] + ".");
chosen->second = answer;
check_vote(false);
}
void Host::end_poll(Guest &guest) {
if (!vote_ || vote_->kind != VoteKind::poll) return reply(guest, "No poll is running.");
if (vote_->starter != guest.member.id && !is_admin(guest.member.id))
return reply(guest, "Only whoever started the poll, or an admin, can end it early.");
check_vote(true);
}
std::set<std::uint64_t> Host::vote_voters(const Vote &vote) const {
// Everyone connected may vote, except the player a kick vote is about.
std::set<std::uint64_t> voters;
for (const auto &[id, guest] : guests_)
if (guest->handshaken && id != vote.target) voters.insert(id);
return voters;
}
unsigned Host::votes_needed(const Vote &vote, unsigned voters) const {
const auto percent = std::clamp(vote_setting(vote.kind, vote.custom).percent, 1U, 100U);
// A kick needs a second player's yes: with only the starter and the target on, it fails.
return std::max(vote.kind == VoteKind::kick ? 2U : 1U, (voters * percent + 99) / 100);
}
std::vector<unsigned> Host::poll_count(const Vote &poll) const {
std::vector<unsigned> count(poll.answers.size());
for (const auto &[voter, answer] : poll.chosen)
if (answer < count.size()) ++count[answer];
return count;
}
// What every game shows of the vote, with the next roster.
void Host::show_vote(const Vote &vote, std::uint8_t outcome, unsigned yes, unsigned no, unsigned needed) {
void Host::show_vote(const Vote &vote, std::uint8_t outcome) {
const auto capped = [](unsigned value) { return static_cast<std::uint16_t>(std::min(value, 65535U)); };
multiplayer::ServerVote shown;
shown.id = vote.id;
shown.kind = vote.kind == VoteKind::map ? server_vote_map : vote.kind == VoteKind::kick ? server_vote_kick : server_vote_time;
shown.kind = vote_bit(vote.kind);
shown.outcome = outcome;
shown.yes = capped(yes);
shown.no = capped(no);
shown.needed = capped(needed);
if (vote.kind == VoteKind::poll) {
shown.answers = vote.answers;
for (const auto n : poll_count(vote)) shown.counts.push_back(capped(n));
} else {
shown.yes = capped(static_cast<unsigned>(vote.yes.size()));
shown.no = capped(static_cast<unsigned>(vote.no.size()));
shown.needed = capped(votes_needed(vote, static_cast<unsigned>(vote_voters(vote).size())));
}
shown.starter = vote.starter;
shown.target = vote.target;
shown.label = vote.label.substr(0, multiplayer::max_vote_label);
// A finished vote stays up a few seconds, to show how it ended.
vote_shown_until_ = outcome == multiplayer::vote_running ? 0 : now_ + 4000000;
shown.label = clean_chat_text(vote.label);
cut_text(shown.label, multiplayer::max_vote_label);
// A finished vote stays up a few seconds, to show how it ended; a poll's result a little longer.
vote_shown_until_ = outcome == multiplayer::vote_running ? 0 : now_ + (vote.kind == VoteKind::poll ? 8000000 : 4000000);
if (shown == vote_shown_) return;
vote_shown_ = std::move(shown);
roster_dirty_ = true;
}
void Host::cancel_vote(const std::string &why) {
if (!vote_) return;
const auto label = vote_->label;
show_vote(*vote_, multiplayer::vote_cancelled, vote_shown_.yes, vote_shown_.no, vote_shown_.needed);
const auto title = vote_title(vote_->kind, vote_->label);
show_vote(*vote_, multiplayer::vote_cancelled);
vote_.reset();
send_chat("The vote to " + label + " was cancelled: " + why + ".");
log_("[vote] The vote to " + label + " was cancelled: " + why + ".");
send_chat(title + " was cancelled: " + why + ".");
log_("[vote] " + title + " was cancelled: " + why + ".");
}
void Host::check_vote(bool expired) {
if (!vote_) return;
auto &vote = *vote_;
// Everyone connected may vote, except the player a kick vote is about.
std::set<std::uint64_t> voters;
for (const auto &[id, guest] : guests_)
if (guest->handshaken && id != vote.target) voters.insert(id);
const auto voters = vote_voters(vote);
std::erase_if(vote.yes, [&](std::uint64_t id) { return !voters.contains(id); });
std::erase_if(vote.no, [&](std::uint64_t id) { return !voters.contains(id); });
std::erase_if(vote.chosen, [&](const auto &entry) { return !voters.contains(entry.first); });
if (vote.kind == VoteKind::poll) {
// Games show the count as it changes (the card); chat is not filled with it.
if (!expired) return show_vote(vote, multiplayer::vote_running);
const auto count = poll_count(vote);
show_vote(vote, multiplayer::vote_passed); // "passed": it ran its course
const auto done = std::move(vote);
vote_.reset();
const auto top = *std::max_element(count.begin(), count.end());
std::vector<std::string> leaders;
for (std::size_t i = 0; i < count.size(); ++i)
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);
return;
}
if (vote.kind == VoteKind::kick && !find(vote.target)) return cancel_vote("the player left");
const auto percent = std::clamp(vote_setting(vote.kind).percent, 1U, 100U);
const auto eligible = static_cast<unsigned>(voters.size());
// A kick needs a second player's yes: with only the starter and the target on, it fails.
const auto needed = std::max(vote.kind == VoteKind::kick ? 2U : 1U, (eligible * percent + 99) / 100);
const auto needed = votes_needed(vote, eligible);
const auto yes = static_cast<unsigned>(vote.yes.size()), no = static_cast<unsigned>(vote.no.size());
const bool passed = yes >= needed;
const bool lost = !passed && (expired || yes + (eligible - std::min(eligible, yes + no)) < needed);
const auto tally = std::to_string(yes) + " yes, " + std::to_string(no) + " no, " + std::to_string(needed) + " needed";
if (!passed && !lost) {
// Games show the tally as it changes (the vote card); chat is not filled with it.
vote.shown_yes = yes;
vote.shown_no = no;
show_vote(vote, multiplayer::vote_running, yes, no, needed);
show_vote(vote, multiplayer::vote_running);
return;
}
show_vote(vote, passed ? multiplayer::vote_passed : multiplayer::vote_failed, yes, no, needed);
show_vote(vote, passed ? multiplayer::vote_passed : multiplayer::vote_failed);
const auto done = std::move(vote);
vote_.reset();
if (lost) {
@@ -247,15 +466,190 @@ void Host::check_vote(bool expired) {
switch (done.kind) {
case VoteKind::map: log_(command("map " + done.value)); break;
case VoteKind::time: log_(command("tod " + done.value)); break;
case VoteKind::custom: log_(command(done.value)); break; // the owner's command, as the console
case VoteKind::kick:
if (find(done.target)) {
kicked_.insert(done.target);
drop(done.target, "You were kicked from this server by a vote.");
}
break;
case VoteKind::poll: break;
}
}
std::pair<std::string, bool> Host::votes_command(std::string_view argument) {
// votes | votes <vote> on|off|<percent>|seconds <n>|cooldown <n>|min-players <n> | votes seconds|cooldown <n>
// | votes polls off|admins|everyone | votes poll-seconds <n> | votes starter-yes on|off
const auto [what_text, rest] = split(argument);
const auto what = lower(what_text);
const auto describe = [&](const std::string &label, const VoteSetting &v) {
if (!v.enabled) return label + ": off";
auto text = label + ": on, " + std::to_string(v.percent) + "% to pass";
if (v.seconds) text += ", runs " + std::to_string(v.seconds) + " s";
if (v.cooldown) text += ", " + std::to_string(v.cooldown) + " s cooldown";
if (v.min_players > 1) text += ", needs " + std::to_string(v.min_players) + " players on";
return text;
};
if (what.empty()) {
auto text = describe("map votes", config_.votes.map) + "\n" + describe("kick votes", config_.votes.kick) + "\n" +
describe("time of day votes", config_.votes.time) + (config_.world_layer_sync ? "" : " (needs layer-sync on)");
for (const auto &v : config_.votes.custom)
text += "\n" + describe("/vote " + v.name + (v.choices.empty() ? "" : " <" + joined(v.choices, "|") + ">"), v.setting) +
" -> " + v.command;
text += "\npolls: " + config_.votes.polls + ", run " + std::to_string(config_.votes.poll_seconds) + " s";
text += "\nvotes last " + std::to_string(config_.votes.seconds) + " s; a player waits " + std::to_string(config_.votes.cooldown) +
" s between votes; the starter " + (config_.votes.starter_votes_yes ? "votes yes" : "does not vote");
if (vote_) text += "\nrunning: " + vote_title(vote_->kind, vote_->label);
return {text, false};
}
const auto value = lower(trim(rest));
if (what == "seconds" || what == "cooldown") {
const auto n = number(value);
const bool seconds = what == "seconds";
if (!n || (seconds ? *n < 10 || *n > 300 : *n > 3600)) return {seconds ? "votes seconds <10-300>" : "votes cooldown <0-3600>", false};
(seconds ? config_.votes.seconds : config_.votes.cooldown) = static_cast<unsigned>(*n);
return {seconds ? "Votes now last " + std::to_string(*n) + " s." : "Players now wait " + std::to_string(*n) + " s between votes.", true};
}
if (what == "polls") {
if (value != "off" && value != "admins" && value != "everyone") return {"votes polls off|admins|everyone (now " + config_.votes.polls + ")", false};
config_.votes.polls = value;
return {value == "off" ? "Polls are off." : value == "admins" ? "Admins can start polls." : "Everyone can start polls.", true};
}
if (what == "poll-seconds") {
const auto n = number(value);
if (!n || *n < 10 || *n > 600) return {"votes poll-seconds <10-600>", false};
config_.votes.poll_seconds = static_cast<unsigned>(*n);
return {"Polls now run " + std::to_string(*n) + " s.", true};
}
if (what == "starter-yes") {
const auto toggle = on_off(value);
if (!toggle) return {"votes starter-yes on|off", false};
config_.votes.starter_votes_yes = *toggle;
return {*toggle ? "Whoever starts a vote votes yes." : "Whoever starts a vote still has to vote.", true};
}
// One vote's settings.
VoteKind kind = VoteKind::map;
std::size_t custom{};
std::string label;
if (what == "map") label = "Map votes";
else if (what == "kick") kind = VoteKind::kick, label = "Kick votes";
else if (what == "tod" || what == "time") kind = VoteKind::time, label = "Time of day votes";
else {
kind = VoteKind::custom;
while (custom < config_.votes.custom.size() && config_.votes.custom[custom].name != what) ++custom;
if (custom == config_.votes.custom.size())
return {"votes [<vote> on|off|<percent>|seconds <n>|cooldown <n>|min-players <n>] (map, kick, tod or a custom vote)\n"
"votes seconds|cooldown <n> | votes polls off|admins|everyone | votes poll-seconds <n> | votes starter-yes on|off",
false};
label = "\"/vote " + what + "\" votes";
}
auto &setting = kind == VoteKind::map ? config_.votes.map : kind == VoteKind::kick ? config_.votes.kick
: kind == VoteKind::time ? config_.votes.time : config_.votes.custom[custom].setting;
const auto [option_text, n_text] = split(value);
const std::string option(option_text);
if (option == "seconds" || option == "cooldown" || option == "min-players") {
const auto n = number(n_text);
if (option == "seconds") {
if (!n || (*n && (*n < 10 || *n > 300))) return {"votes " + what + " seconds <10-300> (0: votes seconds)", false};
setting.seconds = static_cast<unsigned>(*n);
return {label + (*n ? " now last " + std::to_string(*n) + " s." : " now last as long as the others."), true};
}
if (option == "cooldown") {
if (!n || *n > 3600) return {"votes " + what + " cooldown <1-3600> (0: votes cooldown)", false};
setting.cooldown = static_cast<unsigned>(*n);
return {label + (*n ? ": the starter now waits " + std::to_string(*n) + " s before another." : " now use the votes' cooldown."), true};
}
if (!n || *n < 1 || *n >= multiplayer::max_players)
return {"votes " + what + " min-players <1-" + std::to_string(multiplayer::max_players - 1) + ">", false};
setting.min_players = static_cast<unsigned>(*n);
return {label + " now need " + std::to_string(*n) + " players on to start.", true};
}
if (const auto toggle = on_off(option)) {
setting.enabled = *toggle;
if (!*toggle && vote_ && vote_->kind == kind && vote_->custom == custom) cancel_vote("that vote was switched off");
return {label + (*toggle ? " are on (" + std::to_string(setting.percent) + "% to pass)." : " are off."), true};
}
const auto percent = number(option.ends_with("%") ? std::string_view(option).substr(0, option.size() - 1) : std::string_view(option));
if (!percent || *percent < 1 || *percent > 100)
return {"votes " + what + " on|off|<1-100>|seconds <n>|cooldown <n>|min-players <n>", false};
setting.percent = static_cast<unsigned>(*percent);
return {label + " now need " + std::to_string(*percent) + "% to pass.", true};
}
void Host::announce(std::string_view text) {
const auto line = clean_chat_text(text);
if (line.empty()) return;
send_chat(line);
log_("[announcement] " + line);
if (!config_.announcements.card) return;
// Up long enough to read: longer text stays longer.
const auto seconds = static_cast<std::uint16_t>(std::clamp<std::size_t>(5 + line.size() / 20, 6, 15));
if (!++announcement_ids_) ++announcement_ids_;
announcement_ = {announcement_ids_, seconds, line};
announcement_until_ = now_ + microseconds(seconds);
roster_dirty_ = true;
}
void Host::tick_announcements() { // the timer waits while nobody is on
if (announcement_.id && now_ >= announcement_until_) {
announcement_ = {};
roster_dirty_ = true;
}
const auto &a = config_.announcements;
if (!a.interval || a.messages.empty() || !players()) {
announced_at_ = now_;
return;
}
if (now_ - announced_at_ < std::uint64_t{a.interval} * 60000000) return;
announced_at_ = now_;
next_announcement_ %= a.messages.size();
announce(a.messages[next_announcement_++]);
}
std::pair<std::string, bool> Host::announcements_command(std::string_view argument) {
auto &a = config_.announcements;
const auto [what_text, rest] = split(argument);
const auto what = lower(what_text);
const auto value = trim(rest);
if (what.empty() || what == "list") {
std::string text = a.messages.empty() ? "No announcements. Add one with: announcements add <text>"
: "Announcements" + (a.interval ? ", one every " + std::to_string(a.interval) + " min" : std::string(" (timer off)")) +
", card " + (a.card ? "on" : "off") + ":";
for (std::size_t i = 0; i < a.messages.size(); ++i) text += "\n" + std::to_string(i + 1) + ". " + a.messages[i];
return {text, false};
}
if (what == "add") {
const auto line = clean_chat_text(value);
if (line.empty()) return {"announcements add <text>", false};
if (a.messages.size() >= max_announcements) return {"There are already " + std::to_string(max_announcements) + " announcements.", false};
a.messages.push_back(line);
return {"Announcement " + std::to_string(a.messages.size()) + " added" +
(a.interval ? "." : ". The timer is off: announcements interval <minutes>."), true};
}
if (what == "remove") {
const auto n = number(value);
if (!n || !*n || *n > a.messages.size()) return {"announcements remove <1-" + std::to_string(a.messages.size()) + ">", false};
a.messages.erase(a.messages.begin() + static_cast<std::ptrdiff_t>(*n - 1));
return {"Announcement " + std::to_string(*n) + " removed.", true};
}
if (what == "clear") {
a.messages.clear();
return {"Announcements cleared.", true};
}
if (what == "interval") {
const auto n = lower(value) == "off" ? std::optional<std::uint64_t>(0) : number(value);
if (!n || *n > max_announcement_interval) return {"announcements interval <1-" + std::to_string(max_announcement_interval) + ">|off", false};
a.interval = static_cast<unsigned>(*n);
announced_at_ = now_;
return {*n ? "An announcement every " + std::to_string(*n) + " min." : std::string("The announcement timer is off."), true};
}
if (what == "card") {
const auto toggle = on_off(lower(value));
if (!toggle) return {"announcements card on|off", false};
a.card = *toggle;
return {*toggle ? "Announcements also show as a card on each player's screen." : "Announcements show in chat only.", true};
}
return {"announcements [list | add <text> | remove <n> | clear | interval <minutes>|off | card on|off]", false};
}
std::string Host::pool_text() const {
std::string text = config_.map_pool.empty() ? "Map pool: every map" : "Map pool:";
if (!config_.map_pool.empty())