#include "EconomyJobs.h" #include #include #include #include #include "RouteUtils.h" #include "EconomyScan.h" #include "ReportRoutes.h" #include "ClientAssets.h" #include "Alerts.h" #include "WSRoutes.h" #include "Background.h" #include "Scheduler.h" #include "PlayerActions.h" #include "Database.h" #include "Game.h" #include "Logger.h" #include "dConfig.h" #include "GeneralUtils.h" #include "eHTTPMethod.h" using namespace RouteUtils; namespace { constexpr int64_t DAY_SECONDS = 24 * 60 * 60; constexpr uint32_t HISTORY_DAYS = 30; int64_t Setting(const std::string& key, int64_t fallback) { if (!Game::config) return fallback; return GeneralUtils::TryParse(Game::config->GetValue(key)).value_or(fallback); } uint32_t Today() { return static_cast(std::time(nullptr) / DAY_SECONDS); } std::string FormatNumber(int64_t value) { auto text = std::to_string(value < 0 ? -value : value); for (int i = static_cast(text.size()) - 3; i > 0; i -= 3) text.insert(static_cast(i), ","); return (value < 0 ? "-" : "") + text; } // Main thread: turn the day's aggregates (gathered on the worker) into flags uint32_t FlagIncome(uint32_t day, const nlohmann::json& raw, int64_t multiplier, int64_t minimum) { std::vector> incomes; for (const auto& row : raw) incomes.emplace_back(row[0].get(), row[1].get()); int64_t median = 0; uint32_t added = 0; for (const auto& [characterId, earned] : EconomyScan::UnusualIncome(incomes, multiplier, minimum, median)) { const auto multiple = median > 0 ? earned / median : 0; added += Database::Get()->InsertEconomyFlag({ day, IDashboardAdmin::eFlagKind::COIN_INCOME, characterId, 0, 0, earned, median, "Earned " + FormatNumber(earned) + " coins" + (multiple > 0 ? ", " + std::to_string(multiple) + "x the median player (" + FormatNumber(median) + ")" : "") }) ? 1 : 0; } return added; } std::map LotTotals(const nlohmann::json& raw) { std::map totals; for (const auto& row : raw) totals[row[0].get()] = row[1].get(); return totals; } uint32_t FlagItems(uint32_t day, const nlohmann::json& dayRaw, const nlohmann::json& historyRaw, int64_t multiplier, int64_t minimum) { uint32_t added = 0; for (const auto& spike : EconomyScan::ItemSpikes(LotTotals(dayRaw), LotTotals(historyRaw), HISTORY_DAYS, multiplier, minimum)) { added += Database::Get()->InsertEconomyFlag({ day, IDashboardAdmin::eFlagKind::ITEM_SPIKE, 0, spike.lot, 0, spike.created, spike.dailyAverage, FormatNumber(spike.created) + "x " + ClientAssets::ItemName(spike.lot) + " created, usually " + FormatNumber(spike.dailyAverage) + " a day" }) ? 1 : 0; } return added; } // "Alice (Backpack), Bob (mail)" std::string CopyPlaces(const nlohmann::json& copies, bool withItems) { std::string where; for (const auto& copy : copies) { if (!where.empty()) where += ", "; if (withItems) where += copy.value("name", "LOT " + std::to_string(copy.value("lot", 0))) + " on "; where += copy.value("character_name", copy.value("character_id", "")) + " (" + (copy.value("where", "") == "mail" ? "mail" : copy.value("inventory", "")) + ")"; } return where; } // Whose login clears a shared id (a scan group's login_fix): "Alice", "Alice or Bob", "Alice and Bob", // "all but one of Alice, Bob, Carol", "all of Alice, Bob, Carol" std::string WhoMustLogIn(const nlohmann::json& fix) { std::vector names; for (const auto& character : fix.value("characters", nlohmann::json::array())) { const auto name = character.value("character_name", ""); names.push_back(name.empty() ? character.value("character_id", "") : name); } if (names.empty()) return ""; if (names.size() == 1) return names[0]; const bool anyButOne = fix.value("logins_needed", names.size()) < names.size(); if (names.size() == 2) return names[0] + (anyButOne ? " or " : " and ") + names[1]; std::string list; for (const auto& name : names) list += (list.empty() ? "" : ", ") + name; return (anyButOne ? "all but one of " : "all of ") + list; } uint32_t FlagDuplicates(const nlohmann::json& scan) { uint32_t added = 0; for (const auto& duplicate : scan["duplicates"]) { const auto itemId = GeneralUtils::TryParse(duplicate.value("item_id", "")).value_or(0); const auto& copies = duplicate["copies"]; if (itemId == 0 || copies.empty()) continue; auto details = duplicate.value("name", "") + " exists " + std::to_string(copies.size()) + " times: " + CopyPlaces(copies, false); // The login migration gives old saves new item ids. That ends the shared id but keeps every copy, so it hides a // real dupe rather than fixing it: still flagged, with a note to look before the evidence is gone. const auto fix = duplicate.value("login_fix", nlohmann::json::object()); if (fix.value("resolves", false)) { details += ". The IDs split at the next login of " + WhoMustLogIn(fix) + " (an old save: its items get new IDs), so it stops showing up " "then, but the copies stay: check it before that"; } const auto owner = GeneralUtils::TryParse(copies[0].value("character_id", "")).value_or(0); // Day 0: a duplicated item is flagged once, not again every night added += Database::Get()->InsertEconomyFlag({ 0, IDashboardAdmin::eFlagKind::DUPLICATE, owner, duplicate.value("lot", 0), itemId, static_cast(copies.size()), 1, details }) ? 1 : 0; } return added; } /** * Different items sharing an id come from old saves, whose items had random ids. The login migration gives those * saves new ids, so a collision normally clears itself. One that stays after the characters migrated (or with a * copy in mail, which the migration does not touch) will not: flag it once. */ uint32_t FlagCollisions(const nlohmann::json& scan) { uint32_t added = 0; for (const auto& collision : scan.value("collisions", nlohmann::json::array())) { if (collision.value("duplicated", false) || collision.value("login_fix", nlohmann::json::object()).value("resolves", false)) continue; const auto itemId = GeneralUtils::TryParse(collision.value("item_id", "")).value_or(0); const auto& copies = collision["copies"]; if (itemId == 0 || copies.empty()) continue; const auto owner = GeneralUtils::TryParse(copies[0].value("character_id", "")).value_or(0); added += Database::Get()->InsertEconomyFlag({ 0, IDashboardAdmin::eFlagKind::ID_COLLISION, owner, copies[0].value("lot", 0), itemId, static_cast(copies.size()), 1, std::to_string(copies.size()) + " different items share this object ID and logging in will not " "give them new IDs (their characters already migrated, or a copy is in mail): " + CopyPlaces(copies, true) }) ? 1 : 0; } return added; } /** * Earlier scans flagged every object id found twice, including ids that old data gave to two different items. * Those aren't dupes: dismiss the open duplicate flags whose id the scan now finds only as an id collision. */ uint32_t DismissCollisionFlags(const nlohmann::json& scan) { std::map collisions; // id -> review note for (const auto& collision : scan.value("collisions", nlohmann::json::array())) { if (collision.value("duplicated", false)) continue; const auto fix = collision.value("login_fix", nlohmann::json::object()); collisions[collision.value("item_id", "")] = fix.value("resolves", false) ? "Not a duplicate: different items share this object ID (old random IDs). Resolves on login: the next login of " + WhoMustLogIn(fix) + " gives the items of that old save new IDs" : "Not a duplicate: different items share this object ID. Logging in will not fix it, so it is flagged as an ID collision instead"; } if (collisions.empty()) return 0; uint32_t dismissed = 0; const auto flags = Database::Get()->GetEconomyFlagsTable(0, 50000, static_cast(IDashboardAdmin::eFlagStatus::OPEN))["data"]; for (const auto& flag : flags) { const auto note = collisions.find(flag.value("item_id", "")); if (flag.value("kind", 0) != static_cast(IDashboardAdmin::eFlagKind::DUPLICATE) || note == collisions.end()) continue; const auto id = flag["id"].get(); Database::Get()->ReviewEconomyFlag(id, IDashboardAdmin::eFlagStatus::DISMISSED, 0, note->second); Audit(SystemContext(), "review_economy_flag", "Flag " + std::to_string(id) + " marked dismissed: object ID " + flag.value("item_id", "") + " is shared by different items, not duplicated"); dismissed++; } if (dismissed) BroadcastTableChanged("economy_flags"); return dismissed; } void Summarize(uint32_t day, uint32_t income, uint32_t items, uint32_t duplicates, uint32_t collisions = 0) { const auto total = income + items + duplicates + collisions; LOG("Economy checks for day %u: %u new flag(s) (%u income, %u items, %u duplicates, %u id collisions)", day, total, income, items, duplicates, collisions); if (total == 0) return; std::vector fields; if (income) fields.push_back({ "Unusual coin income", std::to_string(income) }); if (items) fields.push_back({ "Item spikes", std::to_string(items) }); if (duplicates) fields.push_back({ "Duplicated items", std::to_string(duplicates) }); if (collisions) fields.push_back({ "Object ID collisions logging in will not fix", std::to_string(collisions) }); Alerts::Emit("economy_flag", std::to_string(total) + " new economy flag" + (total == 1 ? "" : "s"), "The nightly checks found things worth a look.", fields, "/reports#flags"); BroadcastTableChanged("economy_flags"); } void CompactLedger(Scheduler::RunPtr run) { const auto today = Today(); const auto detailDays = static_cast(std::max(Setting("economy_detail_days", 180), 31)); const auto mapDays = static_cast(std::max(Setting("economy_map_days", 90), 31)); const auto transferDays = std::max(Setting("economy_transfer_days", 730), 1); run->Log("Merging daily rows older than " + std::to_string(detailDays) + " days (map: " + std::to_string(mapDays) + ") into months; removing trades and mail older than " + std::to_string(transferDays) + " days"); const bool queued = Background::Run("economy_compaction", [today, detailDays, mapDays, transferDays](GameDatabase& db) -> nlohmann::json { const auto now = static_cast(std::time(nullptr)); return { {"merged", db.CompactEconomy(today > detailDays ? today - detailDays : 0, today > mapDays ? today - mapDays : 0)}, {"transfers", db.PruneTransfers(now - transferDays * DAY_SECONDS)} }; }, [run](nlohmann::json result, const std::string& error) { if (!error.empty()) return run->Finish(false, "Failed: " + error); run->Finish(true, "Merged " + FormatNumber(result["merged"].get()) + " daily row(s) into months, removed " + FormatNumber(result["transfers"].get()) + " old transfer(s)"); }); if (!queued) run->Finish(false, "Compaction is already running"); } void PruneLogs(Scheduler::RunPtr run) { using eLog = IDashboardAdmin::eLog; std::vector, std::string, int64_t>> logs; for (const auto& [log, key, fallback] : { std::tuple{ std::optional(eLog::ACTIVITY), "log_activity_days", 365 }, std::tuple{ std::optional(eLog::COMMAND), "log_command_days", 365 }, std::tuple{ std::optional(eLog::AUDIT), "log_audit_days", 730 }, std::tuple{ std::optional(eLog::CHEAT_DETECTION), "log_cheat_detection_days", 365 }, std::tuple{ std::optional(eLog::CHAT), "log_chat_days", 30 }, std::tuple{ std::optional(eLog::LOGIN_ADDRESS), "log_login_address_days", 90 }, std::tuple{ std::optional(), "log_task_days", 90 }, std::tuple{ std::optional(), "health_days", 30 }, std::tuple{ std::optional(), "traffic_days", 30 }, std::tuple{ std::optional(), "position_history_days", 3 } }) { const auto days = Setting(key, fallback); run->Log(std::string(key) + " = " + (days > 0 ? std::to_string(days) : "0 (keep everything)")); logs.emplace_back(log, key, days); } const bool queued = Background::Run("log_pruning", [logs](GameDatabase& db) -> nlohmann::json { const auto now = static_cast(std::time(nullptr)); nlohmann::json pruned = nlohmann::json::object(); for (const auto& [log, key, days] : logs) { if (days <= 0) continue; const auto before = now - days * DAY_SECONDS; pruned[key] = log ? db.PruneLog(*log, before) : key == std::string("health_days") ? db.PruneHealthSamples(before) : key == std::string("traffic_days") ? db.PruneTrafficMinutes(before) : key == std::string("position_history_days") ? db.PrunePositionSamples(before) : db.PruneTaskRuns(before); } return pruned; }, [run](nlohmann::json pruned, const std::string& error) { if (!error.empty()) return run->Finish(false, "Failed: " + error); uint32_t total = 0; for (const auto& [key, removed] : pruned.items()) { run->Log(key + ": removed " + FormatNumber(removed.get()) + " row(s)"); total += removed.get(); } run->Finish(true, "Removed " + FormatNumber(total) + " old log row(s)"); }); if (!queued) run->Finish(false, "Pruning is already running"); } void CheckYesterday(Scheduler::RunPtr run) { const auto day = Today() - 1; const bool duplicates = Setting("economy_duplicate_scan", 1) != 0; run->Log("Checking day " + std::to_string(day) + (duplicates ? " with" : " without") + " a duplicate scan (economy_duplicate_scan)"); RunEconomyChecks(day, duplicates, [run](const EconomyCheckResult& result) { if (!result.error.empty()) return run->Finish(false, result.error); run->Log("Unusual coin income: " + std::to_string(result.income)); run->Log("Item spikes: " + std::to_string(result.items)); if (result.duplicateScan) run->Log("Duplicated items: " + std::to_string(result.duplicates)); if (result.duplicateScan) run->Log("Object ID collisions logging in will not fix: " + std::to_string(result.collisions)); if (result.collisionsDismissed) run->Log("Dismissed " + std::to_string(result.collisionsDismissed) + " earlier duplicate flag(s) for object IDs shared by different items (ID collisions, not dupes)"); run->Finish(true, std::to_string(result.Total()) + " new flag" + (result.Total() == 1 ? "" : "s")); }); } } void RunEconomyChecks(uint32_t day, bool duplicateScan, std::function done) { const auto coinMultiplier = Setting("anomaly_coin_multiplier", 20), coinMinimum = Setting("anomaly_coin_minimum", 100000); const auto itemMultiplier = Setting("anomaly_item_multiplier", 10), itemMinimum = Setting("anomaly_item_minimum", 200); const bool queued = Background::Run("economy_checks", [day](GameDatabase& db) -> nlohmann::json { nlohmann::json income = nlohmann::json::array(), dayItems = nlohmann::json::array(), history = nlohmann::json::array(); for (const auto& [id, gained] : db.GetDailyIncome(day)) income.push_back({ id, gained }); for (const auto& [lot, created] : db.GetItemCreationTotals(day, day)) dayItems.push_back({ lot, created }); for (const auto& [lot, created] : db.GetItemCreationTotals(day > HISTORY_DAYS ? day - HISTORY_DAYS : 0, day - 1)) history.push_back({ lot, created }); return { {"income", income}, {"day", dayItems}, {"history", history} }; }, [=](nlohmann::json raw, const std::string& error) { if (!error.empty()) { if (done) done({ .error = "The checks failed: " + error }); return; } EconomyCheckResult result; result.income = FlagIncome(day, raw["income"], coinMultiplier, coinMinimum); result.items = FlagItems(day, raw["day"], raw["history"], itemMultiplier, itemMinimum); if (!duplicateScan) { Summarize(day, result.income, result.items, 0); if (done) done(result); return; } const bool started = StartDuplicateScan([=](const nlohmann::json& scan, const std::string& scanError) { auto withScan = result; withScan.duplicateScan = scanError.empty(); withScan.duplicates = scanError.empty() ? FlagDuplicates(scan) : 0; withScan.collisionsDismissed = scanError.empty() ? DismissCollisionFlags(scan) : 0; withScan.collisions = scanError.empty() ? FlagCollisions(scan) : 0; Summarize(day, withScan.income, withScan.items, withScan.duplicates, withScan.collisions); if (done) done(withScan); }); if (!started) { // Someone is running one by hand; its result is the same Summarize(day, result.income, result.items, 0); if (done) done(result); } }); if (!queued && done) done({ .error = "The checks are already running" }); } void RegisterEconomyTasks() { Scheduler::Register({ "economy_checks", "Economy checks", "Flags unusual coin income, item spikes and (with economy_duplicate_scan) duplicated items for the day before; the scan also " "dismisses open duplicate flags whose object ID turns out to be shared by different items (an ID collision from old data, not a dupe; the note " "says whether it resolves on login) and flags collisions that logging in will not fix (characters already migrated, or a copy in mail). " "Thresholds: the anomaly_* settings. The default time leaves world servers a few minutes to write the last totals.", "15 0 * * *", CheckYesterday, 6 * 60 * 60 }); Scheduler::Register({ "economy_compaction", "Ledger compaction", "Merges daily economy rows older than economy_detail_days (map: economy_map_days) into monthly totals and deletes " "trades and mail older than economy_transfer_days.", "30 0 * * *", CompactLedger }); Scheduler::Register({ "log_pruning", "Log pruning", "Deletes activity, command, audit, cheat detection and task run rows older than the log_*_days settings.", "45 0 * * *", PruneLogs }); } void RegisterEconomyJobRoutes() { Route(eHTTPMethod::POST, "/api/reports/flags", Perm("reports_view"), "Economy flags, newest first (DataTables). Body adds {status: -1 all, 0 open, 1 dismissed, 2 actioned}", [](HTTPReply& reply, const HTTPContext& context) { const auto request = ParseDataTablesRequest(context.body); const auto body = ParseBody(context); if (!request || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); auto response = Database::Get()->GetEconomyFlagsTable(request->start, std::min(request->length, 200), body->value("status", 0)); for (auto& row : response["data"]) { if (row.value("lot", 0) > 0) row["name"] = ClientAssets::ItemName(row["lot"].get()); } response["draw"] = request->draw; JsonReply(reply, eHTTPStatusCode::OK, response); }); Route(eHTTPMethod::GET, "/api/reports/flags/csv", Perm("reports_view"), "Economy flags as CSV (up to 50,000). Query: ?status= (-1 all, 0 open, 1 dismissed, 2 actioned)", [](HTTPReply& reply, const HTTPContext& context) { const auto status = GeneralUtils::TryParse(QueryValue(context.queryString, "status")).value_or(-1); auto data = Database::Get()->GetEconomyFlagsTable(0, 50000, status)["data"]; static const char* KINDS[] = { "", "Coin income", "Item spike", "Duplicate", "ID collision" }; static const char* STATUSES[] = { "Open", "Dismissed", "Actioned" }; for (auto& row : data) { const auto kind = row.value("kind", 0), flagStatus = row.value("status", 0); row["kind_name"] = kind >= 1 && kind <= 4 ? KINDS[kind] : ""; row["status_name"] = flagStatus >= 0 && flagStatus <= 2 ? STATUSES[flagStatus] : ""; if (row.value("lot", 0) > 0) row["item_name"] = ClientAssets::ItemName(row["lot"].get()); } CsvReply(reply, "economy_flags.csv", ToCsv(data, { {"id", "ID"}, {"created_at", "Unix time"}, {"day", "Day"}, {"kind_name", "Kind"}, {"character_id", "Character ID"}, {"character_name", "Character"}, {"lot", "LOT"}, {"item_name", "Item"}, {"item_id", "Object ID"}, {"value", "Value"}, {"baseline", "Baseline"}, {"details", "Details"}, {"status_name", "Status"}, {"reviewer_name", "Reviewed by"}, {"note", "Note"} })); }); Route(eHTTPMethod::POST, "/api/reports/flags/:id/review", Perm("reports_review_flags"), "Mark a flag reviewed. Body: {status: open|dismissed|actioned, note}", [](HTTPReply& reply, const HTTPContext& context) { const auto id = PathId(context.path, 3); const auto body = ParseBody(context); if (!id || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid request"); const auto statusText = body->value("status", ""); IDashboardAdmin::eFlagStatus status; if (statusText == "open") status = IDashboardAdmin::eFlagStatus::OPEN; else if (statusText == "dismissed") status = IDashboardAdmin::eFlagStatus::DISMISSED; else if (statusText == "actioned") status = IDashboardAdmin::eFlagStatus::ACTIONED; else return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "status must be open, dismissed or actioned"); const std::string note = body->value("note", ""); if (note.size() > 1000) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Note is too long"); Database::Get()->ReviewEconomyFlag(*id, status, context.accountId, note); Audit(context, "review_economy_flag", "Flag " + std::to_string(*id) + " marked " + statusText + (note.empty() ? "" : ": " + note)); BroadcastTableChanged("economy_flags", std::to_string(*id)); JsonSuccess(reply); }); Route(eHTTPMethod::POST, "/api/reports/flags/run", Perm("reports_run_checks"), "Run the anomaly checks now in the background. Body: {day (days since epoch, default yesterday), duplicates: bool}. Returns {requestId}", [](HTTPReply& reply, const HTTPContext& context) { const auto body = ParseBody(context); if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); if (Background::IsRunning("economy_checks")) return JsonError(reply, eHTTPStatusCode::CONFLICT, "The checks are already running"); const auto today = Today(); const uint32_t day = std::min(body->value("day", today - 1), today); const auto requestId = PlayerActions::Begin(context.accountId, std::chrono::minutes(30)); const auto actor = context; RunEconomyChecks(day, body->value("duplicates", false), [requestId, actor, day](const EconomyCheckResult& result) { if (!result.error.empty()) return PlayerActions::Finish(requestId, { false, result.error }); const auto added = result.Total(); Audit(actor, "run_economy_checks", "Day " + std::to_string(day) + ": " + std::to_string(added) + " new flag(s)"); PlayerActions::Finish(requestId, { true, std::to_string(added) + " new flag" + (added == 1 ? "" : "s") }); }); JsonSuccess(reply, { {"requestId", requestId}, {"message", "Checks started"} }); }); }