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:
zeex64 authored and GitHub committed 2026-10-09 23:03:16 -05:00
commit 1bd1d3437e
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"});
+37 -5
View File
@@ -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
View File
@@ -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.
+23 -9
View File
@@ -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);
+9 -1
View File
@@ -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");
+68 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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_;
}
+4 -2
View File
@@ -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
View File
@@ -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 {