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

+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)