From f7beec83c55999bf3e736193bca291fb4530a241 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 11 Oct 2026 13:39:01 -0500 Subject: [PATCH] Server window: word filter toggle and a roles editor Settings tab: a Chat Word Filter card with "Use Global Word Filter" (use_global_word_filter), buttons that open data/words_filtered.txt and data/words_blocked.txt, and one that has a running server read them again. Moderation tab: - ROLES: create, edit and delete roles (tag, tag colour, rank, default role, inheriting from it, permissions, commands). Saving checks the roles, writes data/roles.json and has a running server reload it. A role's name is fixed once made, since players are given roles by name, and a save keeps who has which role as the server has it then. - PLAYER ROLES (was SERVER ADMINISTRATORS): giving a role picks any of the server's roles, not only admin. The two word files are now made empty when they are missing, without the explanation that was written into them. Also: the README's Chat words section sat inside the Roles section, and the server parks test did not link without server_words.cpp. --- CMakeLists.txt | 2 +- Server/GUI/server_gui.cpp | 269 +++++++++++++++++++++++++++-- Server/README.txt | 8 +- Server/Test/server_words_tests.cpp | 7 +- Server/server_words.cpp | 21 +-- Server/server_words.h | 2 +- 6 files changed, 274 insertions(+), 35 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e9e7d7d..4d820c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,7 +98,7 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) # Park mods spawned by the server's host, over a transport that is not Steam's. add_executable(dingosdk_server_park_tests Server/Test/server_park_tests.cpp Server/server_host.cpp Server/server_party.cpp Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/server_update.cpp Server/server_release.cpp - $<$:Launcher/updater.cpp> Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp + $<$:Launcher/updater.cpp> Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp diff --git a/Server/GUI/server_gui.cpp b/Server/GUI/server_gui.cpp index b6bba58..12bcc3c 100644 --- a/Server/GUI/server_gui.cpp +++ b/Server/GUI/server_gui.cpp @@ -10,6 +10,7 @@ #include "server_gui.h" #include "server_gui_renderer.h" #include "../server_engine.h" +#include "../server_words.h" #include "Extension/UI/skate_theme.h" #include "Extension/UI/Overlay/skate_style.h" #include "Extension/Multiplayer/Net/protocol.h" @@ -1261,11 +1262,210 @@ void draw_world_tab(const ServerStatusSnapshot& stats) { } } +// --- Roles editor (data/roles.json; Server/roles.h) --- +// A copy of the roles is edited here and written back whole: the file is saved and, with the +// server running, read again by it ("roles reload"). Who has which role is not edited here (the +// PLAYER ROLES tab and the role command do that), so a save keeps the players the server has now. +struct RolesEditor { + Roles roles; + bool loaded{}; + int selected{}; + char new_name[32]{}; +}; +static RolesEditor s_roles; + +static bool text_field(const char* label, std::string& value, std::size_t most, const char* hint = "") { + std::array buffer{}; + strncpy_s(buffer.data(), buffer.size(), value.c_str(), _TRUNCATE); + const bool changed = ImGui::InputTextWithHint(label, hint, buffer.data(), std::min(buffer.size(), most + 1)); + if (changed) value = buffer.data(); + return changed; +} + +static void save_roles() { + auto& engine = ServerEngine::instance(); + auto& cfg = engine.config(); + auto roles = s_roles.roles; + // Players keep the roles the server has given them, except a role that was deleted here. + roles.players = cfg.roles.players; + std::erase_if(roles.players, [&](const auto& entry) { return !roles.find(entry.second); }); + if (!roles.default_role.empty() && !roles.find(roles.default_role)) roles.default_role.clear(); + roles.settle(); + if (const auto problem = roles_error(roles); !problem.empty()) { + show_notification("Roles not saved: " + problem, 6.0, danger); + return; + } + try { + const auto file = roles_file(cfg); + std::filesystem::create_directories(file.parent_path()); + { + std::ofstream out(file, std::ios::binary | std::ios::trunc); + if (!(out << roles_text(roles))) throw std::runtime_error("could not write data/roles.json"); + } + if (engine.state() == ServerState::Running) engine.queue_command("roles reload"); + else cfg.roles = roles; + s_roles.roles = roles; + show_notification("Roles saved to data/roles.json.", 3.5, good); + } catch (const std::exception& e) { + show_notification(std::string("Roles not saved: ") + e.what(), 5.0, danger); + } +} + +static void draw_roles_editor() { + auto& cfg = ServerEngine::instance().config(); + if (!s_roles.loaded) { + s_roles.roles = cfg.roles; + s_roles.loaded = true; + } + auto& roles = s_roles.roles; + + if (rough_button("SAVE ROLES", ImVec2(S(150), S(32)))) save_roles(); + ImGui::SameLine(0, S(10)); + if (rough_button("DISCARD CHANGES", ImVec2(S(170), S(32)))) { + roles = cfg.roles; + s_roles.selected = 0; + show_notification("Roles put back as the server has them.", 3.0, blue); + } + ImGui::SameLine(0, S(10)); + if (rough_button("OPEN ROLES FILE", ImVec2(S(170), S(32)))) { + const auto path = roles_file(cfg).string(); + ShellExecuteA(nullptr, "open", path.c_str(), nullptr, nullptr, SW_SHOW); + } + ImGui::Dummy(ImVec2(0, S(6))); + + ImGui::Columns(2, "roles_cols", false); + ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.34f); + + // The list of roles, highest rank first, and making a new one. + const std::string count = std::to_string(roles.list.size()) + " of " + std::to_string(max_roles) + " roles"; + begin_card("card_roles_list", "ROLES", count.c_str()); + std::vector order(roles.list.size()); + for (int i = 0; i < static_cast(order.size()); ++i) order[i] = i; + std::stable_sort(order.begin(), order.end(), [&](int a, int b) { return roles.list[a].rank > roles.list[b].rank; }); + s_roles.selected = std::clamp(s_roles.selected, 0, std::max(0, static_cast(roles.list.size()) - 1)); + for (const int i : order) { + const auto& role = roles.list[i]; + std::string label = role.name + " (rank " + std::to_string(role.rank) + ")"; + if (role.name == roles.default_role) label += " - default"; + ImGui::PushID(i); + if (rough_button(label.c_str(), ImVec2(ImGui::GetContentRegionAvail().x, S(28)), s_roles.selected == i)) s_roles.selected = i; + ImGui::PopID(); + } + ImGui::Spacing(); + ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x); + ImGui::InputTextWithHint("##new_role", "new role name (a-z, 0-9, - and _)", s_roles.new_name, 25); + ImGui::PopItemWidth(); + if (rough_button("ADD ROLE", ImVec2(S(130), S(28)))) { + Role role; + role.name = s_roles.new_name; + Roles tried = roles; + tried.list.push_back(role); + tried.players.clear(); + if (const auto problem = roles_error(tried); !problem.empty()) { + show_notification("Role not added: " + problem, 5.0, danger); + } else { + roles.list.push_back(std::move(role)); + s_roles.selected = static_cast(roles.list.size()) - 1; + s_roles.new_name[0] = '\0'; + } + } + end_card(); + + ImGui::NextColumn(); + + if (roles.list.empty()) { + begin_card("card_role_none", "NO ROLES", "Add a role on the left"); + ImGui::PushStyleColor(ImGuiCol_Text, muted); + ImGui::TextWrapped("Without roles every player chats, votes and joins parties as usual, and nobody but the console runs commands."); + ImGui::PopStyleColor(); + end_card(); + ImGui::Columns(1); + return; + } + + auto& role = roles.list[s_roles.selected]; + const std::string title = "ROLE: " + role.name; + begin_card("card_role_edit", title.c_str(), "Changes apply when you press SAVE ROLES"); + ImGui::PushID(s_roles.selected); + ImGui::PushItemWidth(S(300)); + text_field("Tag", role.tag, multiplayer::max_role_tag, "shown before the player's name; empty for none"); + { + // The tag's colour, kept as the #RRGGBB the file holds. + const auto rgb = role.color.size() == 7 && role.color[0] == '#' ? std::strtoul(role.color.c_str() + 1, nullptr, 16) : 0xFFFFFFUL; + float colour[3]{((rgb >> 16) & 255) / 255.f, ((rgb >> 8) & 255) / 255.f, (rgb & 255) / 255.f}; + if (ImGui::ColorEdit3("Tag Colour", colour, ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoAlpha)) { + const auto part = [](float v) { return static_cast(std::lround(std::clamp(v, 0.f, 1.f) * 255.f)); }; + role.color = std::format("#{:02X}{:02X}{:02X}", part(colour[0]), part(colour[1]), part(colour[2])); + } + ImGui::SameLine(0, S(10)); + ImGui::PushStyleColor(ImGuiCol_Text, muted); + ImGui::TextUnformatted(role.color.c_str()); + ImGui::PopStyleColor(); + } + ImGui::SliderInt("Rank", &role.rank, 0, 1000); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Nobody uses a command on a player whose rank is theirs or higher, or gives out such a role."); + ImGui::PopItemWidth(); + + bool is_default = role.name == roles.default_role; + if (ImGui::Checkbox("Default role (everyone who was given none)", &is_default)) roles.default_role = is_default ? role.name : std::string{}; + ImGui::Checkbox("Also has what the default role has", &role.inherit); + + ImGui::Dummy(ImVec2(0, S(4))); + ImGui::PushFont(g_fonts.bold); + ImGui::TextUnformatted("PERMISSIONS"); + ImGui::PopFont(); + static constexpr std::array permission_labels{ + "Chat", "Start votes and vote", "Parties", "Private messages", "Reserved slot (joins a full server)", + "No object limit, builds when players may not", "Noclip, No Bail and boosts when players may not", + "Never removed for being away", "Cannot be vote-kicked", "Sees anti-cheat alerts", "Starts and ends polls"}; + for (std::size_t i = 0; i < role_permissions.size(); ++i) { + bool on = (role.permissions & role_permissions[i].second) != 0; + if (ImGui::Checkbox(permission_labels[i], &on)) role.permissions = on ? role.permissions | role_permissions[i].second : role.permissions & ~role_permissions[i].second; + if (i % 2 == 0 && i + 1 < role_permissions.size()) ImGui::SameLine(S(330)); + } + + ImGui::Dummy(ImVec2(0, S(4))); + ImGui::PushFont(g_fonts.bold); + ImGui::TextUnformatted("SERVER COMMANDS"); + ImGui::PopFont(); + ImGui::Checkbox("Every command (except role and reserved, unless listed below)", &role.all_commands); + { + // The commands it lists, as words with blanks between. + std::string listed; + for (const auto& command : role.commands) listed += (listed.empty() ? "" : " ") + command; + ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x); + if (text_field("##role_commands", listed, 500, "commands, e.g. kick ban unban bans players msg tphere role")) { + role.commands.clear(); + std::string word; + for (const char c : listed + " ") { + if (c != ' ' && c != ',') { word += static_cast(std::tolower(static_cast(c))); continue; } + if (!word.empty() && std::find(role.commands.begin(), role.commands.end(), word) == role.commands.end()) role.commands.push_back(word); + word.clear(); + } + } + ImGui::PopItemWidth(); + } + + ImGui::Dummy(ImVec2(0, S(6))); + std::size_t holders = 0; + for (const auto& [id, name] : cfg.roles.players) holders += name == role.name; + const std::string remove = "DELETE ROLE" + (holders ? " (" + std::to_string(holders) + " player" + (holders == 1 ? "" : "s") + " lose it)" : std::string{}); + if (colored_action_button(remove.c_str(), ImVec2(S(300), S(28)), danger, white, "Removed when you press SAVE ROLES")) { + if (roles.default_role == role.name) roles.default_role.clear(); + roles.list.erase(roles.list.begin() + s_roles.selected); + s_roles.selected = 0; + } + ImGui::PopID(); + end_card(); + + ImGui::Columns(1); +} + void draw_moderation_tab() { section("MODERATION & ACCESS CONTROLS"); category_tabs(g_state.moderation_subtab, - {"SERVER BANS", "SERVER ADMINISTRATORS"}, + {"SERVER BANS", "PLAYER ROLES", "ROLES"}, "mod_tabs"); if (g_state.moderation_subtab == 0) { @@ -1346,14 +1546,14 @@ void draw_moderation_tab() { end_card(); } else if (g_state.moderation_subtab == 1) { - // SERVER ADMINISTRATORS - // Admins are a role now (roles.h): everyone who was given a role is listed with it, and - // taking it away leaves them the default role. (A copy: the commands below change the list.) + // PLAYER ROLES + // Everyone who was given a role is listed with it, and taking it away leaves them the + // default role. (A copy: the commands below change the list.) const auto admins = ServerEngine::instance().config().roles.players; - begin_card("card_admins", "SERVER ADMINISTRATORS", "Players who were given a role (data/roles.json)"); + begin_card("card_admins", "PLAYER ROLES", "Players who were given a role (data/roles.json)"); if (admins.empty()) { ImGui::PushStyleColor(ImGuiCol_Text, muted); - ImGui::TextUnformatted("No administrators currently configured."); + ImGui::TextUnformatted("Nobody was given a role yet."); ImGui::PopStyleColor(); } else { for (const auto& [admin_id, role_name] : admins) { @@ -1378,15 +1578,32 @@ void draw_moderation_tab() { ImGui::PushItemWidth(S(260)); ImGui::InputTextWithHint("##new_admin_id", "SteamID64 (e.g. 7656119...)", s_new_admin, sizeof(s_new_admin)); ImGui::PopItemWidth(); + // Which role to give: any of the server's, highest rank first. + static std::string s_grant_role = "admin"; + const auto& known = ServerEngine::instance().config().roles; + if (!known.find(s_grant_role) && !known.list.empty()) s_grant_role = known.list.front().name; ImGui::SameLine(0, S(10)); - if (rough_button("GRANT ADMIN", ImVec2(S(140), S(30)))) { - if (s_new_admin[0]) { - ServerEngine::instance().queue_command(std::string("role ") + s_new_admin + " admin"); - show_notification("Added administrator: " + std::string(s_new_admin), 3.5, good); + ImGui::PushItemWidth(S(170)); + if (ImGui::BeginCombo("##grant_role", s_grant_role.c_str())) { + for (const auto& role : known.list) + if (ImGui::Selectable(role.name.c_str(), role.name == s_grant_role)) s_grant_role = role.name; + ImGui::EndCombo(); + } + ImGui::PopItemWidth(); + ImGui::SameLine(0, S(10)); + if (rough_button("GIVE ROLE", ImVec2(S(140), S(30)))) { + if (s_new_admin[0] && known.find(s_grant_role)) { + ServerEngine::instance().queue_command(std::string("role ") + s_new_admin + " " + s_grant_role); + show_notification("Gave " + std::string(s_new_admin) + " the role " + s_grant_role, 3.5, good); s_new_admin[0] = '\0'; } } + ImGui::PushStyleColor(ImGuiCol_Text, muted); + ImGui::TextWrapped("Roles are given while the server is running. Create and edit the roles themselves on the ROLES tab."); + ImGui::PopStyleColor(); end_card(); + } else { + draw_roles_editor(); } } @@ -2362,6 +2579,7 @@ struct SettingsForm { bool enforce_tuning{true}; float bone_scale_limit{2.0f}; bool global_bans{true}; + bool global_words{true}; bool initialized{false}; }; @@ -2400,6 +2618,7 @@ static void sync_form_from_config(SettingsForm& form, const ServerConfig& cfg) { form.enforce_tuning = cfg.enforce_tuning; form.bone_scale_limit = cfg.bone_scale_limit; form.global_bans = cfg.global_bans; + form.global_words = cfg.global_words; form.initialized = true; } @@ -2447,6 +2666,7 @@ void draw_settings_tab(ServerStatusSnapshot& stats) { cfg.enforce_tuning = s_form.enforce_tuning; cfg.bone_scale_limit = std::clamp(s_form.bone_scale_limit, 1.0f, 8.0f); cfg.global_bans = s_form.global_bans; + cfg.global_words = s_form.global_words; try { save_config(cfg); @@ -2465,6 +2685,7 @@ void draw_settings_tab(ServerStatusSnapshot& stats) { ServerEngine::instance().queue_command("speed-check " + cfg.speed_check); ServerEngine::instance().queue_command("score-check " + cfg.score_check); ServerEngine::instance().queue_command("bone-scale " + std::to_string(static_cast(cfg.bone_scale_limit))); + ServerEngine::instance().queue_command(std::string("words global ") + (cfg.global_words ? "on" : "off")); } show_notification("Configuration saved to ReSkateServer.json & synchronized.", 4.0, good); } catch (const std::exception& e) { @@ -2520,6 +2741,34 @@ void draw_settings_tab(ServerStatusSnapshot& stats) { ImGui::PopItemWidth(); end_card(); + // Column 1: Chat words + begin_card("card_cfg_words", "CHAT WORD FILTER", "The ReSkate team's lists and this server's own"); + ImGui::Checkbox("Use Global Word Filter", &s_form.global_words); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Off: only this server's own words are filtered and blocked.\nPlayers' games still star the team's filtered words for players with their chat filter on."); + ImGui::PushStyleColor(ImGuiCol_Text, muted); + ImGui::TextWrapped("This server's own words are in data/words_filtered.txt (starred for players with the chat filter on) and data/words_blocked.txt (never passed on). One word to a line."); + ImGui::PopStyleColor(); + if (rough_button("FILTERED WORDS", ImVec2(S(160), S(28)))) { + const auto path = filtered_words_file(ServerEngine::instance().config()).string(); + ShellExecuteA(nullptr, "open", path.c_str(), nullptr, nullptr, SW_SHOW); + } + ImGui::SameLine(0, S(8)); + if (rough_button("BLOCKED WORDS", ImVec2(S(160), S(28)))) { + const auto path = blocked_words_file(ServerEngine::instance().config()).string(); + ShellExecuteA(nullptr, "open", path.c_str(), nullptr, nullptr, SW_SHOW); + } + ImGui::SameLine(0, S(8)); + if (rough_button("RELOAD WORDS", ImVec2(S(150), S(28)))) { + if (ServerEngine::instance().state() == ServerState::Running) { + ServerEngine::instance().queue_command("words reload"); + show_notification("Reading this server's word files again; see the console.", 3.5, blue); + } else { + show_notification("The word files are read when the server starts.", 3.5, blue); + } + } + end_card(); + ImGui::NextColumn(); // Column 2: Network & Ports diff --git a/Server/README.txt b/Server/README.txt index 99d96ab..df5e9e3 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -401,13 +401,11 @@ join, managed with ban / unban. The ReSkate team's own list is separate: see use_global_bans. A config from an older version that still has "bans" in it has them moved there the first time the server starts. -Roles ------ Chat words ---------- Two files in data/, beside the bans, hold this server's own chat words. Both are -written with an explanation the first time the server starts. One word to a -line; a line starting with # is a comment. Edit them and type: words reload +made, empty, the first time the server starts. One word to a line; a line +starting with # is a comment. Edit them and type: words reload data/words_filtered.txt Words this server filters, on top of the team's. Messages with them are still passed on; the server @@ -427,6 +425,8 @@ filtered words for a player who has their chat filter on, and hides a server whose name has one of the team's words from the server browser. Those are the team's lists in the player's game, whatever a server sets. +Roles +----- Who may do what is in data/roles.json, beside the bans. README-roles.txt has the whole of it: every permission and every command a role can be given, with examples. In short, the server writes the file the first time it starts, with diff --git a/Server/Test/server_words_tests.cpp b/Server/Test/server_words_tests.cpp index b987976..dc13097 100644 --- a/Server/Test/server_words_tests.cpp +++ b/Server/Test/server_words_tests.cpp @@ -26,11 +26,12 @@ int main() { const std::vector filtered{"darn"}, forbidden{"zorblat", "snarf", "snarfing"}; set_word_lists(filtered, forbidden); - // A new server: both files are written with their explanation, and hold no words. + // A new server: both files are made, empty. auto words = use_own_words(config); check(words.problem.empty() && !words.filtered && !words.blocked && words.sent.empty() && - std::filesystem::exists(filtered_words_file(config)) && std::filesystem::exists(blocked_words_file(config)), - "The word files were not written, or their explanation was read as words"); + std::filesystem::exists(filtered_words_file(config)) && std::filesystem::file_size(filtered_words_file(config)) == 0 && + std::filesystem::exists(blocked_words_file(config)) && std::filesystem::file_size(blocked_words_file(config)) == 0, + "The word files were not made empty"); check(contains_forbidden_words("you zorblat") && contains_forbidden_words("snarfing") && !contains_forbidden_words("florp"), "A server without words of its own does not hold the team's lists"); diff --git a/Server/server_words.cpp b/Server/server_words.cpp index 8be8962..f51dad8 100644 --- a/Server/server_words.cpp +++ b/Server/server_words.cpp @@ -11,24 +11,13 @@ namespace { // As many words of as many bytes as one of the team's lists holds. constexpr std::size_t most_words = 5000, longest_word = 64; -constexpr std::string_view filtered_help = - "# Words this server filters in chat, beside the ReSkate team's list.\n" - "# One word to a line. Messages with them are still passed on: players who have their chat filter on\n" - "# see the word as stars. The first 256 words of up to 32 letters are sent to players' games.\n" - "# Read when the server starts and by the console command: words reload\n"; -constexpr std::string_view blocked_help = - "# Words that are not allowed in this server's chat at all, beside the ReSkate team's list.\n" - "# One word to a line. A message with one is not passed on and its player is warned\n" - "# (see word_warnings in the README).\n" - "# Read when the server starts and by the console command: words reload\n"; - -std::vector read(const std::filesystem::path &file, std::string_view help, std::string &problem) { +// The words in `file`, which is made empty when it is not there, so an owner finds it to fill in. +std::vector read(const std::filesystem::path &file, std::string &problem) { std::vector words; std::error_code error; if (!std::filesystem::exists(file, error)) { std::filesystem::create_directories(file.parent_path(), error); - std::ofstream out(file, std::ios::binary); - if (!(out << help)) problem = "could not write " + file.filename().string(); + if (!std::ofstream(file, std::ios::binary)) problem = "could not write " + file.filename().string(); return words; } std::ifstream in(file, std::ios::binary); @@ -52,8 +41,8 @@ std::filesystem::path blocked_words_file(const ServerConfig &c) { return c.file. OwnWords use_own_words(const ServerConfig &config) { OwnWords result; - const auto filtered = read(filtered_words_file(config), filtered_help, result.problem); - const auto blocked = read(blocked_words_file(config), blocked_help, result.problem); + const auto filtered = read(filtered_words_file(config), result.problem); + const auto blocked = read(blocked_words_file(config), result.problem); result.filtered = filtered.size(); result.blocked = blocked.size(); for (const auto &word : filtered) diff --git a/Server/server_words.h b/Server/server_words.h index 3aae4e7..5ef9e44 100644 --- a/Server/server_words.h +++ b/Server/server_words.h @@ -21,7 +21,7 @@ struct OwnWords { std::vector sent; std::string problem; // a file that could not be read or written; the words read still apply }; -// Reads both files (writing each with its explanation first when it is not there) and puts +// Reads both files (making each, empty, when it is not there) and puts // them, and the config's use_global_word_filter, in use. OwnWords use_own_words(const ServerConfig &config); // What that did, for the log.