mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 13:57:05 +02:00
A player kicked or banned only found out why from the status line of the
multiplayer menu, if they opened it.
- What the host or server says as it puts a player out ("You were
kicked...", "You are banned...", away too long, a mod or cheat it does
not allow) is now shown in the middle of the screen with an OK button,
under YOU WERE KICKED, BANNED or REMOVED. The same when they are refused
on trying to join again early. A link that is simply lost shows nothing
new.
- Answered with A or B on a controller, Enter or Esc, or a click on OK. The
game's input is held while it is up, as for the vote for the next map, so
the button that answers is not also a press in the game.
- Console: "popup test" shows it with a sample message, to try it without
being kicked.
Server: a kick by a vote has a time of its own, votes.kick_minutes, 5 by
default (1-1440, or 0 for until the server restarts); it no longer goes by
players.kick_minutes, which is now an admin's kick only and is still until
the restart by default. Console: votes kick-minutes <minutes>|restart, and
"votes" shows it. A config without the key takes the 5 minutes.
915 lines
51 KiB
C++
915 lines
51 KiB
C++
#include "server_host.h"
|
|
#include "server_text.h"
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
|
|
// 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) {
|
|
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";
|
|
case Host::VoteKind::extend: return "Extend";
|
|
}
|
|
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;
|
|
case Host::VoteKind::extend: return multiplayer::server_vote_custom; // a yes or no vote, as the owner's are
|
|
}
|
|
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, 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::extend: return config_.votes.extend;
|
|
case VoteKind::poll: break;
|
|
}
|
|
return none;
|
|
}
|
|
std::uint8_t Host::enabled_votes() const {
|
|
std::uint8_t bits{};
|
|
if (config_.votes.map.enabled) bits |= server_vote_map;
|
|
if (config_.votes.kick.enabled) bits |= server_vote_kick;
|
|
// Time of day is a world layer choice: it only reaches players while layer sync is on.
|
|
if (config_.votes.time.enabled && config_.world_layer_sync && !world_layers().empty()) bits |= server_vote_time;
|
|
return bits;
|
|
}
|
|
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});
|
|
// The server's own "extend" goes with them: games list it in their "/" menu as they do those.
|
|
if (extend_vote_on() && list.size() < multiplayer::server_custom_vote_limit)
|
|
list.push_back({"extend", "Give this map " + std::to_string(config_.rotation_extend) + " more minutes", {}});
|
|
return list;
|
|
}
|
|
bool Host::extend_vote_on() const { return config_.votes.extend.enabled && config_.map_rotation && config_.rotation_extend; }
|
|
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 < 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()) {
|
|
send_chat(line, &guest);
|
|
++lines;
|
|
}
|
|
if (end == std::string_view::npos) break;
|
|
at = end + 1;
|
|
}
|
|
}
|
|
Host::Guest *Host::match_player(std::string_view text) {
|
|
// A SteamID64, or the start of one connected player's name.
|
|
text = trim(text);
|
|
if (text.empty()) return nullptr;
|
|
if (const auto id = number(split(text).first)) {
|
|
auto *guest = find(*id);
|
|
return guest && guest->handshaken ? guest : nullptr;
|
|
}
|
|
Guest *match{};
|
|
for (auto &[id, guest] : guests_)
|
|
if (guest->handshaken && lower(guest_name(*guest)).starts_with(lower(text))) {
|
|
if (match) return nullptr;
|
|
match = guest.get();
|
|
}
|
|
return match;
|
|
}
|
|
|
|
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 (!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 && has(guest.member.id, multiplayer::role_polls)))
|
|
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 (const auto *role = role_of(guest.member.id); staff(role))
|
|
text += role->all_commands
|
|
? "Admins: any server command as /<command>, e.g. /kick, /map, /tpall, /votes, /announce, /announce-to, /msg, /msg-party, /msg-admins\n"
|
|
: "Your role (" + role->name + "): these server commands as /<command>: " + joined(role->commands, ", ") + "\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);
|
|
}
|
|
if ((verb == "party" || verb == "p") && !may(guest.member.id, multiplayer::role_parties))
|
|
return reply(guest, role_refusal(guest.member.id, "use parties"));
|
|
if ((verb == "w" || verb == "whisper" || verb == "tell") && !may(guest.member.id, multiplayer::role_whisper))
|
|
return reply(guest, role_refusal(guest.member.id, "send private messages"));
|
|
if ((verb == "vote" || verb == "yes" || verb == "y" || verb == "no" || verb == "n" || (verb.size() == 1 && verb[0] >= '1' && verb[0] <= '9')) &&
|
|
!may(guest.member.id, multiplayer::role_votes))
|
|
return reply(guest, role_refusal(guest.member.id, "vote"));
|
|
if (verb == "party") return party_command(guest, rest);
|
|
if (verb == "p") {
|
|
if (!config_.parties) return reply(guest, "Parties are off on this server.");
|
|
return party_chat(guest, rest);
|
|
}
|
|
if (verb == "w" || verb == "whisper" || verb == "tell") {
|
|
// A private message to one player, marked "[DM from ...]"; the sender sees an echo.
|
|
const auto [who, text] = split(rest);
|
|
if (text.empty()) return reply(guest, "/w <player> <message>, e.g. /w player hello");
|
|
auto *other = match_player(who);
|
|
if (!other) return reply(guest, "No single connected player matches \"" + std::string(who) + "\".");
|
|
if (other == &guest) return reply(guest, "You cannot message yourself.");
|
|
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);
|
|
// 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") {
|
|
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);
|
|
if (verb == "vote") {
|
|
const auto [what_text, argument] = split(rest);
|
|
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 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 == "extend") return say(start_vote(&guest, VoteKind::extend, argument));
|
|
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 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, /vote extend or /vote list (see /help)."
|
|
: "This server has no player votes.");
|
|
}
|
|
// The owner's own commands (ReSkateServer.json "commands"), never listed in /help.
|
|
for (const auto &custom_command : config_.commands)
|
|
if (custom_command.name == verb && (!custom_command.admin || is_staff(guest.member.id)))
|
|
return run_custom_command(guest, custom_command, trim(rest));
|
|
// 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_staff(guest.member.id)) {
|
|
const auto id = guest.member.id;
|
|
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::run_custom_command(Guest &guest, const CustomCommand &custom_command, std::string_view argument) {
|
|
const auto id = guest.member.id;
|
|
// Copies: the commands it runs may change the config.
|
|
const auto reply_text = custom_command.reply;
|
|
const auto runs = custom_command.commands;
|
|
log_("[command] " + guest_name(guest) + " /" + custom_command.name + (argument.empty() ? "" : " " + std::string(argument)));
|
|
if (!reply_text.empty()) reply(guest, reply_text);
|
|
// As the console, like a custom vote; {arg} last, so what the player typed is never filled in again.
|
|
for (const auto &run : runs)
|
|
log_(command(fill(fill(fill(run, "{player}", std::to_string(id)), "{map}", config_.map), "{arg}", argument)));
|
|
}
|
|
|
|
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.";
|
|
}
|
|
|
|
// `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 : kind == VoteKind::extend ? extend_vote_on() : (enabled_votes() & vote_bit(kind)) != 0;
|
|
if (!on)
|
|
return kind == VoteKind::extend && setting.enabled ? std::string("This server's maps are not on a timer, so there is none to extend.")
|
|
: 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 "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 = starter ? starter->member.id : 0;
|
|
switch (kind) {
|
|
case VoteKind::map: {
|
|
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 "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 "No single connected player matches \"" + std::string(argument) + "\".";
|
|
if (target == starter) return "You cannot vote to kick yourself.";
|
|
if (has(target->member.id, multiplayer::role_vote_immune)) return guest_name(*target) + " cannot be kicked by a vote.";
|
|
vote.target = target->member.id;
|
|
vote.label = "kick " + guest_name(*target);
|
|
break;
|
|
}
|
|
case VoteKind::time: {
|
|
const auto wanted = lower(argument);
|
|
if (std::find(times.begin(), times.end(), wanted) == times.end())
|
|
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;
|
|
}
|
|
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 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);
|
|
// 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;
|
|
}
|
|
case VoteKind::extend:
|
|
vote.value = std::to_string(config_.rotation_extend);
|
|
vote.label = "give " + map_name() + " " + vote.value + " more minutes";
|
|
break;
|
|
case VoteKind::poll: return start_poll(starter, argument);
|
|
}
|
|
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);
|
|
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);
|
|
const auto who = starter ? guest_name(*starter) : std::string("The server");
|
|
send_chat(who + " started a vote to " + label + " (" + std::to_string(setting.percent) + "% of the votes cast passes it, " +
|
|
std::to_string(seconds) + " s). Vote on the card at the right of your screen, or type /yes or /no.");
|
|
log_("[vote] " + who + " started a vote to " + label + ".");
|
|
check_vote(false);
|
|
return {};
|
|
}
|
|
|
|
// `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 std::string slash = starter ? "/" : "";
|
|
const bool admin = !starter || has(starter->member.id, multiplayer::role_polls);
|
|
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();) {
|
|
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 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 = 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_[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);
|
|
const auto who = starter ? guest_name(*starter) : std::string("The server");
|
|
send_chat(who + " asks: " + vote_->label + " (" + std::to_string(config_.votes.poll_seconds) +
|
|
" s). Answer on the card at the right of your screen, or type:");
|
|
send_chat(choices);
|
|
log_("[poll] " + who + " started a poll: " + vote_->label + " " + joined(vote_->answers, " | "));
|
|
check_vote(false);
|
|
return {};
|
|
}
|
|
|
|
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);
|
|
vote.no.erase(guest.member.id);
|
|
(yes ? vote.yes : vote.no).insert(guest.member.id);
|
|
if (!changed) return reply(guest, yes ? "You already voted yes." : "You already voted no.");
|
|
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);
|
|
}
|
|
|
|
// `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 && !has(by->member.id, multiplayer::role_polls))
|
|
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 {
|
|
// 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;
|
|
}
|
|
// The yes votes that pass a vote in which `cast` players voted: its percentage of them. A vote is
|
|
// decided by those who vote, not by everyone on the server.
|
|
unsigned Host::votes_needed(const Vote &vote, unsigned cast) 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, (cast * 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) {
|
|
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_bit(vote.kind);
|
|
shown.outcome = outcome;
|
|
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()));
|
|
// What yes needs of the votes cast so far: where the card's bar tips over.
|
|
shown.needed = capped(votes_needed(vote, static_cast<unsigned>(vote.yes.size() + vote.no.size())));
|
|
}
|
|
shown.starter = vote.starter;
|
|
shown.target = vote.target;
|
|
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 title = vote_title(vote_->kind, vote_->label);
|
|
show_vote(*vote_, multiplayer::vote_cancelled);
|
|
vote_.reset();
|
|
send_chat(title + " was cancelled: " + why + ".");
|
|
log_("[vote] " + title + " was cancelled: " + why + ".");
|
|
}
|
|
|
|
void Host::check_vote(bool expired) {
|
|
if (!vote_) return;
|
|
auto &vote = *vote_;
|
|
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) {
|
|
// (The vote for the next map is over once everyone on has answered.)
|
|
if (vote.next_map && !voters.empty() && vote.chosen.size() >= voters.size()) expired = true;
|
|
// 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();
|
|
if (done.next_map) return finish_next_map_vote(done, count);
|
|
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);
|
|
// 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");
|
|
const auto eligible = static_cast<unsigned>(voters.size());
|
|
const auto yes = static_cast<unsigned>(vote.yes.size()), no = static_cast<unsigned>(vote.no.size());
|
|
const auto cast = yes + no, waiting = eligible - std::min(eligible, cast);
|
|
// It runs its time and is then decided by the votes cast. It ends before that once the
|
|
// players still to vote cannot change it: yes has enough even if they all say no, or not
|
|
// enough even if they all say yes (which is also how it ends when everyone has voted).
|
|
const auto needed = votes_needed(vote, cast), of_all = votes_needed(vote, eligible);
|
|
const bool passed = expired ? cast && yes >= needed : yes >= of_all;
|
|
const bool lost = !passed && (expired || yes + waiting < of_all);
|
|
const auto tally = std::to_string(yes) + " yes, " + std::to_string(no) + " no, " + std::to_string(needed) + " needed of " +
|
|
std::to_string(cast) + " vote" + (cast == 1 ? "" : "s");
|
|
if (!passed && !lost) {
|
|
// Games show the tally as it changes (the vote card); chat is not filled with it.
|
|
show_vote(vote, multiplayer::vote_running);
|
|
return;
|
|
}
|
|
show_vote(vote, passed ? multiplayer::vote_passed : multiplayer::vote_failed);
|
|
const auto done = std::move(vote);
|
|
vote_.reset();
|
|
if (lost) {
|
|
send_chat("The vote to " + done.label + " failed (" + tally + ").");
|
|
log_("[vote] The vote to " + done.label + " failed (" + tally + ").");
|
|
return;
|
|
}
|
|
send_chat("The vote to " + done.label + " passed (" + tally + ").");
|
|
log_("[vote] The vote to " + done.label + " passed (" + tally + ").");
|
|
switch (done.kind) {
|
|
case VoteKind::map: log_(command("map " + done.value)); break;
|
|
case VoteKind::time: log_(command("tod " + done.value)); break;
|
|
case VoteKind::extend: log_(command("rotation extend " + done.value)); break;
|
|
case VoteKind::custom: { // the owner's command, as the console
|
|
by_vote_ = true;
|
|
struct Reset { bool &flag; ~Reset() { flag = false; } } reset{by_vote_};
|
|
log_(command(done.value));
|
|
break;
|
|
}
|
|
case VoteKind::kick:
|
|
if (find(done.target)) {
|
|
// (For as long as votes.kick_minutes says, not as long as an admin's kick.)
|
|
const auto how_long = keep_out(done.target, config_.votes.kick_minutes);
|
|
drop(done.target, "You were kicked from this server by a vote" + how_long + ".");
|
|
}
|
|
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)") + "\n" +
|
|
describe("extend votes", config_.votes.extend) + (config_.map_rotation && config_.rotation_extend ? "" : " (needs rotation on, and rotation stay)");
|
|
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");
|
|
text += "\na vote kick lasts " + (config_.votes.kick_minutes ? std::to_string(config_.votes.kick_minutes) + " min" : std::string("until the server restarts"));
|
|
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 == "kick-minutes") {
|
|
const auto n = value == "restart" ? number("0") : number(value);
|
|
if (!n || *n > 1440)
|
|
return {"votes kick-minutes <1-1440>|restart (now " +
|
|
(config_.votes.kick_minutes ? std::to_string(config_.votes.kick_minutes) + " min" : std::string("until the server restarts")) + ")",
|
|
false};
|
|
config_.votes.kick_minutes = static_cast<unsigned>(*n);
|
|
return {*n ? "A player kicked by a vote can join again after " + std::to_string(*n) + " min." : "A player kicked by a vote is kept out until the server restarts.", 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 if (what == "extend") kind = VoteKind::extend, label = "Extend 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, extend or a custom vote)\n"
|
|
"votes seconds|cooldown <n> | votes kick-minutes <n>|restart | 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 : kind == VoteKind::extend ? config_.votes.extend
|
|
: 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, std::uint64_t to) {
|
|
const auto line = clean_chat_text(text);
|
|
if (line.empty()) return;
|
|
const auto *target = to ? find(to) : nullptr;
|
|
if (to && !target) return;
|
|
log_("[announcement" + (target ? " -> " + guest_name(*target) : std::string()) + "] " + line);
|
|
// 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_for_ = to;
|
|
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_ = {};
|
|
announcement_for_ = 0;
|
|
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)")) + ":";
|
|
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};
|
|
}
|
|
return {"announcements [list | add <text> | remove <n> | clear | interval <minutes>|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())
|
|
for (const auto *level : pool_levels(config_)) text += "\n " + level->name + (same_map(level->asset) ? " (now)" : "");
|
|
return text;
|
|
}
|
|
std::string Host::rotation_text() const {
|
|
if (!config_.map_rotation) return "Map rotation is off.";
|
|
const auto every = "The map changes every " + std::to_string(config_.map_rotation) + " min";
|
|
const auto *next = next_pool_map(config_, config_.map);
|
|
if (!next) return every + ", but the map pool has no other map.";
|
|
if (!players()) return every + " while players are on. Next: " + next->name + ".";
|
|
const auto due = map_since_ + std::uint64_t{config_.map_rotation} * 60000000;
|
|
const auto left = due > now_ ? (due - now_ + 59999999) / 60000000 : 0;
|
|
if (config_.rotation_vote) return every + ". A vote for the next map in about " + std::to_string(std::max<std::uint64_t>(left, 1)) + " min.";
|
|
return every + ". Next: " + next->name + " in about " + std::to_string(std::max<std::uint64_t>(left, 1)) + " min.";
|
|
}
|
|
void Host::resend_maps() {
|
|
for (auto &[id, guest] : guests_) guest->maps_sent = false;
|
|
}
|
|
std::string Host::reload_maps() {
|
|
std::vector<std::string> before;
|
|
for (const auto &level : levels()) before.push_back(level.asset);
|
|
const auto problems = load_levels(config_.mods);
|
|
resend_maps(); // whoever picks maps is sent the list again
|
|
std::string added;
|
|
std::size_t gone{};
|
|
for (const auto &level : levels())
|
|
if (std::find(before.begin(), before.end(), level.asset) == before.end()) added += (added.empty() ? "" : ", ") + level.name;
|
|
for (const auto &asset : before)
|
|
if (!find_level(asset)) ++gone;
|
|
std::string text = "Maps read again from Mods: " + std::to_string(levels().size()) + " maps" +
|
|
(added.empty() ? ", none new" : ", new: " + added) + (gone ? ", " + std::to_string(gone) + " no longer there" : "") + ".";
|
|
for (const auto &problem : problems) text += "\nMods: skipped " + problem;
|
|
return text;
|
|
}
|
|
std::string Host::map_folder_in_play() const {
|
|
if (!players()) return {};
|
|
// The package is the folder and the version (protocol.h: map_package_name).
|
|
const auto package = map_package(config_.map);
|
|
const auto version = multiplayer::map_package_version(package);
|
|
if (package.empty()) return {};
|
|
return version.empty() ? package : package.substr(0, package.size() - version.size() - 1);
|
|
}
|
|
// The rotation's vote: three maps of the pool other than this one, picked at random, then any
|
|
// map of the pool, then staying. Asked as a poll, so every game can answer it (/1, /2...); games
|
|
// that know next_map_vote show the maps to pick from.
|
|
void Host::draw_next_maps() {
|
|
std::vector<const ServerLevel *> others;
|
|
for (const auto *level : pool_levels(config_))
|
|
if (!same_map(level->asset)) others.push_back(level);
|
|
std::shuffle(others.begin(), others.end(), random_);
|
|
if (others.size() > 3) others.resize(3);
|
|
next_maps_.clear();
|
|
for (const auto *level : others) {
|
|
auto name = clean_chat_text(level->name);
|
|
cut_text(name, multiplayer::max_vote_answer);
|
|
if (!name.empty()) next_maps_.push_back({std::move(name), multiplayer::valid_map_package(level->package) ? level->package : std::string()});
|
|
}
|
|
++next_maps_revision_;
|
|
}
|
|
bool Host::start_next_map_vote() {
|
|
// (Drawn a minute ago as a rule; now when the rotation was set shorter than that.)
|
|
if (next_maps_.empty()) draw_next_maps();
|
|
const auto others = next_maps_.size();
|
|
if (others < 2 && !config_.rotation_extend) return false; // one way on: nothing to vote on
|
|
if (!others) return false;
|
|
Vote poll;
|
|
poll.kind = VoteKind::poll;
|
|
poll.next_map = true;
|
|
poll.target = multiplayer::next_map_vote;
|
|
poll.label = "Next map";
|
|
for (const auto &map : next_maps_) poll.answers.push_back(map.name);
|
|
if (others > 1) poll.answers.emplace_back(multiplayer::next_map_random);
|
|
if (config_.rotation_extend) poll.answers.emplace_back(multiplayer::next_map_stay);
|
|
if (poll.answers.size() < 2) return false;
|
|
if (!++vote_ids_) ++vote_ids_;
|
|
poll.id = vote_ids_;
|
|
poll.ends = now_ + microseconds(config_.rotation_vote_seconds);
|
|
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("Time is up on " + map_name() + ". Vote for the next map (" + std::to_string(config_.rotation_vote_seconds) + " s):");
|
|
send_chat(choices);
|
|
log_("[rotation] Vote for the next map: " + joined(vote_->answers, " | "));
|
|
check_vote(false);
|
|
return true;
|
|
}
|
|
void Host::extend_map(unsigned minutes) {
|
|
// Due in `minutes` from now, whatever the rotation's length.
|
|
map_since_ = now_ + std::uint64_t{minutes} * 60000000 - std::uint64_t{config_.map_rotation} * 60000000;
|
|
rotation_warned_ = false;
|
|
rotation_voted_ = false;
|
|
// The marks that are still ahead are announced again.
|
|
rotation_told_ = (minutes <= 10 ? 1U : 0U) | (minutes <= 5 ? 2U : 0U) | (minutes <= 1 ? 4U : 0U);
|
|
}
|
|
void Host::finish_next_map_vote(const Vote &done, const std::vector<unsigned> &count) {
|
|
// The most votes wins; a tie is drawn between the leaders; with no vote at all it is the
|
|
// next map of the pool, as without the vote.
|
|
const auto top = count.empty() ? 0U : *std::max_element(count.begin(), count.end());
|
|
std::vector<std::size_t> leaders;
|
|
for (std::size_t i = 0; i < count.size(); ++i)
|
|
if (top && count[i] == top) leaders.push_back(i);
|
|
std::string answer;
|
|
if (!leaders.empty()) answer = done.answers[leaders[std::uniform_int_distribution<std::size_t>(0, leaders.size() - 1)(random_)]];
|
|
const auto tally = answer_count(done.answers, count);
|
|
if (answer == multiplayer::next_map_stay && config_.rotation_extend) {
|
|
extend_map(config_.rotation_extend);
|
|
const auto line = "The vote keeps " + map_name() + " for " + std::to_string(config_.rotation_extend) + " more minutes (" + tally + ").";
|
|
send_chat(line);
|
|
log_("[rotation] " + line);
|
|
return;
|
|
}
|
|
const ServerLevel *next{};
|
|
if (!answer.empty() && answer != multiplayer::next_map_random && answer != multiplayer::next_map_stay)
|
|
for (const auto *level : pool_levels(config_))
|
|
if (!same_map(level->asset) && clean_chat_text(level->name).starts_with(answer)) { next = level; break; }
|
|
if (!next && !answer.empty()) { // any map of the pool but this one
|
|
std::vector<const ServerLevel *> others;
|
|
for (const auto *level : pool_levels(config_))
|
|
if (!same_map(level->asset)) others.push_back(level);
|
|
if (!others.empty()) next = others[std::uniform_int_distribution<std::size_t>(0, others.size() - 1)(random_)];
|
|
}
|
|
if (!next) next = next_pool_map(config_, config_.map);
|
|
if (!next) {
|
|
map_since_ = now_;
|
|
return;
|
|
}
|
|
const auto line = "Next map: " + next->name + (top ? " (" + tally + ")." : " (nobody voted).");
|
|
send_chat(line);
|
|
log_("[rotation] " + line);
|
|
change_map(next->name);
|
|
save();
|
|
}
|
|
void Host::tick_rotation() { // waits while nobody is on, and for a running vote
|
|
if (!config_.map_rotation || !players()) {
|
|
map_since_ = now_;
|
|
rotation_warned_ = false;
|
|
rotation_told_ = 0;
|
|
rotation_voted_ = false;
|
|
return;
|
|
}
|
|
const auto due = map_since_ + std::uint64_t{config_.map_rotation} * 60000000;
|
|
const auto *next = next_pool_map(config_, config_.map);
|
|
if (!next) {
|
|
map_since_ = now_;
|
|
return;
|
|
}
|
|
// Ten, five and one minute before: a card, when that long is not the whole of the map's time.
|
|
if (config_.rotation_announce && now_ < due) {
|
|
static constexpr unsigned marks[]{10, 5, 1};
|
|
const auto left = due - now_;
|
|
for (unsigned i = 0; i < 3; ++i) {
|
|
if ((rotation_told_ & (1U << i)) || left > std::uint64_t{marks[i]} * 60000000) continue;
|
|
// (Only the nearest mark is said when several have gone by at once.)
|
|
const bool nearest = i == 2 || left > std::uint64_t{marks[i + 1]} * 60000000;
|
|
rotation_told_ |= 1U << i;
|
|
if (!nearest || config_.map_rotation <= marks[i]) continue;
|
|
announce(map_name() + " ends in " + std::to_string(marks[i]) + (marks[i] == 1 ? " minute. " : " minutes. ") +
|
|
(config_.rotation_vote ? "A vote for the next map follows." : "Next: " + next->name + "."));
|
|
}
|
|
}
|
|
if (now_ + 60000000 < due) return;
|
|
if (!config_.rotation_announce && !rotation_warned_ && config_.map_rotation > 1) {
|
|
rotation_warned_ = true;
|
|
send_chat(config_.rotation_vote ? "A vote for the next map starts in 1 minute." : "Next map in 1 minute: " + next->name + ".");
|
|
}
|
|
// The vote's maps are drawn now, a minute ahead, and games told (send_next_maps).
|
|
if (config_.rotation_vote && !rotation_voted_ && next_maps_.empty()) draw_next_maps();
|
|
if (now_ < due || vote_) return;
|
|
if (config_.rotation_vote && !rotation_voted_) {
|
|
rotation_voted_ = true;
|
|
if (start_next_map_vote()) return;
|
|
}
|
|
send_chat("Changing the map to " + next->name + ".");
|
|
log_("[rotation] Changing the map to " + next->name + ".");
|
|
change_map(next->name);
|
|
save();
|
|
}
|
|
} // namespace dingosdk::server
|