This commit is contained in:
AyeZeeBB committed 2026-10-09 16:58:49 -05:00
commit 0798431ef8
4 files changed
+123 -50

No files matched your search

+17
View File
@@ -272,6 +272,17 @@ polls Who may ask everyone a question with up to six answers:
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.
Server votes The console (and scripts that talk to it) starts the same
votes and polls: "vote tod night", "vote map grom",
"vote noclip off", "poll Next map? | Grom | Stadium" and
"poll end". A vote still has to be on, and its "min_players"
met; a poll may be asked whatever "polls" says. The server has
no vote of its own and no cooldown. Chat reads "The server
started a vote to ..." or "The server asks: ...".
"poll-run <command> | <question> | <answer>..." also runs a
command for the answer that wins, {answer} replaced by it
(nothing on a tie or when nobody answered), e.g.
poll-run tod {answer} | Time of day? | morning | noon | night
"announcements" - Messages from the server.
messages Lines the server posts in turn, one every "interval_minutes"
@@ -344,6 +355,12 @@ and change voice, distances, placement and kicks from the Multiplayer menu.
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
vote <map|kick|tod|<custom vote>> [argument] Start a vote as the server
(see Server votes).
poll <question> | <answer> | <answer>... Ask everyone (2 to 6 answers).
poll end End the running poll now.
poll-run <command with {answer}> | <question> | <answer>... A poll whose
winning answer runs the command (console only).
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.
+36
View File
@@ -19,6 +19,8 @@ constexpr std::string_view help_text =
"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 [<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"
"vote <map|kick|tod|<custom vote>> [argument] | poll <question> | <answer> | <answer>... | poll end\n"
"poll-run <command with {answer}> | <question> | <answer> | <answer>...\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"
@@ -486,6 +488,40 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
cancel_vote(console ? "the server cancelled it" : "an admin cancelled it");
return "Vote cancelled.";
}
if (name == "vote") {
// A vote started by the server: the same card and checks as /vote, without a cooldown or a vote of its own.
Guest *by = console ? nullptr : find(admin);
const auto [what_text, rest] = split(argument);
const auto what = lower(what_text);
const auto started = [](std::string why) { return why.empty() ? std::string("Vote started.") : why; };
if (what.empty()) return vote_ ? running_vote_text() : std::string("vote <map|kick|tod|<custom vote>> [argument]");
if (what == "map") return started(start_vote(by, VoteKind::map, rest));
if (what == "kick") return started(start_vote(by, VoteKind::kick, rest));
if (what == "tod" || what == "time") return started(start_vote(by, VoteKind::time, rest));
for (std::size_t i = 0; i < config_.votes.custom.size(); ++i)
if (config_.votes.custom[i].name == what) return started(start_vote(by, VoteKind::custom, rest, i));
return "No vote is called \"" + what + "\": vote map, kick, tod or a custom vote's name (votes lists them).";
}
if (name == "poll") {
Guest *by = console ? nullptr : find(admin);
if (lower(trim(argument)) == "end") {
const auto why = end_poll(by);
return why.empty() ? "Poll ended." : why;
}
const auto why = start_poll(by, argument);
return why.empty() ? "Poll started." : why;
}
if (name == "poll-run") {
// "poll-run <command with {answer}> | <question> | <answer>...": the winner's command runs as
// the console's, so only the console may set one up.
if (!console) return "poll-run is for the server console only.";
const auto bar = argument.find('|');
const auto run = trim(argument.substr(0, bar == std::string_view::npos ? 0 : bar));
if (run.empty())
return "poll-run <command with {answer}> | <question> | <answer> | <answer>..., e.g. poll-run tod {answer} | Time of day? | morning | night";
const auto why = start_poll(nullptr, argument.substr(bar + 1), std::string(run));
return why.empty() ? "Poll started; the winning answer runs: " + std::string(run) : why;
}
if (name == "tpall" || name == "tphere") {
// Where they go: the admin who asked, or (tpall from the console) the named player.
Guest *to{};
+6 -4
View File
@@ -191,7 +191,7 @@ 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.
// A poll is one too: it has answers instead of yes and no, and runs nothing (but the console's poll-run).
struct Vote {
VoteKind kind{};
std::size_t custom{}; // custom: which of config_.votes.custom
@@ -201,6 +201,7 @@ class Host {
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::string run; // poll-run: the console command for the winner ("{answer}")
std::uint64_t ends{};
};
std::optional<Vote> vote_;
@@ -332,11 +333,12 @@ 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, std::size_t custom = 0);
void start_poll(Guest &, std::string_view text);
// Starting a vote or poll: nullptr is the server console; the text says why it did not start ("" = it did).
std::string start_vote(Guest *starter, VoteKind, std::string_view argument, std::size_t custom = 0);
std::string start_poll(Guest *starter, std::string_view text, std::string run = {});
void cast_vote(Guest &, bool yes);
void answer_poll(Guest &, std::size_t answer);
void end_poll(Guest &);
std::string end_poll(Guest *by);
void check_vote(bool expired);
void cancel_vote(const std::string &why);
const VoteSetting &vote_setting(VoteKind, std::size_t custom = 0) const;
+64 -46
View File
@@ -167,9 +167,12 @@ void Host::chat_command(Guest &guest, std::string_view line) {
return static_cast<std::size_t>(word[0] - '1');
};
if (const auto chosen = answer(verb)) return answer_poll(guest, *chosen);
// Starting a vote or poll answers only when it did not start.
const auto say = [&](const std::string &why) {
if (!why.empty()) reply(guest, why);
};
if (verb == "poll") {
if (lower(trim(rest)) == "end") return end_poll(guest);
return start_poll(guest, rest);
return say(lower(trim(rest)) == "end" ? end_poll(&guest) : start_poll(&guest, rest));
}
if (verb == "yes" || verb == "y") return cast_vote(guest, true);
if (verb == "no" || verb == "n") return cast_vote(guest, false);
@@ -179,9 +182,9 @@ void Host::chat_command(Guest &guest, std::string_view line) {
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 (what == "map") return say(start_vote(&guest, VoteKind::map, argument));
if (what == "kick") return say(start_vote(&guest, VoteKind::kick, argument));
if (what == "tod" || what == "time") return say(start_vote(&guest, VoteKind::time, argument));
if (what == "list") {
if (custom.empty()) return reply(guest, "This server has no votes of its own.");
std::string text;
@@ -191,7 +194,7 @@ void Host::chat_command(Guest &guest, std::string_view line) {
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 (config_.votes.custom[i].name == what) return say(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.");
@@ -215,40 +218,43 @@ std::string Host::running_vote_text() const {
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) {
// `starter`: the player who asked, or nullptr for the server console. Returns why it did not
// start, or "" when it did. The server has no vote of its own and no cooldown.
std::string Host::start_vote(Guest *starter, VoteKind kind, std::string_view argument, std::size_t custom) {
const auto &setting = vote_setting(kind, custom);
const std::string slash = starter ? "/" : ""; // usage: "/vote ..." in chat, "vote ..." on the console
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.");
return 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 running_vote_text();
if (const auto wait = starter ? vote_cooldowns_.find(starter->member.id) : vote_cooldowns_.end(); wait != vote_cooldowns_.end() && now_ < wait->second)
return "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).");
return "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;
vote.starter = starter ? starter->member.id : 0;
switch (kind) {
case VoteKind::map: {
if (argument.empty()) return reply(guest, "/vote map <map>, e.g. /vote map grom");
if (argument.empty()) return slash + "vote map <map>, e.g. " + slash + "vote map grom";
// Players vote between the server's own maps (/maps); a raw level path is admins only.
const auto *level = find_level(argument);
if (!level || !valid_map_destination(map_destination(argument)))
return reply(guest, "No single map is called \"" + std::string(argument) + "\".");
if (!in_map_pool(config_, level->asset)) return reply(guest, level->name + " is not one of this server's maps.\n" + pool_text());
if (map_hash(map_destination(argument)) == map_) return reply(guest, "The server is already on that map.");
return "No single map is called \"" + std::string(argument) + "\".";
if (!in_map_pool(config_, level->asset)) return level->name + " is not one of this server's maps.\n" + pool_text();
if (map_hash(map_destination(argument)) == map_) return "The server is already on that map.";
vote.value = std::string(argument);
vote.label = "change the map to " + map_label(argument);
break;
}
case VoteKind::kick: {
auto *target = match_player(argument);
if (!target) return reply(guest, "No single connected player matches \"" + std::string(argument) + "\".");
if (target == &guest) return reply(guest, "You cannot vote to kick yourself.");
if (is_admin(target->member.id)) return reply(guest, "Admins cannot be kicked by a vote.");
if (!target) return "No single connected player matches \"" + std::string(argument) + "\".";
if (target == starter) return "You cannot vote to kick yourself.";
if (is_admin(target->member.id)) return "Admins cannot be kicked by a vote.";
vote.target = target->member.id;
vote.label = "kick " + guest_name(*target);
break;
@@ -256,7 +262,7 @@ void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument, st
case VoteKind::time: {
const auto wanted = lower(argument);
if (std::find(times.begin(), times.end(), wanted) == times.end())
return reply(guest, "/vote tod <default|morning|noon|afternoon|evening|night|weatherday|weathernight>");
return slash + "vote tod <default|morning|noon|afternoon|evening|night|weatherday|weathernight>";
vote.value = wanted;
vote.label = "set the time of day to " + wanted;
break;
@@ -267,7 +273,7 @@ void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument, st
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));
return slash + "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);
@@ -275,31 +281,36 @@ void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument, st
if (!vote.label.empty() && vote.label.front() >= 'A' && vote.label.front() <= 'Z') vote.label.front() = static_cast<char>(vote.label.front() + 32);
break;
}
case VoteKind::poll: return start_poll(guest, argument);
case VoteKind::poll: return start_poll(starter, argument);
}
if (config_.votes.starter_votes_yes) vote.yes.insert(guest.member.id);
if (starter && config_.votes.starter_votes_yes) vote.yes.insert(starter->member.id);
if (!++vote_ids_) ++vote_ids_;
vote.id = vote_ids_;
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);
if (starter) vote_cooldowns_[starter->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, " +
const auto who = starter ? guest_name(*starter) : std::string("The server");
send_chat(who + " started a vote to " + label + " (" + std::to_string(setting.percent) + "% needed, " +
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 + ".");
log_("[vote] " + who + " started a vote to " + label + ".");
check_vote(false);
return {};
}
void Host::start_poll(Guest &guest, std::string_view text) {
// `starter` nullptr: the server console, which may always ask (the "polls" setting is about
// players). `run`: a console command run for a clear winner, "{answer}" replaced by it.
std::string Host::start_poll(Guest *starter, std::string_view text, std::string run) {
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.");
const std::string slash = starter ? "/" : "";
const bool admin = !starter || is_admin(starter->member.id);
if (starter && polls == ServerPolls::off) return "Polls are off on this server.";
if (polls == ServerPolls::admins && !admin) return "Only admins can start a poll on this server.";
if (vote_) return running_vote_text();
if (const auto wait = starter ? vote_cooldowns_.find(starter->member.id) : vote_cooldowns_.end(); !admin && wait != vote_cooldowns_.end() && now_ < wait->second)
return "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();) {
@@ -311,25 +322,28 @@ void Host::start_poll(Guest &guest, std::string_view text) {
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");
return slash + "poll <question> | <answer> | <answer>... (2 to " + std::to_string(multiplayer::max_vote_answers) +
" answers), e.g. " + slash + "poll Next map? | Grom | San Vansterdam";
Vote poll;
poll.kind = VoteKind::poll;
poll.starter = guest.member.id;
poll.starter = starter ? starter->member.id : 0;
poll.label = parts.front();
poll.answers.assign(parts.begin() + 1, parts.end());
poll.run = std::move(run);
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);
if (!admin) vote_cooldowns_[starter->member.id] = now_ + microseconds(config_.votes.cooldown);
std::string choices;
for (std::size_t i = 0; i < poll.answers.size(); ++i) choices += (i ? " " : "") + std::string("/") + std::to_string(i + 1) + " " + poll.answers[i];
vote_ = std::move(poll);
send_chat(guest_name(guest) + " asks: " + vote_->label + " (" + std::to_string(config_.votes.poll_seconds) +
const auto who = starter ? guest_name(*starter) : std::string("The server");
send_chat(who + " asks: " + vote_->label + " (" + std::to_string(config_.votes.poll_seconds) +
" s). Answer on the card at the right of your screen, or type:");
send_chat(choices);
log_("[poll] " + guest_name(guest) + " started a poll: " + vote_->label + " " + joined(vote_->answers, " | "));
log_("[poll] " + who + " started a poll: " + vote_->label + " " + joined(vote_->answers, " | "));
check_vote(false);
return {};
}
void Host::cast_vote(Guest &guest, bool yes) {
@@ -356,11 +370,13 @@ void Host::answer_poll(Guest &guest, std::size_t 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.");
// `by` nullptr: the server console, which may always end it.
std::string Host::end_poll(Guest *by) {
if (!vote_ || vote_->kind != VoteKind::poll) return "No poll is running.";
if (by && vote_->starter != by->member.id && !is_admin(by->member.id))
return "Only whoever started the poll, or an admin, can end it early.";
check_vote(true);
return {};
}
std::set<std::uint64_t> Host::vote_voters(const Vote &vote) const {
@@ -439,6 +455,8 @@ void Host::check_vote(bool expired) {
const auto line = "Poll \"" + done.label + "\" ended: " + answer_count(done.answers, count) + " (" + outcome + ").";
send_chat(line);
log_("[poll] " + line);
// A console poll-run: the winning answer's command, as the console. A tie or no answer runs nothing.
if (!done.run.empty() && top && leaders.size() == 1) log_(command(fill(done.run, "{answer}", leaders.front())));
return;
}
if (vote.kind == VoteKind::kick && !find(vote.target)) return cancel_vote("the player left");