mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 13:57:05 +02:00
Merge pull request #147 from moelrobi/claude/peaceful-bardeen-ode7bj
Server: announcements to one player, card only, and custom chat commands
This commit is contained in:
10 files changed
+218
-41
No files matched your search
@@ -498,6 +498,8 @@ std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
|
||||
list.push_back({"/tod", "/tod <time>", "Admin: set the time of day", "time"});
|
||||
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({"/announce-to", "/announce-to <player> <text>", "Admin: announce something to one player, on a card on their screen",
|
||||
"player"});
|
||||
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"});
|
||||
|
||||
@@ -449,6 +449,31 @@ void draw_command_list(ChatState& c, ImFont* heading, ImFont* body, ImVec2 botto
|
||||
"; Tab for the next, Shift+Tab back").c_str());
|
||||
}
|
||||
|
||||
// Card text with its :emote: images (chat_rich.h), measured and drawn as the chat panel's
|
||||
// lines are; text without any known emote is drawn plainly, as before.
|
||||
struct CardText {
|
||||
bool emotes{};
|
||||
RichText rich;
|
||||
ImVec2 extent;
|
||||
};
|
||||
CardText card_text(ImFont* font, float size, const std::string& text, float wrap, std::size_t max_lines = 0,
|
||||
float emote_scale = 1.3f) {
|
||||
CardText out;
|
||||
out.emotes = has_emotes(text);
|
||||
if (out.emotes) {
|
||||
out.rich = lay_out(font, size, text, wrap, 0.0f, max_lines, emote_scale);
|
||||
out.extent = ImVec2(out.rich.width, out.rich.height);
|
||||
} else {
|
||||
out.extent = font->CalcTextSizeA(size, FLT_MAX, wrap, text.c_str());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
void draw_card_text(ImDrawList* draw, ImFont* font, float size, ImVec2 at, const std::string& text, const CardText& laid,
|
||||
ImU32 colour, float wrap) {
|
||||
if (laid.emotes) draw_rich(draw, font, size, at, text, laid.rich, colour, 1.0f);
|
||||
else draw->AddText(font, size, at, colour, text.c_str(), nullptr, wrap);
|
||||
}
|
||||
|
||||
// The vote a dedicated server is running, or has just finished: a card on the middle of the
|
||||
// screen's right edge, in the look of the overlay's notices (a dark tile with a strip of colour
|
||||
// down its left). It says what is voted on, the tally and the time left, and has Yes and No. The
|
||||
@@ -464,7 +489,8 @@ void draw_poll(const MultiplayerVote& poll, ImFont* heading, ImFont* body, ImVec
|
||||
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 auto question = card_text(heading, size, poll.label, inner);
|
||||
const auto question_extent = question.extent;
|
||||
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 +
|
||||
@@ -482,7 +508,7 @@ void draw_poll(const MultiplayerVote& poll, ImFont* heading, ImFont* body, ImVec
|
||||
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);
|
||||
draw_card_text(draw, heading, size, ImVec2(left, y), poll.label, question, theme::paper, inner);
|
||||
y += question_extent.y + gap;
|
||||
unsigned total{}, most{};
|
||||
for (const auto count : poll.counts) total += count, most = std::max(most, count);
|
||||
@@ -511,7 +537,12 @@ void draw_poll(const MultiplayerVote& poll, ImFont* heading, ImFont* body, ImVec
|
||||
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 + 32.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 + 32.0f * scale, text_y), theme::paper, poll.answers[i].c_str());
|
||||
// One line, cut with "..." where the count starts; an emote is a little smaller here, so it fits the row.
|
||||
const float answer_size = fine + 1.0f * scale;
|
||||
const float answer_wrap = (b.x - count_width - 18.0f * scale) - (a.x + 32.0f * scale);
|
||||
const auto answer = card_text(body, answer_size, poll.answers[i], answer_wrap, 1, 1.15f);
|
||||
const float answer_y = answer.emotes ? y + (row - answer.extent.y) * 0.5f : text_y;
|
||||
draw_card_text(draw, body, answer_size, ImVec2(a.x + 32.0f * scale, answer_y), poll.answers[i], answer, theme::paper, 0.0f);
|
||||
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);
|
||||
@@ -530,7 +561,8 @@ void draw_announcement(const MultiplayerAnnouncement& announcement, ImFont* head
|
||||
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 auto text = card_text(body, size, announcement.text, inner);
|
||||
const auto text_extent = text.extent;
|
||||
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();
|
||||
@@ -538,7 +570,7 @@ void draw_announcement(const MultiplayerAnnouncement& announcement, ImFont* head
|
||||
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);
|
||||
draw_card_text(draw, body, size, ImVec2(left, min.y + pad + title_size + gap), announcement.text, text, theme::paper, inner);
|
||||
}
|
||||
|
||||
void draw_vote(const MultiplayerVote& vote, ImFont* heading, ImFont* body, ImVec2 display, float scale, bool clickable) {
|
||||
|
||||
+22
-5
@@ -302,10 +302,26 @@ Server votes The console (and scripts that talk to it) starts the same
|
||||
|
||||
"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>.
|
||||
(0: off) while players are on. Each is at most one chat line.
|
||||
Announcements show as a card at the top of the screen, not
|
||||
in chat; :emotes: in them (and in polls) show as images. Admins announce something once with announce <text>,
|
||||
or to one player only with announce-to <player> <text>.
|
||||
|
||||
"commands" - Chat commands of your own, e.g. /discord or /rules. A list, each:
|
||||
name 1-16 of a-z, 0-9, - and _; not one of the server's own
|
||||
commands (help, party, vote, kick, map...).
|
||||
reply A chat line sent back to whoever typed it (optional).
|
||||
command A server command, or a list of up to 8, run as the console
|
||||
(optional). {player} is the SteamID64 of whoever typed it,
|
||||
{map} the current map and {arg} the rest of what they
|
||||
typed. Each command needs a reply, a command, or both.
|
||||
admin Only admins may use it (default false).
|
||||
For example:
|
||||
{"name": "discord", "reply": "Join us: discord.gg/..."}
|
||||
{"name": "rules", "command": "announce-to {player} No griefing!"}
|
||||
{"name": "restart", "admin": true, "command": ["map {map}"]}
|
||||
They are not listed in /help: tell players about them yourself.
|
||||
Up to 32. Mind what {arg} lets players put into a command.
|
||||
|
||||
Every change made from the console or by an admin is saved back to this file.
|
||||
|
||||
@@ -378,7 +394,8 @@ and change voice, distances, placement and kicks from the Multiplayer menu.
|
||||
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]
|
||||
announce-to <player> <text> Tell one player, on a card.
|
||||
announcements [list | add <text> | remove <n> | clear | interval <minutes>|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.
|
||||
|
||||
@@ -44,7 +44,7 @@ int run() {
|
||||
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"),
|
||||
has("announcements.messages") && has("announcements.interval_minutes") && has("commands"),
|
||||
"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,
|
||||
@@ -329,7 +329,7 @@ int run() {
|
||||
|
||||
// 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,
|
||||
config.votes.map.min_players == 1 && config.votes.custom.empty() && config.commands.empty(),
|
||||
"The new vote settings do not start at their defaults");
|
||||
{
|
||||
auto voting = pool;
|
||||
@@ -338,8 +338,11 @@ int run() {
|
||||
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");
|
||||
voting.announcements = {{"Welcome to the server!", "Join our Discord"}, 15};
|
||||
voting.commands = {{"discord", "Join us: discord.gg/reskate", {}, false},
|
||||
{"rules", {}, {"announce-to {player} No griefing!", "say {arg}"}, false},
|
||||
{"restart", "Reloading...", {"map {map}"}, true}};
|
||||
check(config_error(voting).empty(), "Valid custom votes, announcements and commands 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",
|
||||
@@ -349,9 +352,12 @@ int run() {
|
||||
!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,
|
||||
check(back.announcements.messages == voting.announcements.messages && back.announcements.interval == 15,
|
||||
"Announcements lost on save");
|
||||
check(back.commands.size() == 3 && back.commands[0].name == "discord" && back.commands[0].reply == "Join us: discord.gg/reskate" &&
|
||||
back.commands[0].commands.empty() && !back.commands[0].admin &&
|
||||
back.commands[1].commands == voting.commands[1].commands && back.commands[2].admin,
|
||||
"Custom commands lost on save");
|
||||
const auto refused = [&](auto change, const char *what) {
|
||||
auto bad = voting;
|
||||
change(bad);
|
||||
@@ -368,15 +374,23 @@ int run() {
|
||||
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");
|
||||
refused([](ServerConfig &c) { c.commands[0].name = "kick"; }, "A custom command named like a server command accepted");
|
||||
refused([](ServerConfig &c) { c.commands[0].name = "party"; }, "A custom command named like a chat command accepted");
|
||||
refused([](ServerConfig &c) { c.commands[0].name = "Discord"; }, "A custom command name with capitals accepted");
|
||||
refused([](ServerConfig &c) { c.commands[1].name = "discord"; }, "Two custom commands with one name accepted");
|
||||
refused([](ServerConfig &c) { c.commands[0].reply.clear(); }, "A custom command with no reply or command accepted");
|
||||
refused([](ServerConfig &c) { c.commands[0].reply = std::string(201, 'a'); }, "A custom reply over one chat line 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}})";
|
||||
"announcements": {"messages": ["hi", "", 3], "interval_minutes": 99999, "card": false},
|
||||
"commands": [{"name": "discord", "reply": "hi", "command": "say hello"}]})";
|
||||
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");
|
||||
hand.announcements.messages == std::vector<std::string>{"hi"} && hand.announcements.interval == 1440 &&
|
||||
hand.commands.size() == 1 && hand.commands[0].commands == std::vector<std::string>{"say hello"},
|
||||
"A hand-written custom vote, announcement list or command was not read sensibly");
|
||||
}
|
||||
|
||||
std::filesystem::remove_all(folder);
|
||||
|
||||
@@ -21,7 +21,7 @@ constexpr std::string_view help_text =
|
||||
"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"
|
||||
"announce <text> | announce-to <player> <text> | announcements [list|add <text>|remove <n>|clear|interval <minutes>|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"
|
||||
@@ -497,6 +497,14 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
|
||||
announce(argument);
|
||||
return "Announced.";
|
||||
}
|
||||
if (name == "announce-to") {
|
||||
const auto [who, text] = split(argument);
|
||||
if (text.empty()) return "announce-to <player> <text>";
|
||||
auto *guest = match_player(who);
|
||||
if (!guest) return "No single connected player matches \"" + std::string(who) + "\".";
|
||||
announce(text, guest->member.id);
|
||||
return "Announced to " + guest_name(*guest) + ".";
|
||||
}
|
||||
if (name == "vote-cancel") {
|
||||
if (!vote_) return "No vote is running.";
|
||||
cancel_vote(console ? "the server cancelled it" : "an admin cancelled it");
|
||||
|
||||
@@ -173,7 +173,19 @@ Layout layout(const ServerConfig &c) {
|
||||
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);
|
||||
|
||||
auto commands = Json::array();
|
||||
for (const auto &command : c.commands) {
|
||||
auto item = Json::object();
|
||||
item["name"] = command.name;
|
||||
item["reply"] = command.reply;
|
||||
auto runs = Json::array();
|
||||
for (const auto &run : command.commands) runs.push_back(run);
|
||||
item["command"] = std::move(runs);
|
||||
item["admin"] = command.admin;
|
||||
commands.push_back(std::move(item));
|
||||
}
|
||||
root.set("commands", std::move(commands));
|
||||
return root;
|
||||
}
|
||||
// Bans have a file of their own, beside the config.
|
||||
@@ -375,13 +387,29 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
|
||||
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"})
|
||||
for (const char *key : {"messages", "interval_minutes"})
|
||||
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);
|
||||
|
||||
// Chat commands came after announcements: a command is a string or a list of them.
|
||||
if (!root.contains("commands")) missing.push_back("commands");
|
||||
else if (!root.at("commands").is_array()) throw std::runtime_error("commands must be a list of commands, each a JSON object.");
|
||||
else
|
||||
for (const auto &item : root.at("commands")) {
|
||||
if (!item.is_object()) throw std::runtime_error("commands must be a list of commands, each a JSON object.");
|
||||
CustomCommand command;
|
||||
command.name = item.value("name", std::string{});
|
||||
command.reply = item.value("reply", std::string{});
|
||||
command.admin = item.value("admin", command.admin);
|
||||
if (item.contains("command") && item.at("command").is_string()) command.commands.push_back(item.at("command").string());
|
||||
else if (item.contains("command") && item.at("command").is_array())
|
||||
for (const auto &run : item.at("command"))
|
||||
if (run.is_string() && !run.string().empty()) command.commands.push_back(run.string());
|
||||
c.commands.push_back(std::move(command));
|
||||
}
|
||||
|
||||
const auto read_bans = [&](const Json &rows) {
|
||||
if (!rows.is_array()) throw std::runtime_error("The bans must be a JSON list.");
|
||||
@@ -501,6 +529,42 @@ std::string custom_votes_error(const std::vector<CustomVote> &votes) {
|
||||
}
|
||||
return {};
|
||||
}
|
||||
bool custom_command_name_free(std::string_view name) noexcept {
|
||||
// The chat's own commands, then the server commands admins type as /<command>.
|
||||
for (const std::string_view taken :
|
||||
{"help", "party", "p", "w", "whisper", "tell", "poll", "vote", "yes", "y", "no", "n", "tp",
|
||||
"activity-log", "admin", "admins", "afk-kick", "announce", "announce-throwdowns", "announce-to", "announcements",
|
||||
"ban", "bans", "bone-scale", "boosts", "boosts-allow", "chat-color", "chat-colour", "clear-objects", "crowd",
|
||||
"distances", "effects", "kick", "layer", "layer-sync", "layers", "listed", "map", "map-pool", "maps", "msg",
|
||||
"msg-admins", "msg-party", "name", "net", "nobail", "nobail-allow", "noclip", "noclip-allow", "object-limit",
|
||||
"object-placement", "object-scaling", "objects", "park", "parties", "party-size", "password", "placement",
|
||||
"players", "rate", "reserved", "rotation", "say", "score-allow", "score-check", "speed-check", "status", "tod",
|
||||
"tpall", "tphere", "tps", "tuning", "tuning-enforce", "unban", "voice", "voice-allow", "voice-range",
|
||||
"vote-cancel", "votes", "welcome", "world-layer-sync", "quit", "exit", "stop", "update"})
|
||||
if (name == taken) return false;
|
||||
// A number is an answer to a poll ("/2").
|
||||
return !std::all_of(name.begin(), name.end(), [](char c) { return c >= '0' && c <= '9'; });
|
||||
}
|
||||
std::string custom_commands_error(const std::vector<CustomCommand> &commands) {
|
||||
using namespace multiplayer;
|
||||
if (commands.size() > max_custom_commands) return "commands: at most " + std::to_string(max_custom_commands) + " commands.";
|
||||
for (std::size_t i = 0; i < commands.size(); ++i) {
|
||||
const auto &c = commands[i];
|
||||
if (!valid_server_vote_name(c.name))
|
||||
return "commands: each needs a name of 1 to 16 lowercase letters, digits, - or _ (\"" + c.name + "\" is not).";
|
||||
const auto where = "commands \"" + c.name + "\": ";
|
||||
if (!custom_command_name_free(c.name)) return where + "that name is one of the server's own commands.";
|
||||
for (std::size_t j = 0; j < i; ++j)
|
||||
if (commands[j].name == c.name) return where + "two commands have that name.";
|
||||
if (c.reply.empty() && c.commands.empty()) return where + "needs a reply, a command, or both.";
|
||||
if (!c.reply.empty() && !valid_chat_text(c.reply)) return where + "reply must be one chat line (at most 200 bytes).";
|
||||
if (c.commands.size() > max_custom_command_runs)
|
||||
return where + "at most " + std::to_string(max_custom_command_runs) + " server commands.";
|
||||
for (const auto &run : c.commands)
|
||||
if (!valid_admin_text(run)) return where + "each command must be one server command, e.g. \"announce-to {player} Hi\".";
|
||||
}
|
||||
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.";
|
||||
@@ -536,6 +600,7 @@ std::string config_error(const ServerConfig &c) {
|
||||
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 (auto error = custom_commands_error(c.commands); !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)
|
||||
|
||||
+16
-1
@@ -49,7 +49,17 @@ inline constexpr unsigned max_announcement_interval = 1440; // minutes
|
||||
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
|
||||
};
|
||||
inline constexpr std::size_t max_custom_commands = 32;
|
||||
inline constexpr std::size_t max_custom_command_runs = 8;
|
||||
// The owner's own chat commands ("/discord"), never listed in /help. Each answers whoever typed
|
||||
// it with `reply`, and/or runs `commands` as the console: {player} is their SteamID64, {map}
|
||||
// the current map, {arg} the rest of what they typed.
|
||||
struct CustomCommand {
|
||||
std::string name; // 1-16 of a-z 0-9 - _
|
||||
std::string reply;
|
||||
std::vector<std::string> commands;
|
||||
bool admin{}; // only admins may use it
|
||||
};
|
||||
// ReSkateServer.json. Every setting an admin or the console changes is saved
|
||||
// back, so a restart keeps it.
|
||||
@@ -152,6 +162,7 @@ struct ServerConfig {
|
||||
bool enforce_tuning = true;
|
||||
VoteSettings votes; // all off until the owner turns them on
|
||||
Announcements announcements;
|
||||
std::vector<CustomCommand> commands;
|
||||
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.
|
||||
@@ -180,6 +191,10 @@ 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);
|
||||
// Why the owner's custom chat commands cannot run, or empty; and whether a chat command may be
|
||||
// called `name` (not one players or admins already type: help, party, vote, kick, map...).
|
||||
bool custom_command_name_free(std::string_view name) noexcept;
|
||||
std::string custom_commands_error(const std::vector<CustomCommand> &commands);
|
||||
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).
|
||||
|
||||
+12
-1
@@ -679,7 +679,18 @@ void Host::send_roster() {
|
||||
if (m.party && std::none_of(p.members.begin(), p.members.end(),
|
||||
[&](const Member &o) { return o.party == m.party && o.party_leader; }))
|
||||
m.party_leader = true; // the first listed member of a party missing its leader
|
||||
broadcast(p, true, false);
|
||||
// An announcement for one player: everyone else's roster goes without it.
|
||||
auto *only = announcement_for_ ? find(announcement_for_) : nullptr;
|
||||
if (only && !only->handshaken) only = nullptr;
|
||||
if (announcement_for_) {
|
||||
const auto shown = p.announcement;
|
||||
p.announcement = {};
|
||||
broadcast(p, true, false, announcement_for_);
|
||||
p.announcement = shown;
|
||||
if (only) send_packet(*only, p, true, false);
|
||||
} else {
|
||||
broadcast(p, true, false);
|
||||
}
|
||||
roster_dirty_ = false;
|
||||
last_roster_ = now_;
|
||||
}
|
||||
|
||||
@@ -214,6 +214,7 @@ class Host {
|
||||
// The announcement games show (the roster carries it), until announcement_until_.
|
||||
multiplayer::ServerAnnouncement announcement_;
|
||||
std::uint64_t announcement_until_{};
|
||||
std::uint64_t announcement_for_{}; // the one player shown it, or 0: everyone
|
||||
std::uint32_t announcement_ids_{};
|
||||
std::uint64_t announced_at_{}; // the last timed announcement, or while nobody is on
|
||||
std::size_t next_announcement_{};
|
||||
@@ -338,6 +339,7 @@ class Host {
|
||||
void apply_layers();
|
||||
// Chat commands and votes (server_votes.cpp).
|
||||
void chat_command(Guest &, std::string_view line);
|
||||
void run_custom_command(Guest &, const CustomCommand &, std::string_view argument);
|
||||
// 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 = {});
|
||||
@@ -357,8 +359,8 @@ class Host {
|
||||
// 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);
|
||||
// The announcement card on every player's screen, or only on player `to`'s (not in chat).
|
||||
void announce(std::string_view text, std::uint64_t to = 0);
|
||||
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);
|
||||
|
||||
+25
-14
@@ -142,7 +142,7 @@ void Host::chat_command(Guest &guest, std::string_view line) {
|
||||
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, /announce, /msg, /msg-party, /msg-admins\n";
|
||||
text += "Admins: any server command as /<command>, e.g. /kick, /map, /tpall, /votes, /announce, /announce-to, /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);
|
||||
}
|
||||
@@ -199,6 +199,10 @@ void Host::chat_command(Guest &guest, std::string_view line) {
|
||||
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.");
|
||||
}
|
||||
// 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_admin(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_admin(guest.member.id)) {
|
||||
@@ -210,6 +214,18 @@ void Host::chat_command(Guest &guest, std::string_view line) {
|
||||
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)
|
||||
@@ -594,22 +610,24 @@ std::pair<std::string, bool> Host::votes_command(std::string_view argument) {
|
||||
return {label + " now need " + std::to_string(*percent) + "% to pass.", true};
|
||||
}
|
||||
|
||||
void Host::announce(std::string_view text) {
|
||||
void Host::announce(std::string_view text, std::uint64_t to) {
|
||||
const auto line = clean_chat_text(text);
|
||||
if (line.empty()) return;
|
||||
send_chat(line);
|
||||
log_("[announcement] " + line);
|
||||
if (!config_.announcements.card) 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;
|
||||
@@ -629,8 +647,7 @@ std::pair<std::string, bool> Host::announcements_command(std::string_view argume
|
||||
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") + ":";
|
||||
: "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};
|
||||
}
|
||||
@@ -659,13 +676,7 @@ std::pair<std::string, bool> Host::announcements_command(std::string_view argume
|
||||
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};
|
||||
return {"announcements [list | add <text> | remove <n> | clear | interval <minutes>|off]", false};
|
||||
}
|
||||
|
||||
std::string Host::pool_text() const {
|
||||
|
||||
Reference in new issue
Block a user