From 3b3dbf8cd767fe9c2d9470ac4e5bf566d3acb2ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20M=C3=B6ller?= Date: Mon, 5 Oct 2026 21:19:38 +0000 Subject: [PATCH] Add server direct messages: msg, msg-party, msg-admins and /w Admins and the console can DM one player, a player's party or all online admins. Players can whisper with /w. Messages carry a [DM from ...] prefix. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EhQ3AZZ7KQddTqV67tZMLc --- CMakeLists.txt | 3 +++ Server/README.txt | 5 +++++ Server/Test/server_text_tests.cpp | 26 ++++++++++++++++++++++ Server/server_host.cpp | 37 ++++++++++++++++++++++++++++++- Server/server_text.h | 10 +++++++++ Server/server_votes.cpp | 15 +++++++++++-- 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 Server/Test/server_text_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 50c2ff1..9eb4ff8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,6 +67,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) Extension/Throwdowns/throwdown_wire.cpp) target_include_directories(dingosdk_server_activity_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME server_activity COMMAND dingosdk_server_activity_tests) + add_executable(dingosdk_server_text_tests Server/Test/server_text_tests.cpp) + target_include_directories(dingosdk_server_text_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME server_text COMMAND dingosdk_server_text_tests) add_executable(dingosdk_speed_check_tests Server/Test/speed_check_tests.cpp) target_include_directories(dingosdk_speed_check_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME server_speed_check COMMAND dingosdk_speed_check_tests) diff --git a/Server/README.txt b/Server/README.txt index 4c198d7..924f7c4 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -123,6 +123,11 @@ and change voice, distances, placement and kicks from the Multiplayer menu. status Name, map, players, code. players Connected players and their SteamID64s. say Chat as the server (console only). + msg Private message, shown to them as "[DM from ] ...". + Name start (one word) or SteamID64. + msg-party Message everyone in that player's party ("[DM from to party]"). + msg-admins Message every admin who is online ("[DM from to admins]"). + Players can whisper each other with /w in chat. kick Until the server restarts. Name start or SteamID64. Admins cannot kick or ban each other; the console can. ban [name] For good. unban bans diff --git a/Server/Test/server_text_tests.cpp b/Server/Test/server_text_tests.cpp new file mode 100644 index 0000000..aa31bf4 --- /dev/null +++ b/Server/Test/server_text_tests.cpp @@ -0,0 +1,26 @@ +#include "Server/server_text.h" +#include +#include +#include + +using dingosdk::server::dm_line; +namespace { +void check(bool ok, const std::string &message) { + if (ok) return; + std::cerr << message << '\n'; + std::exit(1); +} +} // namespace + +int main() { + check(dm_line("Server", {}, "hi", 100) == "[DM from Server] hi", "plain DM"); + check(dm_line("Player", "party", "hi", 100) == "[DM from Player to party] hi", "party DM"); + check(dm_line("Player", "admins", "hi", 100) == "[DM from Player to admins] hi", "admins DM"); + const auto cut = dm_line("Server", {}, std::string(500, 'a'), 40); + check(cut.size() == 40 && cut.starts_with("[DM from Server] "), "text is cut, marker kept"); + // Never cut through a UTF-8 character (\xC3\xA9 is one letter). + const auto utf8 = dm_line("S", {}, "\xC3\xA9\xC3\xA9\xC3\xA9", 12); + check(utf8 == "[DM from S] \xC3\xA9" || utf8 == "[DM from S] ", "UTF-8 boundary"); + check(dm_line("Server", {}, "hi", 3) == "[DM from Server] ", "tiny limit keeps no text"); + return 0; +} diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 484443f..957acfb 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -42,7 +42,7 @@ std::uint64_t nonce() { return value; } constexpr std::string_view help_text = - "status | players | say | kick | ban [name] | unban | bans\n" + "status | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" "map | maps | name | password | welcome | listed on|off\n" "tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances \n" "placement everyone|admins|nobody | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" @@ -863,6 +863,41 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { send_chat(argument); return "[chat] Server: " + clean_chat_text(argument); } + if (name == "msg" || name == "msg-party" || name == "msg-admins") { + // Direct messages from the console or an admin, marked "[DM from ...]" so nobody takes them for chat. + const auto *sender = console ? nullptr : find(admin); + const std::string from = sender ? guest_name(*sender) : "Server"; + const bool to_admins = name == "msg-admins"; + const auto [who, text] = to_admins ? std::pair{{}, trim(argument)} : split(argument); + if (text.empty()) return to_admins ? "msg-admins " : name + " "; + std::vector recipients; + std::string scope, label; + if (to_admins) { + scope = label = "admins"; + for (auto &[id, guest] : guests_) + if (guest->handshaken && is_admin(id)) recipients.push_back(guest.get()); + } else { + auto *guest = match_player(who); + if (!guest) return "No single connected player matches \"" + std::string(who) + "\"."; + label = guest_name(*guest); + if (name == "msg") { + recipients.push_back(guest); + } else { + const auto *details = parties_.party(parties_.party_of(guest->member.id)); + if (!details) return label + " is not in a party."; + scope = "party"; + label += "'s party"; + for (const auto member : details->members) + if (auto *found = find(member); found && found->handshaken) recipients.push_back(found); + } + } + if (recipients.empty()) return "No admins are online."; + const auto line = dm_line(from, scope, text, multiplayer_chat_max_bytes); + for (auto *guest : recipients) send_chat(line, guest); + const auto done = "Sent to " + label + (recipients.size() > 1 || to_admins ? " (" + std::to_string(recipients.size()) + " players)" : "") + "."; + if (!console) log_("[dm] " + from + " -> " + label + ": " + clean_chat_text(text)); + return done; + } if (name == "kick") { auto *guest = target(argument); if (!guest || !guest->handshaken) return "No single connected player matches \"" + std::string(argument) + "\"."; diff --git a/Server/server_text.h b/Server/server_text.h index 8bb15ad..847a8bd 100644 --- a/Server/server_text.h +++ b/Server/server_text.h @@ -44,6 +44,16 @@ inline void cut_text(std::string &text, std::size_t limit) { while (cut && (static_cast(text[cut]) & 0xC0) == 0x80) --cut; text.resize(cut); } +// A direct message as the recipient reads it: "[DM from ] text", or "[DM from to ] text" for +// a group. The text is cut so the whole line fits `limit` bytes and the marker is never lost. +inline std::string dm_line(std::string_view from, std::string_view scope, std::string_view text, std::size_t limit) { + std::string head = "[DM from " + std::string(from); + if (!scope.empty()) head += " to " + std::string(scope); + head += "] "; + std::string body(text); + cut_text(body, limit > head.size() ? limit - head.size() : 0); + return head + body; +} // A command as the log shows it: the log is plain text, so a new password is left out. inline std::string loggable(std::string_view command) { const auto [verb, argument] = split(command); diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index 6228c84..34ed16a 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -68,14 +68,25 @@ void Host::chat_command(Guest &guest, std::string_view line) { if (votes & server_vote_time) text += "/vote tod