Files
DarkflameServer/dGame/dUtilities/DashboardActions.cpp
Aaron Kimbrell 796fd1b941 refactor: chat packets as structs
Every packet of the chat service is now an LUBitStream struct in dNet/ChatPackets.h
(docs/PacketArchitecture.md, PR 13), and nothing in the chat server or the chat side of
the world server reads or writes a packet by hand any more.

- World <-> chat: LoginSessionNotify, UnexpectedDisconnect, GMLevelUpdate, GMMute,
  Announcement (GM announce), CreateTeam, TeamUpdate (TEAM_GET_STATUS to worlds),
  AchievementNotify, ShowAllRequest, FindPlayerRequest.
- Client -> world -> chat (friends, ignore list, teams, general and private chat):
  GetFriendsList, AddFriendRequest, AddFriendResponse, RemoveFriend, GetIgnoreList,
  AddIgnore, RemoveIgnore, GeneralChatMessage, PrivateChatMessage, TeamInvite,
  TeamInviteResponse, TeamLeave, TeamKick, TeamSetLeader, TeamSetLoot, TeamGetStatus.
  The 77 bytes the handlers skipped are named fields now (the sender block the client
  fills in); the 4 unused bytes after the player ID are kept as `unknown`.
- What the client receives. Chat service (ChatPackets::Client): GeneralChatMessage
  (replaces SendChatMessage; SendSystemMessage stays as a helper built on it),
  PrivateChatMessage. Client service (ClientPackets, as structs go in the file of the
  ServiceType in their header): SendCannedText (replaces SendMessageFail), GetFriendsListResponse, AddFriendRequest,
  AddFriendResponse, RemoveFriendResponse, UpdateFriendNotify, WhoResponse,
  ShowAllResponse, Get/Add/RemoveIgnoreResponse, TeamInvite, TeamInviteInitialResponse,
  and the team game messages chat writes (TeamInviteConfirm, TeamGetStatusResponse,
  TeamSetLeader, TeamAddPlayer, TeamRemovePlayer, TeamSetOffWorldFlag) as TeamGameMsg
  structs, since chat doesn't link dGame's NetGameMsg.
- WORLD_ROUTE_PACKET is ChatPackets::WorldRoutePacket (its own file, dNet/WorldRoutePacket.h,
  since it carries packets of other services): the target and the inner packet.
  ChatPacketHandler::SendRouted replaces the per-function route headers and
  SendRoutedMsg.

Dispatch: dNet/PacketDispatcher.h is a dispatch map from packet ID to (struct, handler
function); packets that fail to Deserialize are logged and dropped. The chat server's
switch and the world's HandlePacketChat switch are now maps. The handlers take the
structs; their logic is unchanged. World code sends to chat with ChatServerLink::Send
(dGame) instead of writing to Game::chatServer by hand. eChatChannel,
eChatMessageResponseCode and eAddIgnoreResponse moved to dCommon/dEnums so the structs
can use them.

Verified: tests/dGameTests/dNetTests/Legacy/ChatPacketsLegacy.h is a verbatim copy of
the old senders and readers; ChatPacketsTests (25 tests) sends a grid of inputs through
both and requires identical bits, bytes and destination, checks the old readers read
what the structs write, round trips every struct, checks truncated packets are refused,
and has hand written golden packets for general chat, canned text, GM mute and a routed
team game message. Breaking one field width (UpdateFriendNotify) and the TeamAddPlayer
zone flag made the tests fail. No wire bytes changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:30:50 -05:00

294 lines
13 KiB
C++

#include "DashboardActions.h"
#include "PlayerAction.h"
#include "Game.h"
#include "Logger.h"
#include "Database.h"
#include "dServer.h"
#include "dZoneManager.h"
#include "Entity.h"
#include "Character.h"
#include "CharacterComponent.h"
#include "ChatPackets.h"
#include "GameMessages.h"
#include "PlayerManager.h"
#include "User.h"
#include "UserManager.h"
#include "ClientPackets.h"
#include "ZoneInstanceManager.h"
#include "eGameMasterLevel.h"
#include "eServerDisconnectIdentifiers.h"
#include "InventoryComponent.h"
#include "Amf3.h"
#include "PetComponent.h"
#include "PropertyManagementComponent.h"
#include "GeneralUtils.h"
#include "VanityUtilities.h"
#include "dConfig.h"
#include "dChatFilter.h"
#include "MissionComponent.h"
#include "Mission.h"
#include "LiveEvents.h"
namespace {
std::function<void(const SystemAddress&)> g_LogoutHandler;
uint32_t KickAccount(uint32_t accountId, eServerDisconnectIdentifiers reason) {
const auto users = UserManager::Instance()->GetUsersForAccount(accountId);
for (auto* user : users) {
const SystemAddress sysAddr = user->GetSystemAddress();
LOG("Dashboard disconnecting account %u (%s)", accountId, user->GetUsername().c_str());
if (g_LogoutHandler) g_LogoutHandler(sysAddr);
Game::server->Disconnect(sysAddr, reason);
}
return static_cast<uint32_t>(users.size());
}
// Pick up a GM level change: lower the in-game level if it now exceeds the account's maximum
uint32_t RefreshAccount(uint32_t accountId) {
const auto users = UserManager::Instance()->GetUsersForAccount(accountId);
if (users.empty()) return 0;
const auto info = Database::Get()->GetAccountInfo(users.front()->GetUsername());
if (!info) return 0;
for (auto* user : users) {
user->SetMaxGMLevel(info->maxGmLevel);
auto* entity = PlayerManager::GetPlayer(user->GetSystemAddress());
if (!entity || entity->GetGMLevel() <= info->maxGmLevel) continue;
ClientPackets::MakeGMResponse response;
response.success = true;
response.highestLevel = info->maxGmLevel;
response.previousLevel = entity->GetGMLevel();
response.newLevel = info->maxGmLevel;
response.Send(entity->GetSystemAddress());
GameMessages::SendChatModeUpdate(entity->GetObjectID(), info->maxGmLevel);
entity->SetGMLevel(info->maxGmLevel);
if (info->maxGmLevel == eGameMasterLevel::CIVILIAN) {
GameMessages::ToggleGMInvisEvent msg;
msg.Send(entity->GetObjectID());
}
GameMessages::SendSlashCommandFeedbackText(entity, u"Your game master level has been changed.");
LOG("Dashboard lowered GM level of %s to %i", user->GetUsername().c_str(), static_cast<int>(info->maxGmLevel));
}
return static_cast<uint32_t>(users.size());
}
uint32_t RefreshCharacter(LWOOBJID characterId) {
auto* entity = PlayerManager::GetPlayer(characterId);
auto* character = entity ? entity->GetCharacter() : nullptr;
if (!character) return 0;
const auto info = Database::Get()->GetCharacterInfo(characterId);
if (!info) return 0;
character->SetPermissionMap(info->permissionMap);
return 1;
}
// A moderator's warning, as a popup and in chat, to whichever character of the account is online here
uint32_t WarnAccount(uint32_t accountId, const std::string& text) {
uint32_t affected = 0;
for (auto* player : PlayerManager::GetAllPlayers()) {
auto* user = player ? UserManager::Instance()->GetUser(player->GetSystemAddress()) : nullptr;
if (!user || user->GetAccountID() != accountId) continue;
AMFArrayValue args;
args.Insert("title", std::string("Warning from a moderator"));
args.Insert("message", text);
GameMessages::SendUIMessageServerToSingleClient(player, player->GetSystemAddress(), "ToggleAnnounce", args);
ChatPackets::SendSystemMessage(player->GetSystemAddress(), u"Warning from a moderator: " + GeneralUtils::UTF8ToUTF16(text));
affected++;
}
return affected;
}
// Every world gets the request; only the one with both players acts on it
uint32_t TeleportToPlayer(LWOOBJID characterId, LWOOBJID targetId) {
auto* entity = PlayerManager::GetPlayer(characterId);
auto* target = PlayerManager::GetPlayer(targetId);
if (!entity || !target || entity == target) return 0;
const auto position = target->GetPosition();
GameMessages::SendTeleport(entity->GetObjectID(), position, entity->GetRotation(), entity->GetSystemAddress());
ChatPackets::SendSystemMessage(entity->GetSystemAddress(), u"A moderator moved you to " + GeneralUtils::ASCIIToUTF16(target->GetCharacter() ? target->GetCharacter()->GetName() : "another player") + u".");
LOG("Dashboard teleport of %llu to %llu", characterId, targetId);
return 1;
}
uint32_t RescueCharacter(LWOOBJID characterId, LWOMAPID zoneId, const std::string& spawnPoint) {
auto* entity = PlayerManager::GetPlayer(characterId);
if (!entity || !entity->GetCharacter()) return 0;
if (!Game::zoneManager->CheckIfAccessibleZone(zoneId)) {
LOG("Dashboard rescue of %llu to inaccessible zone %u refused", characterId, zoneId);
return 0;
}
ChatPackets::SendSystemMessage(entity->GetSystemAddress(), u"A moderator is moving you to safety...");
const auto objectId = entity->GetObjectID();
ZoneInstanceManager::Instance()->RequestZoneTransfer(Game::server, zoneId, 0, false,
[objectId, spawnPoint](bool mythranShift, uint32_t zoneID, uint32_t zoneInstance, uint32_t zoneClone, std::string serverIP, uint16_t serverPort) {
auto* entity = Game::entityManager->GetEntity(objectId);
if (!entity || !entity->GetCharacter()) return;
auto* character = entity->GetCharacter();
if (auto* characterComponent = entity->GetComponent<CharacterComponent>()) {
characterComponent->AddVisitedLevel(LWOZONEID(zoneID, LWOINSTANCEID_INVALID, zoneClone));
characterComponent->SetLastRocketConfig(u"");
}
character->SetZoneID(zoneID);
character->SetZoneInstance(zoneInstance);
character->SetZoneClone(zoneClone);
// A named spawn point (a respawnname in the zone), as rocket launchers land players; empty is the zone's default
character->SetTargetScene(spawnPoint);
character->SaveXMLToDatabase();
LOG("Dashboard rescue: transferring %llu to zone %u", objectId, zoneID);
ClientPackets::TransferToWorld transfer;
transfer.serverIP = LUString(serverIP);
transfer.serverPort = serverPort;
transfer.mythranShift = mythranShift;
transfer.Send(entity->GetSystemAddress());
});
return 1;
}
}
namespace {
void Tell(Entity* player, const std::string& message) {
if (player) ChatPackets::SendSystemMessage(player->GetSystemAddress(), GeneralUtils::UTF8ToUTF16(message));
}
uint32_t NameModerated(LWOOBJID characterId, bool approved, const std::string& name) {
auto* entity = PlayerManager::GetPlayer(characterId);
auto* character = entity ? entity->GetCharacter() : nullptr;
if (!character) return 0;
character->ApplyNameModeration(approved);
Tell(entity, approved ? "Your new name \"" + name + "\" was approved. Other players will see it after you next change worlds."
: "Your requested name \"" + name + "\" was not approved. You can pick another one at character select.");
return 1;
}
uint32_t PetNameModerated(LWOOBJID petId, bool approved, const std::string& name) {
uint32_t affected = 0;
// A pet that is out updates for everyone at once
for (auto* player : PlayerManager::GetAllPlayers()) {
auto* pet = player ? PetComponent::GetActivePet(player->GetObjectID()) : nullptr;
if (pet && pet->GetDatabaseId() == petId) pet->ApplyNameModeration(approved);
}
// Tell whoever owns it
for (auto* player : PlayerManager::GetAllPlayers()) {
auto* inventory = player ? player->GetComponent<InventoryComponent>() : nullptr;
if (!inventory || !inventory->GetPetsMut().contains(petId)) continue;
Tell(player, approved ? "Your pet's name \"" + name + "\" was approved." : "Your pet's name \"" + name + "\" was not approved. You can give it a new one.");
affected++;
}
return affected;
}
uint32_t PropertyModerated(LWOOBJID propertyId, LWOOBJID ownerId, bool approved, const std::string& reason) {
uint32_t affected = 0;
if (auto* property = PropertyManagementComponent::Instance(); property && property->GetId() == propertyId) {
property->ApplyModeration(approved, reason);
affected++;
}
if (auto* owner = PlayerManager::GetPlayer(ownerId)) {
Tell(owner, approved ? "Your property was approved and can now be visited by others."
: "Your property was not approved" + (reason.empty() ? std::string(".") : ": " + reason) + " It has been made private.");
affected++;
}
return affected;
}
}
namespace {
/**
* A moderator changing a mission of a character that is here, as the GM commands do: /completemission (rewards
* only when asked), /resetmission and /addmission. Answers 1 when it changed, 2 when there was nothing to change.
*/
uint32_t ChangeMission(ePlayerAction action, LWOOBJID characterId, uint32_t missionId, bool rewards, const std::string& name) {
auto* entity = PlayerManager::GetPlayer(characterId);
auto* missions = entity ? entity->GetComponent<MissionComponent>() : nullptr;
if (!missions || !entity->GetCharacter()) return 0;
auto* mission = missions->GetMission(missionId);
std::string text;
switch (action) {
case ePlayerAction::MISSION_COMPLETE:
if (mission && mission->IsComplete() && !mission->IsRepeatable()) return 2;
missions->CompleteMission(missionId, true, rewards);
text = "A moderator completed \"" + name + "\" for you" + (rewards ? "." : " (without its rewards).");
break;
case ePlayerAction::MISSION_RESET:
if (!mission) return 2;
missions->ResetMission(missionId);
text = "A moderator reset \"" + name + "\" so you can do it again.";
break;
default:
if (mission && !mission->IsRepeatable()) return 2;
missions->AcceptMission(missionId, true);
text = "A moderator gave you \"" + name + "\".";
break;
}
entity->GetCharacter()->SaveXMLToDatabase();
Tell(entity, text);
LOG("Dashboard %s of mission %u for %llu", std::string(magic_enum::enum_name(action)).c_str(), missionId, characterId);
return 1;
}
}
void DashboardActions::SetLogoutHandler(std::function<void(const SystemAddress&)> handler) {
g_LogoutHandler = std::move(handler);
}
uint32_t DashboardActions::Apply(const PlayerActionRequest& request) {
switch (request.action) {
case ePlayerAction::KICK_ACCOUNT:
return KickAccount(request.accountId, static_cast<eServerDisconnectIdentifiers>(request.disconnectReason));
case ePlayerAction::REFRESH_ACCOUNT:
return RefreshAccount(request.accountId);
case ePlayerAction::REFRESH_CHARACTER:
return RefreshCharacter(request.characterId);
case ePlayerAction::RESCUE_CHARACTER:
return RescueCharacter(request.characterId, request.zoneId, request.text);
case ePlayerAction::NAME_MODERATED:
return NameModerated(request.characterId, request.approved, request.text);
case ePlayerAction::PET_NAME_MODERATED:
return PetNameModerated(request.targetId, request.approved, request.text);
case ePlayerAction::PROPERTY_MODERATED:
return PropertyModerated(request.targetId, request.characterId, request.approved, request.text);
case ePlayerAction::TELEPORT_TO_PLAYER:
return TeleportToPlayer(request.characterId, request.targetId);
case ePlayerAction::WARN_ACCOUNT:
return WarnAccount(request.accountId, request.text);
case ePlayerAction::CHAT_MESSAGE: {
// From the dashboard or a chat bridge: shown to everyone in this world (or only the chosen zone or instance), checked by the
// same chat filter as players' messages unless chat_bridge_filter=0
if (Game::server->GetZoneID() == 0 || (request.zoneId != 0 && request.zoneId != Game::server->GetZoneID())) return 0;
if (request.instanceId >= 0 && request.instanceId != Game::server->GetInstanceID()) return 0;
if (Game::config->GetValue("chat_bridge_filter") != "0" && !Game::chatFilter->IsSentenceOkay(request.text, eGameMasterLevel::CIVILIAN).empty()) return 0;
const auto players = PlayerManager::GetAllPlayers().size();
ChatPackets::Client::GeneralChatMessage chatMessage;
chatMessage.chatChannel = 4;
chatMessage.senderName = LUWString(request.name);
chatMessage.message = GeneralUtils::UTF8ToUTF16(request.text);
chatMessage.Broadcast();
return static_cast<uint32_t>(players);
}
case ePlayerAction::RELOAD_VANITY:
// Character select (zone 0) has no vanity
if (Game::server->GetZoneID() == 0) return 0;
VanityUtilities::SpawnVanity();
return 1;
case ePlayerAction::RELOAD_CHAT_FILTER:
if (!Game::chatFilter) return 0;
Game::chatFilter->ReloadCustomWords();
return 1;
case ePlayerAction::MISSION_COMPLETE:
case ePlayerAction::MISSION_RESET:
case ePlayerAction::MISSION_ACCEPT:
return ChangeMission(request.action, request.characterId, static_cast<uint32_t>(request.targetId), request.approved, request.text);
case ePlayerAction::RELOAD_LIVE_OPS:
return LiveEvents::Reload();
}
return 0;
}