mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 17:27:19 +02:00
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:
20 files changed
+1079
-110
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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();) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -33,7 +33,7 @@ void register_multiplayer_commands(Commands ®istry) {
|
||||
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"},
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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) + ".";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user