From b2403b09ea52e896011ee5c0df389f2c9eaa58a3 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sat, 26 Sep 2026 23:20:16 -0500 Subject: [PATCH] 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 --- dCommon/Permissions.cpp | 1 + dDashboardServer/DashboardServer.cpp | 2 + dDashboardServer/routes/APIRoutes.cpp | 36 ++-- dDashboardServer/routes/APIRoutes.h | 7 + dDashboardServer/routes/CMakeLists.txt | 1 + dDashboardServer/routes/ContrabandRoutes.cpp | 111 ++++++++++ dDashboardServer/routes/ContrabandRoutes.h | 11 + dDashboardServer/routes/SettingsCatalog.cpp | 4 + dDashboardServer/static/js/contraband.js | 76 +++++++ dDashboardServer/static/js/related.js | 2 +- dDashboardServer/static/js/reports.js | 2 +- dDashboardServer/templates/contraband.jinja2 | 42 ++++ dDashboardServer/templates/header.jinja2 | 3 +- dDatabase/GameDatabase/GameDatabase.h | 3 +- dDatabase/GameDatabase/ITables/IContraband.h | 40 ++++ .../GameDatabase/ITables/IDashboardAdmin.h | 5 +- dDatabase/GameDatabase/MySQL/MySQLDatabase.h | 5 + .../GameDatabase/MySQL/Tables/CMakeLists.txt | 1 + .../GameDatabase/MySQL/Tables/Contraband.cpp | 22 ++ .../GameDatabase/SQLite/SQLiteDatabase.h | 5 + .../GameDatabase/SQLite/Tables/CMakeLists.txt | 1 + .../GameDatabase/SQLite/Tables/Contraband.cpp | 22 ++ .../GameDatabase/TestSQL/TestSQLDatabase.h | 5 + dGame/dComponents/InventoryComponent.cpp | 4 + dGame/dUtilities/CMakeLists.txt | 1 + dGame/dUtilities/Contraband.cpp | 199 ++++++++++++++++++ dGame/dUtilities/Contraband.h | 77 +++++++ dGame/dUtilities/DashboardActions.cpp | 3 + dNet/master/PlayerAction.h | 1 + dWorldServer/WorldServer.cpp | 4 + docs/Dashboard.md | 19 ++ migrations/dlu/mysql/77_contraband.sql | 9 + migrations/dlu/sqlite/60_contraband.sql | 8 + tests/dDatabaseTests/DatabaseParityTests.cpp | 17 ++ tests/dGameTests/CMakeLists.txt | 1 + tests/dGameTests/ContrabandTests.cpp | 38 ++++ 36 files changed, 766 insertions(+), 22 deletions(-) create mode 100644 dDashboardServer/routes/ContrabandRoutes.cpp create mode 100644 dDashboardServer/routes/ContrabandRoutes.h create mode 100644 dDashboardServer/static/js/contraband.js create mode 100644 dDashboardServer/templates/contraband.jinja2 create mode 100644 dDatabase/GameDatabase/ITables/IContraband.h create mode 100644 dDatabase/GameDatabase/MySQL/Tables/Contraband.cpp create mode 100644 dDatabase/GameDatabase/SQLite/Tables/Contraband.cpp create mode 100644 dGame/dUtilities/Contraband.cpp create mode 100644 dGame/dUtilities/Contraband.h create mode 100644 migrations/dlu/mysql/77_contraband.sql create mode 100644 migrations/dlu/sqlite/60_contraband.sql create mode 100644 tests/dGameTests/ContrabandTests.cpp diff --git a/dCommon/Permissions.cpp b/dCommon/Permissions.cpp index 56f878f5a..6bac75929 100644 --- a/dCommon/Permissions.cpp +++ b/dCommon/Permissions.cpp @@ -86,6 +86,7 @@ namespace { { "reports_review_flags", "Economy and map", "Review flags", "Mark economy flags dismissed or actioned", 3 }, { "items_restore", "Economy and map", "Give items back", "Mail a traced item back to a player, or what a character lost since one of its snapshots (with original IDs when they no longer exist)", 8 }, { "reports_run_checks", "Economy and map", "Run economy checks", "Run the anomaly checks by hand", 8 }, + { "contraband_manage", "Economy and map", "Contraband list", "Add, change and remove contraband items (flagged, or removed from players, when a character has one)", 8 }, { "play_keys_manage", "Server", "Play keys", "Create, edit and delete play keys, and see the key an account used", 8 }, { "client_files", "Server", "Client files", "Browse and download the game client's files", 8 }, diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index dff2edc31..c909d0502 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -72,6 +72,7 @@ #include "master/MessageCapture.h" #include "PublicRoutes.h" #include "Showcase.h" +#include "ContrabandRoutes.h" #include "FeaturedProperties.h" #include "PasswordRecovery.h" #include "SettingsRoutes.h" @@ -524,6 +525,7 @@ int main(int argc, char** argv) { RegisterPublicRoutes(); RegisterShowcaseRoutes(); FeaturedProperties::RegisterRoutes(); + ContrabandRoutes::RegisterRoutes(); RegisterPasswordRecoveryRoutes(); RegisterWSRoutes(); RegisterDashboardRoutes(); // Must be last - catches all unmatched routes diff --git a/dDashboardServer/routes/APIRoutes.cpp b/dDashboardServer/routes/APIRoutes.cpp index 836f4d913..9abf66b63 100644 --- a/dDashboardServer/routes/APIRoutes.cpp +++ b/dDashboardServer/routes/APIRoutes.cpp @@ -1425,27 +1425,29 @@ namespace { } char decoded[256]{}; mg_url_decode(query.c_str(), query.size(), decoded, sizeof(decoded), 1); - query = decoded; - - auto stmt = CDClientDatabase::CreatePreppedStmt( - "SELECT id, name, displayName FROM Objects WHERE type = 'Loot' AND (name LIKE '%' || ? || '%' OR displayName LIKE '%' || ? || '%' OR id = ?) " - "ORDER BY name LIMIT 50;"); - stmt.bind(1, query.c_str()); - stmt.bind(2, query.c_str()); - stmt.bind(3, GeneralUtils::TryParse(query).value_or(-1)); - auto result = stmt.execQuery(); - - nlohmann::json items = nlohmann::json::array(); - while (!result.eof()) { - const std::string displayName = result.getStringField("displayName", ""); - items.push_back({ {"lot", result.getIntField("id")}, {"name", displayName.empty() ? result.getStringField("name") : displayName} }); - result.nextRow(); - } - JsonReply(reply, eHTTPStatusCode::OK, items); + JsonReply(reply, eHTTPStatusCode::OK, SearchItems(decoded)); }); } } +nlohmann::json SearchItems(const std::string& query) { + auto stmt = CDClientDatabase::CreatePreppedStmt( + "SELECT id, name, displayName FROM Objects WHERE type = 'Loot' AND (name LIKE '%' || ? || '%' OR displayName LIKE '%' || ? || '%' OR id = ?) " + "ORDER BY name LIMIT 50;"); + stmt.bind(1, query.c_str()); + stmt.bind(2, query.c_str()); + stmt.bind(3, GeneralUtils::TryParse(query).value_or(-1)); + auto result = stmt.execQuery(); + + nlohmann::json items = nlohmann::json::array(); + while (!result.eof()) { + const std::string displayName = result.getStringField("displayName", ""); + items.push_back({ {"lot", result.getIntField("id")}, {"name", displayName.empty() ? result.getStringField("name") : displayName} }); + result.nextRow(); + } + return items; +} + // For the property showcase (Showcase.cpp), which serves the same model data to people who aren't the owner namespace PropertyAssets { std::optional> ModelLxfml(const IPropertyContents::Model& model, LWOOBJID propertyId) { return ::ModelLxfml(model, propertyId); } diff --git a/dDashboardServer/routes/APIRoutes.h b/dDashboardServer/routes/APIRoutes.h index baa5f49d1..0436937be 100644 --- a/dDashboardServer/routes/APIRoutes.h +++ b/dDashboardServer/routes/APIRoutes.h @@ -1,3 +1,10 @@ #pragma once +#include + +#include "json.hpp" + void RegisterAPIRoutes(); + +// Items (Objects of type Loot) whose name or display name contains the text, or whose LOT it is: [{lot, name}], at most 50 +nlohmann::json SearchItems(const std::string& query); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index 57f15a916..26b4767c9 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -52,6 +52,7 @@ set(DASHBOARDROUTES_SOURCES "PublicRoutes.cpp" "Showcase.cpp" "FeaturedProperties.cpp" + "ContrabandRoutes.cpp" "PasswordRecovery.cpp" "PrometheusMetrics.cpp" "../email/SmtpClient.cpp" diff --git a/dDashboardServer/routes/ContrabandRoutes.cpp b/dDashboardServer/routes/ContrabandRoutes.cpp new file mode 100644 index 000000000..75035dc9c --- /dev/null +++ b/dDashboardServer/routes/ContrabandRoutes.cpp @@ -0,0 +1,111 @@ +#include "ContrabandRoutes.h" + +#include + +#include "APIRoutes.h" +#include "CDClientDatabase.h" +#include "ClientAssets.h" +#include "Database.h" +#include "DashboardRoutes.h" +#include "GeneralUtils.h" +#include "master/PlayerAction.h" +#include "PlayerActions.h" +#include "RouteUtils.h" +#include "WSRoutes.h" +#include "eHTTPMethod.h" + +using namespace RouteUtils; + +namespace { + using eContrabandAction = IContraband::eContrabandAction; + constexpr size_t MAX_REASON = 300; + + const char* ActionName(eContrabandAction action) { + return action == eContrabandAction::REMOVE ? "remove" : "flag"; + } + + std::optional ParseAction(const std::string& text) { + if (text == "flag") return eContrabandAction::FLAG; + if (text == "remove") return eContrabandAction::REMOVE; + return std::nullopt; + } + + bool IsItem(LOT lot) { + auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT 1 FROM Objects WHERE id = ? LIMIT 1;"); + stmt.bind(1, static_cast(lot)); + return !stmt.execQuery().eof(); + } + + std::string Trimmed(std::string text, size_t max) { + text.erase(0, text.find_first_not_of(" \t\r\n")); + text.erase(text.find_last_not_of(" \t\r\n") + 1); + return text.substr(0, max); + } + + // Tell every running world to load the list again + uint32_t ReloadWorlds(uint32_t requester) { + PlayerActionRequest request; + request.action = ePlayerAction::RELOAD_CONTRABAND; + return PlayerActions::Request(request, requester, [](const PlayerActionResult& result) { + return PlayerActions::Outcome{ true, result.affected ? "The list was updated in " + std::to_string(result.affected) + " world(s)" + : "No world is running; they'll use the list when they start" }; + }); + } +} + +namespace ContrabandRoutes { + void RegisterRoutes() { + Route(eHTTPMethod::GET, "/contraband", Perm("reports_view"), "Items players aren't allowed to have", + [](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "contraband.jinja2", "contraband"); }); + + Route(eHTTPMethod::GET, "/api/contraband", Perm("reports_view"), + "The contraband list: {items: [{lot, name, reason, action: flag|remove, added_by, added_at}], canManage}", + [](HTTPReply& reply, const HTTPContext& context) { + nlohmann::json items = nlohmann::json::array(); + for (const auto& item : Database::Get()->GetContrabandItems()) { + items.push_back({ {"lot", item.lot}, {"name", ClientAssets::ItemName(item.lot)}, {"reason", item.reason}, {"action", ActionName(item.action)}, + {"added_by", item.addedBy}, {"added_at", item.addedAt} }); + } + JsonSuccess(reply, { {"items", items}, {"canManage", Can(context, "contraband_manage")} }); + }); + + Route(eHTTPMethod::GET, "/api/contraband/items", Perm("contraband_manage"), "Search items to add by name or LOT: [{lot, name}]. Query: ?q=", + [](HTTPReply& reply, const HTTPContext& context) { + JsonReply(reply, eHTTPStatusCode::OK, SearchItems(Trimmed(QueryValue(context.queryString, "q"), 64))); + }); + + Route(eHTTPMethod::POST, "/api/contraband", Perm("contraband_manage"), + "Add an item to the list or change its entry; running worlds pick it up at once. Body: {lot, reason, action: flag|remove}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto body = ParseBody(context); + if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); + const auto lot = body->contains("lot") && (*body)["lot"].is_number_integer() ? std::optional((*body)["lot"].get()) + : GeneralUtils::TryParse(body->value("lot", "")); + if (!lot || *lot <= 0 || !IsItem(*lot)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick an item from the list or enter its LOT"); + const auto action = ParseAction(body->value("action", "flag")); + if (!action) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "action must be flag or remove"); + const auto reason = Trimmed(body->value("reason", ""), MAX_REASON); + + bool existed = false; + for (const auto& item : Database::Get()->GetContrabandItems()) existed = existed || item.lot == *lot; + Database::Get()->SetContrabandItem({ *lot, reason, *action, context.authenticatedUser, static_cast(std::time(nullptr)) }); + const auto name = ClientAssets::ItemName(*lot); + Audit(context, existed ? "contraband_change" : "contraband_add", std::string(existed ? "Changed" : "Added") + " contraband item " + name + " (" + + std::to_string(*lot) + "): " + (*action == eContrabandAction::REMOVE ? "flag and remove" : "flag only") + (reason.empty() ? "" : ", \"" + reason + "\"")); + BroadcastTableChanged("contraband"); + JsonSuccess(reply, { {"message", name + (existed ? " updated" : " added")}, {"requestId", ReloadWorlds(context.accountId)} }); + }); + + Route(eHTTPMethod::POST, "/api/contraband/delete", Perm("contraband_manage"), "Take an item off the list. Body: {lot}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto body = ParseBody(context); + const auto lot = body && body->contains("lot") && (*body)["lot"].is_number_integer() ? std::optional((*body)["lot"].get()) : std::nullopt; + if (!lot) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid lot"); + if (!Database::Get()->DeleteContrabandItem(*lot)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "That item isn't on the list"); + const auto name = ClientAssets::ItemName(*lot); + Audit(context, "contraband_remove", "Took " + name + " (" + std::to_string(*lot) + ") off the contraband list"); + BroadcastTableChanged("contraband"); + JsonSuccess(reply, { {"message", name + " is no longer contraband"}, {"requestId", ReloadWorlds(context.accountId)} }); + }); + } +} diff --git a/dDashboardServer/routes/ContrabandRoutes.h b/dDashboardServer/routes/ContrabandRoutes.h new file mode 100644 index 000000000..e199dc528 --- /dev/null +++ b/dDashboardServer/routes/ContrabandRoutes.h @@ -0,0 +1,11 @@ +#pragma once + +/** + * The Contraband page: items staff don't want players to have (IContraband). Each has a reason and says whether a + * character holding one is only flagged or also has it removed (see dGame/dUtilities/Contraband.h). Viewing needs + * reports_view, changing the list contraband_manage; every change is audited and running worlds reload the list. + * What was found shows up as economy flags of kind Contraband (Economy page, and the character's related data). + */ +namespace ContrabandRoutes { + void RegisterRoutes(); +} diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 4b6b78a5a..97a76ff30 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -342,6 +342,10 @@ namespace { c.Add(Unit(Int(DASHBOARD, "anomaly_item_minimum", "Item spike: at least", "...and more than this many.", "200", 0, std::nullopt), "items")); c.Add(Bool(DASHBOARD, "economy_duplicate_scan", "Nightly duplicate scan", "Reads every character's inventory.", true)); + c.AddSection("Contraband", "Items on the Contraband page are flagged, or removed, when a character loads or receives one. The list itself is edited on that page."); + c.Add(Bool(WORLD, "contraband_ignore_staff", "Don't check staff", "Accounts with a GM level keep listed items and aren't flagged.", true)); + c.Add(Bool(WORLD, "contraband_notify_players", "Tell players", "A mail (at login) or a chat message (when received) says which item was removed and why.", true)); + c.AddSection("Backups"); c.Add(Format(Text(DASHBOARD, "backup_folder", "Backup folder", "Relative to the server binaries unless absolute.", "backups"), eFormat::PATH)); c.Add(Unit(Int(DASHBOARD, "backup_keep", "Backups to keep", "Older backups are deleted after each new one. 0 keeps them all.", "7", 0, 1000), "backups")); diff --git a/dDashboardServer/static/js/contraband.js b/dDashboardServer/static/js/contraband.js new file mode 100644 index 000000000..1106fd4a7 --- /dev/null +++ b/dDashboardServer/static/js/contraband.js @@ -0,0 +1,76 @@ +/** + * The Contraband page: the list of items players shouldn't have, and (with contraband_manage) adding, changing and + * removing entries. Running worlds reload the list after every change. + */ +(function () { + 'use strict'; + + var canManage = false; + var lotInput = document.getElementById('lotInput'); + + function row(i) { + var action = i.action === 'remove' ? fmt.badge('Flag and remove', 'danger') : fmt.badge('Flag', 'warning'); + var buttons = canManage ? '' + + '' : ''; + return '' + esc(i.name) + ' ' + esc(i.lot) + '' + + '' + esc(i.reason) + '' + action + '' + esc(i.added_by) + ', ' + esc(fmt.unix(i.added_at)) + '' + + '' + buttons + ''; + } + + var items = []; + function load() { + api.get('/api/contraband').then(function (d) { + if (!d.success) return; + items = d.items; + canManage = d.canManage; + document.getElementById('addCard').classList.toggle('d-none', !canManage); + document.getElementById('rows').innerHTML = items.map(row).join('') || + 'Nothing is contraband.'; + }).catch(function () {}); + } + + var timer = null; + lotInput.addEventListener('input', function () { + clearTimeout(timer); + var q = lotInput.value.trim(); + if (q.length < 2 || /^\d+$/.test(q)) return; + timer = setTimeout(function () { + api.get('/api/contraband/items?q=' + encodeURIComponent(q)).then(function (found) { + document.getElementById('lotSuggestions').innerHTML = (Array.isArray(found) ? found : []).map(function (i) { + return ''; + }).join(''); + }); + }, 250); + }); + + function done(d) { + if (!d.success) { toast(d.error || 'Failed', 'danger'); return; } + toast(d.message, 'success'); + load(); + } + + document.getElementById('addForm').addEventListener('submit', function (e) { + e.preventDefault(); + var lot = parseInt(lotInput.value, 10); + if (!(lot > 0)) { toast('Pick an item from the list or enter its LOT', 'warning'); return; } + api.post('/api/contraband', { lot: lot, reason: document.getElementById('reasonInput').value, action: document.getElementById('actionInput').value }) + .then(function (d) { if (d.success) { lotInput.value = ''; document.getElementById('reasonInput').value = ''; } done(d); }); + }); + + document.getElementById('rows').addEventListener('click', function (e) { + var edit = e.target.closest('[data-edit]'), remove = e.target.closest('[data-remove]'); + if (edit) { + var item = items.filter(function (i) { return String(i.lot) === edit.dataset.edit; })[0]; + if (!item) return; + lotInput.value = item.lot; + document.getElementById('reasonInput').value = item.reason; + document.getElementById('actionInput').value = item.action; + lotInput.focus(); + } else if (remove) { + if (!confirm('Take this item off the contraband list?')) return; + api.post('/api/contraband/delete', { lot: parseInt(remove.dataset.remove, 10) }).then(done); + } + }); + + load(); +})(); diff --git a/dDashboardServer/static/js/related.js b/dDashboardServer/static/js/related.js index bdb621d81..4bb9a3341 100644 --- a/dDashboardServer/static/js/related.js +++ b/dDashboardServer/static/js/related.js @@ -11,7 +11,7 @@ // Names come from the server's enums (Labels); only the colours are kept here var METHOD_COLOURS = { 1: 'info', 2: 'secondary', 3: 'success', 4: 'light' }; function methodBadge(value) { return fmt.badge(Labels.name('transferMethods', value) || '?', METHOD_COLOURS[value] || 'secondary'); } - var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger' }; + var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger', 4: 'warning', 5: 'danger' }; var FLAG_STATUS_COLOURS = { 0: 'danger', 1: 'secondary', 2: 'success' }; function flagKindBadge(value) { return fmt.badge(Labels.name('flagKinds', value) || '?', FLAG_KIND_COLOURS[value] || 'secondary'); } function flagStatusBadge(value) { return fmt.badge(Labels.name('flagStatus', value) || '?', FLAG_STATUS_COLOURS[value] || 'secondary'); } diff --git a/dDashboardServer/static/js/reports.js b/dDashboardServer/static/js/reports.js index 16dcee19a..9c2cd5937 100644 --- a/dDashboardServer/static/js/reports.js +++ b/dDashboardServer/static/js/reports.js @@ -1588,7 +1588,7 @@ // ---- Flags ---- - var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger', 4: 'warning' }; + var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger', 4: 'warning', 5: 'danger' }; var FLAG_STATUS_COLOURS = { 0: 'danger', 1: 'secondary', 2: 'success' }; function flagKindBadge(value) { return fmt.badge(Labels.name('flagKinds', value) || '?', FLAG_KIND_COLOURS[value] || 'secondary'); } function flagStatusBadge(value) { return fmt.badge(Labels.name('flagStatus', value) || '?', FLAG_STATUS_COLOURS[value] || 'secondary'); } diff --git a/dDashboardServer/templates/contraband.jinja2 b/dDashboardServer/templates/contraband.jinja2 new file mode 100644 index 000000000..c4afd315c --- /dev/null +++ b/dDashboardServer/templates/contraband.jinja2 @@ -0,0 +1,42 @@ +{% extends "base.jinja2" %} + +{% block title %}Contraband - DarkflameServer{% endblock %} + +{% block content %} +
+
+

Contraband

+

Items players shouldn't have. World servers check every inventory (vault included) when a character loads, + and every item a player receives. Flag adds an economy flag for staff to review; Remove flags it and takes the + item away (a snapshot of the character is kept first, so it can be given back from the character page). Staff accounts aren't checked unless + contraband_ignore_staff is off. Running worlds pick up changes straight away. + Flags found: Economy & Map, Flags.

+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+ + + +
ItemReasonWhen foundAdded
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/dDashboardServer/templates/header.jinja2 b/dDashboardServer/templates/header.jinja2 index 277738ad6..314b3c715 100644 --- a/dDashboardServer/templates/header.jinja2 +++ b/dDashboardServer/templates/header.jinja2 @@ -48,7 +48,7 @@ {% endif %} - {% set worldPages = ["world3d", "reports"] %} + {% set worldPages = ["world3d", "reports", "contraband"] %} {% if can.players_view or can.reports_view %} diff --git a/dDatabase/GameDatabase/GameDatabase.h b/dDatabase/GameDatabase/GameDatabase.h index cd48f8550..59f067a53 100644 --- a/dDatabase/GameDatabase/GameDatabase.h +++ b/dDatabase/GameDatabase/GameDatabase.h @@ -47,6 +47,7 @@ #include "ILiveOps.h" #include "IFeaturedProperties.h" #include "IMessageCaptures.h" +#include "IContraband.h" #ifdef _DEBUG # define DLU_SQL_TRY_CATCH_RETHROW(x) do { try { x; } catch (std::exception& ex) { LOG("SQL Error: %s", ex.what()); throw; } } while(0) @@ -60,7 +61,7 @@ class GameDatabase : public IPropertyContents, public IProperty, public IPetNames, public ICharXml, public IMigrationHistory, public IUgc, public IFriends, public ICharInfo, public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList, - public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures { + public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband { public: virtual ~GameDatabase() = default; // TODO: These should be made private. diff --git a/dDatabase/GameDatabase/ITables/IContraband.h b/dDatabase/GameDatabase/ITables/IContraband.h new file mode 100644 index 000000000..72b0b098a --- /dev/null +++ b/dDatabase/GameDatabase/ITables/IContraband.h @@ -0,0 +1,40 @@ +#ifndef __ICONTRABAND__H__ +#define __ICONTRABAND__H__ + +#include +#include +#include + +#include "dCommonVars.h" + +/** + * Contraband: items staff don't want players to have (issue #1563), edited on the dashboard. World servers check a + * character's items against the list when it loads and when items are added, and flag (an economy flag of kind + * CONTRABAND) or also remove them. + */ +class IContraband { +public: + enum class eContrabandAction : uint8_t { + FLAG = 0, // only flag the character + REMOVE = 1 // flag the character and remove the item + }; + + struct ContrabandItem { + LOT lot{}; + std::string reason; + eContrabandAction action{}; + std::string addedBy; + int64_t addedAt{}; + }; + + // Every listed item, by LOT + virtual std::vector GetContrabandItems() = 0; + + // Insert or replace the row of item.lot + virtual void SetContrabandItem(const ContrabandItem& item) = 0; + + // Whether there was a row to delete + virtual bool DeleteContrabandItem(LOT lot) = 0; +}; + +#endif //!__ICONTRABAND__H__ diff --git a/dDatabase/GameDatabase/ITables/IDashboardAdmin.h b/dDatabase/GameDatabase/ITables/IDashboardAdmin.h index 0990cecfd..44151ce72 100644 --- a/dDatabase/GameDatabase/ITables/IDashboardAdmin.h +++ b/dDatabase/GameDatabase/ITables/IDashboardAdmin.h @@ -58,7 +58,10 @@ public: DUPLICATE = 3, // an object id exists in more than one place (day 0, so each item is flagged once) // different items share an object id and the login migration will not separate them: the characters holding // them already migrated, or a copy is in mail (day 0) - ID_COLLISION = 4 + ID_COLLISION = 4, + // a character has an item on the contraband list (value: how many, baseline: 1 when it was removed). Found when + // the character loaded (day 0, one flag per item) or when the item was added (that day, item id 0) + CONTRABAND = 5 }; enum class eFlagStatus : uint8_t { OPEN = 0, DISMISSED = 1, ACTIONED = 2 }; diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h index 6a20c2a6a..06ec9bc41 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h @@ -215,6 +215,11 @@ public: std::vector GetZoneLimits() override; void SetZoneLimit(const ZoneLimit& limit) override; void DeleteZoneLimit(uint32_t zoneId) override; + // IContraband + std::vector GetContrabandItems() override; + void SetContrabandItem(const ContrabandItem& item) override; + bool DeleteContrabandItem(LOT lot) override; + // IFeaturedProperties std::vector GetFeaturedPropertySlots() override; void SetFeaturedPropertySlot(const FeaturedSlot& slot) override; diff --git a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt index 8b2fbe9d3..e37fb508d 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt @@ -18,6 +18,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES "ServerOperations.cpp" "LiveOps.cpp" "FeaturedProperties.cpp" + "Contraband.cpp" "MessageCaptures.cpp" "ChatLog.cpp" "RelatedData.cpp" diff --git a/dDatabase/GameDatabase/MySQL/Tables/Contraband.cpp b/dDatabase/GameDatabase/MySQL/Tables/Contraband.cpp new file mode 100644 index 000000000..ba043a22e --- /dev/null +++ b/dDatabase/GameDatabase/MySQL/Tables/Contraband.cpp @@ -0,0 +1,22 @@ +#include "MySQLDatabase.h" + +std::vector MySQLDatabase::GetContrabandItems() { + std::vector items; + auto result = ExecuteSelect("SELECT * FROM contraband_items ORDER BY lot;"); + while (result->next()) { + items.push_back({ result->getInt("lot"), result->getString("reason").c_str(), static_cast(result->getUInt("action")), + result->getString("added_by").c_str(), result->getInt64("added_at") }); + } + return items; +} + +void MySQLDatabase::SetContrabandItem(const ContrabandItem& item) { + ExecuteInsert( + "INSERT INTO contraband_items (lot, reason, action, added_by, added_at) VALUES (?, ?, ?, ?, ?) " + "ON DUPLICATE KEY UPDATE reason = VALUES(reason), action = VALUES(action), added_by = VALUES(added_by), added_at = VALUES(added_at);", + item.lot, item.reason, static_cast(item.action), item.addedBy, item.addedAt); +} + +bool MySQLDatabase::DeleteContrabandItem(LOT lot) { + return ExecuteUpdate("DELETE FROM contraband_items WHERE lot = ?;", lot) > 0; +} diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h index 543c4cfe1..4c1dc212c 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h @@ -199,6 +199,11 @@ public: std::vector GetZoneLimits() override; void SetZoneLimit(const ZoneLimit& limit) override; void DeleteZoneLimit(uint32_t zoneId) override; + // IContraband + std::vector GetContrabandItems() override; + void SetContrabandItem(const ContrabandItem& item) override; + bool DeleteContrabandItem(LOT lot) override; + // IFeaturedProperties std::vector GetFeaturedPropertySlots() override; void SetFeaturedPropertySlot(const FeaturedSlot& slot) override; diff --git a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt index c94c62698..2af0d28ab 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt @@ -18,6 +18,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES "ServerOperations.cpp" "LiveOps.cpp" "FeaturedProperties.cpp" + "Contraband.cpp" "MessageCaptures.cpp" "ChatLog.cpp" "RelatedData.cpp" diff --git a/dDatabase/GameDatabase/SQLite/Tables/Contraband.cpp b/dDatabase/GameDatabase/SQLite/Tables/Contraband.cpp new file mode 100644 index 000000000..6857503fd --- /dev/null +++ b/dDatabase/GameDatabase/SQLite/Tables/Contraband.cpp @@ -0,0 +1,22 @@ +#include "SQLiteDatabase.h" + +std::vector SQLiteDatabase::GetContrabandItems() { + std::vector items; + auto [_, result] = ExecuteSelect("SELECT * FROM contraband_items ORDER BY lot;"); + for (; !result.eof(); result.nextRow()) { + items.push_back({ result.getIntField("lot"), result.getStringField("reason"), static_cast(result.getIntField("action")), + result.getStringField("added_by"), result.getInt64Field("added_at") }); + } + return items; +} + +void SQLiteDatabase::SetContrabandItem(const ContrabandItem& item) { + ExecuteInsert( + "INSERT INTO contraband_items (lot, reason, action, added_by, added_at) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(lot) DO UPDATE SET reason = excluded.reason, action = excluded.action, added_by = excluded.added_by, added_at = excluded.added_at;", + item.lot, item.reason, static_cast(item.action), item.addedBy, item.addedAt); +} + +bool SQLiteDatabase::DeleteContrabandItem(LOT lot) { + return ExecuteUpdate("DELETE FROM contraband_items WHERE lot = ?;", lot) > 0; +} diff --git a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h index c158962f9..be88ac7ce 100644 --- a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h +++ b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h @@ -245,6 +245,11 @@ class TestSQLDatabase : public GameDatabase { std::vector GetZoneLimits() override { return {}; } void SetZoneLimit(const ZoneLimit& limit) override {} void DeleteZoneLimit(uint32_t zoneId) override {} + // IContraband + std::vector GetContrabandItems() override { return {}; } + void SetContrabandItem(const ContrabandItem& item) override {} + bool DeleteContrabandItem(LOT lot) override { return false; } + // IFeaturedProperties std::vector GetFeaturedPropertySlots() override { return {}; } void SetFeaturedPropertySlot(const FeaturedSlot& slot) override {} diff --git a/dGame/dComponents/InventoryComponent.cpp b/dGame/dComponents/InventoryComponent.cpp index f5679a5a6..a6fc452e2 100644 --- a/dGame/dComponents/InventoryComponent.cpp +++ b/dGame/dComponents/InventoryComponent.cpp @@ -1,4 +1,5 @@ #include "InventoryComponent.h" +#include "Contraband.h" #include "EconomyLedger.h" #include @@ -204,6 +205,8 @@ void InventoryComponent::AddItem( inventoryType = Inventory::FindInventoryTypeForLot(lot); } + Contraband::OnItemAdded(m_Parent, lot, count, lootSourceType, inventorySourceType); + auto* missions = static_cast(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(lootSourceType)); + Contraband::OnItemAdded(m_Parent, lot, count, lootSourceType, options.sourceInventory); if (options.equip) { item->Equip(); Game::entityManager->SerializeEntity(m_Parent); diff --git a/dGame/dUtilities/CMakeLists.txt b/dGame/dUtilities/CMakeLists.txt index 0b7370805..db950505c 100644 --- a/dGame/dUtilities/CMakeLists.txt +++ b/dGame/dUtilities/CMakeLists.txt @@ -6,6 +6,7 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp" "DashboardNotify.cpp" "GUID.cpp" "LiveEvents.cpp" + "Contraband.cpp" "Loot.cpp" "Mail.cpp" "MessageInspector.cpp" diff --git a/dGame/dUtilities/Contraband.cpp b/dGame/dUtilities/Contraband.cpp new file mode 100644 index 000000000..d1cffb81d --- /dev/null +++ b/dGame/dUtilities/Contraband.cpp @@ -0,0 +1,199 @@ +#include "Contraband.h" + +#include +#include + +#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 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(data.size())), '\0'); + const auto size = ZCompression::Compress(reinterpret_cast(data.data()), static_cast(data.size()), + reinterpret_cast(out.data()), static_cast(out.size())); + out.resize(size > 0 ? static_cast(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 HeldItems(InventoryComponent& inventory) { + std::vector 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 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 Find(const std::vector& items, const List& list) { + std::vector 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() : 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 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(std::time(nullptr)), "before contraband removal", ACTOR, + static_cast(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(); + 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)); + } + }); + } +} diff --git a/dGame/dUtilities/Contraband.h b/dGame/dUtilities/Contraband.h new file mode 100644 index 000000000..ab00eb03e --- /dev/null +++ b/dGame/dUtilities/Contraband.h @@ -0,0 +1,77 @@ +#ifndef __CONTRABAND__H__ +#define __CONTRABAND__H__ + +#include +#include +#include +#include + +#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; + + 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 Find(const std::vector& 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__ diff --git a/dGame/dUtilities/DashboardActions.cpp b/dGame/dUtilities/DashboardActions.cpp index 8e66532a1..27f9e29b7 100644 --- a/dGame/dUtilities/DashboardActions.cpp +++ b/dGame/dUtilities/DashboardActions.cpp @@ -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(request.targetId), request.approved, request.text); case ePlayerAction::RELOAD_LIVE_OPS: return LiveEvents::Reload(); + case ePlayerAction::RELOAD_CONTRABAND: + return Contraband::Reload(); } return 0; } diff --git a/dNet/master/PlayerAction.h b/dNet/master/PlayerAction.h index a554f9fdc..1f31e2c0d 100644 --- a/dNet/master/PlayerAction.h +++ b/dNet/master/PlayerAction.h @@ -35,6 +35,7 @@ enum class ePlayerAction : uint8_t { MISSION_RESET, // as /resetmission MISSION_ACCEPT, // as /addmission (prerequisites are skipped) RELOAD_LIVE_OPS, // load the running live events and open challenges again (every world, answering 1 each; see LiveEvents.h) + RELOAD_CONTRABAND, // load the contraband list again (every world, answering 1 each; see Contraband.h) }; struct PlayerActionRequest : public LUBitStream { diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index 27c7f9367..108c2a18c 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -6,6 +6,7 @@ #include "master/PlayerAction.h" #include "master/MessageCapture.h" #include "MessageInspector.h" +#include "Contraband.h" #include "LiveEvents.h" #include #include @@ -952,6 +953,9 @@ void LoadPlayer(const SystemAddress& sysAddr) { } } + // Contraband is flagged (and removed if its entry says so) before the character is saved and sent + Contraband::CheckOnLoad(player); + // Update the characters xml to ensure the update above is not only saved, but so the client picks up on the changes. c->SaveXMLToDatabase(); diff --git a/docs/Dashboard.md b/docs/Dashboard.md index 25a2fed6c..fd3784f23 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -1015,6 +1015,25 @@ only for collisions that logging in will not fix (both characters already migrat duplicate (the same item twice) is still flagged even when one copy is on an old save: the login gives it a new ID but keeps both copies, so check it before then. +### Contraband + +The **Contraband** page (World & Economy; viewing needs `reports_view`, changing the list `contraband_manage`, GM 8) +lists items players shouldn't have. Pick an item with the item search (or type its LOT), give a reason and choose +what happens when a character has one: + +- **Flag**: an economy flag of kind *Contraband* is added (Economy & Map, Flags; and the character's related data), + to review like any other flag. +- **Flag and remove**: the same flag, and the item is taken away. + +World servers check every inventory, the vault included, when a character loads (one flag per item), and every +item a player receives from loot, trades, mail, vendors and so on (one flag per character, item and day). Moving an +item between a player's own inventories doesn't count. Before removing items at login the world keeps a snapshot of +the character (reason "before contraband removal"), so **Give back lost items** on the character page can return +them if an item was listed by mistake; every removal is in the audit log as `contraband_removed`. Players get a mail +(at login) or a chat message (when received) saying what was removed and why, unless `contraband_notify_players` is +off. Staff accounts (GM level above 0) aren't checked unless `contraband_ignore_staff` is off. Every change to the +list is audited and running worlds load it again straight away. + ### Saved views and report emails On the Economy page, **Views** saves the current tab, range, staff toggle and item filter under a name, so you can go diff --git a/migrations/dlu/mysql/77_contraband.sql b/migrations/dlu/mysql/77_contraband.sql new file mode 100644 index 000000000..68c13632d --- /dev/null +++ b/migrations/dlu/mysql/77_contraband.sql @@ -0,0 +1,9 @@ +/* Contraband: items staff don't want players to have, edited on the dashboard's Contraband page. action: 0 = flag + (an economy flag of kind 5 when a character has one), 1 = flag and remove the item. */ +CREATE TABLE IF NOT EXISTS contraband_items ( + lot INT NOT NULL PRIMARY KEY, + reason TEXT NOT NULL, + action TINYINT NOT NULL DEFAULT 0, + added_by VARCHAR(64) NOT NULL DEFAULT '', + added_at BIGINT NOT NULL DEFAULT 0 +); diff --git a/migrations/dlu/sqlite/60_contraband.sql b/migrations/dlu/sqlite/60_contraband.sql new file mode 100644 index 000000000..a932d43dc --- /dev/null +++ b/migrations/dlu/sqlite/60_contraband.sql @@ -0,0 +1,8 @@ +/* contraband_items: see the MySQL migration. */ +CREATE TABLE IF NOT EXISTS contraband_items ( + lot INTEGER NOT NULL PRIMARY KEY, + reason TEXT NOT NULL, + action INTEGER NOT NULL DEFAULT 0, + added_by TEXT NOT NULL DEFAULT '', + added_at BIGINT NOT NULL DEFAULT 0 +); diff --git a/tests/dDatabaseTests/DatabaseParityTests.cpp b/tests/dDatabaseTests/DatabaseParityTests.cpp index 1d55cabd8..d6322a180 100644 --- a/tests/dDatabaseTests/DatabaseParityTests.cpp +++ b/tests/dDatabaseTests/DatabaseParityTests.cpp @@ -99,6 +99,7 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILiveOps::Challenge, id, title, description, NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILiveOps::ChallengeTotal, total, contributors); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILiveOps::Reward, challengeId, characterId, amount, coins, rewardedAt, claimedAt); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IFeaturedProperties::FeaturedSlot, templateId, mode, propertyId, updatedAt, updatedBy, zoneId); +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IContraband::ContrabandItem, lot, reason, action, addedBy, addedAt); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IFeaturedProperties::FeaturedSettings, fullAuto, updatedAt, updatedBy); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IMessageCaptures::MessageCaptureSession, id, characterId, characterName, accountId, accountName, startedById, startedBy, startedAt, endsAt, endedAt, endReason, toServer, toClient, onlyMessages, skipMessages, zoneId, instanceId, cloneId, zones, messageCount, byteCount, dropped); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IMessageCaptures::MessageCaptureRecord, sessionId, seq, timeMs, direction, messageId, objectId, bits, droppedBefore, zoneId, instanceId, cloneId, payload, decoded); @@ -1395,6 +1396,22 @@ TEST_F(ParitySeeded, FeaturedProperties) { }); } +TEST_F(ParitySeeded, Contraband) { + Both("GetContrabandItems empty", [](GameDatabase& db) { return db.GetContrabandItems(); }); + Both("SetContrabandItem", [](GameDatabase& db) { + db.SetContrabandItem({ 14128, "Atlantis Squid Helm", IContraband::eContrabandAction::REMOVE, "admin", 1700000000 }); + db.SetContrabandItem({ 6655, "Stig's Helmet", IContraband::eContrabandAction::FLAG, "admin", 1700000001 }); + db.SetContrabandItem({ 6655, "changed", IContraband::eContrabandAction::REMOVE, "mod", 1700000002 }); + return db.GetContrabandItems(); + }); + Both("DeleteContrabandItem", [](GameDatabase& db) { return json{ db.DeleteContrabandItem(14128), db.DeleteContrabandItem(14128), db.GetContrabandItems() }; }); + Both("Contraband economy flag", [](GameDatabase& db) { + const bool first = db.InsertEconomyFlag({ 0, IDashboardAdmin::eFlagKind::CONTRABAND, CHAR_BOB, 6655, 1152921510000300001LL, 1, 1, "Removed at login" }); + const bool again = db.InsertEconomyFlag({ 0, IDashboardAdmin::eFlagKind::CONTRABAND, CHAR_BOB, 6655, 1152921510000300001LL, 1, 1, "Removed at login" }); + return json{ first, again, db.GetEconomyFlagsFor(CHAR_BOB, 5).size() }; + }); +} + TEST_F(ParitySeeded, MessageCaptures) { using Query = IMessageCaptures::SessionQuery; using eOrder = IMessageCaptures::eSessionOrder; diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index 04ba7611a..4b1dd4ed1 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -6,6 +6,7 @@ set(DGAMETEST_SOURCES "PlayerReportsLimitTests.cpp" "SlashCommandPermissionTests.cpp" "StaleSaveGuardTests.cpp" + "ContrabandTests.cpp" ) add_subdirectory(dComponentsTests) diff --git a/tests/dGameTests/ContrabandTests.cpp b/tests/dGameTests/ContrabandTests.cpp new file mode 100644 index 000000000..4b073dadd --- /dev/null +++ b/tests/dGameTests/ContrabandTests.cpp @@ -0,0 +1,38 @@ +#include + +#include "Contraband.h" +#include "eGameMasterLevel.h" + +using Contraband::HeldItem; +using eAction = IContraband::eContrabandAction; + +TEST(ContrabandTests, FindsListedItemsOnly) { + Contraband::List list{ { 14128, { "Atlantis Squid Helm", eAction::REMOVE } }, { 6655, { "Stig's Helmet", eAction::FLAG } } }; + const std::vector items{ + { 1, 6086, 1, eInventoryType::ITEMS }, + { 2, 14128, 1, eInventoryType::VAULT_ITEMS }, + { 3, 6655, 2, eInventoryType::ITEMS }, + { 4, 6655, 0, eInventoryType::ITEMS }, // an empty stack is nothing + }; + const auto found = Contraband::Find(items, list); + ASSERT_EQ(found.size(), 2u); + EXPECT_EQ(found[0].item.id, 2); + EXPECT_EQ(found[0].entry.action, eAction::REMOVE); + EXPECT_EQ(found[1].item.id, 3); + EXPECT_EQ(found[1].entry.reason, "Stig's Helmet"); + EXPECT_TRUE(Contraband::Find(items, {}).empty()); +} + +TEST(ContrabandTests, StaffAreSkippedUnlessConfigured) { + EXPECT_TRUE(Contraband::Applies(eGameMasterLevel::CIVILIAN, true)); + EXPECT_FALSE(Contraband::Applies(eGameMasterLevel::MODERATOR, true)); + EXPECT_TRUE(Contraband::Applies(eGameMasterLevel::MODERATOR, false)); +} + +TEST(ContrabandTests, OwnInventoryMovesAreNotNewItems) { + EXPECT_TRUE(Contraband::CountsAsAdded(eLootSourceType::TRADE, eInventoryType::INVALID)); + EXPECT_TRUE(Contraband::CountsAsAdded(eLootSourceType::NONE, eInventoryType::INVALID)); + EXPECT_FALSE(Contraband::CountsAsAdded(eLootSourceType::NONE, eInventoryType::VAULT_ITEMS)); + EXPECT_FALSE(Contraband::CountsAsAdded(eLootSourceType::RELOCATE, eInventoryType::INVALID)); + EXPECT_FALSE(Contraband::CountsAsAdded(eLootSourceType::INVENTORY, eInventoryType::INVALID)); +}