mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-21 12:12:48 +00:00
feat: web dashboard and playground work
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch: accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp"
|
||||
"CheatDetection.cpp"
|
||||
"DashboardActions.cpp"
|
||||
"EconomyLedger.cpp"
|
||||
"DashboardNotify.cpp"
|
||||
"GUID.cpp"
|
||||
"LiveEvents.cpp"
|
||||
"Loot.cpp"
|
||||
"Mail.cpp"
|
||||
"MessageInspector.cpp"
|
||||
"ObjectIDManager.cpp"
|
||||
"PlayerReports.cpp"
|
||||
"Preconditions.cpp"
|
||||
"SlashCommandHandler.cpp"
|
||||
"VanityUtilities.cpp"
|
||||
@@ -26,6 +32,7 @@ target_include_directories(dUtilities PUBLIC "." "SlashCommands"
|
||||
"${PROJECT_SOURCE_DIR}/dGame/dMission" # transitive via MissionComponent.h
|
||||
"${PROJECT_SOURCE_DIR}/dGame/dEntity" # transitive via dZoneManager/Spawner.h
|
||||
"${PROJECT_SOURCE_DIR}/dZoneManager" # Mail.cpp
|
||||
"${PROJECT_SOURCE_DIR}/dChatFilter" # DashboardActions.cpp (messages sent into chat)
|
||||
)
|
||||
target_link_libraries(dUtilities
|
||||
PUBLIC dDatabase dPhysics
|
||||
|
||||
280
dGame/dUtilities/DashboardActions.cpp
Normal file
280
dGame/dUtilities/DashboardActions.cpp
Normal file
@@ -0,0 +1,280 @@
|
||||
#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 "WorldPackets.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;
|
||||
|
||||
WorldPackets::SendGMLevelChange(entity->GetSystemAddress(), true, info->maxGmLevel, entity->GetGMLevel(), info->maxGmLevel);
|
||||
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);
|
||||
WorldPackets::SendTransferToWorld(entity->GetSystemAddress(), serverIP, serverPort, mythranShift);
|
||||
});
|
||||
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::SendChatMessage(UNASSIGNED_SYSTEM_ADDRESS, static_cast<char>(4), request.name, LWOOBJID_EMPTY, false, GeneralUtils::UTF8ToUTF16(request.text));
|
||||
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;
|
||||
}
|
||||
26
dGame/dUtilities/DashboardActions.h
Normal file
26
dGame/dUtilities/DashboardActions.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#ifndef __DASHBOARDACTIONS__H__
|
||||
#define __DASHBOARDACTIONS__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
#include "RakNetTypes.h"
|
||||
|
||||
struct PlayerActionRequest;
|
||||
|
||||
/**
|
||||
* Applies web dashboard moderation actions to players connected to this world server,
|
||||
* so bans, restrictions and rescues take effect immediately instead of on next login.
|
||||
*/
|
||||
namespace DashboardActions {
|
||||
// Apply the action to this world's players. Returns how many sessions or characters it applied to.
|
||||
uint32_t Apply(const PlayerActionRequest& request);
|
||||
|
||||
/**
|
||||
* Set how the world saves and removes a user. Kicks run it before closing the connection so the
|
||||
* character is saved by the time the dashboard hears back, and nothing is saved after that.
|
||||
*/
|
||||
void SetLogoutHandler(std::function<void(const SystemAddress&)> handler);
|
||||
}
|
||||
|
||||
#endif //!__DASHBOARDACTIONS__H__
|
||||
102
dGame/dUtilities/DashboardNotify.cpp
Normal file
102
dGame/dUtilities/DashboardNotify.cpp
Normal file
@@ -0,0 +1,102 @@
|
||||
#include "DashboardNotify.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <set>
|
||||
|
||||
#include "BitStreamUtils.h"
|
||||
#include "DataChanged.h"
|
||||
#include "Game.h"
|
||||
#include "dServer.h"
|
||||
#include "MessageType/Master.h"
|
||||
#include "ServiceType.h"
|
||||
#include "DashboardMessages.h"
|
||||
#include "PlayerManager.h"
|
||||
#include "Entity.h"
|
||||
#include "Character.h"
|
||||
#include "dZoneManager.h"
|
||||
#include "GameMessages.h"
|
||||
#include "ChatPackets.h"
|
||||
#include "Amf3.h"
|
||||
#include "GeneralUtils.h"
|
||||
|
||||
namespace {
|
||||
constexpr auto SEND_INTERVAL = std::chrono::milliseconds(500);
|
||||
constexpr auto POSITION_INTERVAL = std::chrono::seconds(1);
|
||||
std::chrono::steady_clock::time_point g_LastPositions{};
|
||||
bool g_HadPlayers = false;
|
||||
std::set<std::pair<std::string, LWOOBJID>> g_Pending;
|
||||
std::chrono::steady_clock::time_point g_LastSend{};
|
||||
}
|
||||
|
||||
namespace DashboardNotify {
|
||||
void Changed(const std::string& table, LWOOBJID id) {
|
||||
if (g_Pending.size() >= DataChanged::MAX_ENTRIES) return; // a burst this big reaches the dashboard's own check anyway
|
||||
g_Pending.emplace(table, id);
|
||||
}
|
||||
|
||||
void Flush(bool force) {
|
||||
if (g_Pending.empty() || !Game::server) return;
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (!force && now - g_LastSend < SEND_INTERVAL) return;
|
||||
g_LastSend = now;
|
||||
|
||||
DataChanged message;
|
||||
for (const auto& [table, id] : g_Pending) message.entries.push_back({ table, id });
|
||||
g_Pending.clear();
|
||||
|
||||
CBITSTREAM;
|
||||
BitStreamUtils::WriteHeader(bitStream, ServiceType::MASTER, MessageType::Master::DATA_CHANGED);
|
||||
message.Serialize(bitStream);
|
||||
Game::server->SendToMaster(bitStream);
|
||||
}
|
||||
}
|
||||
|
||||
namespace DashboardNotify {
|
||||
void SendPlayerPositions(uint32_t instanceId) {
|
||||
if (!Game::server || !Game::zoneManager) return;
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now - g_LastPositions < POSITION_INTERVAL) return;
|
||||
g_LastPositions = now;
|
||||
|
||||
const auto& players = PlayerManager::GetAllPlayers();
|
||||
// Once more after the last player leaves, so the dashboard clears this world
|
||||
if (players.empty() && !g_HadPlayers) return;
|
||||
g_HadPlayers = !players.empty();
|
||||
|
||||
PlayerPositions message;
|
||||
const auto zone = Game::zoneManager->GetZoneID();
|
||||
message.zoneId = zone.GetMapID();
|
||||
message.instanceId = instanceId;
|
||||
message.cloneId = zone.GetCloneID();
|
||||
for (const auto* player : players) {
|
||||
const auto* character = player ? player->GetCharacter() : nullptr;
|
||||
if (!character) continue;
|
||||
const auto position = player->GetPosition();
|
||||
message.players.push_back({ character->GetID(), position.x, position.y, position.z });
|
||||
}
|
||||
|
||||
CBITSTREAM;
|
||||
BitStreamUtils::WriteHeader(bitStream, ServiceType::MASTER, MessageType::Master::PLAYER_POSITIONS);
|
||||
message.Serialize(bitStream);
|
||||
Game::server->SendToMaster(bitStream);
|
||||
}
|
||||
|
||||
std::string ChatLine(const std::string& title, const std::string& message) {
|
||||
if (title.empty()) return message;
|
||||
const char last = title.back();
|
||||
const bool punctuated = last == '!' || last == '?' || last == '.' || last == ':';
|
||||
return title + (punctuated ? " " : ": ") + message;
|
||||
}
|
||||
|
||||
void Announce(const std::string& title, const std::string& message) {
|
||||
AMFArrayValue args;
|
||||
args.Insert("title", title);
|
||||
args.Insert("message", message);
|
||||
GameMessages::SendUIMessageServerToAllClients("ToggleAnnounce", args);
|
||||
// Also in chat, for anyone who closes the popup without reading it
|
||||
const auto text = GeneralUtils::UTF8ToUTF16(ChatLine(title, message));
|
||||
for (const auto* player : PlayerManager::GetAllPlayers()) {
|
||||
if (player) ChatPackets::SendSystemMessage(player->GetSystemAddress(), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
dGame/dUtilities/DashboardNotify.h
Normal file
29
dGame/dUtilities/DashboardNotify.h
Normal file
@@ -0,0 +1,29 @@
|
||||
#ifndef __DASHBOARDNOTIFY__H__
|
||||
#define __DASHBOARDNOTIFY__H__
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
|
||||
/**
|
||||
* Tells the web dashboard (through master) which rows this world server just wrote, so pages showing them update
|
||||
* right away. Changes are collected and sent in one message every half second; repeats in between are merged.
|
||||
*/
|
||||
namespace DashboardNotify {
|
||||
void Changed(const std::string& table, LWOOBJID id = 0);
|
||||
|
||||
// Send what has been collected. Called from the world server loop; `force` sends immediately (shutdown).
|
||||
void Flush(bool force = false);
|
||||
|
||||
// Report where this world's players are, every few seconds (called from the world server loop)
|
||||
void SendPlayerPositions(uint32_t instanceId);
|
||||
|
||||
// Show a dashboard announcement to every player in this world
|
||||
void Announce(const std::string& title, const std::string& message);
|
||||
|
||||
// The announcement's line in chat: "Title: message", without the colon after a title that already ends in
|
||||
// punctuation ("Challenge complete! Builders: ...")
|
||||
std::string ChatLine(const std::string& title, const std::string& message);
|
||||
}
|
||||
|
||||
#endif //!__DASHBOARDNOTIFY__H__
|
||||
167
dGame/dUtilities/EconomyLedger.cpp
Normal file
167
dGame/dUtilities/EconomyLedger.cpp
Normal file
@@ -0,0 +1,167 @@
|
||||
#include "EconomyLedger.h"
|
||||
#include "DashboardNotify.h"
|
||||
#include "LiveEvents.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
|
||||
#include "Character.h"
|
||||
#include "Database.h"
|
||||
#include "Entity.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
#include "User.h"
|
||||
#include "dServer.h"
|
||||
#include "dZoneManager.h"
|
||||
#include "eGameMasterLevel.h"
|
||||
|
||||
namespace {
|
||||
EconomyLedger::Accumulator g_Pending;
|
||||
uint32_t g_TransferDepth = 0;
|
||||
|
||||
// Staff items are flagged so reports can leave out items spawned by moderators and developers
|
||||
bool IsStaff(const Entity* owner) {
|
||||
const auto* character = owner ? owner->GetCharacter() : nullptr;
|
||||
const auto* user = character ? character->GetParentUser() : nullptr;
|
||||
return user && user->GetMaxGMLevel() >= eGameMasterLevel::MODERATOR;
|
||||
}
|
||||
}
|
||||
|
||||
namespace EconomyLedger {
|
||||
void Accumulator::AddCoins(uint32_t day, LWOOBJID characterId, uint32_t source, int64_t delta) {
|
||||
if (delta == 0) return;
|
||||
auto& [gained, spent] = m_Currency[{ day, characterId, source }];
|
||||
(delta > 0 ? gained : spent) += delta > 0 ? delta : -delta;
|
||||
}
|
||||
|
||||
void Accumulator::AddUScore(uint32_t day, LWOOBJID characterId, uint32_t source, int64_t delta) {
|
||||
if (delta == 0) return;
|
||||
auto& [gained, lost] = m_UScore[{ day, characterId, source }];
|
||||
(delta > 0 ? gained : lost) += delta > 0 ? delta : -delta;
|
||||
}
|
||||
|
||||
void Accumulator::AddItems(uint32_t day, LOT lot, uint32_t source, bool gm, int64_t delta) {
|
||||
if (delta == 0) return;
|
||||
auto& [created, destroyed] = m_Items[{ day, lot, source, gm }];
|
||||
(delta > 0 ? created : destroyed) += delta > 0 ? delta : -delta;
|
||||
}
|
||||
|
||||
void Accumulator::AddTransfer(const IEconomyLedger::ItemTransfer& transfer) {
|
||||
m_Transfers.push_back(transfer);
|
||||
}
|
||||
|
||||
void Accumulator::AddMapEvent(uint32_t day, uint32_t zone, uint32_t clone, IEconomyLedger::eMapEvent kind, LOT lot, float x, float z, int64_t quantity) {
|
||||
const auto cellX = static_cast<int32_t>(std::floor(x / IEconomyLedger::MAP_CELL_SIZE));
|
||||
const auto cellZ = static_cast<int32_t>(std::floor(z / IEconomyLedger::MAP_CELL_SIZE));
|
||||
auto& [events, total] = m_MapEvents[{ day, zone, clone, static_cast<uint8_t>(kind), lot, cellX, cellZ }];
|
||||
events++;
|
||||
total += quantity;
|
||||
}
|
||||
|
||||
void Accumulator::AddStat(uint32_t day, uint32_t zone, uint32_t clone, uint32_t stat, bool gm, int64_t amount) {
|
||||
if (amount <= 0) return;
|
||||
m_Stats[{ day, zone, clone, stat, gm }] += amount;
|
||||
}
|
||||
|
||||
bool Accumulator::Empty() const {
|
||||
return m_Currency.empty() && m_UScore.empty() && m_Items.empty() && m_Transfers.empty() && m_MapEvents.empty() && m_Stats.empty();
|
||||
}
|
||||
|
||||
Accumulator::Batch Accumulator::Take() {
|
||||
Batch batch;
|
||||
for (const auto& [key, value] : m_Currency) {
|
||||
const auto& [day, character, source] = key;
|
||||
batch.currency.push_back({ day, character, source, value.first, value.second });
|
||||
}
|
||||
for (const auto& [key, value] : m_UScore) {
|
||||
const auto& [day, character, source] = key;
|
||||
batch.uscore.push_back({ day, character, source, value.first, value.second });
|
||||
}
|
||||
for (const auto& [key, value] : m_Items) {
|
||||
const auto& [day, lot, source, gm] = key;
|
||||
batch.items.push_back({ day, lot, source, gm, value.first, value.second });
|
||||
}
|
||||
for (const auto& [key, value] : m_MapEvents) {
|
||||
const auto& [day, zone, clone, kind, lot, cellX, cellZ] = key;
|
||||
batch.mapEvents.push_back({ day, zone, clone, static_cast<IEconomyLedger::eMapEvent>(kind), lot, cellX, cellZ, value.first, value.second });
|
||||
}
|
||||
for (const auto& [key, amount] : m_Stats) {
|
||||
const auto& [day, zone, clone, stat, gm] = key;
|
||||
batch.stats.push_back({ day, zone, clone, stat, gm, amount });
|
||||
}
|
||||
batch.transfers = std::move(m_Transfers);
|
||||
m_Currency.clear();
|
||||
m_UScore.clear();
|
||||
m_Items.clear();
|
||||
m_Transfers.clear();
|
||||
m_MapEvents.clear();
|
||||
m_Stats.clear();
|
||||
return batch;
|
||||
}
|
||||
|
||||
// The instance's clone id: a property's (the owner's property clone) on property worlds, 0 on every other world
|
||||
uint32_t CurrentClone() {
|
||||
return Game::zoneManager ? Game::zoneManager->GetZoneID().GetCloneID() : 0;
|
||||
}
|
||||
|
||||
uint32_t Today() {
|
||||
return static_cast<uint32_t>(std::time(nullptr) / (24 * 60 * 60));
|
||||
}
|
||||
|
||||
void RecordCoins(LWOOBJID characterId, int64_t delta, eLootSourceType source) {
|
||||
g_Pending.AddCoins(Today(), characterId, static_cast<uint32_t>(source), delta);
|
||||
}
|
||||
|
||||
void RecordUScore(LWOOBJID characterId, int64_t delta, uint32_t source) {
|
||||
g_Pending.AddUScore(Today(), characterId, source, delta);
|
||||
}
|
||||
|
||||
void RecordItems(const Entity* owner, LOT lot, int64_t delta, uint32_t source) {
|
||||
if (g_TransferDepth > 0) return;
|
||||
// Moves between a player's own inventories are not creation or destruction
|
||||
if (source == static_cast<uint32_t>(eLootSourceType::RELOCATE)) return;
|
||||
RecordItemsUnsuppressed(owner, lot, delta, source);
|
||||
}
|
||||
|
||||
void RecordItemsUnsuppressed(const Entity* owner, LOT lot, int64_t delta, uint32_t source) {
|
||||
if (!owner || !owner->IsPlayer()) return;
|
||||
g_Pending.AddItems(Today(), lot, source, IsStaff(owner), delta);
|
||||
}
|
||||
|
||||
void RecordTransfer(IEconomyLedger::ItemTransfer transfer) {
|
||||
if (transfer.time == 0) transfer.time = std::time(nullptr);
|
||||
g_Pending.AddTransfer(transfer);
|
||||
}
|
||||
|
||||
void RecordMapEvent(IEconomyLedger::eMapEvent kind, LOT lot, const NiPoint3& position, int64_t quantity, const Entity* player) {
|
||||
if (!Game::server) return;
|
||||
g_Pending.AddMapEvent(Today(), Game::server->GetZoneID(), CurrentClone(), kind, lot, position.x, position.z, quantity);
|
||||
if (player && player->IsPlayer()) LiveEvents::OnMapEvent(player, static_cast<uint8_t>(kind), lot, quantity);
|
||||
}
|
||||
|
||||
void RecordStat(const Entity* player, StatisticID stat, uint64_t amount) {
|
||||
if (!Game::server || !player || !player->IsPlayer()) return;
|
||||
g_Pending.AddStat(Today(), Game::server->GetZoneID(), CurrentClone(), static_cast<uint32_t>(stat), IsStaff(player), static_cast<int64_t>(amount));
|
||||
LiveEvents::OnStat(player, static_cast<uint32_t>(stat), amount);
|
||||
}
|
||||
|
||||
void Flush() {
|
||||
if (g_Pending.Empty()) return;
|
||||
auto batch = g_Pending.Take();
|
||||
try {
|
||||
Database::Get()->RecordEconomy(batch.currency, batch.uscore, batch.items, batch.transfers, batch.mapEvents, batch.stats);
|
||||
DashboardNotify::Changed("economy");
|
||||
} catch (const std::exception& ex) {
|
||||
// Losing a few seconds of report data is better than taking the world server down
|
||||
LOG("Failed to write economy ledger: %s", ex.what());
|
||||
}
|
||||
}
|
||||
|
||||
ScopedItemTransfer::ScopedItemTransfer() {
|
||||
g_TransferDepth++;
|
||||
}
|
||||
|
||||
ScopedItemTransfer::~ScopedItemTransfer() {
|
||||
g_TransferDepth--;
|
||||
}
|
||||
}
|
||||
104
dGame/dUtilities/EconomyLedger.h
Normal file
104
dGame/dUtilities/EconomyLedger.h
Normal file
@@ -0,0 +1,104 @@
|
||||
#ifndef __ECONOMYLEDGER__H__
|
||||
#define __ECONOMYLEDGER__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
#include "NiPoint3.h"
|
||||
#include "eLootSourceType.h"
|
||||
#include "StatisticID.h"
|
||||
#include "IEconomyLedger.h"
|
||||
|
||||
class Entity;
|
||||
|
||||
/**
|
||||
* Records where coins, U-score and items come from and go to, where things happen in the world and daily totals of
|
||||
* player statistics, for the dashboard's reports.
|
||||
*
|
||||
* Changes are summed in memory per day and source and written in batches (Flush), so a coin pickup costs a
|
||||
* map update rather than a database write. Player-to-player transfers are kept individually, with object ids,
|
||||
* so an item can be traced from owner to owner.
|
||||
*/
|
||||
namespace EconomyLedger {
|
||||
constexpr uint32_t DONATION_SOURCE = IEconomyLedger::DONATION_SOURCE;
|
||||
constexpr uint32_t HARDCORE_DEATH_SOURCE = IEconomyLedger::HARDCORE_DEATH_SOURCE;
|
||||
constexpr uint32_t HARDCORE_KILL_SOURCE = IEconomyLedger::HARDCORE_KILL_SOURCE;
|
||||
|
||||
/**
|
||||
* Pure aggregation, separated from the game so it can be unit tested.
|
||||
*/
|
||||
class Accumulator {
|
||||
public:
|
||||
void AddCoins(uint32_t day, LWOOBJID characterId, uint32_t source, int64_t delta);
|
||||
void AddUScore(uint32_t day, LWOOBJID characterId, uint32_t source, int64_t delta);
|
||||
void AddItems(uint32_t day, LOT lot, uint32_t source, bool gm, int64_t delta);
|
||||
void AddTransfer(const IEconomyLedger::ItemTransfer& transfer);
|
||||
void AddMapEvent(uint32_t day, uint32_t zone, uint32_t clone, IEconomyLedger::eMapEvent kind, LOT lot, float x, float z, int64_t quantity);
|
||||
void AddStat(uint32_t day, uint32_t zone, uint32_t clone, uint32_t stat, bool gm, int64_t amount);
|
||||
|
||||
bool Empty() const;
|
||||
|
||||
struct Batch {
|
||||
std::vector<IEconomyLedger::CurrencyFlow> currency;
|
||||
std::vector<IEconomyLedger::UScoreFlow> uscore;
|
||||
std::vector<IEconomyLedger::ItemFlow> items;
|
||||
std::vector<IEconomyLedger::ItemTransfer> transfers;
|
||||
std::vector<IEconomyLedger::MapEvent> mapEvents;
|
||||
std::vector<IEconomyLedger::PlayerStat> stats;
|
||||
};
|
||||
|
||||
// Everything accumulated so far, leaving the accumulator empty
|
||||
Batch Take();
|
||||
|
||||
private:
|
||||
std::map<std::tuple<uint32_t, LWOOBJID, uint32_t>, std::pair<int64_t, int64_t>> m_Currency;
|
||||
std::map<std::tuple<uint32_t, LWOOBJID, uint32_t>, std::pair<int64_t, int64_t>> m_UScore;
|
||||
std::map<std::tuple<uint32_t, LOT, uint32_t, bool>, std::pair<int64_t, int64_t>> m_Items;
|
||||
std::vector<IEconomyLedger::ItemTransfer> m_Transfers;
|
||||
// day, zone, clone, kind, lot, cell x, cell z
|
||||
std::map<std::tuple<uint32_t, uint32_t, uint32_t, uint8_t, LOT, int32_t, int32_t>, std::pair<int64_t, int64_t>> m_MapEvents;
|
||||
// day, zone, clone, stat, gm
|
||||
std::map<std::tuple<uint32_t, uint32_t, uint32_t, uint32_t, bool>, int64_t> m_Stats;
|
||||
};
|
||||
|
||||
void RecordCoins(LWOOBJID characterId, int64_t delta, eLootSourceType source);
|
||||
void RecordUScore(LWOOBJID characterId, int64_t delta, uint32_t source);
|
||||
|
||||
// Items created (positive delta) or destroyed (negative) in a player's inventory
|
||||
void RecordItems(const Entity* owner, LOT lot, int64_t delta, uint32_t source);
|
||||
|
||||
// RecordItems that also counts inside a ScopedItemTransfer, for sinks reached through an inventory move
|
||||
void RecordItemsUnsuppressed(const Entity* owner, LOT lot, int64_t delta, uint32_t source);
|
||||
|
||||
void RecordTransfer(IEconomyLedger::ItemTransfer transfer);
|
||||
|
||||
// Where something happened in this world, for the dashboard's heatmaps. Recorded with this world's zone and clone
|
||||
// (a property's instance; 0 elsewhere).
|
||||
// player: whose doing it was, when known (counts for community challenges)
|
||||
void RecordMapEvent(IEconomyLedger::eMapEvent kind, LOT lot, const NiPoint3& position, int64_t quantity = 1, const Entity* player = nullptr);
|
||||
|
||||
// A player's statistic went up (CharacterComponent::UpdatePlayerStatistic), summed per day, zone and clone
|
||||
void RecordStat(const Entity* player, StatisticID stat, uint64_t amount);
|
||||
|
||||
// Write everything recorded so far to the database. Called periodically and on shutdown.
|
||||
void Flush();
|
||||
|
||||
/**
|
||||
* While alive, item counts are not recorded as created/destroyed. Used when items only change hands or
|
||||
* inventories (trades, mail, moving to the vault), which are recorded as transfers or not at all.
|
||||
*/
|
||||
class ScopedItemTransfer {
|
||||
public:
|
||||
ScopedItemTransfer();
|
||||
~ScopedItemTransfer();
|
||||
ScopedItemTransfer(const ScopedItemTransfer&) = delete;
|
||||
ScopedItemTransfer& operator=(const ScopedItemTransfer&) = delete;
|
||||
};
|
||||
|
||||
uint32_t Today();
|
||||
}
|
||||
|
||||
#endif //!__ECONOMYLEDGER__H__
|
||||
679
dGame/dUtilities/LiveEvents.cpp
Normal file
679
dGame/dUtilities/LiveEvents.cpp
Normal file
@@ -0,0 +1,679 @@
|
||||
#include "LiveEvents.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
#include "json.hpp"
|
||||
|
||||
#include "Database.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
#include "dServer.h"
|
||||
#include "dZoneManager.h"
|
||||
#include "Zone.h"
|
||||
#include "dpWorld.h"
|
||||
#include "dNavMesh.h"
|
||||
#include "Entity.h"
|
||||
#include "EntityInfo.h"
|
||||
#include "EntityManager.h"
|
||||
#include "PlayerManager.h"
|
||||
#include "Character.h"
|
||||
#include "User.h"
|
||||
#include "InventoryComponent.h"
|
||||
#include "DestroyableComponent.h"
|
||||
#include "GameMessages.h"
|
||||
#include "ChatPackets.h"
|
||||
#include "DashboardNotify.h"
|
||||
#include "CDClientManager.h"
|
||||
#include "CDObjectsTable.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "eGameMasterLevel.h"
|
||||
#include "eLootSourceType.h"
|
||||
|
||||
using LiveOpsRules::eEventType;
|
||||
|
||||
namespace LiveEvents::Detail {
|
||||
LiveOpsRules::Multipliers g_Multipliers;
|
||||
bool g_Counting = false;
|
||||
}
|
||||
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
constexpr auto TICK = std::chrono::milliseconds(250);
|
||||
constexpr auto FIRST_LOAD_DELAY = std::chrono::seconds(5); // let the zone's own objects load first
|
||||
constexpr auto FLUSH_INTERVAL = std::chrono::seconds(5);
|
||||
constexpr auto STATUS_INTERVAL = std::chrono::seconds(2);
|
||||
|
||||
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
|
||||
|
||||
struct Treasure {
|
||||
LWOOBJID id{};
|
||||
NiPoint3 position;
|
||||
bool gone{};
|
||||
};
|
||||
|
||||
struct Running {
|
||||
ILiveOps::LiveEvent row;
|
||||
eEventType type{};
|
||||
nlohmann::json config;
|
||||
int64_t startedAt{};
|
||||
// Treasure hunt
|
||||
LOT lot{};
|
||||
std::vector<Treasure> treasures;
|
||||
uint32_t found{};
|
||||
float pickupRadius{ 4.0f };
|
||||
// Invasion
|
||||
NiPoint3 center;
|
||||
uint32_t wavesSent{};
|
||||
uint32_t spawned{};
|
||||
uint32_t kills{};
|
||||
std::set<LWOOBJID> alive;
|
||||
// Celebration
|
||||
int64_t nextBurst{};
|
||||
uint32_t bursts{};
|
||||
// Scores not written yet, and whether the instance status changed since it was written
|
||||
std::map<LWOOBJID, int64_t> scores;
|
||||
bool dirty{ true };
|
||||
};
|
||||
|
||||
struct TrackedChallenge {
|
||||
uint64_t id{};
|
||||
ILiveOps::eChallengeMetric kind{};
|
||||
uint32_t metric{};
|
||||
LOT lot{};
|
||||
int64_t startsAt{};
|
||||
int64_t endsAt{};
|
||||
bool includeStaff{};
|
||||
};
|
||||
|
||||
bool g_Disabled = false;
|
||||
bool g_Loaded = false;
|
||||
Clock::time_point g_FirstUpdate{};
|
||||
Clock::time_point g_NextTick{};
|
||||
Clock::time_point g_NextFlush{};
|
||||
Clock::time_point g_NextStatus{};
|
||||
std::map<uint64_t, Running> g_Running;
|
||||
std::vector<TrackedChallenge> g_Challenges;
|
||||
std::map<std::pair<uint64_t, LWOOBJID>, int64_t> g_Contributions;
|
||||
|
||||
uint32_t ZoneId() { return Game::server ? Game::server->GetZoneID() : 0; }
|
||||
uint32_t InstanceId() { return Game::server ? static_cast<uint32_t>(Game::server->GetInstanceID()) : 0; }
|
||||
bool IsClone() { return Game::zoneManager && Game::zoneManager->GetZoneID().GetCloneID() != 0; }
|
||||
|
||||
bool IsStaff(const Entity* player) {
|
||||
const auto* character = player ? player->GetCharacter() : nullptr;
|
||||
const auto* user = character ? character->GetParentUser() : nullptr;
|
||||
return user && user->GetMaxGMLevel() >= eGameMasterLevel::MODERATOR;
|
||||
}
|
||||
|
||||
void TellAll(const std::string& text) {
|
||||
ChatPackets::SendSystemMessage(UNASSIGNED_SYSTEM_ADDRESS, GeneralUtils::UTF8ToUTF16(text), true);
|
||||
}
|
||||
|
||||
void Tell(const SystemAddress& sysAddr, const std::string& text) {
|
||||
ChatPackets::SendSystemMessage(sysAddr, GeneralUtils::UTF8ToUTF16(text));
|
||||
}
|
||||
|
||||
// 2 -> "2", 1.5 -> "1.5"
|
||||
std::string Times(float multiplier) {
|
||||
char text[16];
|
||||
std::snprintf(text, sizeof(text), "%g", std::round(multiplier * 100.0f) / 100.0f);
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string TimeLeft(int64_t seconds) {
|
||||
if (seconds >= 2 * 86400) return std::to_string(seconds / 86400) + " days";
|
||||
if (seconds >= 2 * 3600) return std::to_string(seconds / 3600) + " hours";
|
||||
if (seconds >= 120) return std::to_string(seconds / 60) + " minutes";
|
||||
return std::to_string(std::max<int64_t>(seconds, 0)) + " seconds";
|
||||
}
|
||||
|
||||
// Whether an event is for this world: its zone (or every zone for bonuses), its instance, and not a property
|
||||
bool AppliesHere(const ILiveOps::LiveEvent& row, eEventType type, int64_t now) {
|
||||
if (!LiveOpsRules::InWindow(row.startsAt, row.endsAt, now)) return false;
|
||||
if (row.instanceId >= 0 && static_cast<uint32_t>(row.instanceId) != InstanceId()) return false;
|
||||
if (type != eEventType::BONUS && IsClone()) return false;
|
||||
if (row.zones.empty()) return type == eEventType::BONUS;
|
||||
return LiveOpsRules::CountsInZone(row.zones, ZoneId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Places known to be on walkable ground: the waypoints of the zone's movement paths (where NPCs walk), where the
|
||||
* zone's enemies spawn and the zone's spawn point. With a navmesh they are moved onto it.
|
||||
*/
|
||||
std::vector<NiPoint3> WalkablePoints() {
|
||||
std::vector<NiPoint3> points;
|
||||
if (!Game::zoneManager) return points;
|
||||
if (const auto* zone = Game::zoneManager->GetZone()) {
|
||||
for (const auto& path : zone->GetPaths()) {
|
||||
if (path.pathType != PathType::Movement) continue;
|
||||
for (const auto& waypoint : path.pathWaypoints) points.push_back(waypoint.position);
|
||||
}
|
||||
points.push_back(zone->GetSpawnPos());
|
||||
}
|
||||
auto* objects = CDClientManager::GetTable<CDObjectsTable>();
|
||||
for (const auto& [id, spawner] : Game::zoneManager->GetSpawners()) {
|
||||
if (!spawner || objects->GetByID(spawner->m_Info.templateID).type != "Enemies") continue;
|
||||
for (const auto* node : spawner->m_Info.nodes) if (node) points.push_back(node->position);
|
||||
}
|
||||
if (auto* navMesh = dpWorld::GetNavMesh(); navMesh && navMesh->IsNavmeshLoaded()) {
|
||||
for (auto& point : points) point = navMesh->NearestPoint(point, 3.0f);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
Entity* SpawnAt(LOT lot, const NiPoint3& position) {
|
||||
auto* control = Game::entityManager->GetZoneControlEntity();
|
||||
EntityInfo info;
|
||||
info.lot = lot;
|
||||
info.pos = position;
|
||||
info.rot = QuatUtils::FromEuler(NiPoint3(0.0f, GeneralUtils::GenerateRandomNumber<float>(0.0f, 6.28f), 0.0f));
|
||||
info.spawnerID = control ? control->GetObjectID() : LWOOBJID_EMPTY;
|
||||
info.settings.Insert<bool>(u"SpawnedFromLiveEvent", true);
|
||||
auto* entity = Game::entityManager->CreateEntity(info);
|
||||
if (!entity) return nullptr;
|
||||
Game::entityManager->ConstructEntity(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
// Who smashed an entity that is dying: the player, or the player owning what did it
|
||||
Entity* KillerPlayer(Entity* victim) {
|
||||
const auto* destroyable = victim ? victim->GetComponent<DestroyableComponent>() : nullptr;
|
||||
auto* killer = destroyable ? Game::entityManager->GetEntity(destroyable->GetKillerID()) : nullptr;
|
||||
if (killer) killer = killer->GetOwner();
|
||||
return killer && killer->IsPlayer() ? killer : nullptr;
|
||||
}
|
||||
|
||||
void AddScore(Running& event, const Entity* player, int64_t amount) {
|
||||
const auto* character = player ? player->GetCharacter() : nullptr;
|
||||
if (!character) return;
|
||||
event.scores[character->GetID()] += amount;
|
||||
}
|
||||
|
||||
std::string PlayerName(const Entity* player) {
|
||||
const auto* character = player ? player->GetCharacter() : nullptr;
|
||||
return character ? character->GetName() : "Someone";
|
||||
}
|
||||
|
||||
// ---------- Treasure hunt ----------
|
||||
|
||||
void TreasureFound(Running& event, Treasure& treasure, Entity* player, bool destroy) {
|
||||
if (treasure.gone) return;
|
||||
treasure.gone = true;
|
||||
event.dirty = true;
|
||||
if (destroy) Game::entityManager->DestroyEntity(treasure.id);
|
||||
if (!player) return; // smashed by something else: lost
|
||||
event.found++;
|
||||
AddScore(event, player, 1);
|
||||
auto* character = player->GetCharacter();
|
||||
const auto coins = std::clamp<int64_t>(event.config.value("coins", int64_t{ 0 }), 0, 100000);
|
||||
// Not ACTIVITY: the client holds ACTIVITY coins back for an activity's summary, so the HUD wouldn't show them until the next login
|
||||
if (character && coins > 0) character->SetCoins(character->GetCoins() + coins, eLootSourceType::CURRENCY);
|
||||
const LOT item = event.config.value("itemLot", 0);
|
||||
const auto itemCount = std::clamp<uint32_t>(event.config.value("itemCount", 1u), 1, 999);
|
||||
if (auto* inventory = player->GetComponent<InventoryComponent>(); inventory && item > 0) inventory->AddItem(item, itemCount, eLootSourceType::ACTIVITY);
|
||||
if (const auto effect = event.config.value("foundEffectId", 0); effect > 0) {
|
||||
GameMessages::SendPlayFXEffect(player->GetObjectID(), effect, GeneralUtils::UTF8ToUTF16(event.config.value("foundEffectType", std::string{})), "live_event_found");
|
||||
}
|
||||
const auto left = std::ranges::count_if(event.treasures, [](const Treasure& t) { return !t.gone; });
|
||||
TellAll(PlayerName(player) + " found a treasure! " + (left == 0 ? "That was the last one." : std::to_string(left) + " left to find."));
|
||||
if (left == 0) DashboardNotify::Announce(event.row.title.empty() ? "Treasure hunt" : event.row.title, "Every treasure here has been found. Well done!");
|
||||
}
|
||||
|
||||
void StartTreasureHunt(Running& event) {
|
||||
event.lot = event.config.value("lot", 0);
|
||||
event.pickupRadius = std::clamp(event.config.value("radius", 4.0f), 2.0f, 15.0f);
|
||||
const auto count = std::clamp<uint32_t>(event.config.value("count", 10u), 1, LiveOpsRules::MAX_TREASURES);
|
||||
if (event.lot <= 0) return;
|
||||
LiveOpsRules::Placement placement{ .count = count, .minSpacing = 30.0f };
|
||||
for (const auto& position : LiveOpsRules::ChoosePositions(WalkablePoints(), placement, Game::randomEngine)) {
|
||||
auto* entity = SpawnAt(event.lot, position);
|
||||
if (!entity) continue;
|
||||
entity->SetIsGhostingCandidate(false); // seen from afar, so players can hunt for them
|
||||
event.treasures.push_back({ entity->GetObjectID(), position });
|
||||
// A smashable treasure counts for whoever smashes it
|
||||
const auto eventId = event.row.id;
|
||||
const auto objectId = entity->GetObjectID();
|
||||
entity->AddDieCallback([eventId, objectId, entity]() {
|
||||
auto it = g_Running.find(eventId);
|
||||
if (it == g_Running.end()) return;
|
||||
for (auto& treasure : it->second.treasures) {
|
||||
if (treasure.id == objectId) TreasureFound(it->second, treasure, KillerPlayer(entity), false);
|
||||
}
|
||||
});
|
||||
}
|
||||
LOG("Live event %llu: hid %zu treasures (LOT %i)", static_cast<unsigned long long>(event.row.id), event.treasures.size(), event.lot);
|
||||
}
|
||||
|
||||
void TickTreasureHunt(Running& event) {
|
||||
const auto radiusSq = event.pickupRadius * event.pickupRadius;
|
||||
for (auto* player : PlayerManager::GetAllPlayers()) {
|
||||
if (!player || player->GetIsDead()) continue;
|
||||
const auto position = player->GetPosition();
|
||||
for (auto& treasure : event.treasures) {
|
||||
if (!treasure.gone && NiPoint3::DistanceSquared(position, treasure.position) <= radiusSq) TreasureFound(event, treasure, player, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Invasion ----------
|
||||
|
||||
void StartInvasion(Running& event) {
|
||||
const auto* zone = Game::zoneManager->GetZone();
|
||||
event.center = zone ? zone->GetSpawnPos() : NiPoint3Constant::ZERO;
|
||||
if (event.config.value("center", std::string{ "spawn" }) == "players") {
|
||||
const auto& players = PlayerManager::GetAllPlayers();
|
||||
if (!players.empty()) {
|
||||
const auto* player = players[GeneralUtils::GenerateRandomNumber<size_t>(0, players.size() - 1)];
|
||||
if (player) event.center = player->GetPosition();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<NiPoint3> InvasionPoints(const Running& event, float radius) {
|
||||
auto points = WalkablePoints();
|
||||
// With a navmesh, points on a ring around the center, moved onto it (dropped where there is none)
|
||||
if (auto* navMesh = dpWorld::GetNavMesh(); navMesh && navMesh->IsNavmeshLoaded()) {
|
||||
for (int i = 0; i < 24; i++) {
|
||||
const auto angle = static_cast<float>(i) * 0.2618f;
|
||||
const auto distance = radius * (0.4f + 0.6f * GeneralUtils::GenerateRandomNumber<float>(0.0f, 1.0f));
|
||||
const NiPoint3 ring{ event.center.x + std::cos(angle) * distance, event.center.y, event.center.z + std::sin(angle) * distance };
|
||||
const auto snapped = navMesh->NearestPoint(ring, 8.0f);
|
||||
if (snapped != ring) points.push_back(snapped);
|
||||
}
|
||||
}
|
||||
points.push_back(event.center);
|
||||
return points;
|
||||
}
|
||||
|
||||
void SendWave(Running& event) {
|
||||
std::vector<LOT> lots;
|
||||
if (event.config.contains("lots") && event.config["lots"].is_array()) {
|
||||
for (const auto& lot : event.config["lots"]) if (lot.is_number_integer() && lot.get<int32_t>() > 0) lots.push_back(lot.get<int32_t>());
|
||||
}
|
||||
event.wavesSent++;
|
||||
event.dirty = true;
|
||||
if (lots.empty()) return;
|
||||
const auto perWave = std::clamp<uint32_t>(event.config.value("perWave", 5u), 1, LiveOpsRules::MAX_PER_WAVE);
|
||||
const auto room = LiveOpsRules::MAX_INVADERS_ALIVE > event.alive.size() ? LiveOpsRules::MAX_INVADERS_ALIVE - static_cast<uint32_t>(event.alive.size()) : 0;
|
||||
const auto count = std::min(perWave, room);
|
||||
const auto radius = std::clamp(event.config.value("radius", 25.0f), 5.0f, 80.0f);
|
||||
LiveOpsRules::Placement placement{ .count = count, .minSpacing = 3.0f, .center = event.center, .radius = radius, .reuse = true };
|
||||
auto positions = LiveOpsRules::ChoosePositions(InvasionPoints(event, radius), placement, Game::randomEngine);
|
||||
for (const auto& position : positions) {
|
||||
auto* entity = SpawnAt(lots[GeneralUtils::GenerateRandomNumber<size_t>(0, lots.size() - 1)], position);
|
||||
if (!entity) continue;
|
||||
event.alive.insert(entity->GetObjectID());
|
||||
event.spawned++;
|
||||
const auto eventId = event.row.id;
|
||||
const auto objectId = entity->GetObjectID();
|
||||
entity->AddDieCallback([eventId, objectId, entity]() {
|
||||
auto it = g_Running.find(eventId);
|
||||
if (it == g_Running.end() || !it->second.alive.erase(objectId)) return;
|
||||
auto& running = it->second;
|
||||
running.dirty = true;
|
||||
if (auto* player = KillerPlayer(entity)) {
|
||||
running.kills++;
|
||||
AddScore(running, player, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
const auto waves = std::clamp<uint32_t>(event.config.value("waves", 3u), 1, LiveOpsRules::MAX_WAVES);
|
||||
TellAll("Invasion wave " + std::to_string(event.wavesSent) + " of " + std::to_string(waves) + ": " + std::to_string(positions.size()) + " enemies are attacking!");
|
||||
}
|
||||
|
||||
void TickInvasion(Running& event, int64_t now) {
|
||||
// Invaders removed some other way than being smashed
|
||||
const auto before = event.alive.size();
|
||||
std::erase_if(event.alive, [](LWOOBJID id) { return Game::entityManager->GetEntity(id) == nullptr; });
|
||||
if (event.alive.size() != before) event.dirty = true;
|
||||
|
||||
const auto waves = std::clamp<uint32_t>(event.config.value("waves", 3u), 1, LiveOpsRules::MAX_WAVES);
|
||||
const auto interval = std::clamp<int64_t>(event.config.value("interval", int64_t{ 90 }), 15, 3600);
|
||||
const auto due = LiveOpsRules::WavesDue(event.startedAt, interval, waves, now);
|
||||
const bool wasOpen = event.wavesSent < waves || !event.alive.empty();
|
||||
while (event.wavesSent < due) SendWave(event);
|
||||
if (wasOpen && event.wavesSent >= waves && event.alive.empty() && event.spawned > 0) {
|
||||
DashboardNotify::Announce(event.row.title.empty() ? "Invasion" : event.row.title, "The invasion has been pushed back! " + std::to_string(event.kills) + " invaders smashed.");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Celebration ----------
|
||||
|
||||
void TickCelebration(Running& event, int64_t now) {
|
||||
if (now < event.nextBurst) return;
|
||||
event.nextBurst = now + std::clamp<int64_t>(event.config.value("interval", int64_t{ 10 }), 3, 600);
|
||||
const auto effect = event.config.value("effectId", 0);
|
||||
const auto type = GeneralUtils::UTF8ToUTF16(event.config.value("effectType", std::string{}));
|
||||
if (effect <= 0) return;
|
||||
const auto name = "live_event_" + std::to_string(event.row.id);
|
||||
for (auto* player : PlayerManager::GetAllPlayers()) {
|
||||
if (player) GameMessages::SendPlayFXEffect(player->GetObjectID(), effect, type, name);
|
||||
}
|
||||
event.bursts++;
|
||||
event.dirty = true;
|
||||
}
|
||||
|
||||
// ---------- Common ----------
|
||||
|
||||
nlohmann::json StatusJson(const Running& event, bool closed) {
|
||||
nlohmann::json status{ {"players", PlayerManager::GetAllPlayers().size()}, {"closed", closed} };
|
||||
switch (event.type) {
|
||||
case eEventType::TREASURE_HUNT:
|
||||
status["treasures"] = event.treasures.size();
|
||||
status["found"] = event.found;
|
||||
status["left"] = std::ranges::count_if(event.treasures, [](const Treasure& t) { return !t.gone; });
|
||||
break;
|
||||
case eEventType::INVASION:
|
||||
status["wavesSent"] = event.wavesSent;
|
||||
status["spawned"] = event.spawned;
|
||||
status["alive"] = event.alive.size();
|
||||
status["kills"] = event.kills;
|
||||
break;
|
||||
case eEventType::CELEBRATION:
|
||||
status["bursts"] = event.bursts;
|
||||
break;
|
||||
case eEventType::BONUS:
|
||||
break;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
void WriteStatus(Running& event, bool closed) {
|
||||
event.dirty = false;
|
||||
try {
|
||||
Database::Get()->SetLiveEventInstance({ event.row.id, ZoneId(), InstanceId(), StatusJson(event, closed).dump(), Now() });
|
||||
DashboardNotify::Changed("live_events", static_cast<LWOOBJID>(event.row.id));
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not write live event %llu's status: %s", static_cast<unsigned long long>(event.row.id), ex.what());
|
||||
}
|
||||
}
|
||||
|
||||
void FlushScores(Running& event) {
|
||||
if (event.scores.empty()) return;
|
||||
std::vector<std::pair<LWOOBJID, int64_t>> scores(event.scores.begin(), event.scores.end());
|
||||
event.scores.clear();
|
||||
try {
|
||||
Database::Get()->AddLiveEventScores(event.row.id, scores, Now());
|
||||
DashboardNotify::Changed("live_events", static_cast<LWOOBJID>(event.row.id));
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not write live event %llu's scores: %s", static_cast<unsigned long long>(event.row.id), ex.what());
|
||||
}
|
||||
}
|
||||
|
||||
void FlushContributions() {
|
||||
if (g_Contributions.empty()) return;
|
||||
std::vector<ILiveOps::Contribution> rows;
|
||||
for (const auto& [key, amount] : g_Contributions) rows.push_back({ key.first, key.second, amount });
|
||||
g_Contributions.clear();
|
||||
try {
|
||||
Database::Get()->AddChallengeContributions(rows, Now());
|
||||
DashboardNotify::Changed("challenges");
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not write challenge contributions: %s", ex.what());
|
||||
}
|
||||
}
|
||||
|
||||
void RecomputeMultipliers(int64_t now) {
|
||||
std::vector<LiveOpsRules::BonusWindow> windows;
|
||||
for (const auto& [id, event] : g_Running) {
|
||||
if (event.type != eEventType::BONUS) continue;
|
||||
windows.push_back({ event.row.startsAt, event.row.endsAt,
|
||||
{ event.config.value("coins", 1.0f), event.config.value("uscore", 1.0f), event.config.value("lootChance", 1.0f) } });
|
||||
}
|
||||
LiveEvents::Detail::g_Multipliers = LiveOpsRules::ActiveMultipliers(windows, now);
|
||||
}
|
||||
|
||||
void RecomputeCounting(int64_t now) {
|
||||
std::erase_if(g_Challenges, [now](const TrackedChallenge& challenge) { return now >= challenge.endsAt; });
|
||||
LiveEvents::Detail::g_Counting = std::ranges::any_of(g_Challenges, [now](const TrackedChallenge& c) { return LiveOpsRules::InWindow(c.startsAt, c.endsAt, now); });
|
||||
}
|
||||
|
||||
void Start(Running& event) {
|
||||
try {
|
||||
switch (event.type) {
|
||||
case eEventType::TREASURE_HUNT: StartTreasureHunt(event); break;
|
||||
case eEventType::INVASION: StartInvasion(event); break;
|
||||
case eEventType::CELEBRATION: event.nextBurst = event.startedAt; break;
|
||||
case eEventType::BONUS: break;
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Live event %llu could not start: %s", static_cast<unsigned long long>(event.row.id), ex.what());
|
||||
}
|
||||
LOG("Live event %llu (%s) running in zone %u instance %u until %lld", static_cast<unsigned long long>(event.row.id), event.row.type.c_str(),
|
||||
ZoneId(), InstanceId(), static_cast<long long>(event.row.endsAt));
|
||||
}
|
||||
|
||||
// Remove everything the event put in this world
|
||||
void Stop(Running& event, bool timeUp) {
|
||||
for (const auto& treasure : event.treasures) {
|
||||
if (!treasure.gone) Game::entityManager->DestroyEntity(treasure.id);
|
||||
}
|
||||
for (const auto id : event.alive) Game::entityManager->DestroyEntity(id);
|
||||
event.alive.clear();
|
||||
if (event.type == eEventType::CELEBRATION) {
|
||||
const auto name = "live_event_" + std::to_string(event.row.id);
|
||||
for (auto* player : PlayerManager::GetAllPlayers()) if (player) GameMessages::SendStopFXEffect(player, true, name);
|
||||
}
|
||||
FlushScores(event);
|
||||
WriteStatus(event, true);
|
||||
switch (event.type) {
|
||||
case eEventType::TREASURE_HUNT:
|
||||
TellAll("The treasure hunt is over: " + std::to_string(event.found) + " of " + std::to_string(event.treasures.size()) + " treasures were found here.");
|
||||
break;
|
||||
case eEventType::INVASION:
|
||||
TellAll("The invasion is over: " + std::to_string(event.kills) + " invaders smashed here." + (timeUp ? "" : " The rest retreated."));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
LOG("Live event %llu stopped in zone %u instance %u", static_cast<unsigned long long>(event.row.id), ZoneId(), InstanceId());
|
||||
}
|
||||
|
||||
void StopAll(const std::function<bool(const Running&)>& shouldStop, bool timeUp) {
|
||||
for (auto it = g_Running.begin(); it != g_Running.end();) {
|
||||
if (!shouldStop(it->second)) { ++it; continue; }
|
||||
Stop(it->second, timeUp);
|
||||
it = g_Running.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
// Give online players the challenge coins waiting for them
|
||||
uint32_t GiveWaitingCoins(Entity* player) {
|
||||
auto* character = player ? player->GetCharacter() : nullptr;
|
||||
if (!character) return 0;
|
||||
int64_t given = 0;
|
||||
for (const auto& reward : Database::Get()->GetUnclaimedChallengeCoins(character->GetID())) {
|
||||
if (!Database::Get()->ClaimChallengeCoins(reward.challengeId, reward.characterId, Now())) continue;
|
||||
given += reward.coins;
|
||||
}
|
||||
if (given <= 0) return 0;
|
||||
character->SetCoins(character->GetCoins() + given, eLootSourceType::CURRENCY); // not ACTIVITY, see TreasureFound
|
||||
Tell(player->GetSystemAddress(), "You received " + std::to_string(given) + " coins for your part in a community challenge. Thank you!");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
namespace LiveEvents::Detail {
|
||||
void CountStat(const Entity* player, uint32_t stat, int64_t amount) {
|
||||
const auto* character = player ? player->GetCharacter() : nullptr;
|
||||
if (!character || amount <= 0) return;
|
||||
const auto now = Now();
|
||||
const bool staff = IsStaff(player);
|
||||
for (const auto& challenge : g_Challenges) {
|
||||
if (challenge.kind != ILiveOps::eChallengeMetric::STATISTIC || challenge.metric != stat) continue;
|
||||
if ((staff && !challenge.includeStaff) || !LiveOpsRules::InWindow(challenge.startsAt, challenge.endsAt, now)) continue;
|
||||
g_Contributions[{ challenge.id, character->GetID() }] += amount;
|
||||
}
|
||||
}
|
||||
|
||||
void CountMapEvent(const Entity* player, uint8_t kind, LOT lot, int64_t quantity) {
|
||||
const auto* character = player->GetCharacter();
|
||||
if (!character || quantity <= 0) return;
|
||||
const auto now = Now();
|
||||
const bool staff = IsStaff(player);
|
||||
for (const auto& challenge : g_Challenges) {
|
||||
if (challenge.kind != ILiveOps::eChallengeMetric::MAP_EVENT || challenge.metric != kind || (challenge.lot != 0 && challenge.lot != lot)) continue;
|
||||
if ((staff && !challenge.includeStaff) || !LiveOpsRules::InWindow(challenge.startsAt, challenge.endsAt, now)) continue;
|
||||
g_Contributions[{ challenge.id, character->GetID() }] += quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace LiveEvents {
|
||||
uint32_t Reload() {
|
||||
if (!Game::server || ZoneId() == 0 || !Game::zoneManager || !Game::entityManager) return 0;
|
||||
g_Loaded = true;
|
||||
const auto now = Now();
|
||||
std::vector<ILiveOps::LiveEvent> events;
|
||||
std::vector<ILiveOps::Challenge> challenges;
|
||||
try {
|
||||
events = Database::Get()->GetLiveEvents(true, 200);
|
||||
challenges = Database::Get()->GetChallenges(true);
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not load live events: %s", ex.what());
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::map<uint64_t, std::pair<ILiveOps::LiveEvent, eEventType>> wanted;
|
||||
for (auto& row : events) {
|
||||
const auto type = LiveOpsRules::ParseType(row.type);
|
||||
if (type && AppliesHere(row, *type, now)) wanted[row.id] = { std::move(row), *type };
|
||||
}
|
||||
StopAll([&wanted](const Running& event) { return !wanted.contains(event.row.id); }, false);
|
||||
for (auto& [id, entry] : wanted) {
|
||||
if (g_Running.contains(id)) {
|
||||
g_Running[id].row.endsAt = entry.first.endsAt; // may have been ended early by changing the end
|
||||
continue;
|
||||
}
|
||||
auto& event = g_Running[id];
|
||||
event.row = std::move(entry.first);
|
||||
event.type = entry.second;
|
||||
event.config = nlohmann::json::parse(event.row.config, nullptr, false);
|
||||
if (!event.config.is_object()) event.config = nlohmann::json::object();
|
||||
event.startedAt = now;
|
||||
Start(event);
|
||||
}
|
||||
RecomputeMultipliers(now);
|
||||
|
||||
g_Challenges.clear();
|
||||
for (const auto& challenge : challenges) {
|
||||
if (now >= challenge.endsAt || !LiveOpsRules::CountsInZone(challenge.zones, ZoneId())) continue;
|
||||
g_Challenges.push_back({ challenge.id, challenge.metricKind, challenge.metric, challenge.lot, challenge.startsAt, challenge.endsAt, challenge.includeStaff });
|
||||
}
|
||||
RecomputeCounting(now);
|
||||
|
||||
try {
|
||||
for (auto* player : PlayerManager::GetAllPlayers()) GiveWaitingCoins(player);
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not give challenge coins: %s", ex.what());
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void Update() {
|
||||
if (g_Disabled) return;
|
||||
const auto steady = Clock::now();
|
||||
if (steady < g_NextTick) return;
|
||||
g_NextTick = steady + TICK;
|
||||
if (!g_Loaded) {
|
||||
if (!Game::server || ZoneId() == 0) { g_Disabled = true; return; }
|
||||
if (g_FirstUpdate == Clock::time_point{}) g_FirstUpdate = steady;
|
||||
if (steady - g_FirstUpdate < FIRST_LOAD_DELAY) return;
|
||||
Reload();
|
||||
if (!g_Loaded) { g_Disabled = true; return; }
|
||||
}
|
||||
if (g_Running.empty() && g_Challenges.empty() && g_Contributions.empty()) return;
|
||||
|
||||
const auto now = Now();
|
||||
StopAll([now](const Running& event) { return now >= event.row.endsAt; }, true);
|
||||
for (auto& [id, event] : g_Running) {
|
||||
switch (event.type) {
|
||||
case eEventType::TREASURE_HUNT: TickTreasureHunt(event); break;
|
||||
case eEventType::INVASION: TickInvasion(event, now); break;
|
||||
case eEventType::CELEBRATION: TickCelebration(event, now); break;
|
||||
case eEventType::BONUS: break;
|
||||
}
|
||||
}
|
||||
RecomputeMultipliers(now);
|
||||
RecomputeCounting(now);
|
||||
|
||||
if (steady >= g_NextStatus) {
|
||||
g_NextStatus = steady + STATUS_INTERVAL;
|
||||
for (auto& [id, event] : g_Running) if (event.dirty) WriteStatus(event, false);
|
||||
}
|
||||
if (steady >= g_NextFlush) {
|
||||
g_NextFlush = steady + FLUSH_INTERVAL;
|
||||
for (auto& [id, event] : g_Running) FlushScores(event);
|
||||
FlushContributions();
|
||||
}
|
||||
}
|
||||
|
||||
void Shutdown() {
|
||||
for (auto& [id, event] : g_Running) {
|
||||
FlushScores(event);
|
||||
WriteStatus(event, true);
|
||||
}
|
||||
g_Running.clear();
|
||||
FlushContributions();
|
||||
Detail::g_Multipliers = {};
|
||||
Detail::g_Counting = false;
|
||||
}
|
||||
|
||||
void ChallengeCommand(Entity* entity, const SystemAddress& sysAddr, const std::string args) {
|
||||
auto* character = entity ? entity->GetCharacter() : nullptr;
|
||||
if (!character) return;
|
||||
try {
|
||||
if (GiveWaitingCoins(entity) == 0 && args == "claim") Tell(sysAddr, "You have no challenge rewards waiting.");
|
||||
|
||||
const auto now = Now();
|
||||
auto challenges = Database::Get()->GetChallenges(true);
|
||||
std::erase_if(challenges, [now](const ILiveOps::Challenge& c) { return LiveOpsRules::Phase(0, c.startsAt, c.endsAt, now) != LiveOpsRules::ePhase::ACTIVE; });
|
||||
std::vector<uint64_t> ids;
|
||||
for (const auto& challenge : challenges) ids.push_back(challenge.id);
|
||||
const auto totals = Database::Get()->GetChallengeTotals(ids);
|
||||
auto mine = Database::Get()->GetCharacterContributions(character->GetID());
|
||||
for (const auto& [key, amount] : g_Contributions) if (key.second == character->GetID()) mine[key.first] += amount;
|
||||
|
||||
if (challenges.empty()) Tell(sysAddr, "There are no community challenges running right now.");
|
||||
for (const auto& challenge : challenges) {
|
||||
const auto it = totals.find(challenge.id);
|
||||
const auto total = it == totals.end() ? 0 : it->second.total;
|
||||
const auto contributed = mine.contains(challenge.id) ? mine[challenge.id] : 0;
|
||||
Tell(sysAddr, "Challenge: " + challenge.title + " - " + std::to_string(total) + " of " + std::to_string(challenge.target) + " (" +
|
||||
std::to_string(LiveOpsRules::Percent(total, challenge.target)) + "%), " + TimeLeft(challenge.endsAt - now) + " left. You: " + std::to_string(contributed) +
|
||||
(contributed > 0 && !LiveOpsRules::Eligible(contributed, challenge.rewardMin) ? " (" + std::to_string(challenge.rewardMin) + " needed for the reward)" : ""));
|
||||
}
|
||||
|
||||
const auto position = entity->GetPosition();
|
||||
for (const auto& [id, event] : g_Running) {
|
||||
std::string text = "Live event here: " + (event.row.title.empty() ? std::string(LiveOpsRules::TypeName(event.type)) : event.row.title) + ", " + TimeLeft(event.row.endsAt - now) + " left.";
|
||||
if (event.type == eEventType::TREASURE_HUNT) {
|
||||
float nearest = -1.0f;
|
||||
size_t left = 0;
|
||||
for (const auto& treasure : event.treasures) {
|
||||
if (treasure.gone) continue;
|
||||
left++;
|
||||
const auto distance = NiPoint3::Distance(position, treasure.position);
|
||||
if (nearest < 0 || distance < nearest) nearest = distance;
|
||||
}
|
||||
text += " " + std::to_string(left) + " treasures left" + (nearest >= 0 ? ", the nearest about " + std::to_string(static_cast<int32_t>(nearest)) + " away." : ".");
|
||||
} else if (event.type == eEventType::INVASION) {
|
||||
text += " " + std::to_string(event.kills) + " invaders smashed, " + std::to_string(event.alive.size()) + " still here.";
|
||||
} else if (event.type == eEventType::BONUS) {
|
||||
const auto& bonus = Detail::g_Multipliers;
|
||||
text += " Coins x" + Times(bonus.coins) + ", U-score x" + Times(bonus.uscore) + ", loot chance x" + Times(bonus.lootChance) + ".";
|
||||
}
|
||||
Tell(sysAddr, text);
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("/challenge failed: %s", ex.what());
|
||||
Tell(sysAddr, "Challenges can't be shown right now.");
|
||||
}
|
||||
}
|
||||
}
|
||||
68
dGame/dUtilities/LiveEvents.h
Normal file
68
dGame/dUtilities/LiveEvents.h
Normal file
@@ -0,0 +1,68 @@
|
||||
#ifndef __LIVEEVENTS__H__
|
||||
#define __LIVEEVENTS__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
#include "RakNetTypes.h"
|
||||
#include "LiveOpsRules.h"
|
||||
|
||||
class Entity;
|
||||
|
||||
/**
|
||||
* Live events and community challenges as this world server runs them (see ILiveOps and the dashboard's Live Events
|
||||
* and Challenges pages). The dashboard stores them; each world loads what applies to its zone and instance when it
|
||||
* starts and when told to reload (ePlayerAction::RELOAD_LIVE_OPS), and ends its part of an event on its own at the end
|
||||
* time, cleaning up everything it spawned.
|
||||
*
|
||||
* The hooks below sit where the game grants coins, U-score and loot and records statistics. They only read a value
|
||||
* that is 1 (or false) while nothing is running, so they cost nothing and change nothing then.
|
||||
*/
|
||||
namespace LiveEvents {
|
||||
namespace Detail {
|
||||
extern LiveOpsRules::Multipliers g_Multipliers;
|
||||
extern bool g_Counting;
|
||||
void CountStat(const Entity* player, uint32_t stat, int64_t amount);
|
||||
void CountMapEvent(const Entity* player, uint8_t kind, LOT lot, int64_t quantity);
|
||||
}
|
||||
|
||||
// Coins dropped as loot, times a running coin bonus
|
||||
inline uint32_t ScaleCoins(uint32_t coins) {
|
||||
return Detail::g_Multipliers.coins == 1.0f ? coins : LiveOpsRules::Scale(coins, Detail::g_Multipliers.coins);
|
||||
}
|
||||
|
||||
// U-score from a mission or achievement, times a running U-score bonus
|
||||
inline int64_t ScaleUScore(int64_t uscore) {
|
||||
return Detail::g_Multipliers.uscore == 1.0f ? uscore : LiveOpsRules::Scale(uscore, Detail::g_Multipliers.uscore);
|
||||
}
|
||||
|
||||
// What loot matrix drop chances are multiplied by (1: no bonus)
|
||||
inline float LootChanceMultiplier() {
|
||||
return Detail::g_Multipliers.lootChance;
|
||||
}
|
||||
|
||||
// A player's statistic went up (server-side only; what the client reports doesn't count)
|
||||
inline void OnStat(const Entity* player, uint32_t stat, uint64_t amount) {
|
||||
if (Detail::g_Counting) Detail::CountStat(player, stat, static_cast<int64_t>(amount));
|
||||
}
|
||||
|
||||
// Something a player did was recorded on the map (IEconomyLedger::eMapEvent)
|
||||
inline void OnMapEvent(const Entity* player, uint8_t kind, LOT lot, int64_t quantity) {
|
||||
if (Detail::g_Counting && player) Detail::CountMapEvent(player, kind, lot, quantity);
|
||||
}
|
||||
|
||||
// World server loop
|
||||
void Update();
|
||||
|
||||
// Load the running events and open challenges again; answers 1 (0 in character select)
|
||||
uint32_t Reload();
|
||||
|
||||
// World shutting down: write what is left and mark this instance's part of events closed
|
||||
void Shutdown();
|
||||
|
||||
// /challenge [claim]: community challenges and this world's live events, and the rewards waiting
|
||||
void ChallengeCommand(Entity* entity, const SystemAddress& sysAddr, const std::string args);
|
||||
}
|
||||
|
||||
#endif //!__LIVEEVENTS__H__
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "Loot.h"
|
||||
#include "EconomyLedger.h"
|
||||
#include "LiveEvents.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <unordered_set>
|
||||
@@ -34,7 +36,8 @@ struct LootDropInfo {
|
||||
uint32_t count{ 0 };
|
||||
};
|
||||
|
||||
std::map<LOT, LootDropInfo> RollLootMatrix(uint32_t matrixIndex) {
|
||||
// chanceMultiplier: a live event's loot bonus (1: none)
|
||||
std::map<LOT, LootDropInfo> RollLootMatrix(uint32_t matrixIndex, float chanceMultiplier = 1.0f) {
|
||||
CDComponentsRegistryTable* componentsRegistryTable = CDClientManager::GetTable<CDComponentsRegistryTable>();
|
||||
CDItemComponentTable* itemComponentTable = CDClientManager::GetTable<CDItemComponentTable>();
|
||||
CDLootMatrixTable* lootMatrixTable = CDClientManager::GetTable<CDLootMatrixTable>();
|
||||
@@ -45,7 +48,7 @@ std::map<LOT, LootDropInfo> RollLootMatrix(uint32_t matrixIndex) {
|
||||
const auto& matrix = lootMatrixTable->GetMatrix(matrixIndex);
|
||||
|
||||
for (const auto& entry : matrix) {
|
||||
if (GeneralUtils::GenerateRandomNumber<float>(0, 1) < entry.percent) {
|
||||
if (GeneralUtils::GenerateRandomNumber<float>(0, 1) < LiveOpsRules::ScaleChance(entry.percent, chanceMultiplier)) {
|
||||
const auto& lootTable = lootTableTable->GetTable(entry.LootTableIndex);
|
||||
const auto& rarityTable = rarityTableTable->GetRarityTable(entry.RarityTableIndex);
|
||||
|
||||
@@ -187,6 +190,7 @@ void DropPowerupLoot(Entity& player, GameMessages::DropClientLoot& lootMsg) {
|
||||
GameMessages::DeliverLocally(lootMsg);
|
||||
// Visually drop it for the player
|
||||
lootMsg.Send(player.GetSystemAddress());
|
||||
EconomyLedger::RecordMapEvent(IEconomyLedger::eMapEvent::POWERUP_DROPS, lootMsg.item, lootMsg.spawnPos, 1, &player);
|
||||
}
|
||||
|
||||
// Drop the power up with no owner
|
||||
@@ -197,6 +201,9 @@ void DropPowerupLoot(const Team& team, GameMessages::DropClientLoot& lootMsg, co
|
||||
lootMsg.ownerID = LWOOBJID_EMPTY; // By setting ownerID to empty, any client that gets this DropClientLoot message can pick up the item.
|
||||
CalcFinalDropPos(lootMsg);
|
||||
|
||||
// One drop, however many teammates see it
|
||||
EconomyLedger::RecordMapEvent(IEconomyLedger::eMapEvent::POWERUP_DROPS, lootMsg.item, lootMsg.spawnPos, 1);
|
||||
|
||||
// We want to drop the powerups as the same ID and the same position to all members of the team
|
||||
for (const auto member : team.members) {
|
||||
GameMessages::GetPosition memberPosMsg{};
|
||||
@@ -347,6 +354,10 @@ void DropLoot(Entity* player, const LWOOBJID source, const std::map<LOT, LootDro
|
||||
const auto spawnPosition = posMsg.pos;
|
||||
auto* const objectsTable = CDClientManager::GetTable<CDObjectsTable>();
|
||||
|
||||
for (const auto& [lootLot, info] : rolledItems) {
|
||||
if (info.count > 0) EconomyLedger::RecordMapEvent(IEconomyLedger::eMapEvent::ITEM_DROPS, lootLot, spawnPosition, info.count, player);
|
||||
}
|
||||
|
||||
constexpr LOT TOKEN_PROXY = 13763;
|
||||
// Go through the drops 1 at a time to drop them
|
||||
for (auto it = rolledItems.begin(); it != rolledItems.end(); it++) {
|
||||
@@ -391,10 +402,14 @@ void DropLoot(Entity* player, const LWOOBJID source, const std::map<LOT, LootDro
|
||||
}
|
||||
|
||||
// Coin roll is divided up between the members, rounded up, then dropped for each player
|
||||
const uint32_t coinRoll = static_cast<uint32_t>(minCoins + GeneralUtils::GenerateRandomNumber<float>(0, 1) * (maxCoins - minCoins));
|
||||
const uint32_t coinRoll = LiveEvents::ScaleCoins(static_cast<uint32_t>(minCoins + GeneralUtils::GenerateRandomNumber<float>(0, 1) * (maxCoins - minCoins)));
|
||||
// Just in case its empty don't allow divide by 0
|
||||
const auto droppedCoins = lootEarners.empty() ? coinRoll : static_cast<uint32_t>(std::ceil(static_cast<float>(coinRoll) / lootEarners.size()));
|
||||
|
||||
if (droppedCoins > 0 && !lootEarners.empty()) {
|
||||
EconomyLedger::RecordMapEvent(IEconomyLedger::eMapEvent::COIN_DROPS, 0, spawnPosition, static_cast<int64_t>(droppedCoins) * lootEarners.size(), player);
|
||||
}
|
||||
|
||||
// Drops coins for each alive member of a team (or just a player)
|
||||
for (auto member : lootEarners) {
|
||||
GameMessages::DropClientLoot lootMsg{};
|
||||
@@ -568,7 +583,7 @@ void Loot::DropLoot(Entity* player, const LWOOBJID source, uint32_t matrixIndex,
|
||||
if (!inventoryComponent)
|
||||
return;
|
||||
|
||||
const auto result = ::RollLootMatrix(matrixIndex);
|
||||
const auto result = ::RollLootMatrix(matrixIndex, LiveEvents::LootChanceMultiplier());
|
||||
|
||||
::DropLoot(player, source, result, minCoins, maxCoins, noTeamLootOnDeath);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "Mail.h"
|
||||
#include "DashboardNotify.h"
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
@@ -28,6 +29,8 @@
|
||||
#include "eReplicaComponentType.h"
|
||||
#include "ServiceType.h"
|
||||
#include "User.h"
|
||||
#include "EconomyLedger.h"
|
||||
#include "ObjectIDManager.h"
|
||||
#include "StringifiedEnum.h"
|
||||
#include "UserManager.h"
|
||||
|
||||
@@ -105,7 +108,7 @@ namespace Mail {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAttachment && !item) {
|
||||
if (hasAttachment && (!item || static_cast<uint32_t>(mailInfo.itemCount) > item->GetCount())) {
|
||||
response.status = eSendResponse::AttachmentNotFound;
|
||||
} else if (player->GetCharacter()->GetCoins() - mailCost < 0) {
|
||||
response.status = eSendResponse::NotEnoughCoins;
|
||||
@@ -113,10 +116,29 @@ namespace Mail {
|
||||
bool removeSuccess = true;
|
||||
// Remove coins and items from the sender
|
||||
player->GetCharacter()->SetCoins(player->GetCharacter()->GetCoins() - mailCost, eLootSourceType::MAIL);
|
||||
const auto sentItemId = mailInfo.itemID;
|
||||
mailInfo.itemSubkey = LWOOBJID_EMPTY;
|
||||
mailInfo.itemConfig.clear();
|
||||
if (inventoryComponent && hasAttachment && item) {
|
||||
removeSuccess = inventoryComponent->RemoveItem(mailInfo.itemLOT, mailInfo.itemCount, ALL, true);
|
||||
// The attached item waits in the mailbox rather than being destroyed, so the ledger records a transfer
|
||||
EconomyLedger::ScopedItemTransfer transfer;
|
||||
const auto count = static_cast<uint32_t>(mailInfo.itemCount);
|
||||
if (count == item->GetCount()) {
|
||||
// The whole item goes with its data (subkey, config)
|
||||
mailInfo.itemSubkey = item->GetSubKey();
|
||||
mailInfo.itemConfig = item->GetConfig().ToLines();
|
||||
}
|
||||
// Always a new object id while it waits in the mailbox: the sender is saved after the mail is
|
||||
// written, so reusing the id could leave two objects with it if the server stopped in between.
|
||||
// (Only mail from the dashboard's item restore brings back original ids, for objects that no
|
||||
// longer exist.)
|
||||
mailInfo.itemID = ObjectIDManager::GetPersistentID();
|
||||
// Take the attached item itself, not whichever stack of that LOT comes first
|
||||
item->SetCount(item->GetCount() - count);
|
||||
auto* missionComponent = player->GetComponent<MissionComponent>();
|
||||
if (missionComponent && removeSuccess) missionComponent->Progress(eMissionTaskType::GATHER, mailInfo.itemLOT, LWOOBJID_EMPTY, "", -mailInfo.itemCount);
|
||||
if (missionComponent) missionComponent->Progress(eMissionTaskType::GATHER, mailInfo.itemLOT, LWOOBJID_EMPTY, "", -mailInfo.itemCount);
|
||||
} else {
|
||||
mailInfo.itemID = LWOOBJID_EMPTY;
|
||||
}
|
||||
|
||||
// we passed all the checks, now we can actully send the mail
|
||||
@@ -124,12 +146,14 @@ namespace Mail {
|
||||
mailInfo.senderId = character->GetID();
|
||||
mailInfo.senderUsername = character->GetName();
|
||||
mailInfo.receiverId = receiverID->id;
|
||||
mailInfo.itemSubkey = LWOOBJID_EMPTY;
|
||||
|
||||
//clear out the attachementID
|
||||
mailInfo.itemID = 0;
|
||||
|
||||
Database::Get()->InsertNewMail(mailInfo);
|
||||
DashboardNotify::Changed("mail", mailInfo.receiverId);
|
||||
if (hasAttachment) {
|
||||
EconomyLedger::RecordTransfer({ .method = IEconomyLedger::eTransferMethod::MAIL_SENT, .itemId = sentItemId,
|
||||
.newItemId = mailInfo.itemID, .lot = mailInfo.itemLOT, .count = static_cast<uint32_t>(mailInfo.itemCount),
|
||||
.fromCharacter = character->GetID(), .toCharacter = receiverID->id, .zone = Game::server->GetZoneID() });
|
||||
}
|
||||
response.status = eSendResponse::Success;
|
||||
character->SaveXMLToDatabase();
|
||||
} else {
|
||||
@@ -203,8 +227,23 @@ namespace Mail {
|
||||
} else if (!inv->HasSpaceForLoot({ {playerMail->itemLOT, playerMail->itemCount} })) {
|
||||
response.status = eAttachmentCollectResponse::NoSpaceInInventory;
|
||||
} else {
|
||||
inv->AddItem(playerMail->itemLOT, playerMail->itemCount, eLootSourceType::MAIL);
|
||||
// Mail from players only moves items between them; mail from the game (sender 0) hands out new items
|
||||
const bool fromPlayer = playerMail->senderId != LWOOBJID_EMPTY;
|
||||
std::optional<EconomyLedger::ScopedItemTransfer> transfer;
|
||||
if (fromPlayer) transfer.emplace();
|
||||
|
||||
LwoNameValue config;
|
||||
config.InsertLines(playerMail->itemConfig);
|
||||
const auto claimed = inv->ReceiveItem(playerMail->itemID, playerMail->itemLOT, playerMail->itemCount, eLootSourceType::MAIL, config, playerMail->itemSubkey);
|
||||
Database::Get()->ClaimMailItem(mailID);
|
||||
DashboardNotify::Changed("mail", playerMail->receiverId);
|
||||
|
||||
if (fromPlayer) {
|
||||
EconomyLedger::RecordTransfer({ .method = IEconomyLedger::eTransferMethod::MAIL_CLAIMED, .itemId = playerMail->itemID,
|
||||
.newItemId = claimed.id, .lot = playerMail->itemLOT, .count = static_cast<uint32_t>(playerMail->itemCount),
|
||||
.fromCharacter = playerMail->senderId, .toCharacter = playerMail->receiverId, .zone = Game::server->GetZoneID(),
|
||||
.merged = claimed.merged });
|
||||
}
|
||||
response.status = eAttachmentCollectResponse::Success;
|
||||
}
|
||||
}
|
||||
@@ -238,6 +277,7 @@ namespace Mail {
|
||||
} else {
|
||||
if (!(mailData->itemLOT > 0 && mailData->itemCount > 0)) {
|
||||
Database::Get()->DeleteMail(mailID);
|
||||
DashboardNotify::Changed("mail", playerID);
|
||||
response.status = eDeleteResponse::Success;
|
||||
} else if (mailData->itemLOT > 0 && mailData->itemCount > 0) {
|
||||
response.status = eDeleteResponse::HasAttachments;
|
||||
@@ -272,6 +312,7 @@ namespace Mail {
|
||||
if (mail->receiverId == player->GetObjectID()) {
|
||||
response.status = eReadResponse::Success;
|
||||
Database::Get()->MarkMailRead(mailID);
|
||||
DashboardNotify::Changed("mail", mail->receiverId);
|
||||
} else {
|
||||
LOG("Player %llu tried to mark mail read for player %llu", mail->receiverId, player->GetObjectID());
|
||||
}
|
||||
@@ -388,6 +429,7 @@ void Mail::SendMail(const LWOOBJID sender, const std::string& senderName, LWOOBJ
|
||||
mailInsert.itemSubkey = LWOOBJID_EMPTY;
|
||||
|
||||
Database::Get()->InsertNewMail(mailInsert);
|
||||
DashboardNotify::Changed("mail", mailInsert.receiverId);
|
||||
|
||||
if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS) return; // TODO: Echo to chat server
|
||||
NotificationResponse response;
|
||||
|
||||
215
dGame/dUtilities/MessageInspector.cpp
Normal file
215
dGame/dUtilities/MessageInspector.cpp
Normal file
@@ -0,0 +1,215 @@
|
||||
#include "MessageInspector.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "BitStream.h"
|
||||
#include "BitStreamUtils.h"
|
||||
#include "Entity.h"
|
||||
#include "Game.h"
|
||||
#include "GameMessageDecoder.h"
|
||||
#include "Logger.h"
|
||||
#include "MessageCapture.h"
|
||||
#include "MessageType/Client.h"
|
||||
#include "MessageType/Master.h"
|
||||
#include "PlayerManager.h"
|
||||
#include "ServiceType.h"
|
||||
#include "dServer.h"
|
||||
#include "dZoneManager.h"
|
||||
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
constexpr size_t MAX_CAPTURES = 8;
|
||||
// Per capture: at most this many messages a second, and this many waiting to be sent
|
||||
constexpr uint32_t MAX_PER_SECOND = 200;
|
||||
constexpr size_t MAX_PENDING = 1000;
|
||||
constexpr auto SEND_INTERVAL = std::chrono::milliseconds(250);
|
||||
// Sent even when nothing was captured, so the dashboard knows the capture is still running
|
||||
constexpr auto HEARTBEAT_INTERVAL = std::chrono::seconds(5);
|
||||
constexpr size_t BATCH_ENTRIES = 100;
|
||||
constexpr size_t BATCH_BYTES = 48 * 1024;
|
||||
constexpr size_t BATCHES_PER_SEND = 2;
|
||||
|
||||
// Game messages to a client start with the packet header, the object ID and the message ID
|
||||
constexpr size_t GAME_MESSAGE_HEADER_BYTES = 8 + sizeof(LWOOBJID) + sizeof(MessageType::Game);
|
||||
|
||||
struct Capture {
|
||||
MessageCaptureControl control;
|
||||
SystemAddress sysAddr;
|
||||
Clock::time_point until;
|
||||
MessageCaptureQueue queue{ MAX_PENDING, MAX_PER_SECOND };
|
||||
uint32_t nextSequence{ 1 };
|
||||
Clock::time_point lastSent{};
|
||||
};
|
||||
|
||||
std::vector<Capture> g_Captures;
|
||||
|
||||
int64_t NowMs() {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
void Send(const Capture& capture, eMessageCaptureStatus status, std::vector<MessageCaptureEntry> entries = {}, uint32_t dropped = 0, eMessageCaptureEnd reason = {}) {
|
||||
if (!Game::server) return;
|
||||
MessageCaptureData data;
|
||||
data.captureId = capture.control.captureId;
|
||||
data.status = status;
|
||||
data.characterId = capture.control.characterId;
|
||||
data.zoneId = Game::zoneManager ? Game::zoneManager->GetZoneID().GetMapID() : 0;
|
||||
data.instanceId = Game::server->GetInstanceID();
|
||||
data.cloneId = Game::zoneManager ? Game::zoneManager->GetZoneID().GetCloneID() : 0;
|
||||
data.reason = reason;
|
||||
data.dropped = dropped;
|
||||
data.entries = std::move(entries);
|
||||
|
||||
CBITSTREAM;
|
||||
BitStreamUtils::WriteHeader(bitStream, ServiceType::MASTER, MessageType::Master::MESSAGE_CAPTURE_DATA);
|
||||
data.Serialize(bitStream);
|
||||
Game::server->SendToMaster(bitStream);
|
||||
}
|
||||
|
||||
// Send what is waiting: a couple of batches per call (the rest goes next time), or everything when `all`
|
||||
void Flush(Capture& capture, Clock::time_point now, bool all) {
|
||||
if (!all && now - capture.lastSent < SEND_INTERVAL) return;
|
||||
bool sent = false;
|
||||
for (size_t i = 0; all || i < BATCHES_PER_SEND; i++) {
|
||||
auto batch = capture.queue.Take(BATCH_ENTRIES, BATCH_BYTES);
|
||||
const auto dropped = capture.queue.TakeDropped();
|
||||
if (batch.empty() && dropped == 0) break;
|
||||
Send(capture, eMessageCaptureStatus::ENTRIES, std::move(batch), dropped);
|
||||
sent = true;
|
||||
}
|
||||
if (!sent && !all && now - capture.lastSent >= HEARTBEAT_INTERVAL) {
|
||||
Send(capture, eMessageCaptureStatus::ENTRIES);
|
||||
sent = true;
|
||||
}
|
||||
if (sent) capture.lastSent = now;
|
||||
}
|
||||
|
||||
void SetHook();
|
||||
|
||||
void End(size_t index, eMessageCaptureEnd reason) {
|
||||
auto& capture = g_Captures[index];
|
||||
Flush(capture, Clock::now(), true);
|
||||
Send(capture, eMessageCaptureStatus::ENDED, {}, 0, reason);
|
||||
LOG("Message capture %u of character %llu ended (%i)", capture.control.captureId, capture.control.characterId, static_cast<int>(reason));
|
||||
g_Captures.erase(g_Captures.begin() + index);
|
||||
SetHook();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to every capture that wants it. Decoding happens once, and only for messages a capture keeps.
|
||||
* `address` is who the message goes to (or, for a broadcast, who it skips).
|
||||
*/
|
||||
void Record(eMessageDirection direction, const SystemAddress& address, bool broadcast, LWOOBJID objectId, uint16_t messageId,
|
||||
const unsigned char* data, uint32_t bits) {
|
||||
const auto now = Clock::now();
|
||||
const auto timeMs = NowMs();
|
||||
std::optional<std::string> decoded;
|
||||
for (auto& capture : g_Captures) {
|
||||
// A broadcast reaches everyone except the address given (when one is given)
|
||||
const bool reaches = broadcast ? capture.sysAddr != address : capture.sysAddr == address;
|
||||
if (!reaches || !capture.control.Wants(direction, messageId)) continue;
|
||||
|
||||
const auto bytes = (bits + 7) / 8;
|
||||
if (!decoded) {
|
||||
decoded.emplace();
|
||||
const bool toServer = direction == eMessageDirection::TO_SERVER;
|
||||
if (GameMessageDecoder::CanDecode(static_cast<MessageType::Game>(messageId), toServer)) {
|
||||
RakNet::BitStream payload(const_cast<unsigned char*>(data), bytes, true);
|
||||
const auto fields = GameMessageDecoder::Decode(static_cast<MessageType::Game>(messageId), toServer, payload);
|
||||
if (fields) {
|
||||
*decoded = fields->dump();
|
||||
if (decoded->size() > MessageCapture::MAX_DECODED) decoded->clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageCaptureEntry entry;
|
||||
entry.sequence = capture.nextSequence++;
|
||||
entry.timeMs = timeMs;
|
||||
entry.direction = direction;
|
||||
entry.messageId = messageId;
|
||||
entry.objectId = objectId;
|
||||
entry.bits = bits;
|
||||
entry.payload.assign(reinterpret_cast<const char*>(data), std::min<size_t>(bytes, MessageCapture::MAX_PAYLOAD));
|
||||
entry.decoded = *decoded;
|
||||
capture.queue.Push(std::move(entry), now);
|
||||
}
|
||||
}
|
||||
|
||||
// Packets the server sends: only game messages are kept
|
||||
void OnSend(const RakNet::BitStream& bitStream, const SystemAddress& sysAddr, bool broadcast) {
|
||||
static const auto header = [] {
|
||||
RakNet::BitStream expected;
|
||||
BitStreamUtils::WriteHeader(expected, ServiceType::CLIENT, MessageType::Client::GAME_MSG);
|
||||
return std::string(reinterpret_cast<const char*>(expected.GetData()), expected.GetNumberOfBytesUsed());
|
||||
}();
|
||||
const auto totalBits = bitStream.GetNumberOfBitsUsed();
|
||||
if (totalBits < GAME_MESSAGE_HEADER_BYTES * 8) return;
|
||||
const auto* data = bitStream.GetData();
|
||||
if (std::memcmp(data, header.data(), header.size()) != 0) return;
|
||||
|
||||
LWOOBJID objectId{};
|
||||
uint16_t messageId{};
|
||||
std::memcpy(&objectId, data + 8, sizeof(objectId));
|
||||
std::memcpy(&messageId, data + 8 + sizeof(objectId), sizeof(messageId));
|
||||
Record(eMessageDirection::TO_CLIENT, sysAddr, broadcast, objectId, messageId, data + GAME_MESSAGE_HEADER_BYTES,
|
||||
static_cast<uint32_t>(totalBits - GAME_MESSAGE_HEADER_BYTES * 8));
|
||||
}
|
||||
|
||||
// Watch sent packets only while something is captured
|
||||
void SetHook() {
|
||||
MessageInspector::g_Capturing = !g_Captures.empty();
|
||||
if (!Game::server) return;
|
||||
Game::server->SetSendObserver(MessageInspector::g_Capturing ? dServer::SendObserver(OnSend) : nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
namespace MessageInspector {
|
||||
bool g_Capturing = false;
|
||||
|
||||
void Control(const MessageCaptureControl& control) {
|
||||
const auto existing = std::ranges::find_if(g_Captures, [&](const Capture& c) { return c.control.captureId == control.captureId; });
|
||||
if (control.action == eMessageCaptureControl::STOP) {
|
||||
if (existing != g_Captures.end()) End(existing - g_Captures.begin(), eMessageCaptureEnd::STOPPED);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only the world the character is in captures it
|
||||
auto* player = PlayerManager::GetPlayer(control.characterId);
|
||||
if (!player || control.seconds == 0) return;
|
||||
if (existing == g_Captures.end() && g_Captures.size() >= MAX_CAPTURES) {
|
||||
LOG("Not starting message capture %u: %zu captures are already running here", control.captureId, g_Captures.size());
|
||||
return;
|
||||
}
|
||||
|
||||
Capture& capture = existing != g_Captures.end() ? *existing : g_Captures.emplace_back();
|
||||
capture.control = control;
|
||||
capture.sysAddr = player->GetSystemAddress();
|
||||
capture.until = Clock::now() + std::chrono::seconds(std::min(control.seconds, MessageCapture::MAX_SECONDS));
|
||||
capture.lastSent = Clock::now();
|
||||
LOG("Capturing game messages of character %llu for %u seconds (capture %u)", control.characterId, control.seconds, control.captureId);
|
||||
Send(capture, eMessageCaptureStatus::STARTED);
|
||||
SetHook();
|
||||
}
|
||||
|
||||
void RecordReceived(const SystemAddress& sysAddr, LWOOBJID objectId, MessageType::Game messageId, const RakNet::BitStream& payload) {
|
||||
Record(eMessageDirection::TO_SERVER, sysAddr, false, objectId, static_cast<uint16_t>(messageId), payload.GetData(),
|
||||
static_cast<uint32_t>(payload.GetNumberOfBitsUsed()));
|
||||
}
|
||||
|
||||
void Update() {
|
||||
if (!g_Capturing) return;
|
||||
const auto now = Clock::now();
|
||||
for (size_t i = g_Captures.size(); i-- > 0;) {
|
||||
auto& capture = g_Captures[i];
|
||||
const auto* player = PlayerManager::GetPlayer(capture.control.characterId);
|
||||
if (!player || player->GetSystemAddress() != capture.sysAddr) End(i, eMessageCaptureEnd::PLAYER_LEFT);
|
||||
else if (now >= capture.until) End(i, eMessageCaptureEnd::TIME_LIMIT);
|
||||
else Flush(capture, now, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
dGame/dUtilities/MessageInspector.h
Normal file
36
dGame/dUtilities/MessageInspector.h
Normal file
@@ -0,0 +1,36 @@
|
||||
#ifndef __MESSAGEINSPECTOR__H__
|
||||
#define __MESSAGEINSPECTOR__H__
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
#include "MessageType/Game.h"
|
||||
|
||||
namespace RakNet { class BitStream; }
|
||||
struct MessageCaptureControl;
|
||||
struct SystemAddress;
|
||||
|
||||
/**
|
||||
* This world's side of the dashboard's game message inspector (see MessageCapture.h): captures the game messages
|
||||
* one player sends and receives and sends them to the dashboard through master in small, rate-limited batches.
|
||||
*
|
||||
* Messages are seen at the two places every game message passes: GameMessageHandler::HandleMessage for what
|
||||
* clients send, and dServer::Send for what the server sends (hooked only while a capture runs). With no capture
|
||||
* running, the only cost is the IsCapturing() check on received messages.
|
||||
*/
|
||||
namespace MessageInspector {
|
||||
extern bool g_Capturing;
|
||||
|
||||
inline bool IsCapturing() { return g_Capturing; }
|
||||
|
||||
// A start or stop from the dashboard. Starts only if the character is in this world.
|
||||
void Control(const MessageCaptureControl& control);
|
||||
|
||||
// A game message a client sent (payload: the bits after the object ID and message ID)
|
||||
void RecordReceived(const SystemAddress& sysAddr, LWOOBJID objectId, MessageType::Game messageId, const RakNet::BitStream& payload);
|
||||
|
||||
// Called from the world server loop: sends what was captured and ends captures that ran out or lost their player
|
||||
void Update();
|
||||
}
|
||||
|
||||
#endif //!__MESSAGEINSPECTOR__H__
|
||||
@@ -1,11 +1,11 @@
|
||||
#include "ObjectIDManager.h"
|
||||
|
||||
// Custom Classes
|
||||
#include "MasterPackets.h"
|
||||
#include "Database.h"
|
||||
#include "Logger.h"
|
||||
#include "Game.h"
|
||||
#include "eObjectBits.h"
|
||||
#include "GeneralUtils.h"
|
||||
|
||||
// should the spawners from vanity also have the CLIENT flag?
|
||||
|
||||
@@ -35,6 +35,28 @@ uint64_t ObjectIDManager::GetPersistentID() {
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
ObjectIDManager::ModelIDs ObjectIDManager::GetNewModelIDs() {
|
||||
// Legacy logic to check for old random IDs (regenerating these is not really feasible)
|
||||
// Probably good to have this anyway in case someone messes with the last_object_id or it gets reset somehow
|
||||
const uint32_t maxRetries = 100;
|
||||
uint32_t retries = 0;
|
||||
bool blueprintIDExists = true;
|
||||
bool modelExists = true;
|
||||
|
||||
LWOOBJID newID = LWOOBJID_EMPTY;
|
||||
LWOOBJID blueprintID = LWOOBJID_EMPTY;
|
||||
do {
|
||||
if (newID != LWOOBJID_EMPTY) LOG("Generating blueprintID for UGC model, collision with existing model ID: %llu", blueprintID);
|
||||
newID = GetPersistentID();
|
||||
blueprintID = GetPersistentID();
|
||||
++retries;
|
||||
blueprintIDExists = Database::Get()->GetUgcModel(blueprintID).has_value();
|
||||
modelExists = Database::Get()->GetModel(newID).has_value();
|
||||
} while ((blueprintIDExists || modelExists) && retries < maxRetries);
|
||||
|
||||
return { newID, blueprintID };
|
||||
}
|
||||
|
||||
// Generates an object ID server-sided (used for regular entities like smashables)
|
||||
uint32_t ObjectIDManager::GenerateObjectID() {
|
||||
return ++CurrentObjectID;
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
#include <vector>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
|
||||
/**
|
||||
* Also built into the DashboardServer, which adds items and models the same way.
|
||||
*
|
||||
* There are 2 types of IDs:
|
||||
* Persistent IDs - These are used for anything that needs to be persist between worlds.
|
||||
* Ephemeral IDs - These are used for any objects that only need to be unique for this world session.
|
||||
@@ -20,6 +24,18 @@ namespace ObjectIDManager {
|
||||
*/
|
||||
uint64_t GetPersistentID();
|
||||
|
||||
struct ModelIDs {
|
||||
LWOOBJID modelID = LWOOBJID_EMPTY;
|
||||
LWOOBJID blueprintID = LWOOBJID_EMPTY; // the UGC model's ID
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Persistent IDs for a new player-built model, retrying past IDs already in use.
|
||||
*
|
||||
* @return ModelIDs The placed model's ID and its blueprint's ID.
|
||||
*/
|
||||
ModelIDs GetNewModelIDs();
|
||||
|
||||
/**
|
||||
* @brief Generates an ephemeral object ID for non-persistent objects.
|
||||
*
|
||||
|
||||
123
dGame/dUtilities/PlayerReports.cpp
Normal file
123
dGame/dUtilities/PlayerReports.cpp
Normal file
@@ -0,0 +1,123 @@
|
||||
#include "PlayerReports.h"
|
||||
|
||||
#include <ctime>
|
||||
|
||||
#include "BitStream.h"
|
||||
#include "Database.h"
|
||||
#include "DashboardNotify.h"
|
||||
#include "Entity.h"
|
||||
#include "EntityManager.h"
|
||||
#include "Character.h"
|
||||
#include "User.h"
|
||||
#include "Game.h"
|
||||
#include "dServer.h"
|
||||
#include "dZoneManager.h"
|
||||
#include "Logger.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "PetComponent.h"
|
||||
#include "PropertyManagementComponent.h"
|
||||
#include "ePlayerReportKind.h"
|
||||
#include "magic_enum.hpp"
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t MAX_BODY = 2000;
|
||||
|
||||
// Who sent it and where they are
|
||||
IModeration::PlayerReport NewReport(Entity* reporter, ePlayerReportKind kind, const std::string& body) {
|
||||
IModeration::PlayerReport report;
|
||||
report.createdAt = static_cast<int64_t>(std::time(nullptr));
|
||||
report.kind = std::string(magic_enum::enum_name(kind));
|
||||
report.body = body.substr(0, MAX_BODY);
|
||||
if (const auto* character = reporter ? reporter->GetCharacter() : nullptr) {
|
||||
report.reporterId = character->GetID();
|
||||
if (const auto* user = character->GetParentUser()) report.reporterAccountId = user->GetAccountID();
|
||||
}
|
||||
report.zoneId = Game::server->GetZoneID();
|
||||
report.instanceId = Game::server->GetInstanceID();
|
||||
report.cloneId = Game::zoneManager->GetZoneID().GetCloneID();
|
||||
return report;
|
||||
}
|
||||
|
||||
// The player a report is about, when the world knows
|
||||
void SetTarget(IModeration::PlayerReport& report, LWOOBJID characterId) {
|
||||
const auto info = characterId ? Database::Get()->GetCharacterInfo(characterId) : std::nullopt;
|
||||
if (!info) return;
|
||||
report.targetCharacterId = info->id;
|
||||
report.targetAccountId = info->accountId;
|
||||
}
|
||||
|
||||
PlayerReports::RateLimit g_RateLimit;
|
||||
|
||||
void Save(const IModeration::PlayerReport& report) {
|
||||
// By account (a player can't get more by switching characters), or by character when the account is unknown
|
||||
const uint64_t reporter = report.reporterAccountId != 0 ? report.reporterAccountId : static_cast<uint64_t>(report.reporterId) | (1ULL << 63);
|
||||
if (!g_RateLimit.Allow(reporter, report.createdAt)) {
|
||||
LOG("Dropped a player report (%s) from account %u / character %llu: over the report limit", report.kind.c_str(), report.reporterAccountId, report.reporterId);
|
||||
return;
|
||||
}
|
||||
const auto id = Database::Get()->InsertPlayerReport(report);
|
||||
LOG("Player report %llu (%s) from %llu about %llu", id, report.kind.c_str(), report.reporterId, report.targetCharacterId);
|
||||
DashboardNotify::Changed("player_reports");
|
||||
}
|
||||
|
||||
// Description (u32 length + UTF-16) followed by an object ID
|
||||
bool ReadDescriptionAndObject(RakNet::BitStream& inStream, std::string& description, LWOOBJID& objectId) {
|
||||
uint32_t length{};
|
||||
if (!inStream.Read(length) || length > MAX_BODY) return false;
|
||||
std::u16string text;
|
||||
text.reserve(length);
|
||||
for (uint32_t i = 0; i < length; i++) {
|
||||
char16_t character{};
|
||||
if (!inStream.Read(character)) return false;
|
||||
text.push_back(character);
|
||||
}
|
||||
description = GeneralUtils::UTF16ToWTF8(text);
|
||||
return inStream.Read(objectId);
|
||||
}
|
||||
}
|
||||
|
||||
namespace PlayerReports {
|
||||
void ReportPlayer(Entity* reporter, LWOOBJID reportedId, const std::string& body) {
|
||||
auto report = NewReport(reporter, ePlayerReportKind::PLAYER, body);
|
||||
report.objectId = reportedId;
|
||||
// The client sends the player's object ID; when they're in this world, their character says which one it is
|
||||
const auto* reported = Game::entityManager->GetEntity(reportedId);
|
||||
const auto* character = reported ? reported->GetCharacter() : nullptr;
|
||||
SetTarget(report, character ? character->GetID() : reportedId);
|
||||
Save(report);
|
||||
}
|
||||
|
||||
void HandleReportOffensiveModel(RakNet::BitStream& inStream, Entity* reporter) {
|
||||
std::string description;
|
||||
LWOOBJID objectId{};
|
||||
if (!ReadDescriptionAndObject(inStream, description, objectId)) return;
|
||||
auto report = NewReport(reporter, ePlayerReportKind::MODEL, description);
|
||||
report.objectId = objectId;
|
||||
auto* object = Game::entityManager->GetEntity(objectId);
|
||||
if (object) report.objectLot = object->GetLOT();
|
||||
// A model placed on this property belongs to the property's owner; a pet to its owner
|
||||
auto* property = PropertyManagementComponent::Instance();
|
||||
if (property && property->GetModels().contains(objectId)) {
|
||||
report.propertyId = property->GetId();
|
||||
SetTarget(report, property->GetOwnerId());
|
||||
} else if (auto* pet = object ? object->GetComponent<PetComponent>() : nullptr) {
|
||||
SetTarget(report, pet->GetOwnerId());
|
||||
}
|
||||
Save(report);
|
||||
}
|
||||
|
||||
void HandleReportOffensiveProperty(RakNet::BitStream& inStream, Entity* reporter) {
|
||||
std::string description;
|
||||
LWOOBJID plaqueId{};
|
||||
if (!ReadDescriptionAndObject(inStream, description, plaqueId)) return;
|
||||
auto report = NewReport(reporter, ePlayerReportKind::PROPERTY, description);
|
||||
report.objectId = plaqueId;
|
||||
if (auto* plaque = Game::entityManager->GetEntity(plaqueId)) report.objectLot = plaque->GetLOT();
|
||||
// The plaque stands on the property this world holds
|
||||
if (auto* property = PropertyManagementComponent::Instance()) {
|
||||
report.propertyId = property->GetId();
|
||||
SetTarget(report, property->GetOwnerId());
|
||||
}
|
||||
Save(report);
|
||||
}
|
||||
}
|
||||
55
dGame/dUtilities/PlayerReports.h
Normal file
55
dGame/dUtilities/PlayerReports.h
Normal file
@@ -0,0 +1,55 @@
|
||||
#ifndef __PLAYERREPORTS__H__
|
||||
#define __PLAYERREPORTS__H__
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
|
||||
class Entity;
|
||||
namespace RakNet { class BitStream; }
|
||||
|
||||
/**
|
||||
* Reports players send from the game's Report Abuse window (the help menu, or "Report" on another player's name), kept
|
||||
* in player_reports for the dashboard. The client sends a report about another player as ReportBug naming the player
|
||||
* (nOtherPlayerID), and one about a model or a property as ReportOffensiveModel / ReportOffensiveProperty.
|
||||
*/
|
||||
namespace PlayerReports {
|
||||
/**
|
||||
* How many reports one account may send from this world: a few a minute and a few dozen a day. Anything over is
|
||||
* dropped (with a log line), so a player can't flood the table or the world's thread with 2000-character inserts.
|
||||
* Times are unix seconds, passed in so it can be tested.
|
||||
*/
|
||||
class RateLimit {
|
||||
public:
|
||||
static constexpr size_t PER_MINUTE = 3;
|
||||
static constexpr size_t PER_DAY = 20;
|
||||
|
||||
bool Allow(uint64_t reporter, int64_t now) {
|
||||
auto& times = m_Sent[reporter];
|
||||
while (!times.empty() && times.front() <= now - 24 * 60 * 60) times.pop_front();
|
||||
const auto lastMinute = static_cast<size_t>(std::count_if(times.begin(), times.end(), [now](int64_t t) { return t > now - 60; }));
|
||||
if (times.size() >= PER_DAY || lastMinute >= PER_MINUTE) return false;
|
||||
times.push_back(now);
|
||||
if (m_Sent.size() > 10000) std::erase_if(m_Sent, [now](const auto& entry) { return entry.second.empty() || entry.second.back() <= now - 24 * 60 * 60; });
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
std::map<uint64_t, std::deque<int64_t>> m_Sent;
|
||||
};
|
||||
|
||||
// ReportBug with nOtherPlayerID set; body is what the player wrote
|
||||
void ReportPlayer(Entity* reporter, LWOOBJID reportedId, const std::string& body);
|
||||
|
||||
// ReportOffensiveModel: description, then the object the player picked
|
||||
void HandleReportOffensiveModel(RakNet::BitStream& inStream, Entity* reporter);
|
||||
|
||||
// ReportOffensiveProperty: description, then the property's plaque
|
||||
void HandleReportOffensiveProperty(RakNet::BitStream& inStream, Entity* reporter);
|
||||
}
|
||||
|
||||
#endif //!__PLAYERREPORTS__H__
|
||||
@@ -10,19 +10,32 @@
|
||||
|
||||
#include <iomanip>
|
||||
#include <ranges>
|
||||
#include <set>
|
||||
|
||||
#include "DEVGMCommands.h"
|
||||
#include "GMGreaterThanZeroCommands.h"
|
||||
#include "GMZeroCommands.h"
|
||||
#include "LiveEvents.h"
|
||||
|
||||
#include "Amf3.h"
|
||||
#include "Database.h"
|
||||
#include "MessageType/Chat.h"
|
||||
#include "dServer.h"
|
||||
#include "dConfig.h"
|
||||
#include "SlashCommandLevels.h"
|
||||
#include "Permissions.h"
|
||||
#include "Character.h"
|
||||
#include "ChatPackets.h"
|
||||
#include "PlayerManager.h"
|
||||
#include "User.h"
|
||||
#include "eObjectBits.h"
|
||||
|
||||
namespace {
|
||||
// Each command once, by the first alias it got
|
||||
std::map<std::string, Command> CommandInfos;
|
||||
std::map<std::string, Command> RegisteredCommands;
|
||||
|
||||
using Handler = void(*)(Entity*, const SystemAddress&, const std::string);
|
||||
}
|
||||
|
||||
void SlashCommandHandler::RegisterCommand(Command command) {
|
||||
@@ -31,6 +44,25 @@ void SlashCommandHandler::RegisterCommand(Command command) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The client acts on these by itself, so a level set here would change nothing
|
||||
if (const auto* target = command.handle.target<Handler>(); target && *target == GMZeroCommands::ClientHandled) {
|
||||
command.fixedLevel = true;
|
||||
command.clientHandled = true;
|
||||
if (command.levelNote.empty()) command.levelNote = "Handled by the game client";
|
||||
}
|
||||
if (!command.minLevel) command.minLevel = static_cast<eGameMasterLevel>(SlashCommandLevels::DefaultMinLevel(static_cast<uint8_t>(command.requiredLevel)));
|
||||
|
||||
// Named after the first alias no other command has taken
|
||||
const auto primary = std::ranges::find_if(command.aliases, [](const auto& alias) { return !RegisteredCommands.contains(alias); });
|
||||
if (primary == command.aliases.end()) {
|
||||
LOG("Every alias of command %s is already registered! Skipping!", command.aliases[0].c_str());
|
||||
return;
|
||||
}
|
||||
command.name = SlashCommandLevels::SettingName(*primary);
|
||||
if (std::ranges::any_of(CommandInfos, [&command](const auto& info) { return info.second.name == command.name; })) {
|
||||
LOG("Command %s shares its settings name %s with another command", primary->c_str(), command.name.c_str());
|
||||
}
|
||||
|
||||
for (const auto& alias : command.aliases) {
|
||||
auto [_, success] = RegisteredCommands.try_emplace(alias, command);
|
||||
// Don't allow duplicate commands
|
||||
@@ -39,7 +71,163 @@ void SlashCommandHandler::RegisterCommand(Command command) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
CommandInfos[command.aliases[0]] = command;
|
||||
CommandInfos[*primary] = command;
|
||||
}
|
||||
|
||||
eGameMasterLevel SlashCommandHandler::GetRequiredLevel(const Command& command) {
|
||||
if (command.fixedLevel) return command.requiredLevel;
|
||||
const auto defaultLevel = static_cast<uint8_t>(command.requiredLevel);
|
||||
const auto minLevel = static_cast<uint8_t>(command.minLevel.value_or(eGameMasterLevel::CIVILIAN));
|
||||
const std::string value = Game::config ? Game::config->GetValue(SlashCommandLevels::ConfigName(command.name)) : "";
|
||||
// Paired with a dashboard permission: its level, unless an explicit command level overrides it
|
||||
if (Permissions::Find(command.dashboardPermission)) {
|
||||
return static_cast<eGameMasterLevel>(SlashCommandLevels::ResolvePaired(defaultLevel, minLevel, false, Permissions::Level(command.dashboardPermission), value).level);
|
||||
}
|
||||
return static_cast<eGameMasterLevel>(SlashCommandLevels::Resolve(defaultLevel, minLevel, false, value));
|
||||
}
|
||||
|
||||
const Command* SlashCommandHandler::FindCommand(const std::string& alias) {
|
||||
const auto it = RegisteredCommands.find(alias);
|
||||
return it == RegisteredCommands.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
uint32_t SlashCommandHandler::AccountOf(Entity* player) {
|
||||
auto* character = player ? player->GetCharacter() : nullptr;
|
||||
auto* user = character ? character->GetParentUser() : nullptr;
|
||||
return user ? user->GetAccountID() : 0;
|
||||
}
|
||||
|
||||
std::optional<CommandTarget> SlashCommandHandler::TargetOf(Entity* player) {
|
||||
auto* character = player ? player->GetCharacter() : nullptr;
|
||||
if (!character) return std::nullopt;
|
||||
auto* user = character->GetParentUser();
|
||||
CommandTarget target{ .accountId = user ? user->GetAccountID() : 0, .characterId = player->GetObjectID(), .name = character->GetName(), .entity = player };
|
||||
// The account's rank protects it even while its owner plays at a lower level (/setgmlevel)
|
||||
target.gmLevel = static_cast<uint8_t>(std::max(player->GetGMLevel(), user ? user->GetMaxGMLevel() : eGameMasterLevel::CIVILIAN));
|
||||
return target;
|
||||
}
|
||||
|
||||
std::optional<CommandTarget> SlashCommandHandler::FindTarget(const std::string& name) {
|
||||
if (name.empty()) return std::nullopt;
|
||||
if (auto* player = PlayerManager::GetPlayer(name)) return TargetOf(player);
|
||||
const auto info = Database::Get()->GetCharacterInfo(name);
|
||||
if (!info || info->accountId == 0) return std::nullopt;
|
||||
CommandTarget target{ .accountId = info->accountId, .characterId = info->id, .name = info->name };
|
||||
GeneralUtils::SetBit(target.characterId, eObjectBits::CHARACTER);
|
||||
const auto account = Database::Get()->GetAccountById(info->accountId);
|
||||
// No account row: treat it as the highest level, so nobody below GM 9 acts on it by mistake
|
||||
target.gmLevel = account.contains("error") ? AccountRules::OPERATOR_LEVEL : account.value("gm_level", static_cast<uint8_t>(0));
|
||||
return target;
|
||||
}
|
||||
|
||||
AccountRules::eManageDenial SlashCommandHandler::TargetDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, SlashCommandLevels::eTargetRule rule) {
|
||||
using SlashCommandLevels::eTargetRule;
|
||||
switch (rule) {
|
||||
case eTargetRule::NONE: return AccountRules::eManageDenial::NONE;
|
||||
case eTargetRule::OTHERS:
|
||||
if (actorAccountId != 0 && actorAccountId == targetAccountId) return AccountRules::eManageDenial::NONE;
|
||||
return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::TOOLS);
|
||||
case eTargetRule::ITEMS: return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::ITEMS);
|
||||
case eTargetRule::MODERATION: return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::MODERATION);
|
||||
default: return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::TOOLS);
|
||||
}
|
||||
}
|
||||
|
||||
std::string SlashCommandHandler::TargetRefusal(AccountRules::eManageDenial denial, SlashCommandLevels::eTargetRule rule, std::string_view command) {
|
||||
if (denial == AccountRules::eManageDenial::NONE) return "";
|
||||
using SlashCommandLevels::eTargetRule;
|
||||
const auto action = rule == eTargetRule::ITEMS ? AccountRules::eAccountAction::ITEMS
|
||||
: rule == eTargetRule::MODERATION ? AccountRules::eAccountAction::MODERATION : AccountRules::eAccountAction::TOOLS;
|
||||
return "/" + std::string(command) + ": " + AccountRules::DenialMessage(denial, action);
|
||||
}
|
||||
|
||||
bool SlashCommandHandler::MayActOn(Entity* actor, const SystemAddress& sysAddr, const CommandTarget& target, SlashCommandLevels::eTargetRule rule, std::string_view command) {
|
||||
if (!actor) return false;
|
||||
const auto denial = TargetDenial(static_cast<uint8_t>(actor->GetGMLevel()), AccountOf(actor), target.gmLevel, target.accountId, rule);
|
||||
if (denial == AccountRules::eManageDenial::NONE) return true;
|
||||
ChatPackets::SendSystemMessage(sysAddr, GeneralUtils::UTF8ToUTF16(TargetRefusal(denial, rule, command)));
|
||||
return false;
|
||||
}
|
||||
|
||||
namespace {
|
||||
/**
|
||||
* A command that now uses its dashboard permission's level, on a server that stored it before it did: keep the level it
|
||||
* had as an override (a web value, like one set on the Permissions page, by UPGRADE_ACTOR) so the upgrade changes
|
||||
* nothing by itself. The page shows it and can drop it. Returns whether one was stored.
|
||||
*/
|
||||
bool KeepLevelFromBeforePairing(const Command& command, const ISlashCommands::SlashCommand* before) {
|
||||
const auto* permission = Permissions::Find(command.dashboardPermission);
|
||||
if (!permission || !before) return false;
|
||||
const auto name = SlashCommandLevels::ConfigName(command.name);
|
||||
const std::string value = Game::config ? Game::config->GetValue(name) : "";
|
||||
const auto minLevel = static_cast<uint8_t>(command.minLevel.value_or(eGameMasterLevel::CIVILIAN));
|
||||
const auto permissionLevel = SlashCommandLevels::ResolvePaired(static_cast<uint8_t>(command.requiredLevel), minLevel, false, Permissions::Level(permission->key), "").level;
|
||||
const bool followedBefore = before->followsPermission && before->dashboardPermission == command.dashboardPermission;
|
||||
const auto keep = SlashCommandLevels::UpgradeOverride(true, followedBefore, command.fixedLevel, before->defaultLevel, minLevel, value, permissionLevel);
|
||||
if (!keep) return false;
|
||||
|
||||
// Several worlds may start at once: the first one stores it
|
||||
const std::string file(SlashCommandLevels::CONFIG_FILE);
|
||||
for (const auto& row : Database::Get()->GetServerConfig({ file })) {
|
||||
if (row.name == name && row.webValue) return false;
|
||||
}
|
||||
const std::string actor(SlashCommandLevels::UPGRADE_ACTOR);
|
||||
Database::Get()->SetWebConfigValue(file, name, std::to_string(*keep), true, actor);
|
||||
const auto alias = "/" + command.aliases.front();
|
||||
const auto description = alias + " (" + command.name + "): kept at GM " + std::to_string(*keep) + "+ as an override, the level it had before it used the " +
|
||||
permission->key + " permission's level (GM " + std::to_string(permissionLevel) + "+). Drop the override on the Permissions page to follow the permission";
|
||||
try {
|
||||
Database::Get()->InsertAuditLog(0, actor, "change_command_level", description, 0, 0);
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not write the audit log entry for %s: %s", alias.c_str(), ex.what());
|
||||
}
|
||||
LOG("%s", description.c_str());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void SlashCommandHandler::ReportCommands() {
|
||||
try {
|
||||
std::map<std::string, ISlashCommands::SlashCommand> stored;
|
||||
for (auto& row : Database::Get()->GetSlashCommands()) stored[row.name] = std::move(row);
|
||||
|
||||
std::set<std::string> current;
|
||||
uint32_t changed = 0;
|
||||
bool kept = false;
|
||||
for (const auto& [primary, command] : CommandInfos) {
|
||||
ISlashCommands::SlashCommand row{
|
||||
.name = command.name, .help = command.help, .info = command.info,
|
||||
.defaultLevel = static_cast<uint8_t>(command.requiredLevel),
|
||||
.minLevel = static_cast<uint8_t>(command.minLevel.value_or(command.requiredLevel)),
|
||||
.fixed = command.fixedLevel, .clientHandled = command.clientHandled, .note = command.levelNote, .dashboardPermission = command.dashboardPermission,
|
||||
.targetRule = std::string(SlashCommandLevels::TargetRuleName(command.targetRule)),
|
||||
.followsPermission = Permissions::Find(command.dashboardPermission) != nullptr
|
||||
};
|
||||
// Only the aliases that really run this command (another command may have one of them)
|
||||
for (const auto& alias : command.aliases) {
|
||||
const auto it = RegisteredCommands.find(alias);
|
||||
if (it != RegisteredCommands.end() && it->second.name == command.name) row.aliases.push_back(alias);
|
||||
}
|
||||
current.insert(row.name);
|
||||
const auto it = stored.find(row.name);
|
||||
if (it != stored.end() && it->second == row) continue;
|
||||
// Before the row says it follows the permission, so this happens once
|
||||
if (row.followsPermission && it != stored.end()) kept = KeepLevelFromBeforePairing(command, &it->second) || kept;
|
||||
Database::Get()->SetSlashCommand(row);
|
||||
changed++;
|
||||
}
|
||||
for (const auto& [name, row] : stored) {
|
||||
if (current.contains(name)) continue;
|
||||
Database::Get()->DeleteSlashCommand(name);
|
||||
changed++;
|
||||
}
|
||||
if (changed > 0) LOG("Updated %u slash command(s) for the dashboard", changed);
|
||||
// Use the levels kept from before straight away
|
||||
if (kept && Game::config) Game::config->ReloadConfig();
|
||||
} catch (const std::exception& ex) {
|
||||
// Before the migration has run there is no table yet
|
||||
LOG_DEBUG("Could not store the slash commands: %s", ex.what());
|
||||
}
|
||||
}
|
||||
|
||||
void SlashCommandHandler::HandleChatCommand(const std::u16string& chat, Entity* entity, const SystemAddress& sysAddr) {
|
||||
@@ -57,8 +245,9 @@ void SlashCommandHandler::HandleChatCommand(const std::u16string& chat, Entity*
|
||||
std::string error;
|
||||
if (commandItr != RegisteredCommands.end()) {
|
||||
auto& [alias, commandHandle] = *commandItr;
|
||||
if (entity->GetGMLevel() >= commandHandle.requiredLevel) {
|
||||
if (commandHandle.requiredLevel > eGameMasterLevel::CIVILIAN) Database::Get()->InsertSlashCommandUsage(entity->GetObjectID(), input);
|
||||
const auto requiredLevel = GetRequiredLevel(commandHandle);
|
||||
if (entity->GetGMLevel() >= requiredLevel) {
|
||||
if (requiredLevel > eGameMasterLevel::CIVILIAN) Database::Get()->InsertSlashCommandUsage(entity->GetObjectID(), input);
|
||||
commandHandle.handle(entity, sysAddr, args);
|
||||
} else if (entity->GetGMLevel() != eGameMasterLevel::CIVILIAN) {
|
||||
error = "You are not high enough GM level to use \"" + command + "\"";
|
||||
@@ -90,7 +279,7 @@ void GMZeroCommands::Help(Entity* entity, const SystemAddress& sysAddr, const st
|
||||
|
||||
std::map<std::string, Command> accessibleCommands;
|
||||
for (const auto& [commandName, command] : CommandInfos) {
|
||||
if (command.requiredLevel <= entity->GetGMLevel()) {
|
||||
if (SlashCommandHandler::GetRequiredLevel(command) <= entity->GetGMLevel()) {
|
||||
accessibleCommands.emplace(commandName, command);
|
||||
}
|
||||
}
|
||||
@@ -119,11 +308,8 @@ void GMZeroCommands::Help(Entity* entity, const SystemAddress& sysAddr, const st
|
||||
return;
|
||||
}
|
||||
|
||||
auto it = std::ranges::find_if(CommandInfos, [&trimmedArgs](const auto& pair) {
|
||||
return std::ranges::find(pair.second.aliases, trimmedArgs) != pair.second.aliases.end();
|
||||
});
|
||||
|
||||
if (it != CommandInfos.end() && entity->GetGMLevel() >= it->second.requiredLevel) {
|
||||
const auto it = RegisteredCommands.find(trimmedArgs);
|
||||
if (it != RegisteredCommands.end() && entity->GetGMLevel() >= SlashCommandHandler::GetRequiredLevel(it->second)) {
|
||||
const auto& command = it->second;
|
||||
feedback << "----- " << it->first << " Info -----\n";
|
||||
feedback << command.info << "\n";
|
||||
@@ -176,7 +362,9 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Within the authorized range of levels for the current account, changes the character's game master level to the specified value. This is required to use certain commands",
|
||||
.aliases = { "setgmlevel", "makegm", "gmlevel" },
|
||||
.handle = DEVGMCommands::SetGMLevel,
|
||||
.requiredLevel = eGameMasterLevel::CIVILIAN
|
||||
.requiredLevel = eGameMasterLevel::CIVILIAN,
|
||||
.fixedLevel = true,
|
||||
.levelNote = "Everyone keeps it so staff who lowered their GM level can raise it again (it never goes above the account's GM level)"
|
||||
};
|
||||
RegisterCommand(SetGMLevelCommand);
|
||||
|
||||
@@ -203,7 +391,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Smashes the character whom the given user is playing",
|
||||
.aliases = { "kill" },
|
||||
.handle = DEVGMCommands::Kill,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER,
|
||||
.targetRule = SlashCommandLevels::eTargetRule::TOOLS
|
||||
};
|
||||
RegisterCommand(KillCommand);
|
||||
|
||||
@@ -212,7 +401,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Prints some information about the server's performance",
|
||||
.aliases = { "metrics" },
|
||||
.handle = DEVGMCommands::Metrics,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER,
|
||||
.dashboardPermission = "health_view"
|
||||
};
|
||||
RegisterCommand(MetricsCommand);
|
||||
|
||||
@@ -221,7 +411,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Sends an announcement. `/setanntitle` and `/setannmsg` must be called first to configure the announcement.",
|
||||
.aliases = { "announce" },
|
||||
.handle = DEVGMCommands::Announce,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER,
|
||||
.dashboardPermission = "server_announce"
|
||||
};
|
||||
RegisterCommand(AnnounceCommand);
|
||||
|
||||
@@ -230,7 +421,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Sets the title of an announcement. Use with `/setannmsg` and `/announce`",
|
||||
.aliases = { "setanntitle" },
|
||||
.handle = DEVGMCommands::SetAnnTitle,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER,
|
||||
.dashboardPermission = "server_announce"
|
||||
};
|
||||
RegisterCommand(SetAnnTitleCommand);
|
||||
|
||||
@@ -239,7 +431,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Sets the message of an announcement. Use with `/setannmtitle` and `/announce`",
|
||||
.aliases = { "setannmsg" },
|
||||
.handle = DEVGMCommands::SetAnnMsg,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER,
|
||||
.dashboardPermission = "server_announce"
|
||||
};
|
||||
RegisterCommand(SetAnnMsgCommand);
|
||||
|
||||
@@ -247,7 +440,8 @@ void SlashCommandHandler::Startup() {
|
||||
.help = "Sends a shutdown message to the master server",
|
||||
.info = "Sends a shutdown message to the master server. This will send an announcement to all players that the universe will shut down in 10 minutes.",
|
||||
.aliases = { "shutdownuniverse" },
|
||||
.handle = DEVGMCommands::ShutdownUniverse
|
||||
.handle = DEVGMCommands::ShutdownUniverse,
|
||||
.dashboardPermission = "server_restart"
|
||||
};
|
||||
RegisterCommand(ShutdownUniverseCommand);
|
||||
|
||||
@@ -292,7 +486,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Teleports you. If no Y is given, you are teleported to the height of the terrain or physics object at (x, z). Any of the coordinates can use the syntax of an exact position (10.0), or a relative position (~+10.0). A ~ means use the current value of that axis as the base value. Addition or subtraction is supported (~+10) (~-10). If source player and target player are players that exist in the world, then the source player will be teleported to target player.",
|
||||
.aliases = { "teleport", "tele", "tp" },
|
||||
.handle = DEVGMCommands::Teleport,
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_DEVELOPER,
|
||||
.targetRule = SlashCommandLevels::eTargetRule::OTHERS
|
||||
};
|
||||
RegisterCommand(TeleportCommand);
|
||||
|
||||
@@ -697,7 +892,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Teleports all characters to your current position",
|
||||
.aliases = { "tpall" },
|
||||
.handle = DEVGMCommands::TpAll,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER,
|
||||
.targetRule = SlashCommandLevels::eTargetRule::OTHERS
|
||||
};
|
||||
RegisterCommand(TpAllCommand);
|
||||
|
||||
@@ -724,7 +920,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Sets the using entities level to the requested level. Takes an optional parameter of an in-game players username to set the level of",
|
||||
.aliases = { "setlevel" },
|
||||
.handle = DEVGMCommands::SetLevel,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER,
|
||||
.targetRule = SlashCommandLevels::eTargetRule::OTHERS
|
||||
};
|
||||
RegisterCommand(SetLevelCommand);
|
||||
|
||||
@@ -814,7 +1011,10 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Execute commands as different entities or from different positions. Usage: /execute <subcommand> ... run <command>. Subcommands: as <entity>, at <entity>, positioned <x> <y> <z>",
|
||||
.aliases = { "execute", "exec" },
|
||||
.handle = DEVGMCommands::Execute,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER,
|
||||
.minLevel = eGameMasterLevel::DEVELOPER,
|
||||
.levelNote = "Runs commands as another player, with that player's GM level, so it can't go below its default",
|
||||
.targetRule = SlashCommandLevels::eTargetRule::OTHERS
|
||||
};
|
||||
RegisterCommand(ExecuteCommand);
|
||||
|
||||
@@ -825,7 +1025,9 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Kicks the player off the server",
|
||||
.aliases = { "kick" },
|
||||
.handle = GMGreaterThanZeroCommands::Kick,
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_MODERATOR
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_MODERATOR,
|
||||
.dashboardPermission = "accounts_kick",
|
||||
.targetRule = SlashCommandLevels::eTargetRule::TOOLS
|
||||
};
|
||||
RegisterCommand(KickCommand);
|
||||
|
||||
@@ -834,7 +1036,9 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Mails an item to the given player. The mailed item has predetermined content. The sender name is set to \"Darkflame Universe\". The title of the message is \"Lost item\". The body of the message is \"This is a replacement item for one you lost\".",
|
||||
.aliases = { "mailitem" },
|
||||
.handle = GMGreaterThanZeroCommands::MailItem,
|
||||
.requiredLevel = eGameMasterLevel::MODERATOR
|
||||
.requiredLevel = eGameMasterLevel::MODERATOR,
|
||||
.dashboardPermission = "mail_items",
|
||||
.targetRule = SlashCommandLevels::eTargetRule::ITEMS
|
||||
};
|
||||
RegisterCommand(MailItemCommand);
|
||||
|
||||
@@ -843,7 +1047,9 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Bans a user from the server",
|
||||
.aliases = { "ban" },
|
||||
.handle = GMGreaterThanZeroCommands::Ban,
|
||||
.requiredLevel = eGameMasterLevel::SENIOR_MODERATOR
|
||||
.requiredLevel = eGameMasterLevel::SENIOR_MODERATOR,
|
||||
.dashboardPermission = "accounts_ban",
|
||||
.targetRule = SlashCommandLevels::eTargetRule::MODERATION
|
||||
};
|
||||
RegisterCommand(BanCommand);
|
||||
|
||||
@@ -852,7 +1058,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Approves the property the player is currently visiting",
|
||||
.aliases = { "approveproperty" },
|
||||
.handle = GMGreaterThanZeroCommands::ApproveProperty,
|
||||
.requiredLevel = eGameMasterLevel::LEAD_MODERATOR
|
||||
.requiredLevel = eGameMasterLevel::LEAD_MODERATOR,
|
||||
.dashboardPermission = "moderate_properties"
|
||||
};
|
||||
RegisterCommand(ApprovePropertyCommand);
|
||||
|
||||
@@ -861,7 +1068,9 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Mute player for the given amount of time. If no time is given, the mute is indefinite.",
|
||||
.aliases = { "mute" },
|
||||
.handle = GMGreaterThanZeroCommands::Mute,
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_DEVELOPER,
|
||||
.dashboardPermission = "accounts_mute",
|
||||
.targetRule = SlashCommandLevels::eTargetRule::MODERATION
|
||||
};
|
||||
RegisterCommand(MuteCommand);
|
||||
|
||||
@@ -925,7 +1134,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Usage: /showall (displayZoneData: Default 1) (displayIndividualPlayers: Default 1)",
|
||||
.aliases = { "showall" },
|
||||
.handle = GMGreaterThanZeroCommands::ShowAll,
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_MODERATOR
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_MODERATOR,
|
||||
.dashboardPermission = "players_view"
|
||||
};
|
||||
RegisterCommand(ShowAllCommand);
|
||||
|
||||
@@ -934,7 +1144,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Find the World Server a player is in if they are online",
|
||||
.aliases = { "findplayer" },
|
||||
.handle = GMGreaterThanZeroCommands::FindPlayer,
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_MODERATOR
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_MODERATOR,
|
||||
.dashboardPermission = "players_view"
|
||||
};
|
||||
RegisterCommand(FindPlayerCommand);
|
||||
|
||||
@@ -943,7 +1154,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Specify a player name to spectate. They must be in the same world as you. Leave blank to stop spectating",
|
||||
.aliases = { "spectate", "follow" },
|
||||
.handle = GMGreaterThanZeroCommands::Spectate,
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_MODERATOR
|
||||
.requiredLevel = eGameMasterLevel::JUNIOR_MODERATOR,
|
||||
.dashboardPermission = "players_view"
|
||||
};
|
||||
RegisterCommand(SpectateCommand);
|
||||
|
||||
@@ -1071,7 +1283,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Display the time the current world server has been active",
|
||||
.aliases = { "uptime" },
|
||||
.handle = GMZeroCommands::ServerUptime,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER,
|
||||
.dashboardPermission = "health_view"
|
||||
};
|
||||
RegisterCommand(ServerUptimeCommand);
|
||||
|
||||
@@ -1460,7 +1673,8 @@ void SlashCommandHandler::Startup() {
|
||||
.info = "Shuts this world down",
|
||||
.aliases = {"shutdown"},
|
||||
.handle = DEVGMCommands::Shutdown,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER,
|
||||
.dashboardPermission = "worlds_manage"
|
||||
};
|
||||
RegisterCommand(command);
|
||||
|
||||
@@ -1494,4 +1708,12 @@ void SlashCommandHandler::Startup() {
|
||||
.handle = WorldMigration::MergeInstanceCommand,
|
||||
.requiredLevel = eGameMasterLevel::DEVELOPER
|
||||
});
|
||||
|
||||
RegisterCommand({
|
||||
.help = "[claim] Community challenges and live events",
|
||||
.info = "Shows the server-wide community challenges running now, how far along they are and what you added, and the live events in this world. Also gives you the coins waiting from challenges you helped complete",
|
||||
.aliases = {"challenge", "challenges"},
|
||||
.handle = LiveEvents::ChallengeCommand,
|
||||
.requiredLevel = eGameMasterLevel::CIVILIAN
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
|
||||
#include "RakNetTypes.h"
|
||||
#include "eGameMasterLevel.h"
|
||||
#include "dCommonVars.h"
|
||||
#include "AccountRules.h"
|
||||
#include "SlashCommandLevels.h"
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
class Entity;
|
||||
|
||||
@@ -17,7 +24,33 @@ struct Command {
|
||||
std::string info;
|
||||
std::vector<std::string> aliases;
|
||||
std::function<void(Entity*, const SystemAddress&,const std::string)> handle;
|
||||
// The default level; command_level_<name> in the settings (e.g. from the dashboard) can change it
|
||||
eGameMasterLevel requiredLevel = eGameMasterLevel::OPERATOR;
|
||||
// The lowest level the settings may give it; unset: GM 1 for staff commands (never players), GM 0 for the rest
|
||||
std::optional<eGameMasterLevel> minLevel;
|
||||
// Always requiredLevel, whatever the settings say
|
||||
bool fixedLevel = false;
|
||||
// Set by RegisterCommand for commands the game client acts on by itself (their level is fixed)
|
||||
bool clientHandled = false;
|
||||
// Why the command has a floor or a fixed level, shown on the dashboard
|
||||
std::string levelNote;
|
||||
// The dashboard permission (Permissions.cpp key) that does the same thing. The command then needs that permission's
|
||||
// level (one setting for both); a command_level_<name> value still overrides it, so older setups don't change silently
|
||||
std::string dashboardPermission;
|
||||
// Who it may be used on when it acts on another player: the dashboard's self and rank rules (the handler checks them
|
||||
// with SlashCommandHandler::MayActOn; this tells the dashboard which rule it follows)
|
||||
SlashCommandLevels::eTargetRule targetRule = SlashCommandLevels::eTargetRule::NONE;
|
||||
// Set by RegisterCommand: the command's name in the settings, from its first alias
|
||||
std::string name;
|
||||
};
|
||||
|
||||
// A player a command acts on, in this world or not
|
||||
struct CommandTarget {
|
||||
uint32_t accountId{};
|
||||
uint8_t gmLevel{}; // the account's GM level (the character's in-game level if that is higher)
|
||||
LWOOBJID characterId{};
|
||||
std::string name; // the character's name
|
||||
Entity* entity{}; // set when the player is in this world
|
||||
};
|
||||
|
||||
namespace SlashCommandHandler {
|
||||
@@ -25,6 +58,41 @@ namespace SlashCommandHandler {
|
||||
void SendAnnouncement(const std::string& title, const std::string& message);
|
||||
void RegisterCommand(Command info);
|
||||
void Startup();
|
||||
|
||||
// The command an alias runs (nullptr: none)
|
||||
const Command* FindCommand(const std::string& alias);
|
||||
|
||||
// The level needed to use a command right now: its default unless the settings change it
|
||||
eGameMasterLevel GetRequiredLevel(const Command& command);
|
||||
|
||||
// Store the registered commands in the database for the dashboard (only what changed)
|
||||
void ReportCommands();
|
||||
|
||||
// A player in this world, as a command target
|
||||
std::optional<CommandTarget> TargetOf(Entity* player);
|
||||
|
||||
// The character with this name: in this world if they are here, else from the database. nullopt: no such character
|
||||
std::optional<CommandTarget> FindTarget(const std::string& name);
|
||||
|
||||
/**
|
||||
* Pure (apart from the permission levels): whether staff at actorLevel may use a command with this rule on the target
|
||||
* account. The same rules as the dashboard's tools: GM 9 may act on anyone; below GM 9 never on a higher GM level, on
|
||||
* their own level only with manage_equal_rank, and on themselves only with the self_* permission for the rule
|
||||
* (OTHERS: on themselves always, as before).
|
||||
*/
|
||||
AccountRules::eManageDenial TargetDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, SlashCommandLevels::eTargetRule rule);
|
||||
|
||||
// Why a command may not be used on someone, for the chat; empty when it may
|
||||
std::string TargetRefusal(AccountRules::eManageDenial denial, SlashCommandLevels::eTargetRule rule, std::string_view command);
|
||||
|
||||
/**
|
||||
* Whether the actor may use the command on the target (TargetDenial with the actor's current GM level and account).
|
||||
* When not, tells whoever typed the command (sysAddr) why in the chat.
|
||||
*/
|
||||
bool MayActOn(Entity* actor, const SystemAddress& sysAddr, const CommandTarget& target, SlashCommandLevels::eTargetRule rule, std::string_view command);
|
||||
|
||||
// The account of the player using a command (0 if unknown)
|
||||
uint32_t AccountOf(Entity* player);
|
||||
};
|
||||
|
||||
namespace GMZeroCommands {
|
||||
|
||||
@@ -238,6 +238,8 @@ namespace DEVGMCommands {
|
||||
|
||||
auto* player = PlayerManager::GetPlayer(splitArgs[0]);
|
||||
if (player) {
|
||||
const auto target = SlashCommandHandler::TargetOf(player);
|
||||
if (!target || !SlashCommandHandler::MayActOn(entity, sysAddr, *target, SlashCommandLevels::eTargetRule::TOOLS, "kill")) return;
|
||||
player->Smash(entity->GetObjectID());
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"It has been done, do you feel good about yourself now?");
|
||||
return;
|
||||
@@ -626,6 +628,11 @@ namespace DEVGMCommands {
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"Invalid x or source player not found.");
|
||||
return;
|
||||
}
|
||||
if (sourcePlayer && sourcePlayer != entity) {
|
||||
// Moving someone else: the same rules as moving their character on the dashboard
|
||||
const auto target = SlashCommandHandler::TargetOf(sourcePlayer);
|
||||
if (!target || !SlashCommandHandler::MayActOn(entity, sysAddr, *target, SlashCommandLevels::eTargetRule::OTHERS, "teleport")) return;
|
||||
}
|
||||
if (sourcePlayer) sourceEntity = sourcePlayer;
|
||||
|
||||
const auto z = ParseRelativeAxis(sourcePos.z, splitArgs[1]);
|
||||
@@ -670,9 +677,22 @@ namespace DEVGMCommands {
|
||||
|
||||
const auto characters = Game::entityManager->GetEntitiesByComponent(eReplicaComponentType::CHARACTER);
|
||||
|
||||
// Only players this GM may move (never a higher GM level; their own level with manage_equal_rank)
|
||||
const auto actorLevel = static_cast<uint8_t>(entity->GetGMLevel());
|
||||
const auto actorAccountId = SlashCommandHandler::AccountOf(entity);
|
||||
uint32_t skipped = 0;
|
||||
for (auto* character : characters) {
|
||||
const auto target = SlashCommandHandler::TargetOf(character);
|
||||
if (target && SlashCommandHandler::TargetDenial(actorLevel, actorAccountId, target->gmLevel, target->accountId, SlashCommandLevels::eTargetRule::OTHERS) != AccountRules::eManageDenial::NONE) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
GameMessages::SendTeleport(character->GetObjectID(), pos, QuatUtils::IDENTITY, character->GetSystemAddress());
|
||||
}
|
||||
if (skipped > 0) {
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"/tpall: left " + GeneralUtils::to_u16string(skipped) +
|
||||
u" player(s) where they are: you cannot move players with a higher GM level, or your own without the manage_equal_rank permission");
|
||||
}
|
||||
}
|
||||
|
||||
void Dismount(Entity* entity, const SystemAddress& sysAddr, const std::string args) {
|
||||
@@ -880,7 +900,7 @@ namespace DEVGMCommands {
|
||||
const int32_t uscore = uscoreOptional.value();
|
||||
|
||||
CharacterComponent* character = entity->GetComponent<CharacterComponent>();
|
||||
if (character) character->SetUScore(character->GetUScore() + uscore);
|
||||
if (character) character->SetUScore(character->GetUScore() + uscore, static_cast<uint32_t>(eLootSourceType::MODERATION));
|
||||
// MODERATION should work but it doesn't. Relog to see uscore changes
|
||||
|
||||
eLootSourceType lootType = eLootSourceType::MODERATION;
|
||||
@@ -914,6 +934,10 @@ namespace DEVGMCommands {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedPlayer->GetOwner() != entity) {
|
||||
const auto target = SlashCommandHandler::TargetOf(requestedPlayer->GetOwner());
|
||||
if (!target || !SlashCommandHandler::MayActOn(entity, sysAddr, *target, SlashCommandLevels::eTargetRule::OTHERS, "setlevel")) return;
|
||||
}
|
||||
entity = requestedPlayer->GetOwner();
|
||||
}
|
||||
const auto requestedLevelOptional = GeneralUtils::TryParse<uint32_t>(splitArgs[0]);
|
||||
@@ -938,7 +962,7 @@ namespace DEVGMCommands {
|
||||
|
||||
// Set the UScore first
|
||||
oldLevel = levelComponent->GetLevel();
|
||||
characterComponent->SetUScore(result.getIntField(0, characterComponent->GetUScore()));
|
||||
characterComponent->SetUScore(result.getIntField(0, characterComponent->GetUScore()), static_cast<uint32_t>(eLootSourceType::MODERATION));
|
||||
|
||||
// handle level up for each level we have passed if we set our level to be higher than the current one.
|
||||
if (oldLevel < requestedLevel) {
|
||||
@@ -1792,6 +1816,11 @@ namespace DEVGMCommands {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetPlayer != entity) {
|
||||
// Runs commands with that player's GM level, so never as a higher GM level than the one running it
|
||||
const auto target = SlashCommandHandler::TargetOf(targetPlayer);
|
||||
if (!target || !SlashCommandHandler::MayActOn(entity, sysAddr, *target, SlashCommandLevels::eTargetRule::OTHERS, "execute")) return;
|
||||
}
|
||||
execEntity = targetPlayer;
|
||||
i += 2;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "GMGreaterThanZeroCommands.h"
|
||||
#include "DashboardNotify.h"
|
||||
#include "SlashCommandHandler.h"
|
||||
|
||||
// Classes
|
||||
#include "Character.h"
|
||||
@@ -31,6 +33,8 @@ namespace GMGreaterThanZeroCommands {
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"Count not find player of name: " + username);
|
||||
return;
|
||||
}
|
||||
const auto target = SlashCommandHandler::TargetOf(player);
|
||||
if (!target || !SlashCommandHandler::MayActOn(entity, sysAddr, *target, SlashCommandLevels::eTargetRule::TOOLS, "kick")) return;
|
||||
|
||||
Game::server->Disconnect(player->GetSystemAddress(), eServerDisconnectIdentifiers::KICK);
|
||||
|
||||
@@ -44,32 +48,25 @@ namespace GMGreaterThanZeroCommands {
|
||||
const auto splitArgs = GeneralUtils::SplitString(args, ' ');
|
||||
|
||||
if (splitArgs.size() == 1) {
|
||||
auto* player = PlayerManager::GetPlayer(splitArgs[0]);
|
||||
|
||||
uint32_t accountId = 0;
|
||||
|
||||
if (player == nullptr) {
|
||||
auto characterInfo = Database::Get()->GetCharacterInfo(splitArgs[0]);
|
||||
|
||||
if (characterInfo) {
|
||||
accountId = characterInfo->accountId;
|
||||
}
|
||||
|
||||
if (accountId == 0) {
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"Count not find player of name: " + GeneralUtils::UTF8ToUTF16(splitArgs[0]));
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
auto* character = player->GetCharacter();
|
||||
auto* user = character != nullptr ? character->GetParentUser() : nullptr;
|
||||
if (user) accountId = user->GetAccountID();
|
||||
const auto target = SlashCommandHandler::FindTarget(splitArgs[0]);
|
||||
if (!target) {
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"Count not find player of name: " + GeneralUtils::UTF8ToUTF16(splitArgs[0]));
|
||||
return;
|
||||
}
|
||||
if (!SlashCommandHandler::MayActOn(entity, sysAddr, *target, SlashCommandLevels::eTargetRule::MODERATION, "ban")) return;
|
||||
// The server always keeps a GM 9 account that can sign in
|
||||
if (target->gmLevel >= AccountRules::OPERATOR_LEVEL &&
|
||||
AccountRules::RemovesLastOperator(target->gmLevel, Database::Get()->CountActiveAccountsAtGmLevel(AccountRules::OPERATOR_LEVEL, target->accountId))) {
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"/ban: " + GeneralUtils::UTF8ToUTF16(AccountRules::LastOperatorMessage("banned")));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto accountId = target->accountId;
|
||||
if (accountId != 0) Database::Get()->UpdateAccountBan(accountId, true);
|
||||
if (accountId != 0) DashboardNotify::Changed("account_banned", accountId);
|
||||
|
||||
if (player != nullptr) {
|
||||
Game::server->Disconnect(player->GetSystemAddress(), eServerDisconnectIdentifiers::FREE_TRIAL_EXPIRED);
|
||||
if (target->entity != nullptr) {
|
||||
Game::server->Disconnect(target->entity->GetSystemAddress(), eServerDisconnectIdentifiers::FREE_TRIAL_EXPIRED);
|
||||
}
|
||||
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"Banned: " + GeneralUtils::ASCIIToUTF16(splitArgs[0]));
|
||||
@@ -95,6 +92,9 @@ namespace GMGreaterThanZeroCommands {
|
||||
|
||||
receiverID = playerInfo->id;
|
||||
|
||||
const auto target = SlashCommandHandler::FindTarget(playerName);
|
||||
if (!target || !SlashCommandHandler::MayActOn(entity, sysAddr, *target, SlashCommandLevels::eTargetRule::ITEMS, "mailitem")) return;
|
||||
|
||||
const auto lot = GeneralUtils::TryParse<LOT>(splitArgs.at(1));
|
||||
|
||||
if (!lot) {
|
||||
@@ -114,6 +114,7 @@ namespace GMGreaterThanZeroCommands {
|
||||
mailInsert.itemSubkey = LWOOBJID_EMPTY;
|
||||
mailInsert.itemCount = 1;
|
||||
Database::Get()->InsertNewMail(mailInsert);
|
||||
DashboardNotify::Changed("mail", mailInsert.receiverId);
|
||||
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"Mail sent");
|
||||
}
|
||||
@@ -128,32 +129,15 @@ namespace GMGreaterThanZeroCommands {
|
||||
const auto splitArgs = GeneralUtils::SplitString(args, ' ');
|
||||
|
||||
if (splitArgs.size() >= 1) {
|
||||
auto* player = PlayerManager::GetPlayer(splitArgs[0]);
|
||||
|
||||
uint32_t accountId = 0;
|
||||
LWOOBJID characterId = 0;
|
||||
|
||||
if (player == nullptr) {
|
||||
auto characterInfo = Database::Get()->GetCharacterInfo(splitArgs[0]);
|
||||
|
||||
if (characterInfo) {
|
||||
accountId = characterInfo->accountId;
|
||||
characterId = characterInfo->id;
|
||||
|
||||
GeneralUtils::SetBit(characterId, eObjectBits::CHARACTER);
|
||||
}
|
||||
|
||||
if (accountId == 0) {
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"Count not find player of name: " + GeneralUtils::UTF8ToUTF16(splitArgs[0]));
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
auto* character = player->GetCharacter();
|
||||
auto* user = character != nullptr ? character->GetParentUser() : nullptr;
|
||||
if (user) accountId = user->GetAccountID();
|
||||
characterId = player->GetObjectID();
|
||||
const auto target = SlashCommandHandler::FindTarget(splitArgs[0]);
|
||||
if (!target) {
|
||||
ChatPackets::SendSystemMessage(sysAddr, u"Count not find player of name: " + GeneralUtils::UTF8ToUTF16(splitArgs[0]));
|
||||
return;
|
||||
}
|
||||
if (!SlashCommandHandler::MayActOn(entity, sysAddr, *target, SlashCommandLevels::eTargetRule::MODERATION, "mute")) return;
|
||||
|
||||
const uint32_t accountId = target->accountId;
|
||||
const LWOOBJID characterId = target->characterId;
|
||||
|
||||
time_t expire = 1; // Default to indefinate mute
|
||||
|
||||
@@ -181,6 +165,7 @@ namespace GMGreaterThanZeroCommands {
|
||||
}
|
||||
|
||||
if (accountId != 0) Database::Get()->UpdateAccountUnmuteTime(accountId, expire);
|
||||
if (accountId != 0) DashboardNotify::Changed("account_muted", accountId);
|
||||
|
||||
char buffer[32] = "brought up for review.\0";
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include "dCommonVars.h"
|
||||
#include "dConfig.h"
|
||||
#include "dServer.h"
|
||||
#include "tinyxml2.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
#include "BinaryPathFinder.h"
|
||||
@@ -19,18 +18,24 @@
|
||||
#include "dZoneManager.h"
|
||||
#include "ObjectIDManager.h"
|
||||
#include "Level.h"
|
||||
#include "VanityXml.h"
|
||||
#include "VanityEvents.h"
|
||||
#include "EventParts.h"
|
||||
#include "Database.h"
|
||||
|
||||
#include <ctime>
|
||||
#include <fstream>
|
||||
|
||||
|
||||
namespace {
|
||||
std::vector<VanityObject> objects;
|
||||
std::set<std::string> loadedFiles;
|
||||
LWOOBJID testament = LWOOBJID_EMPTY; // Nimbus Station's testament sign
|
||||
}
|
||||
|
||||
void SetupNPCTalk(Entity* npc);
|
||||
void NPCTalk(Entity* npc);
|
||||
void ParseXml(const std::string& file);
|
||||
void AddObjects(const std::vector<VanityXml::Object>& documentObjects);
|
||||
std::vector<VanityXml::Object> LoadVanity(const std::filesystem::path& folder);
|
||||
LWOOBJID SpawnSpawner(const VanityObject& object, const VanityObjectLocation& location);
|
||||
Entity* SpawnObject(const VanityObject& object, const VanityObjectLocation& location);
|
||||
VanityObject* GetObject(const std::string& name);
|
||||
@@ -38,6 +43,11 @@ VanityObject* GetObject(const std::string& name);
|
||||
void VanityUtilities::SpawnVanity() {
|
||||
const uint32_t zoneID = Game::server->GetZoneID();
|
||||
|
||||
// Spawning again (the dashboard's reload) replaces what's there. Destroyed outright rather than smashed,
|
||||
// so players don't see the old ones linger through a death next to the new ones.
|
||||
Game::entityManager->DestroyEntity(Game::entityManager->GetEntity(testament));
|
||||
testament = LWOOBJID_EMPTY;
|
||||
|
||||
if (zoneID == 1200) {
|
||||
{
|
||||
EntityInfo info;
|
||||
@@ -50,26 +60,24 @@ void VanityUtilities::SpawnVanity() {
|
||||
|
||||
auto* entity = Game::entityManager->CreateEntity(info);
|
||||
Game::entityManager->ConstructEntity(entity);
|
||||
testament = entity->GetObjectID();
|
||||
}
|
||||
}
|
||||
|
||||
if (Game::config->GetValue("disable_vanity") == "1") return;
|
||||
|
||||
for (const auto& npc : objects) {
|
||||
if (npc.m_ID == LWOOBJID_EMPTY) continue;
|
||||
if (npc.m_LOT == 176) {
|
||||
Game::zoneManager->RemoveSpawner(npc.m_ID);
|
||||
} else {
|
||||
auto* entity = Game::entityManager->GetEntity(npc.m_ID);
|
||||
if (!entity) continue;
|
||||
entity->Smash(LWOOBJID_EMPTY, eKillType::VIOLENT);
|
||||
Game::entityManager->DestroyEntity(Game::entityManager->GetEntity(npc.m_ID));
|
||||
}
|
||||
}
|
||||
|
||||
objects.clear();
|
||||
loadedFiles.clear();
|
||||
|
||||
ParseXml((BinaryPathFinder::GetBinaryDir() / "vanity/root.xml").string());
|
||||
if (Game::config->GetValue("disable_vanity") == "1") return;
|
||||
|
||||
AddObjects(LoadVanity(BinaryPathFinder::GetBinaryDir() / "vanity"));
|
||||
|
||||
// Loop through all objects
|
||||
for (auto& object : objects) {
|
||||
@@ -143,182 +151,95 @@ Entity* SpawnObject(const VanityObject& object, const VanityObjectLocation& loca
|
||||
return entity;
|
||||
}
|
||||
|
||||
void ParseXml(const std::string& file) {
|
||||
if (loadedFiles.contains(file)) {
|
||||
LOG("Trying to load vanity file %s twice!!!", file.c_str());
|
||||
return;
|
||||
// root.xml and the files it switches on, with the vanity parts of the scheduled events that are on now: their file
|
||||
// switches, then their overlays
|
||||
std::vector<VanityXml::Object> LoadVanity(const std::filesystem::path& folder) {
|
||||
std::vector<IServerOperations::ScheduledEvent> events;
|
||||
try {
|
||||
events = Database::Get()->GetScheduledEvents();
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not read the scheduled events, spawning without their vanity changes: %s", ex.what());
|
||||
}
|
||||
loadedFiles.insert(file);
|
||||
// Read the entire file
|
||||
std::ifstream xmlFile(file);
|
||||
std::string xml((std::istreambuf_iterator<char>(xmlFile)), std::istreambuf_iterator<char>());
|
||||
|
||||
// Parse the XML
|
||||
tinyxml2::XMLDocument doc;
|
||||
doc.Parse(xml.c_str(), xml.size());
|
||||
|
||||
// Read the objects
|
||||
auto* files = doc.FirstChildElement("files");
|
||||
if (files) {
|
||||
for (auto* file = files->FirstChildElement("file"); file != nullptr; file = file->NextSiblingElement("file")) {
|
||||
std::string enabled = file->Attribute("enabled");
|
||||
std::string filename = file->Attribute("name");
|
||||
if (enabled != "1") {
|
||||
continue;
|
||||
}
|
||||
ParseXml((BinaryPathFinder::GetBinaryDir() / "vanity" / filename).string());
|
||||
const auto now = static_cast<int64_t>(std::time(nullptr));
|
||||
std::erase_if(events, [now](const auto& event) {
|
||||
return !ScheduleRules::IsOn(static_cast<ScheduleRules::eMode>(event.mode), event.schedule, event.startsAt, event.endsAt, now);
|
||||
});
|
||||
VanityEvents::SortForMerge(events);
|
||||
std::vector<VanityEvents::Changes> changes;
|
||||
for (const auto& event : events) {
|
||||
for (auto& change : EventParts::VanityChanges(event.name, event.parts)) {
|
||||
LOG("Vanity changes of event %s are on", event.name.c_str());
|
||||
changes.push_back(std::move(change));
|
||||
}
|
||||
}
|
||||
|
||||
auto world = VanityEvents::LoadWorld(folder, "root.xml", changes);
|
||||
for (const auto& warning : world.warnings) LOG("Vanity: %s", warning.c_str());
|
||||
for (const auto& conflict : world.fileConflicts) {
|
||||
LOG("Vanity file %s is switched by more than one event; %s wins", conflict.file.c_str(), conflict.switches.back().first.c_str());
|
||||
}
|
||||
for (const auto& conflict : world.conflicts) {
|
||||
LOG("Vanity NPC %s is changed by more than one event; %s wins", conflict.npc.c_str(), conflict.events.back().c_str());
|
||||
}
|
||||
return std::move(world.objects);
|
||||
}
|
||||
|
||||
void AddObjects(const std::vector<VanityXml::Object>& documentObjects) {
|
||||
// Read the objects
|
||||
auto* objectsElement = doc.FirstChildElement("objects");
|
||||
const uint32_t currentZoneID = Game::server->GetZoneID();
|
||||
if (objectsElement) {
|
||||
for (auto* object = objectsElement->FirstChildElement("object"); object != nullptr; object = object->NextSiblingElement("object")) {
|
||||
// for use later when adding to the vector of VanityObjects
|
||||
bool useLocationsAsRandomSpawnPoint = false;
|
||||
// Get the NPC name
|
||||
auto* name = object->Attribute("name");
|
||||
for (const auto& object : documentObjects) {
|
||||
// for use later when adding to the vector of VanityObjects
|
||||
bool useLocationsAsRandomSpawnPoint = false;
|
||||
|
||||
if (!name) name = "";
|
||||
if (object.lot == LOT_NULL) {
|
||||
LOG("Failed to parse object lot");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the NPC lot
|
||||
auto lot = GeneralUtils::TryParse<LOT>(object->Attribute("lot")).value_or(LOT_NULL);
|
||||
std::vector<std::u16string> keys = {};
|
||||
LwoNameValue config;
|
||||
for (const auto& data : object.config) {
|
||||
const auto& configData = config.ParseInsert(data);
|
||||
if (configData->GetKey() == u"useLocationsAsRandomSpawnPoint" && configData->GetValueType() == eLDFType::LDF_TYPE_BOOLEAN) {
|
||||
useLocationsAsRandomSpawnPoint = static_cast<const LDFData<bool>*>(configData.get())->GetValue();
|
||||
config.Erase(u"useLocationsAsRandomSpawnPoint");
|
||||
continue;
|
||||
}
|
||||
keys.push_back(configData->GetKey());
|
||||
}
|
||||
if (!keys.empty()) config.Insert<std::vector<std::u16string>>(u"syncLDF", keys);
|
||||
|
||||
if (lot == LOT_NULL) {
|
||||
LOG("Failed to parse object lot");
|
||||
VanityObject objectData{
|
||||
.m_Name = object.name,
|
||||
.m_LOT = object.lot,
|
||||
.m_Equipment = object.equipment,
|
||||
.m_Phrases = object.phrases,
|
||||
.m_Config = config
|
||||
};
|
||||
|
||||
for (const auto& location : object.locations) {
|
||||
if (location.zone != currentZoneID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the equipment
|
||||
auto* equipment = object->FirstChildElement("equipment");
|
||||
std::vector<LOT> inventory;
|
||||
|
||||
if (equipment) {
|
||||
auto* text = equipment->GetText();
|
||||
|
||||
if (text != nullptr) {
|
||||
std::string equipmentString(text);
|
||||
|
||||
std::vector<std::string> splitEquipment = GeneralUtils::SplitString(equipmentString, ',');
|
||||
|
||||
for (auto& item : splitEquipment) {
|
||||
// remove spaces for tryParse to work
|
||||
item.erase(remove_if(item.begin(), item.end(), isspace), item.end());
|
||||
auto itemInt = GeneralUtils::TryParse<uint32_t>(item);
|
||||
if (itemInt) inventory.push_back(itemInt.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the phrases
|
||||
auto* phrases = object->FirstChildElement("phrases");
|
||||
std::vector<std::string> phraseList = {};
|
||||
if (phrases) {
|
||||
for (auto* phrase = phrases->FirstChildElement("phrase"); phrase != nullptr;
|
||||
phrase = phrase->NextSiblingElement("phrase")) {
|
||||
// Get the phrase
|
||||
auto* text = phrase->GetText();
|
||||
if (text == nullptr) {
|
||||
LOG("Failed to parse NPC phrase");
|
||||
continue;
|
||||
}
|
||||
phraseList.push_back(text);
|
||||
}
|
||||
}
|
||||
|
||||
auto* configElement = object->FirstChildElement("config");
|
||||
std::vector<std::u16string> keys = {};
|
||||
LwoNameValue config;
|
||||
if (configElement) {
|
||||
for (auto* key = configElement->FirstChildElement("key"); key != nullptr;
|
||||
key = key->NextSiblingElement("key")) {
|
||||
// Get the config data
|
||||
auto* data = key->GetText();
|
||||
if (!data) continue;
|
||||
|
||||
const auto& configData = config.ParseInsert(data);
|
||||
if (configData->GetKey() == u"useLocationsAsRandomSpawnPoint" && configData->GetValueType() == eLDFType::LDF_TYPE_BOOLEAN) {
|
||||
useLocationsAsRandomSpawnPoint = static_cast<const LDFData<bool>*>(configData.get())->GetValue();
|
||||
config.Erase(u"useLocationsAsRandomSpawnPoint");
|
||||
continue;
|
||||
}
|
||||
keys.push_back(configData->GetKey());
|
||||
}
|
||||
}
|
||||
if (!keys.empty()) config.Insert<std::vector<std::u16string>>(u"syncLDF", keys);
|
||||
|
||||
VanityObject objectData{
|
||||
.m_Name = name,
|
||||
.m_LOT = lot,
|
||||
.m_Equipment = inventory,
|
||||
.m_Phrases = phraseList,
|
||||
.m_Config = config
|
||||
VanityObjectLocation locationData{
|
||||
.m_Chance = location.chance.value_or(1.0f),
|
||||
.m_Position = { location.x, location.y, location.z },
|
||||
.m_Rotation = { location.rw, location.rx, location.ry, location.rz },
|
||||
.m_Scale = location.scale.value_or(1.0f),
|
||||
};
|
||||
|
||||
// Get the locations
|
||||
auto* locations = object->FirstChildElement("locations");
|
||||
objectData.m_Locations[location.zone].push_back(locationData);
|
||||
|
||||
if (locations == nullptr) {
|
||||
LOG("Failed to parse NPC locations");
|
||||
continue;
|
||||
}
|
||||
|
||||
for (auto* location = locations->FirstChildElement("location"); location != nullptr;
|
||||
location = location->NextSiblingElement("location")) {
|
||||
|
||||
// Get the location data
|
||||
auto zoneID = GeneralUtils::TryParse<uint32_t>(location->Attribute("zone"));
|
||||
auto x = GeneralUtils::TryParse<float>(location->Attribute("x"));
|
||||
auto y = GeneralUtils::TryParse<float>(location->Attribute("y"));
|
||||
auto z = GeneralUtils::TryParse<float>(location->Attribute("z"));
|
||||
auto rw = GeneralUtils::TryParse<float>(location->Attribute("rw"));
|
||||
auto rx = GeneralUtils::TryParse<float>(location->Attribute("rx"));
|
||||
auto ry = GeneralUtils::TryParse<float>(location->Attribute("ry"));
|
||||
auto rz = GeneralUtils::TryParse<float>(location->Attribute("rz"));
|
||||
|
||||
if (!zoneID || !x || !y || !z || !rw || !rx || !ry || !rz) {
|
||||
LOG("Failed to parse NPC location data");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (zoneID.value() != currentZoneID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
VanityObjectLocation locationData{
|
||||
.m_Position = { x.value(), y.value(), z.value() },
|
||||
.m_Rotation = { rw.value(), rx.value(), ry.value(), rz.value() },
|
||||
};
|
||||
|
||||
if (location->Attribute("chance")) {
|
||||
locationData.m_Chance = GeneralUtils::TryParse<float>(location->Attribute("chance")).value_or(1.0f);
|
||||
}
|
||||
|
||||
if (location->Attribute("scale")) {
|
||||
locationData.m_Scale = GeneralUtils::TryParse<float>(location->Attribute("scale")).value_or(1.0f);
|
||||
}
|
||||
|
||||
const auto& it = objectData.m_Locations.find(zoneID.value());
|
||||
|
||||
if (it != objectData.m_Locations.end()) {
|
||||
it->second.push_back(locationData);
|
||||
} else {
|
||||
std::vector<VanityObjectLocation> locations;
|
||||
locations.push_back(locationData);
|
||||
objectData.m_Locations.insert(std::make_pair(zoneID.value(), locations));
|
||||
}
|
||||
|
||||
if (!useLocationsAsRandomSpawnPoint) {
|
||||
objects.push_back(objectData);
|
||||
objectData.m_Locations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (useLocationsAsRandomSpawnPoint && !objectData.m_Locations.empty()) {
|
||||
if (!useLocationsAsRandomSpawnPoint) {
|
||||
objects.push_back(objectData);
|
||||
objectData.m_Locations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (useLocationsAsRandomSpawnPoint && !objectData.m_Locations.empty()) {
|
||||
objects.push_back(objectData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user