feat: contraband list with flagging and optional removal

Staff list contraband items on a new dashboard page (item search, reason, flag or flag and remove;
contraband_manage to edit, reports_view to see). World servers check every inventory when a
character loads and every item a player receives: each find is an economy flag of the new kind
Contraband, shown with the other flags and in the character's related data. Items marked for
removal are taken away, with a character snapshot kept first so they can be given back, an audit
entry and a mail or chat message telling the player why. Staff are skipped unless
contraband_ignore_staff is off. Worlds reload the list when it changes (RELOAD_CONTRABAND, added
at the end of ePlayerAction).

Fixes #1563

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 23:20:16 -05:00
parent 341ce89a6a
commit b2403b09ea
36 changed files with 766 additions and 22 deletions

View File

@@ -1,4 +1,5 @@
#include "InventoryComponent.h"
#include "Contraband.h"
#include "EconomyLedger.h"
#include <sstream>
@@ -204,6 +205,8 @@ void InventoryComponent::AddItem(
inventoryType = Inventory::FindInventoryTypeForLot(lot);
}
Contraband::OnItemAdded(m_Parent, lot, count, lootSourceType, inventorySourceType);
auto* missions = static_cast<MissionComponent*>(this->m_Parent->GetComponent(eReplicaComponentType::MISSION));
auto* inventory = GetInventory(inventoryType);
@@ -336,6 +339,7 @@ ReceivedItem InventoryComponent::ReceiveItem(const LWOOBJID id, const LOT lot, c
addItem.showFlyingLoot = options.showFlyingLoot && !options.equip;
addItem.SendToClient(m_Parent->GetSystemAddress());
EconomyLedger::RecordItems(m_Parent, lot, count, static_cast<uint32_t>(lootSourceType));
Contraband::OnItemAdded(m_Parent, lot, count, lootSourceType, options.sourceInventory);
if (options.equip) {
item->Equip();
Game::entityManager->SerializeEntity(m_Parent);

View File

@@ -6,6 +6,7 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp"
"DashboardNotify.cpp"
"GUID.cpp"
"LiveEvents.cpp"
"Contraband.cpp"
"Loot.cpp"
"Mail.cpp"
"MessageInspector.cpp"

View File

@@ -0,0 +1,199 @@
#include "Contraband.h"
#include <ctime>
#include <optional>
#include "magic_enum.hpp"
#include "Character.h"
#include "ChatPackets.h"
#include "Database.h"
#include "dConfig.h"
#include "dServer.h"
#include "eGameMasterLevel.h"
#include "EconomyLedger.h"
#include "Entity.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "Inventory.h"
#include "InventoryComponent.h"
#include "Item.h"
#include "Logger.h"
#include "Mail.h"
#include "User.h"
#include "ZCompression.h"
namespace {
std::optional<Contraband::List> g_List;
constexpr const char* ACTOR = "World server";
bool IgnoreStaff() {
return !Game::config || Game::config->GetValue("contraband_ignore_staff") != "0";
}
bool NotifyPlayers() {
return !Game::config || Game::config->GetValue("contraband_notify_players") != "0";
}
eGameMasterLevel AccountLevel(const Entity* player) {
const auto* character = player ? player->GetCharacter() : nullptr;
const auto* user = character ? character->GetParentUser() : nullptr;
return user ? user->GetMaxGMLevel() : eGameMasterLevel::CIVILIAN;
}
std::string Compress(const std::string& data) {
std::string out(ZCompression::GetMaxCompressedLength(static_cast<uint32_t>(data.size())), '\0');
const auto size = ZCompression::Compress(reinterpret_cast<const uint8_t*>(data.data()), static_cast<uint32_t>(data.size()),
reinterpret_cast<uint8_t*>(out.data()), static_cast<uint32_t>(out.size()));
out.resize(size > 0 ? static_cast<size_t>(size) : 0);
return out;
}
std::string Where() {
if (!Game::server) return "";
return " (zone " + std::to_string(Game::server->GetZoneID()) + " instance " + std::to_string(Game::server->GetInstanceID()) + ")";
}
// Every item a player has (not the proxy items of sets), across every inventory
std::vector<Contraband::HeldItem> HeldItems(InventoryComponent& inventory) {
std::vector<Contraband::HeldItem> items;
for (const auto& [type, inv] : inventory.GetInventories()) {
if (!inv) continue;
for (const auto& [id, item] : inv->GetItems()) {
if (!item || item->GetParent() != LWOOBJID_EMPTY) continue;
items.push_back({ id, item->GetLot(), item->GetCount(), type });
}
}
return items;
}
// Remove every item of a LOT (all inventories); how many were removed
uint32_t RemoveLot(InventoryComponent& inventory, LOT lot, bool silent) {
uint32_t removed = 0;
for (const auto& [type, inv] : inventory.GetInventories()) {
if (!inv) continue;
std::vector<Item*> items;
for (const auto& [id, item] : inv->GetItems()) if (item && item->GetLot() == lot && item->GetParent() == LWOOBJID_EMPTY) items.push_back(item);
for (auto* item : items) {
removed += item->GetCount();
item->SetCount(0, silent, false, false, eLootSourceType::MODERATION);
}
}
return removed;
}
}
namespace Contraband {
std::vector<Finding> Find(const std::vector<HeldItem>& items, const List& list) {
std::vector<Finding> found;
if (list.empty()) return found;
for (const auto& item : items) {
const auto it = list.find(item.lot);
if (it != list.end() && item.count > 0) found.push_back({ item, it->second });
}
return found;
}
bool Applies(eGameMasterLevel accountLevel, bool ignoreStaff) {
return !ignoreStaff || accountLevel <= eGameMasterLevel::CIVILIAN;
}
bool CountsAsAdded(eLootSourceType source, eInventoryType sourceInventory) {
return sourceInventory == eInventoryType::INVALID && source != eLootSourceType::RELOCATE && source != eLootSourceType::INVENTORY;
}
uint32_t Reload() {
List list;
for (const auto& item : Database::Get()->GetContrabandItems()) list[item.lot] = { item.reason, item.action };
LOG("Loaded %zu contraband item(s)", list.size());
g_List = std::move(list);
return 1;
}
const List& Get() {
if (!g_List) Reload();
return *g_List;
}
void CheckOnLoad(Entity* player) {
auto* character = player ? player->GetCharacter() : nullptr;
auto* inventory = player ? player->GetComponent<InventoryComponent>() : nullptr;
if (!character || !inventory || Get().empty() || !Applies(AccountLevel(player), IgnoreStaff())) return;
const auto findings = Find(HeldItems(*inventory), Get());
if (findings.empty()) return;
const auto characterId = character->GetID();
const auto accountId = character->GetParentUser() ? character->GetParentUser()->GetAccountID() : 0;
bool snapshotTaken = false;
std::vector<std::string> removedNames;
for (const auto& finding : findings) {
const bool remove = finding.entry.action == IContraband::eContrabandAction::REMOVE;
const auto where = std::string(magic_enum::enum_name(finding.item.inventory));
Database::Get()->InsertEconomyFlag({ 0, IDashboardAdmin::eFlagKind::CONTRABAND, characterId, finding.item.lot, finding.item.id,
finding.item.count, remove ? 1 : 0, (remove ? "Removed at login from " : "Found at login in ") + where + ": " + finding.entry.reason });
if (!remove) continue;
// Keep the character as it was, so staff can give the items back from the character page
if (!snapshotTaken) {
const auto& xml = character->GetXMLData();
Database::Get()->InsertCharacterSnapshot({ 0, characterId, static_cast<int64_t>(std::time(nullptr)), "before contraband removal", ACTOR,
static_cast<uint32_t>(xml.size()), "", Compress(xml) });
snapshotTaken = true;
}
auto* item = inventory->FindItemById(finding.item.id);
if (!item) continue;
// Silent: the character hasn't been sent to the client yet
item->SetCount(0, true, false, false, eLootSourceType::MODERATION);
removedNames.push_back(std::to_string(finding.item.count) + "x " + std::to_string(finding.item.lot));
Database::Get()->InsertAuditLog(0, ACTOR, "contraband_removed", character->GetName() + ": removed " + std::to_string(finding.item.count) +
" of item " + std::to_string(finding.item.lot) + " (id " + std::to_string(finding.item.id) + ", " + where + ") at login" + Where() + ": " + finding.entry.reason,
accountId, characterId);
}
LOG("Character %llu:%s has %zu contraband item stack(s), %zu removed", characterId, character->GetName().c_str(), findings.size(), removedNames.size());
if (!removedNames.empty() && NotifyPlayers()) {
std::string body = "Items that are not allowed on this server were removed from your character:";
for (const auto& finding : findings) {
if (finding.entry.action != IContraband::eContrabandAction::REMOVE) continue;
body += "\n- " + std::to_string(finding.item.count) + " x item " + std::to_string(finding.item.lot) + (finding.entry.reason.empty() ? "" : ": " + finding.entry.reason);
}
body += "\nIf you think this is a mistake, contact the server's staff.";
Mail::SendMail(player, "Items removed", body, LOT_NULL, 0);
}
}
void OnItemAdded(Entity* owner, LOT lot, uint32_t count, eLootSourceType source, eInventoryType sourceInventory) {
if (!owner || !owner->IsPlayer() || count == 0) return;
const auto& list = Get();
const auto it = list.find(lot);
if (it == list.end() || !CountsAsAdded(source, sourceInventory) || !Applies(AccountLevel(owner), IgnoreStaff())) return;
auto* character = owner->GetCharacter();
if (!character) return;
const auto entry = it->second;
const bool remove = entry.action == IContraband::eContrabandAction::REMOVE;
const auto sourceName = std::string(magic_enum::enum_name(source));
// One flag per character, item and day for items that arrive (the load check flags each item on its own)
Database::Get()->InsertEconomyFlag({ EconomyLedger::Today(), IDashboardAdmin::eFlagKind::CONTRABAND, character->GetID(), lot, 0,
count, remove ? 1 : 0, std::string(remove ? "Removed when received" : "Received") + " (" + sourceName + ")" + Where() + ": " + entry.reason });
if (!remove) return;
// Take it away once the add (and whatever trade, mail or loot code called it) has finished
owner->AddCallbackTimer(0.0f, [owner, lot, entry]() {
auto* inventory = owner->GetComponent<InventoryComponent>();
auto* character = owner->GetCharacter();
if (!inventory || !character) return;
const auto removed = RemoveLot(*inventory, lot, false);
if (removed == 0) return;
const auto accountId = character->GetParentUser() ? character->GetParentUser()->GetAccountID() : 0;
Database::Get()->InsertAuditLog(0, ACTOR, "contraband_removed", character->GetName() + ": removed " + std::to_string(removed) + " of item " +
std::to_string(lot) + " right after receiving it" + Where() + ": " + entry.reason, accountId, character->GetID());
if (NotifyPlayers()) {
ChatPackets::SendSystemMessage(owner->GetSystemAddress(), "An item you received (" + std::to_string(lot) + ") is not allowed on this server and was removed" +
(entry.reason.empty() ? "." : ": " + entry.reason));
}
});
}
}

View File

@@ -0,0 +1,77 @@
#ifndef __CONTRABAND__H__
#define __CONTRABAND__H__
#include <cstdint>
#include <map>
#include <string>
#include <vector>
#include "dCommonVars.h"
#include "eInventoryType.h"
#include "eLootSourceType.h"
#include "IContraband.h"
class Entity;
enum class eGameMasterLevel : uint8_t;
/**
* Contraband (issue #1563): items staff don't want players to have, listed on the dashboard's Contraband page
* (IContraband). This world loads the list when it is first needed and again when the dashboard changes it
* (ePlayerAction::RELOAD_CONTRABAND).
*
* - When a character loads (before it is sent to the client), every inventory (vault included) is checked. Each
* listed item is flagged (an economy flag of kind CONTRABAND, once per item). Items whose entry says so are removed
* too: a snapshot of the character is kept first (so the dashboard's "Give back lost items" can return them), the
* removal is audited and the player gets a mail saying why.
* - When a listed item is added (loot, trade, mail, vendors, ...), the character is flagged for that item for the
* day, and an item to remove is removed right after it arrives, with a chat message saying why.
*
* Staff accounts (GM level above civilian) are not checked unless contraband_ignore_staff is 0.
*/
namespace Contraband {
struct Entry {
std::string reason;
IContraband::eContrabandAction action{};
};
using List = std::map<LOT, Entry>;
struct HeldItem {
LWOOBJID id{};
LOT lot{};
uint32_t count{};
eInventoryType inventory{};
};
struct Finding {
HeldItem item;
Entry entry;
};
// ---- Pure rules, unit tested ----
// The held items that are on the list, in the order given
std::vector<Finding> Find(const std::vector<HeldItem>& items, const List& list);
// Whether an account at this GM level is checked
bool Applies(eGameMasterLevel accountLevel, bool ignoreStaff);
// Whether an added item should be checked: moves between a player's own inventories are not new items
bool CountsAsAdded(eLootSourceType source, eInventoryType sourceInventory);
// ---- This world ----
// Load the list again from the database. Returns 1 (one world reloaded).
uint32_t Reload();
// The list as loaded (loads it the first time)
const List& Get();
// Check a player's inventories when their character loads, before it is sent to the client
void CheckOnLoad(Entity* player);
// A listed item was added to a player's inventory (called for every add; cheap when the LOT isn't listed)
void OnItemAdded(Entity* owner, LOT lot, uint32_t count, eLootSourceType source, eInventoryType sourceInventory);
}
#endif //!__CONTRABAND__H__

View File

@@ -29,6 +29,7 @@
#include "dChatFilter.h"
#include "MissionComponent.h"
#include "Mission.h"
#include "Contraband.h"
#include "LiveEvents.h"
namespace {
@@ -293,6 +294,8 @@ uint32_t DashboardActions::Apply(const PlayerActionRequest& request) {
return ChangeMission(request.action, request.characterId, static_cast<uint32_t>(request.targetId), request.approved, request.text);
case ePlayerAction::RELOAD_LIVE_OPS:
return LiveEvents::Reload();
case ePlayerAction::RELOAD_CONTRABAND:
return Contraband::Reload();
}
return 0;
}