Multicast chat messages

Only the sender and the recipients receive the chat messages.
This commit only has an affecto on messages where the addressee(s) are
selected through the dropdown. Addressee(s) selected with a "/" command
are still sent to evevyone and filteret by the receiver.
This commit is contained in:
phosit 2024-11-30 11:18:24 +01:00 committed by phosit
parent 023527e56e
commit e04506814a
11 changed files with 207 additions and 53 deletions

View file

@ -78,7 +78,7 @@ class Chat
}
/**
* Send the given chat message.
* Send the given chat message to the addressees.
*/
submitChat(text, command = "")
{
@ -88,7 +88,7 @@ class Chat
let msg = command ? command + " " + text : text;
if (Engine.HasNetClient())
Engine.SendNetworkChat(msg);
Engine.SendNetworkChat(msg, this.getReceiverGUIDs(text, command));
else
this.ChatMessageHandler.handleMessage({
"type": "message",
@ -96,4 +96,39 @@ class Chat
"text": msg
});
}
getReceiverGUIDs(text, command)
{
const senderGUID = Engine.GetPlayerGUID();
if (command.startsWith("/msg "))
{
const receiverGUID =
this.ChatMessageFormatPlayer.matchUsername(
command.substr("/msg ".length));
if (!receiverGUID)
{
warn("Unknown chat addressee: " + text);
return [];
}
return [senderGUID, receiverGUID];
}
const isAddressee = this.ChatAddressees.AddresseeTypes.find(
type => type.command === command)?.isAddressee;
if (!isAddressee)
{
warn("Unknown chat command " + command);
return [];
}
const senderID = Engine.GetPlayerID();
return Object.keys(g_PlayerAssignments).filter(potentialReceiverGUID => {
return potentialReceiverGUID === senderGUID ||
isAddressee(senderID, g_PlayerAssignments[potentialReceiverGUID].player);
});
}
}

View file

@ -86,6 +86,16 @@ class ChatAddressees
}
}
/**
* isAddressee is used to determine the receiverGUIDs when sending and
* when displaying deciding whether to display network chat.
*
* The code may assume sender != receiver.
*
* The function must return true when the message should be sent from X to Y and
* it must return true when the message should be received by Y if sent by X and
* return false otherwise.
*/
ChatAddressees.prototype.AddresseeTypes = [
{
"command": "",
@ -98,36 +108,35 @@ ChatAddressees.prototype.AddresseeTypes = [
"isSelectable": () => !g_IsObserver,
"label": markForTranslationWithContext("chat addressee", "Allies"),
"context": markForTranslationWithContext("chat message context", "Ally"),
"isAddressee":
senderID =>
g_Players[senderID] &&
g_Players[Engine.GetPlayerID()] &&
g_Players[senderID].isMutualAlly[Engine.GetPlayerID()],
"isAddressee": (senderID, receiverID) =>
g_Players[senderID] &&
g_Players[receiverID] &&
g_Players[senderID].isMutualAlly[receiverID]
},
{
"command": "/enemies",
"isSelectable": () => !g_IsObserver,
"label": markForTranslationWithContext("chat addressee", "Enemies"),
"context": markForTranslationWithContext("chat message context", "Enemy"),
"isAddressee":
senderID =>
g_Players[senderID] &&
g_Players[Engine.GetPlayerID()] &&
g_Players[senderID].isEnemy[Engine.GetPlayerID()],
"isAddressee": (senderID, receiverID) =>
g_Players[senderID] &&
g_Players[receiverID] &&
g_Players[senderID].isEnemy[receiverID]
},
{
"command": "/observers",
"isSelectable": () => true,
"label": markForTranslationWithContext("chat addressee", "Observers"),
"context": markForTranslationWithContext("chat message context", "Observer"),
"isAddressee": senderID => g_IsObserver
"isAddressee": (_, receiverID) => isPlayerObserver(receiverID)
},
{
"command": "/msg",
"isSelectable": () => false,
"label": undefined,
"context": markForTranslationWithContext("chat message context", "Private"),
"isAddressee": (senderID, addresseeGUID) => addresseeGUID == Engine.GetPlayerGUID()
"isAddressee": (senderID, receiverID) =>
!isPlayerObserver(senderID) || isPlayerObserver(receiverID)
}
];

View file

@ -89,24 +89,17 @@ class ChatMessageFormatPlayer
// Parse private message
let isPM = msg.cmd == "/msg";
let addresseeGUID;
let addresseeIndex;
if (isPM)
{
addresseeGUID = this.matchUsername(msg.text);
let addressee = g_PlayerAssignments[addresseeGUID];
if (!addressee)
{
if (isSender)
warn("Couldn't match username: " + msg.text);
warn("Couldn't find chat message receiver: " + msg.text);
return false;
}
// Prohibit PM if addressee and sender are identical
if (isSender && addresseeGUID == Engine.GetPlayerGUID())
return false;
msg.text = msg.text.substr(addressee.name.length + 1);
addresseeIndex = addressee.player;
}
// Set context string
@ -120,11 +113,30 @@ class ChatMessageFormatPlayer
msg.context = addresseeType.context;
// For observers only permit public- and observer-chat and PM to observers
if (isPlayerObserver(senderID) &&
(isPM && !isPlayerObserver(addresseeIndex) || !isPM && msg.cmd != "/observers"))
return false;
if (isPlayerObserver(senderID))
{
if (isPM && !g_IsObserver)
{
warn("Received unexpected private chat message from observer " +
g_PlayerAssignments[msg.guid]?.name +
" to active player " +
g_PlayerAssignments[addresseeGUID]?.name);
return false;
}
let visible = isSender || addresseeType.isAddressee(senderID, addresseeGUID);
if (!isPM && msg.cmd != "/observers")
{
warn("Received unexpected chat message from observer " +
g_PlayerAssignments[msg.guid]?.name + " to " + msg.cmd);
return false;
}
}
// We already should only be receiving messages that were meant for us. The following
// consistency check rejects a message only if it was manipulated by the sender or if it was
// sent before a relevant simulation change occurred.
const visible = isSender || (addresseeType.isAddressee(senderID, Engine.GetPlayerID()) &&
(!isPM || addresseeGUID === Engine.GetPlayerGUID()));
msg.isVisiblePM = isPM && visible;
return visible;
@ -143,7 +155,7 @@ class ChatMessageFormatPlayer
for (let guid in g_PlayerAssignments)
{
let pName = g_PlayerAssignments[guid].name;
if (text.indexOf(pName + " ") == 0 && pName.length > match.length)
if (text.startsWith(pName) && pName.length > match.length)
{
match = pName;
playerGUID = guid;

View file

@ -465,10 +465,17 @@ void CNetClient::SendAssignPlayerMessage(const int playerID, const CStr& guid)
SendMessage(&assignPlayer);
}
void CNetClient::SendChatMessage(const std::wstring& text)
void CNetClient::SendChatMessage(const std::wstring& text,
std::optional<std::vector<std::string>> receivers)
{
CChatMessage chat;
chat.m_Message = text;
if (receivers)
std::transform(receivers->begin(), receivers->end(), std::back_inserter(chat.m_Receivers),
[](std::string& receiver)
{
return CChatMessage::S_m_Receivers{std::move(receiver)};
});
SendMessage(&chat);
}
@ -732,7 +739,7 @@ bool CNetClient::OnChat(CNetClient* client, CFsmEvent* event)
client->PushGuiMessage(
"type", "chat",
"guid", message->m_GUID,
"guid", message->m_SenderGUID,
"text", message->m_Message);
return true;

View file

@ -28,6 +28,7 @@
#include <ctime>
#include <deque>
#include <optional>
#include <thread>
class CGame;
@ -229,7 +230,12 @@ public:
void SendAssignPlayerMessage(const int playerID, const CStr& guid);
void SendChatMessage(const std::wstring& text);
/**
* @param text The message to send.
* @param receivers The GUID of the receiving clients. If empty send it to
* all clients.
*/
void SendChatMessage(const std::wstring& text, std::optional<std::vector<std::string>> receivers);
void SendReadyMessage(const int status);

View file

@ -136,8 +136,11 @@ START_NMT_CLASS_(AuthenticateResult, NMT_AUTHENTICATE_RESULT)
END_NMT_CLASS()
START_NMT_CLASS_(Chat, NMT_CHAT)
NMT_FIELD(CStr, m_GUID) // ignored when client->server, valid when server->client
NMT_FIELD(CStr, m_SenderGUID) // ignored when client->server
NMT_FIELD(CStrW, m_Message)
NMT_START_ARRAY(m_Receivers) // send to all when empty
NMT_FIELD(CStr, m_ReceiverGUID) // ignored when server->client
NMT_END_ARRAY()
END_NMT_CLASS()
START_NMT_CLASS_(Ready, NMT_READY)

View file

@ -29,6 +29,7 @@
#include "lib/external_libraries/enet.h"
#include "lib/types.h"
#include "network/StunClient.h"
#include "ps/algorithm.h"
#include "ps/CLogger.h"
#include "ps/ConfigDB.h"
#include "ps/GUID.h"
@ -355,18 +356,31 @@ bool CNetServerWorker::SendMessage(ENetPeer* peer, const CNetMessage* message)
return CNetHost::SendMessage(message, peer, DebugName(session).c_str());
}
bool CNetServerWorker::Broadcast(const CNetMessage* message, const std::vector<NetServerSessionState>& targetStates)
bool CNetServerWorker::Multicast(const CNetMessage* message,
const std::vector<NetServerSessionState>& targetStates,
const std::optional<std::vector<std::string>>& receivers /* = std::nullopt */)
{
ENSURE(m_Host);
const auto isReceiver = [&](const CNetServerSession& session)
{
if (!PS::contains(targetStates,
static_cast<NetServerSessionState>(session.GetCurrState())))
{
return false;
}
if (!receivers)
return true;
return PS::contains(*receivers, session.GetGUID());
};
bool ok = true;
// TODO: this does lots of repeated message serialisation if we have lots
// of remote peers; could do it more efficiently if that's a real problem
for (CNetServerSession* session : m_Sessions)
if (std::find(targetStates.begin(), targetStates.end(), static_cast<NetServerSessionState>(session->GetCurrState())) != targetStates.end() &&
!session->SendMessage(message))
if (isReceiver(*session) && !session->SendMessage(message))
ok = false;
return ok;
@ -822,7 +836,7 @@ void CNetServerWorker::KickPlayer(const CStrW& playerName, const bool ban)
CKickedMessage kickedMessage;
kickedMessage.m_Name = playerName;
kickedMessage.m_Ban = ban;
Broadcast(&kickedMessage, { NSS_PREGAME, NSS_JOIN_SYNCING, NSS_INGAME });
Multicast(&kickedMessage, { NSS_PREGAME, NSS_JOIN_SYNCING, NSS_INGAME });
}
void CNetServerWorker::AssignPlayer(int playerID, const CStr& guid)
@ -861,7 +875,7 @@ void CNetServerWorker::SendPlayerAssignments()
{
CPlayerAssignmentMessage message;
ConstructPlayerAssignmentMessage(message);
Broadcast(&message, { NSS_PREGAME, NSS_JOIN_SYNCING, NSS_INGAME });
Multicast(&message, { NSS_PREGAME, NSS_JOIN_SYNCING, NSS_INGAME });
}
const ScriptInterface& CNetServerWorker::GetScriptInterface()
@ -1191,7 +1205,7 @@ bool CNetServerWorker::OnSimulationCommand(CNetServerSession* session, CFsmEvent
// Send it back to all clients that have finished
// the loading screen (and the synchronization when rejoining)
server.Broadcast(message, { NSS_INGAME });
server.Multicast(message, { NSS_INGAME });
// Save all the received commands
if (server.m_SavedCommands.size() < message->m_Turn + 1)
@ -1209,7 +1223,7 @@ bool CNetServerWorker::OnFlare(CNetServerSession* session, CFsmEvent* event)
CNetServerWorker& server = session->GetServer();
CFlareMessage* message = (CFlareMessage*)event->GetParamRef();
message->m_GUID = session->GetGUID();
server.Broadcast(message, { NSS_INGAME });
server.Multicast(message, { NSS_INGAME });
return true;
}
@ -1247,9 +1261,20 @@ bool CNetServerWorker::OnChat(CNetServerSession* session, CFsmEvent* event)
CChatMessage* message = (CChatMessage*)event->GetParamRef();
message->m_GUID = session->GetGUID();
message->m_SenderGUID = session->GetGUID();
server.Broadcast(message, { NSS_PREGAME, NSS_INGAME });
const std::vector<NetServerSessionState> receivingStates{NSS_PREGAME, NSS_INGAME};
const std::vector messageReceivers{std::exchange(message->m_Receivers, {})};
if (messageReceivers.empty())
server.Multicast(message, receivingStates);
else
{
auto receivers = std::make_optional<std::vector<std::string>>();
std::transform(messageReceivers.begin(), messageReceivers.end(), std::back_inserter(*receivers),
std::mem_fn(&CChatMessage::S_m_Receivers::m_ReceiverGUID));
server.Multicast(message, receivingStates, std::move(receivers));
}
return true;
}
@ -1267,7 +1292,7 @@ bool CNetServerWorker::OnReady(CNetServerSession* session, CFsmEvent* event)
CReadyMessage* message = (CReadyMessage*)event->GetParamRef();
message->m_GUID = session->GetGUID();
server.Broadcast(message, { NSS_PREGAME });
server.Multicast(message, { NSS_PREGAME });
server.m_PlayerAssignments[message->m_GUID].m_Status = message->m_Status;
@ -1304,7 +1329,7 @@ bool CNetServerWorker::OnGameSetup(CNetServerSession* session, CFsmEvent* event)
if (session->GetGUID() == server.m_ControllerGUID)
{
CGameSetupMessage* message = (CGameSetupMessage*)event->GetParamRef();
server.Broadcast(message, { NSS_PREGAME });
server.Multicast(message, { NSS_PREGAME });
}
return true;
}
@ -1383,7 +1408,7 @@ bool CNetServerWorker::OnLoadedGame(CNetServerSession* loadedSession, CFsmEvent*
// Send to the client who has loaded the game but did not reach the NSS_INGAME state yet
loadedSession->SendMessage(&message);
server.Broadcast(&message, { NSS_INGAME });
server.Multicast(&message, { NSS_INGAME });
return true;
}
@ -1450,7 +1475,7 @@ bool CNetServerWorker::OnRejoined(CNetServerSession* session, CFsmEvent* event)
// Inform everyone of the client having rejoined
CRejoinedMessage* message = (CRejoinedMessage*)event->GetParamRef();
message->m_GUID = session->GetGUID();
server.Broadcast(message, { NSS_INGAME });
server.Multicast(message, { NSS_INGAME });
// Send all pausing players to the rejoined client.
for (const CStr& guid : server.m_PausingPlayers)
@ -1536,7 +1561,7 @@ bool CNetServerWorker::CheckGameLoadStatus(CNetServerSession* changedSession)
loaded.m_CurrentTurn = 0;
// Notice the changedSession is still in the NSS_PREGAME state
Broadcast(&loaded, { NSS_PREGAME, NSS_INGAME });
Multicast(&loaded, { NSS_PREGAME, NSS_INGAME });
m_State = SERVER_STATE_INGAME;
return true;
@ -1581,7 +1606,7 @@ void CNetServerWorker::StartGame(const CStr& initAttribs)
CGameStartMessage gameStart;
gameStart.m_InitAttributes = initAttribs;
Broadcast(&gameStart, { NSS_PREGAME });
Multicast(&gameStart, { NSS_PREGAME });
}
void CNetServerWorker::StartSavedGame(const CStr& initAttribs)
@ -1590,7 +1615,7 @@ void CNetServerWorker::StartSavedGame(const CStr& initAttribs)
CGameSavedStartMessage gameSavedStart;
gameSavedStart.m_InitAttributes = initAttribs;
Broadcast(&gameSavedStart, { NSS_PREGAME });
Multicast(&gameSavedStart, { NSS_PREGAME });
}
CStrW CNetServerWorker::SanitisePlayerName(const CStrW& original)

View file

@ -26,6 +26,7 @@
#include <ctime>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <unordered_map>
@ -229,7 +230,8 @@ public:
/**
* Send a message to all clients who match one of the given states.
*/
bool Broadcast(const CNetMessage* message, const std::vector<NetServerSessionState>& targetStates);
bool Multicast(const CNetMessage* message, const std::vector<NetServerSessionState>& targetStates,
const std::optional<std::vector<std::string>>& receivers = std::nullopt);
private:
friend class CNetServer;

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2021 Wildfire Games.
/* Copyright (C) 2024 Wildfire Games.
* This file is part of 0 A.D.
*
* 0 A.D. is free software: you can redistribute it and/or modify
@ -98,7 +98,7 @@ void CNetServerTurnManager::CheckClientsReady()
CEndCommandBatchMessage msg;
msg.m_TurnLength = m_TurnLength;
msg.m_Turn = m_ReadyTurn;
m_NetServer.Broadcast(&msg, { NSS_INGAME });
m_NetServer.Multicast(&msg, { NSS_INGAME });
ENSURE(m_SavedTurnLengths.size() == m_ReadyTurn);
m_SavedTurnLengths.push_back(m_TurnLength);
@ -172,7 +172,7 @@ void CNetServerTurnManager::NotifyFinishedClientUpdate(CNetServerSession& sessio
h.m_Name = oosPlayername;
msg.m_PlayerNames.push_back(h);
}
m_NetServer.Broadcast(&msg, { NSS_INGAME });
m_NetServer.Multicast(&msg, { NSS_INGAME });
break;
}
}

View file

@ -41,6 +41,8 @@
#include "third_party/encryption/pkcs5_pbkdf2.h"
#include <optional>
namespace JSI_Network
{
u16 GetDefaultPort()
@ -237,11 +239,25 @@ void KickPlayer(const CStrW& playerName, bool ban)
g_NetClient->SendKickPlayerMessage(playerName, ban);
}
void SendNetworkChat(const CStrW& message)
void SendNetworkChat(const ScriptRequest& rq, const CStrW& message, JS::HandleValue handle)
{
ENSURE(g_NetClient);
g_NetClient->SendChatMessage(message);
if (handle.isNullOrUndefined())
{
g_NetClient->SendChatMessage(message, std::nullopt);
return;
}
auto receivers = std::make_optional<std::vector<std::string>>();
if (!Script::FromJSVal(rq, handle, *receivers))
{
ScriptException::Raise(rq, "The second argument to `SendNetworkChat` has to be either an Array "
"or a nullish value.");
return;
}
g_NetClient->SendChatMessage(message, std::move(receivers));
}
void SendNetworkReady(int message)

39
source/ps/algorithm.h Normal file
View file

@ -0,0 +1,39 @@
/* Copyright (C) 2024 Wildfire Games.
* This file is part of 0 A.D.
*
* 0 A.D. is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* 0 A.D. is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef ALGORITHM_H
#define ALGORITHM_H
namespace PS
{
/**
* Simplifed version of std::ranges::contains (C++23) as we don't support the
* original one yet. The naming intentionally follows the STL version to make
* the future replacement easier with less changing.
* It supports only a subset of std::ranges::contains functionality.
*/
template<typename Range, typename T = typename Range::value_type>
bool contains(Range&& range, const T& value)
{
return std::any_of(range.begin(), range.end(), [&](const auto& elem)
{
return elem == value;
});
}
}
#endif // ALGORITHM_H