Files
DarkflameServer/dDashboardServer/routes/ReportRoutes.cpp
Aaron Kimbrell 2da96ada83 feat(dashboard): 3D views show a zone's scenes as the game loads them
A "Scenes" choice in the world and property 3D views: every scene (as
before), the scenes the game keeps loaded around the camera or the followed
player (the scene under it from the terrain's scene map, the scenes its
transitions connect to, and the global scene, following as it moves), or
scenes picked from a list (world view). The lighting blends to the lighting
of the scene under the focus, as the client blends between scenes.

The scenery manifest now carries each object's scene, the zone's scenes with
their neighbours and lighting, and the scene map as runs (37 KB for Avant
Gardens); scenery-core.js finds the scene at a point exactly as ZoneScenes
does (checked against it on 2000 points of Avant Gardens).

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

1340 lines
70 KiB
C++

#include "ReportRoutes.h"
#include "OnceCache.h"
#include <deque>
#include <future>
#include "ZonePaths.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <ctime>
#include <map>
#include <set>
#include <mutex>
#include "RouteUtils.h"
#include "EconomyScan.h"
#include "TerrainMap.h"
#include "SceneColor.h"
#include "WSRoutes.h"
#include "Background.h"
#include "PlayerActions.h"
#include "CDClientDatabase.h"
#include "mongoose.h"
#include "ClientAssets.h"
#include "Database.h"
#include "GeneralUtils.h"
#include "eHTTPMethod.h"
#include "eInventoryType.h"
#include "eLootSourceType.h"
#include "magic_enum.hpp"
#include "LevelObjects.h"
#include "GameLabels.h"
#include "StatisticID.h"
#include "ItemTrace.h"
#include "EconomyPlaces.h"
using namespace RouteUtils;
namespace {
constexpr uint32_t DEFAULT_DAYS = 30;
constexpr uint32_t MAX_DAYS = 366;
uint32_t Today() {
return static_cast<uint32_t>(std::time(nullptr) / (24 * 60 * 60));
}
std::pair<uint32_t, uint32_t> RequestedDays(const HTTPContext& context) {
return EconomyScan::DayRange(
GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "from")),
GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "to")),
Today(), DEFAULT_DAYS, MAX_DAYS);
}
// Staff are left out unless ?staff=1, so moderators testing things do not skew the numbers
bool ExcludeStaff(const HTTPContext& context) {
return QueryValue(context.queryString, "staff") != "1";
}
nlohmann::json SourceNames() {
nlohmann::json names = nlohmann::json::object();
for (const auto source : magic_enum::enum_values<eLootSourceType>()) {
names[std::to_string(static_cast<uint32_t>(source))] = std::string(magic_enum::enum_name(source));
}
names[std::to_string(IEconomyLedger::DONATION_SOURCE)] = "DONATION";
names[std::to_string(IEconomyLedger::HARDCORE_DEATH_SOURCE)] = "HARDCORE_DEATH";
names[std::to_string(IEconomyLedger::HARDCORE_KILL_SOURCE)] = "HARDCORE_KILL";
return names;
}
std::map<LWOOBJID, std::string> CharacterNames() {
std::map<LWOOBJID, std::string> names;
for (auto& [id, name] : Database::Get()->GetCharacterIdsAndNames()) names.emplace(id, std::move(name));
return names;
}
std::string NameOf(const std::map<LWOOBJID, std::string>& names, LWOOBJID id) {
const auto it = names.find(id);
return it == names.end() ? "" : it->second;
}
// Items that are in the game for real: not set sub-items, and not sold back to a vendor
bool CountsAsHeld(const EconomyScan::InventoryItem& item) {
return !item.isProxy && item.inventoryType != eInventoryType::VENDOR_BUYBACK && item.inventoryType != eInventoryType::VENDOR;
}
std::string InventoryName(uint32_t type) {
if (type >= NUMBER_OF_INVENTORIES) return std::to_string(type);
return GameLabels::Name(static_cast<eInventoryType>(type)); // VAULT_ITEMS -> "Vault Items", as the other pages show it
}
nlohmann::json LocationJson(const EconomyScan::Location& location, const std::map<LWOOBJID, std::string>& names) {
const bool mail = location.kind == EconomyScan::Location::eKind::MAIL;
nlohmann::json json{
{"where", mail ? "mail" : "inventory"},
{"character_id", std::to_string(location.ownerId)},
{"character_name", NameOf(names, location.ownerId)},
{"lot", location.lot},
{"name", ClientAssets::ItemName(location.lot)},
{"count", location.count}
};
if (mail) json["mail_id"] = std::to_string(location.mailId);
else {
json["inventory"] = InventoryName(location.inventoryType);
json["character_version"] = location.characterVersion;
json["new_id_at_login"] = location.NewIdAtLogin();
}
return json;
}
// What the next logins do to an id held in several places (EconomyScan::ResolveOnLogin), for the pages
nlohmann::json LoginFixJson(const std::vector<EconomyScan::Location>& copies, const std::map<LWOOBJID, std::string>& names) {
const auto fix = EconomyScan::ResolveOnLogin(copies);
nlohmann::json characters = nlohmann::json::array();
for (const auto id : fix.characters) characters.push_back({ {"character_id", std::to_string(id)}, {"character_name", NameOf(names, id)} });
return { {"resolves", fix.resolves}, {"characters", characters}, {"logins_needed", fix.loginsNeeded} };
}
// Result of the last full duplicate scan; scans read every character, so they only run when asked
struct DuplicateScan {
int64_t time{};
double seconds{};
size_t characters{};
size_t items{};
nlohmann::json duplicates = nlohmann::json::array();
// Object ids shared by different LOTs: old data reused ids, so these are not dupes (see EconomyScan::Classify)
nlohmann::json collisions = nlohmann::json::array();
nlohmann::json heldByLot = nlohmann::json::array();
};
std::optional<DuplicateScan> g_LastScan;
constexpr size_t MAX_DUPLICATES_SHOWN = 500;
constexpr size_t MAX_LOTS_SHOWN = 200;
nlohmann::json RawLocation(const EconomyScan::Location& location) {
return { location.kind == EconomyScan::Location::eKind::MAIL ? 1 : 0, location.ownerId, location.mailId, location.inventoryType, location.lot, location.count, location.characterVersion };
}
EconomyScan::Location FromRawLocation(const nlohmann::json& raw) {
return { raw[0].get<int>() == 1 ? EconomyScan::Location::eKind::MAIL : EconomyScan::Location::eKind::CHARACTER,
raw[1].get<LWOOBJID>(), raw[2].get<uint64_t>(), raw[3].get<uint32_t>(), raw[4].get<LOT>(), raw[5].get<uint32_t>(), raw.size() > 6 ? raw[6].get<uint32_t>() : 0u };
}
// Worker thread: read every character and unclaimed mail. Only ids and counts; names are added on the main thread.
nlohmann::json ScanDuplicatesRaw(GameDatabase& db) {
const auto started = std::chrono::steady_clock::now();
EconomyScan::DuplicateFinder finder;
std::map<LOT, std::pair<uint64_t, uint32_t>> held; // lot -> (count, holders)
size_t characters = 0;
db.ForEachCharacterXml([&](LWOOBJID characterId, const std::string& xml) {
characters++;
std::set<LOT> lotsHere;
const auto version = EconomyScan::CharacterVersion(xml);
EconomyScan::ForEachInventoryItem(xml, [&](const EconomyScan::InventoryItem& item) {
if (item.isProxy) return;
finder.Add(item.id, { EconomyScan::Location::eKind::CHARACTER, characterId, 0, item.inventoryType, item.lot, item.count, version });
if (!CountsAsHeld(item)) return;
held[item.lot].first += item.count;
if (lotsHere.insert(item.lot).second) held[item.lot].second++;
});
});
db.ForEachMailAttachment([&](const IEconomyLedger::MailAttachment& attachment) {
finder.Add(attachment.itemId, { EconomyScan::Location::eKind::MAIL, attachment.receiverId, attachment.mailId, 0, attachment.lot, attachment.count });
held[attachment.lot].first += attachment.count;
});
const auto rawCopies = [](const std::vector<EconomyScan::Location>& locations) {
nlohmann::json copies = nlohmann::json::array();
for (const auto& location : locations) copies.push_back(RawLocation(location));
return copies;
};
nlohmann::json duplicates = nlohmann::json::array();
nlohmann::json collisions = nlohmann::json::array();
for (const auto& [id, locations] : finder.Duplicates()) {
const auto groups = EconomyScan::Classify(locations);
for (const auto& copies : groups.duplicates) {
if (duplicates.size() < MAX_DUPLICATES_SHOWN) duplicates.push_back({ {"id", id}, {"copies", rawCopies(copies)} });
}
if (groups.collision && collisions.size() < MAX_DUPLICATES_SHOWN) {
collisions.push_back({ {"id", id}, {"copies", rawCopies(locations)}, {"duplicated", !groups.duplicates.empty()} });
}
}
std::vector<std::pair<LOT, std::pair<uint64_t, uint32_t>>> byCount(held.begin(), held.end());
std::ranges::sort(byCount, [](const auto& a, const auto& b) { return a.second.first > b.second.first; });
nlohmann::json heldByLot = nlohmann::json::array();
for (const auto& [lot, totals] : byCount) {
if (heldByLot.size() >= MAX_LOTS_SHOWN) break;
heldByLot.push_back({ lot, totals.first, totals.second });
}
return {
{"characters", characters}, {"items", finder.ItemsSeen()}, {"duplicates", duplicates}, {"collisions", collisions}, {"held", heldByLot},
{"seconds", std::chrono::duration<double>(std::chrono::steady_clock::now() - started).count()}
};
}
// Main thread: add character and item names to a raw scan
DuplicateScan FinishDuplicateScan(const nlohmann::json& raw) {
DuplicateScan scan;
const auto names = CharacterNames();
for (const auto& duplicate : raw["duplicates"]) {
nlohmann::json copies = nlohmann::json::array();
std::vector<EconomyScan::Location> locations;
LOT lot = 0;
for (const auto& copy : duplicate["copies"]) {
const auto location = FromRawLocation(copy);
lot = location.lot;
locations.push_back(location);
copies.push_back(LocationJson(location, names));
}
scan.duplicates.push_back({ {"item_id", std::to_string(duplicate["id"].get<LWOOBJID>())}, {"lot", lot}, {"name", ClientAssets::ItemName(lot)}, {"copies", copies},
{"login_fix", LoginFixJson(locations, names)} });
}
for (const auto& collision : raw.value("collisions", nlohmann::json::array())) {
nlohmann::json copies = nlohmann::json::array();
std::vector<EconomyScan::Location> locations;
for (const auto& copy : collision["copies"]) {
locations.push_back(FromRawLocation(copy));
copies.push_back(LocationJson(locations.back(), names));
}
scan.collisions.push_back({ {"item_id", std::to_string(collision["id"].get<LWOOBJID>())}, {"duplicated", collision["duplicated"]}, {"copies", copies},
{"login_fix", LoginFixJson(locations, names)} });
}
for (const auto& entry : raw["held"]) {
const auto lot = entry[0].get<LOT>();
scan.heldByLot.push_back({ {"lot", lot}, {"name", ClientAssets::ItemName(lot)}, {"count", entry[1]}, {"holders", entry[2]} });
}
scan.characters = raw.value("characters", size_t{ 0 });
scan.items = raw.value("items", size_t{ 0 });
scan.seconds = raw.value("seconds", 0.0);
scan.time = std::time(nullptr);
return scan;
}
// Worker thread: where an object id is now
nlohmann::json FindObjectRaw(GameDatabase& db, LWOOBJID itemId) {
nlohmann::json locations = nlohmann::json::array();
db.ForEachCharacterXmlContaining("id=\"" + std::to_string(itemId) + "\"", [&](LWOOBJID characterId, const std::string& xml) {
EconomyScan::ForEachInventoryItem(xml, [&](const EconomyScan::InventoryItem& item) {
if (item.id == itemId) locations.push_back(RawLocation({ EconomyScan::Location::eKind::CHARACTER, characterId, 0, item.inventoryType, item.lot, item.count, EconomyScan::CharacterVersion(xml) }));
});
});
db.ForEachMailAttachment([&](const IEconomyLedger::MailAttachment& attachment) {
if (attachment.itemId == itemId) locations.push_back(RawLocation({ EconomyScan::Location::eKind::MAIL, attachment.receiverId, attachment.mailId, 0, attachment.lot, attachment.count }));
});
return locations;
}
// Worker thread: where each of several object ids is now: {"<id>": [raw locations]}. A few ids are looked up one
// by one (the database narrows it down); more are found in one pass over every character.
nlohmann::json FindObjectsRaw(GameDatabase& db, const std::vector<LWOOBJID>& itemIds) {
std::map<LWOOBJID, nlohmann::json> found;
for (const auto id : itemIds) found[id] = nlohmann::json::array();
const auto visitCharacter = [&](LWOOBJID characterId, const std::string& xml) {
EconomyScan::ForEachInventoryItem(xml, [&](const EconomyScan::InventoryItem& item) {
const auto it = found.find(item.id);
if (it != found.end()) it->second.push_back(RawLocation({ EconomyScan::Location::eKind::CHARACTER, characterId, 0, item.inventoryType, item.lot, item.count, EconomyScan::CharacterVersion(xml) }));
});
};
if (itemIds.size() <= 4) {
for (const auto id : itemIds) {
db.ForEachCharacterXmlContaining("id=\"" + std::to_string(id) + "\"", [&](LWOOBJID characterId, const std::string& xml) {
EconomyScan::ForEachInventoryItem(xml, [&](const EconomyScan::InventoryItem& item) {
if (item.id == id) found[id].push_back(RawLocation({ EconomyScan::Location::eKind::CHARACTER, characterId, 0, item.inventoryType, item.lot, item.count, EconomyScan::CharacterVersion(xml) }));
});
});
}
} else {
std::vector<std::string> needles;
for (const auto id : itemIds) needles.push_back("id=\"" + std::to_string(id) + "\"");
db.ForEachCharacterXml([&](LWOOBJID characterId, const std::string& xml) {
if (std::ranges::any_of(needles, [&](const std::string& needle) { return xml.find(needle) != std::string::npos; })) visitCharacter(characterId, xml);
});
}
db.ForEachMailAttachment([&](const IEconomyLedger::MailAttachment& attachment) {
const auto it = found.find(attachment.itemId);
if (it != found.end()) it->second.push_back(RawLocation({ EconomyScan::Location::eKind::MAIL, attachment.receiverId, attachment.mailId, 0, attachment.lot, attachment.count }));
});
nlohmann::json result = nlohmann::json::object();
for (auto& [id, locations] : found) result[std::to_string(id)] = std::move(locations);
return result;
}
constexpr size_t TRACE_MAX_HOPS = 200;
constexpr size_t TRACE_MAX_LOOKUPS = 100; // ids looked up in inventories and mail, the latest first
// Worker thread: an item's chain of transfers (ItemTrace) and where each of its ids is now
nlohmann::json TraceRaw(GameDatabase& db, LWOOBJID itemId, bool followMerges) {
std::map<int64_t, nlohmann::json> rowsById;
const auto chain = ItemTrace::Build(itemId, [&](const std::vector<LWOOBJID>& ids) {
std::vector<ItemTrace::Hop> hops;
for (auto& row : db.GetTransfersForItems(ids)) {
hops.push_back(ItemTrace::FromRow(row));
rowsById.emplace(hops.back().row, std::move(row));
}
return hops;
}, TRACE_MAX_HOPS, followMerges);
nlohmann::json hops = nlohmann::json::array();
for (const auto& traced : chain.hops) {
auto row = rowsById.at(traced.hop.row);
row["role"] = std::string(magic_enum::enum_name(traced.role));
row["merge"] = traced.merge;
row["merge_proven"] = traced.mergeProven;
row["gap"] = traced.gap ? nlohmann::json(std::string(magic_enum::enum_name(*traced.gap))) : nlohmann::json();
hops.push_back(std::move(row));
}
// Look up the latest ids first; the rest may still hold part of a split stack
std::vector<LWOOBJID> lookups(chain.latest.begin(), chain.latest.end());
for (const auto id : chain.ids) {
if (lookups.size() >= TRACE_MAX_LOOKUPS) break;
if (std::ranges::find(lookups, id) == lookups.end()) lookups.push_back(id);
}
nlohmann::json ids = nlohmann::json::array();
for (const auto id : chain.ids) ids.push_back(std::to_string(id));
nlohmann::json latest = nlohmann::json::array();
for (const auto id : chain.latest) latest.push_back(std::to_string(id));
return {
{"hops", hops}, {"ids", ids}, {"latest", latest}, {"first", std::to_string(chain.first)}, {"truncated", chain.truncated},
{"checked", lookups.size()}, {"locations", FindObjectsRaw(db, lookups)}
};
}
// Worker thread: who holds a LOT, by character, with counts per inventory
nlohmann::json FindHoldersRaw(GameDatabase& db, LOT lot) {
std::map<LWOOBJID, std::map<std::string, uint64_t>> holders;
db.ForEachCharacterXmlContaining("l=\"" + std::to_string(lot) + "\"", [&](LWOOBJID characterId, const std::string& xml) {
EconomyScan::ForEachInventoryItem(xml, [&](const EconomyScan::InventoryItem& item) {
if (item.lot == lot && CountsAsHeld(item)) holders[characterId][InventoryName(item.inventoryType)] += item.count;
});
});
db.ForEachMailAttachment([&](const IEconomyLedger::MailAttachment& attachment) {
if (attachment.lot == lot) holders[attachment.receiverId]["MAIL"] += attachment.count;
});
nlohmann::json rows = nlohmann::json::array();
for (const auto& [characterId, places] : holders) rows.push_back({ {"id", characterId}, {"places", places} });
return rows;
}
nlohmann::json ScanJson(const DuplicateScan& scan) {
return {
{"time", scan.time}, {"seconds", scan.seconds}, {"characters", scan.characters}, {"items", scan.items},
{"duplicateCount", scan.duplicates.size()}, {"duplicates", scan.duplicates},
{"collisionCount", scan.collisions.size()}, {"collisions", scan.collisions}, {"heldByLot", scan.heldByLot}
};
}
// ?kind= is an eMapEvent value
std::optional<IEconomyLedger::eMapEvent> MapKind(const std::string& kind) {
const auto value = GeneralUtils::TryParse<uint8_t>(kind);
return value ? magic_enum::enum_cast<IEconomyLedger::eMapEvent>(*value) : std::nullopt;
}
// How the pages show a kind of map event: the heading for its LOT column (empty when the LOT means nothing) and
// what its quantity counts (empty when it is just the number of events)
std::pair<std::string, std::string> MapKindColumns(IEconomyLedger::eMapEvent kind) {
using enum IEconomyLedger::eMapEvent;
switch (kind) {
case ENEMY_KILLS: return { "Enemy", "" };
case ITEM_DROPS: return { "Item", "Items" };
case COIN_DROPS: return { "", "Coins" };
case PLAYER_DEATHS: return { "Killed by", "" };
case PLAYER_COIN_DROPS: return { "", "Coins" };
case SMASHABLES_SMASHED: return { "Object", "" };
case QUICKBUILDS_COMPLETED: return { "Quickbuild", "" };
case POWERUP_DROPS: return { "Powerup", "" };
case POWERUP_PICKUPS: return { "Powerup", "" };
}
return { "Object", "" };
}
// [{value, name, lots, quantity}] for every kind of map event
nlohmann::json MapKindsJson() {
nlohmann::json kinds = nlohmann::json::array();
for (const auto kind : magic_enum::enum_values<IEconomyLedger::eMapEvent>()) {
const auto [lots, quantity] = MapKindColumns(kind);
kinds.push_back({ {"value", static_cast<int>(kind)}, {"name", GameLabels::Name(kind)}, {"lots", lots}, {"quantity", quantity} });
}
return kinds;
}
// [{value, name}] for every player statistic
nlohmann::json StatNamesJson() {
nlohmann::json stats = nlohmann::json::array();
for (const auto stat : magic_enum::enum_values<StatisticID>()) stats.push_back({ {"value", static_cast<int>(stat)}, {"name", GameLabels::Name(stat)} });
return stats;
}
struct ZoneInfo {
std::string name;
std::string luzPath; // relative to res/maps
};
// Standard base64 (plain C++, no mongoose: zone data is built on worker threads)
std::string Base64(std::string_view bytes) {
static constexpr char ALPHABET[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve((bytes.size() + 2) / 3 * 4);
size_t i = 0;
for (; i + 2 < bytes.size(); i += 3) {
const uint32_t n = (static_cast<uint8_t>(bytes[i]) << 16) | (static_cast<uint8_t>(bytes[i + 1]) << 8) | static_cast<uint8_t>(bytes[i + 2]);
for (const int shift : { 18, 12, 6, 0 }) out += ALPHABET[(n >> shift) & 63];
}
if (i < bytes.size()) {
uint32_t n = static_cast<uint8_t>(bytes[i]) << 16;
if (i + 1 < bytes.size()) n |= static_cast<uint8_t>(bytes[i + 1]) << 8;
out += ALPHABET[(n >> 18) & 63];
out += ALPHABET[(n >> 12) & 63];
out += i + 1 < bytes.size() ? ALPHABET[(n >> 6) & 63] : '=';
out += '=';
}
return out;
}
std::map<uint32_t, ZoneInfo> ReadZoneTable() {
std::map<uint32_t, ZoneInfo> zones;
try {
auto result = CDClientDatabase::ExecuteQuery("SELECT zoneID, zoneName, DisplayDescription FROM ZoneTable;");
for (; !result.eof(); result.nextRow()) {
const std::string description = result.getStringField("DisplayDescription", "");
const auto zoneId = static_cast<uint32_t>(result.getIntField("zoneID", 0));
zones.try_emplace(zoneId, ZoneInfo{ description.empty() ? "Zone " + std::to_string(zoneId) : description, result.getStringField("zoneName", "") });
}
} catch (const std::exception& ex) {
LOG("Could not read the zone table: %s", ex.what());
}
return zones;
}
// ZoneTable, read at startup (PreloadZoneData) so worker threads never query the CDClient
const std::map<uint32_t, ZoneInfo>& ZoneTable() {
static const auto zones = ReadZoneTable();
return zones;
}
std::optional<ZoneInfo> GetZoneInfo(uint32_t zoneId) {
const auto& zones = ZoneTable();
const auto it = zones.find(zoneId);
if (it == zones.end()) return std::nullopt;
return it->second;
}
// mapTextureResource: texture id -> file name (lowercase), read at startup
const std::map<uint32_t, std::string>& TerrainTextureNames() {
static const auto names = [] {
std::map<uint32_t, std::string> names;
try {
auto result = CDClientDatabase::ExecuteQuery("SELECT id, texturepath FROM mapTextureResource;");
for (; !result.eof(); result.nextRow()) {
std::string file = result.getStringField(1, "");
std::transform(file.begin(), file.end(), file.begin(), ::tolower);
names.try_emplace(static_cast<uint32_t>(result.getIntField(0, 0)), file);
}
} catch (const std::exception& ex) {
LOG("Could not read the terrain textures: %s", ex.what());
}
return names;
}();
return names;
}
/**
* A zone's terrain file read whole (Raw, dCommon): the one its .luz names, in the .luz's folder, else the .luz's
* name with .raw. Only the start of the .luz is read.
*/
std::optional<Raw::Raw> ReadZoneRaw(const ZoneInfo& zone) {
if (!zone.luzPath.ends_with(".luz")) return std::nullopt;
const auto folder = zone.luzPath.substr(0, zone.luzPath.find_last_of('/') + 1);
std::string rawPath = zone.luzPath.substr(0, zone.luzPath.size() - 4) + ".raw";
if (const auto luz = ClientAssets::ReadResFile("maps/" + zone.luzPath)) {
std::string error;
const auto header = ZonePaths::ReadHeader(*luz, error);
if (header && header->zoneRawPath.ends_with(".raw")) rawPath = folder + header->zoneRawPath;
}
auto data = ClientAssets::ReadResFile("maps/" + rawPath);
if (!data) return std::nullopt;
auto raw = TerrainMap::Read(std::move(*data));
if (!raw) LOG("Could not read the terrain of %s", rawPath.c_str());
return raw;
}
// The luz file name without folder or extension, which also names the zone's minimap folder
std::string ZoneBaseName(const ZoneInfo& zone) {
auto name = zone.luzPath.substr(zone.luzPath.find_last_of('/') + 1);
if (name.ends_with(".luz")) name.resize(name.size() - 4);
return name;
}
struct Minimap {
std::string folder;
uint32_t tiles{}; // per side
};
/**
* The game's minimap for a zone: square grids of 256px tiles (image_0001.dds, row by row) covering the terrain's
* bounds, drawn rotated 180 degrees (x and z both decrease to the right and down). Checked in Avant Gardens and
* Nimbus Station against object positions. Uses the most detailed zoom level with at most 6x6 tiles (1536 pixels).
*/
std::optional<Minimap> FindMinimap(const ZoneInfo& zone) {
std::optional<Minimap> best;
for (int zoom = 0; zoom < 3; zoom++) {
const auto folder = "maps/minimaps/" + ZoneBaseName(zone) + "/zoom_" + std::to_string(zoom);
const auto listing = ClientAssets::ListDirectory(folder);
if (!listing) break;
uint32_t images = 0;
for (const auto& entry : (*listing)["entries"]) {
const auto name = entry.value("name", "");
if (name.size() == 14 && (name.starts_with("image_") || name.starts_with("Image_")) && name.ends_with(".dds")) images++;
}
const auto side = static_cast<uint32_t>(std::lround(std::sqrt(images)));
if (side == 0 || side * side != images || side > 6) break;
best = Minimap{ folder, side };
}
return best;
}
// Terrain for a zone as JSON with base64 16-bit heights, built once per zone
OnceCache<uint32_t, std::optional<std::string>> g_TerrainJson;
std::optional<std::string> BuildTerrainJson(uint32_t zoneId) {
std::optional<std::string> json;
const auto zone = GetZoneInfo(zoneId);
if (zone) {
const auto raw = ReadZoneRaw(*zone);
// Full resolution for every live zone (Avant Gardens is 704 samples a side)
const auto grid = raw ? TerrainMap::Parse(*raw, 1024) : std::nullopt;
if (grid) {
const auto packed = TerrainMap::Quantize(*grid);
const auto encoded = Base64({ reinterpret_cast<const char*>(packed.data()), packed.size() * sizeof(packed[0]) });
nlohmann::json terrain{
{"zone", zoneId}, {"minX", grid->minX}, {"minZ", grid->minZ}, {"step", grid->step},
{"width", grid->width}, {"height", grid->height}, {"minY", grid->minY}, {"maxY", grid->maxY}, {"heights", encoded}
};
if (const auto minimap = FindMinimap(*zone)) terrain["minimapTiles"] = minimap->tiles;
json = terrain.dump();
}
}
return json;
}
std::optional<std::string> TerrainJson(uint32_t zoneId) {
return g_TerrainJson.Get(zoneId, [zoneId] { return BuildTerrainJson(zoneId); });
}
std::string DayText(uint32_t day) {
const auto time = static_cast<std::time_t>(day) * 24 * 60 * 60;
std::tm tm{};
#ifdef _WIN32
gmtime_s(&tm, &time);
#else
gmtime_r(&time, &tm);
#endif
char buffer[16];
std::strftime(buffer, sizeof(buffer), "%Y-%m-%d", &tm);
return buffer;
}
// Flow rows with readable dates and source names, for CSV
nlohmann::json ReadableFlows(nlohmann::json rows) {
const auto sources = SourceNames();
for (auto& row : rows) {
row["date"] = DayText(row.value("day", 0u));
row["source_name"] = sources.value(std::to_string(row.value("source", 0)), std::to_string(row.value("source", 0)));
}
return rows;
}
std::string RangeSuffix(uint32_t from, uint32_t to) {
return DayText(from) + "_to_" + DayText(to) + ".csv";
}
void WithItemNames(nlohmann::json& rows) {
for (auto& row : rows) {
if (row.contains("lot") && row["lot"].get<LOT>() > 0) row["name"] = ClientAssets::ItemName(row["lot"].get<LOT>());
}
}
}
bool StartDuplicateScan(std::function<void(const nlohmann::json& scan, const std::string& error)> done) {
return Background::Run("duplicate_scan", ScanDuplicatesRaw, [done = std::move(done)](nlohmann::json raw, const std::string& error) {
if (!error.empty()) return done(nullptr, error);
g_LastScan = FinishDuplicateScan(raw);
BroadcastTableChanged("duplicate_scan");
done(ScanJson(*g_LastScan), "");
});
}
nlohmann::json EconomySourceNames() {
return SourceNames();
}
std::optional<std::string> ZoneTerrainJson(uint32_t zoneId) {
return TerrainJson(zoneId);
}
namespace {
}
std::string ZoneDataBase64(std::string_view bytes) {
return Base64(bytes);
}
std::optional<Raw::Raw> ZoneRaw(uint32_t zoneId) {
const auto zone = GetZoneInfo(zoneId);
return zone ? ReadZoneRaw(*zone) : std::nullopt;
}
std::shared_ptr<const Raw::Raw> ZoneRawShared(uint32_t zoneId) {
// The last few zones: their chunks, layers and flairs are built one after the other when a zone is first viewed
constexpr size_t KEEP = 2;
static std::mutex mutex;
static std::deque<std::pair<uint32_t, std::shared_future<std::shared_ptr<const Raw::Raw>>>> recent;
std::promise<std::shared_ptr<const Raw::Raw>> promise;
std::shared_future<std::shared_ptr<const Raw::Raw>> future;
{
std::lock_guard lock(mutex);
const auto it = std::find_if(recent.begin(), recent.end(), [zoneId](const auto& entry) { return entry.first == zoneId; });
if (it != recent.end()) return it->second.get(); // may wait for the thread reading it
future = promise.get_future().share();
recent.emplace_back(zoneId, future);
if (recent.size() > KEEP) recent.pop_front();
}
std::shared_ptr<const Raw::Raw> raw;
try {
if (auto read = ZoneRaw(zoneId)) raw = std::make_shared<const Raw::Raw>(std::move(*read));
} catch (const std::exception& ex) {
LOG("Could not read the terrain of zone %u: %s", zoneId, ex.what());
}
promise.set_value(raw);
return raw;
}
namespace {
std::string_view BytesOf(const std::vector<uint8_t>& bytes) {
return { reinterpret_cast<const char*>(bytes.data()), bytes.size() };
}
}
namespace {
OnceCache<uint32_t, std::optional<std::string>> g_TerrainChunks;
OnceCache<uint32_t, std::optional<std::string>> g_TerrainLayers;
OnceCache<uint32_t, std::string> g_TerrainTexturePaths; // id -> path under res/ ("" when the client lacks it)
}
bool ZoneTerrainJsonReady(uint32_t zoneId) {
return g_TerrainJson.Ready(zoneId);
}
bool ZoneTerrainChunksReady(uint32_t zoneId) {
return g_TerrainChunks.Ready(zoneId);
}
bool ZoneTerrainLayersReady(uint32_t zoneId) {
return g_TerrainLayers.Ready(zoneId);
}
std::optional<std::string> ZoneLuzPath(uint32_t zoneId) {
const auto zone = GetZoneInfo(zoneId);
if (!zone || !zone->luzPath.ends_with(".luz")) return std::nullopt;
return zone->luzPath;
}
void PreloadZoneData() {
ZoneTable();
TerrainTextureNames();
}
std::optional<std::string> ZoneTerrainChunksJson(uint32_t zoneId) {
return g_TerrainChunks.Get(zoneId, [zoneId]() -> std::optional<std::string> {
std::optional<std::string> json;
if (const auto raw = ZoneRawShared(zoneId)) {
nlohmann::json out{ {"zone", zoneId}, {"chunks", nlohmann::json::array()} };
std::set<uint32_t> textures;
for (const auto& chunk : raw->chunks) {
const std::string_view heights(reinterpret_cast<const char*>(chunk.heightMap.data()), chunk.heightMap.size() * sizeof(float));
std::array<uint32_t, 4> ids{};
std::copy_n(chunk.textureIds.begin(), std::min<size_t>(4, chunk.textureIds.size()), ids.begin());
// The maps are BGRA, as the file stores them; older files' color maps aren't square to the resolution
const bool colorFits = chunk.colorMap.size() == static_cast<size_t>(chunk.colorMapResolution) * chunk.colorMapResolution * 4;
out["chunks"].push_back({ {"x", chunk.offsetX}, {"z", chunk.offsetZ}, {"width", chunk.width}, {"height", chunk.height}, {"scale", chunk.scaleFactor},
{"textures", ids},
{"heights", Base64(heights)}, // float32, little-endian; heights[width * i + j] is at x = x + i * scale, z = z + j * scale
{"colorSize", colorFits ? chunk.colorMapResolution : 0}, {"color", colorFits ? Base64(BytesOf(chunk.colorMap)) : std::string{}},
{"blendSize", chunk.textureMapResolution}, {"blend", Base64(BytesOf(chunk.textureMap))} });
for (const auto texture : ids) textures.insert(texture);
}
out["textures"] = textures;
json = out.dump();
}
return json;
});
}
std::optional<std::string> ZoneTerrainLayersJson(uint32_t zoneId) {
return g_TerrainLayers.Get(zoneId, [zoneId]() -> std::optional<std::string> {
std::optional<std::string> json;
const auto zone = GetZoneInfo(zoneId);
const auto raw = zone ? ZoneRawShared(zoneId) : nullptr;
if (raw) {
// Scene names from the .luz: the terrain's scene map holds the ids of its general scenes (audio scenes share them)
std::map<uint32_t, std::string> names;
const auto luz = ClientAssets::ReadResFile("maps/" + zone->luzPath);
std::string error;
if (const auto header = luz ? ZonePaths::ReadHeader(*luz, error) : std::nullopt) {
for (const auto& scene : header->scenes) if (scene.sceneType == 0) names.try_emplace(scene.id, scene.name);
}
std::map<uint8_t, uint64_t> cells;
nlohmann::json chunks = nlohmann::json::array();
for (const auto& chunk : raw->chunks) {
const bool fits = chunk.colorMapResolution > 0 && chunk.sceneMap.size() == static_cast<size_t>(chunk.colorMapResolution) * chunk.colorMapResolution;
if (fits) for (const auto scene : chunk.sceneMap) cells[scene]++;
// scenes[size * i + j] is the scene of the cell at x = x + i / size * (width - 1) * scale, z likewise with j
chunks.push_back({ {"sceneSize", fits ? chunk.colorMapResolution : 0}, {"scenes", fits ? Base64(BytesOf(chunk.sceneMap)) : std::string{}} });
}
uint64_t total = 0;
for (const auto& [scene, count] : cells) total += count;
nlohmann::json scenes = nlohmann::json::array();
for (const auto& [scene, count] : cells) {
// The game's own scene colors (its level editor's template colors, by scene id)
const auto& color = SceneColor::Get(scene);
const auto name = names.find(scene);
scenes.push_back({ {"id", scene}, {"name", name == names.end() ? "Scene " + std::to_string(scene) : name->second},
{"color", { std::lround(color.m_Red * 255), std::lround(color.m_Green * 255), std::lround(color.m_Blue * 255) }},
{"share", total ? std::round(static_cast<double>(count) / static_cast<double>(total) * 10000.0) / 10000.0 : 0.0} });
}
size_t flairs = 0;
for (const auto& chunk : raw->chunks) flairs += chunk.flairs.size();
json = nlohmann::json{ {"zone", zoneId}, {"version", raw->version}, {"scenes", scenes}, {"chunks", chunks}, {"flairs", flairs} }.dump();
}
return json;
});
}
namespace {
// Where terrain texture `textureId` is under res/, found once ("" when the client lacks it)
const std::string& TerrainTexturePath(uint32_t textureId) {
return g_TerrainTexturePaths.Get(textureId, [textureId] {
const auto& names = TerrainTextureNames();
const auto name = names.find(textureId);
if (name == names.end()) return std::string{};
if (ClientAssets::ResolveResFile("textures/env/" + name->second)) return "textures/env/" + name->second;
return ClientAssets::FindResFile("textures", name->second).value_or("");
});
}
// Tiled 4 times per chunk: 512 pixels is plenty and keeps the page light
constexpr uint32_t TERRAIN_TEXTURE_SIZE = 512;
}
std::optional<std::string> TerrainTextureFile(uint32_t textureId) {
const auto& path = TerrainTexturePath(textureId);
if (path.empty()) return std::nullopt;
return ClientAssets::TextureAsPng(path, TERRAIN_TEXTURE_SIZE, true);
}
bool TerrainTextureReady(uint32_t textureId) {
if (!g_TerrainTexturePaths.Ready(textureId)) return false;
const auto& path = TerrainTexturePath(textureId);
return path.empty() || ClientAssets::TextureAsPngReady(path, TERRAIN_TEXTURE_SIZE, true);
}
std::optional<nlohmann::json> ZonePropertyAreasJson(uint32_t zoneId) {
static std::mutex mutex;
static std::map<uint32_t, std::optional<nlohmann::json>> cache;
std::lock_guard lock(mutex);
if (const auto it = cache.find(zoneId); it != cache.end()) return it->second;
std::optional<nlohmann::json> json;
const auto zone = GetZoneInfo(zoneId);
const auto luz = zone ? ClientAssets::ReadResFile("maps/" + zone->luzPath) : std::nullopt;
if (luz) {
std::string error;
if (const auto areas = ZonePaths::ReadPropertyAreas(*luz, error)) {
json = nlohmann::json::array();
for (const auto& area : *areas) {
nlohmann::json outline = nlohmann::json::array();
for (const auto& point : area.outline) outline.push_back({ point.x, point.y, point.z });
json->push_back({ {"name", area.name}, {"displayName", area.displayName}, {"type", area.areaType}, {"maxBuildHeight", area.maxBuildHeight}, {"outline", outline} });
}
} else {
LOG("Couldn't read the property areas of zone %u: %s", zoneId, error.c_str());
}
}
return cache[zoneId] = json;
}
std::optional<nlohmann::json> ZoneSpawnPointsJson(uint32_t zoneId) {
static std::mutex mutex;
static std::map<uint32_t, std::optional<nlohmann::json>> cache;
std::lock_guard lock(mutex);
if (const auto it = cache.find(zoneId); it != cache.end()) return it->second;
const auto zone = GetZoneInfo(zoneId);
const auto luz = zone ? ClientAssets::ReadResFile("maps/" + zone->luzPath) : std::nullopt;
if (!luz) return cache[zoneId] = std::nullopt;
// Scene files are named relative to the .luz's folder
const auto folder = zone->luzPath.substr(0, zone->luzPath.find_last_of('/') + 1);
nlohmann::json points = nlohmann::json::array();
std::set<std::string> seen; // the world keeps the last one of a name; the list names each once
for (const auto& scene : ZonePaths::ReadSceneFiles(*luz)) {
const auto lvl = ClientAssets::ReadResFile("maps/" + folder + scene);
if (!lvl) continue;
for (const auto& point : LevelObjects::ReadSpawnPoints(*lvl)) {
if (!seen.insert(point.name).second) continue;
points.push_back({ {"name", point.name}, {"lot", point.lot}, {"x", point.x}, {"y", point.y}, {"z", point.z} });
}
}
std::sort(points.begin(), points.end(), [](const auto& a, const auto& b) { return a["name"].template get<std::string>() < b["name"].template get<std::string>(); });
return cache[zoneId] = points;
}
void RegisterReportRoutes() {
EconomyPlaces::RegisterRoutes();
Route(eHTTPMethod::GET, "/api/reports/meta", Perm("reports_view"), "Report metadata: today's day number, the names of economy sources, map event kinds and player statistics",
[](HTTPReply& reply, const HTTPContext&) {
JsonReply(reply, eHTTPStatusCode::OK, { {"today", Today()}, {"sources", SourceNames()}, {"defaultDays", DEFAULT_DAYS}, {"maxDays", MAX_DAYS},
{"mapKinds", MapKindsJson()}, {"stats", StatNamesJson()},
{"powerupKinds", { {"drops", static_cast<int>(IEconomyLedger::eMapEvent::POWERUP_DROPS)}, {"pickups", static_cast<int>(IEconomyLedger::eMapEvent::POWERUP_PICKUPS)} }} });
});
// Days in these routes count from the Unix epoch (UTC); ranges default to the last 30 days and span at most a year
Route(eHTTPMethod::GET, "/api/reports/currency", Perm("reports_view"), "Coins gained and spent per day and source. Query: ?from=&to= (days since epoch), &staff=1 to include staff",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
JsonReply(reply, eHTTPStatusCode::OK, { {"from", from}, {"to", to}, {"rows", Database::Get()->GetCurrencyFlows(from, to, ExcludeStaff(context))} });
});
Route(eHTTPMethod::GET, "/api/reports/uscore", Perm("reports_view"), "U-score gained and lost per day and source. Query: ?from=&to=&staff=1",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
JsonReply(reply, eHTTPStatusCode::OK, { {"from", from}, {"to", to}, {"rows", Database::Get()->GetUScoreFlows(from, to, ExcludeStaff(context))} });
});
Route(eHTTPMethod::GET, "/api/reports/items", Perm("reports_view"), "Items created and destroyed per day and source, for all items or one. Query: ?from=&to=&lot=&staff=1",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
const LOT lot = GeneralUtils::TryParse<LOT>(QueryValue(context.queryString, "lot")).value_or(0);
nlohmann::json response{ {"from", from}, {"to", to}, {"lot", lot}, {"rows", Database::Get()->GetItemFlows(from, to, lot, ExcludeStaff(context))} };
if (lot > 0) response["name"] = ClientAssets::ItemName(lot);
JsonReply(reply, eHTTPStatusCode::OK, response);
});
Route(eHTTPMethod::GET, "/api/reports/top_earners", Perm("reports_view"), "Characters with the most coin income (trades and mail excluded). Query: ?from=&to=&limit=&staff=1",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
const auto limit = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "limit")).value_or(25), 1, 200);
JsonReply(reply, eHTTPStatusCode::OK, { {"from", from}, {"to", to}, {"rows", Database::Get()->GetTopEarners(from, to, limit, ExcludeStaff(context))} });
});
Route(eHTTPMethod::GET, "/api/reports/top_items", Perm("reports_view"), "Items created the most. Query: ?from=&to=&limit=&staff=1",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
const auto limit = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "limit")).value_or(25), 1, 200);
auto rows = Database::Get()->GetTopItems(from, to, limit, ExcludeStaff(context));
WithItemNames(rows);
JsonReply(reply, eHTTPStatusCode::OK, { {"from", from}, {"to", to}, {"rows", rows} });
});
Route(eHTTPMethod::POST, "/api/reports/transfers", Perm("reports_view"), "Trades and mail between players, newest first (DataTables). Body adds {character (name or ID), lot} filters",
[](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");
LWOOBJID characterId = 0;
const auto& character = body->contains("character") ? (*body)["character"] : body->contains("character_id") ? (*body)["character_id"] : nlohmann::json("");
const auto text = character.is_string() ? character.get<std::string>() : character.is_number_integer() ? character.dump() : "";
if (text.find_first_not_of(" \t") != std::string::npos) {
const auto resolved = ResolveCharacter(text);
if (!resolved) return JsonReply(reply, eHTTPStatusCode::OK, { {"draw", request->draw}, {"recordsTotal", 0}, {"recordsFiltered", 0},
{"data", nlohmann::json::array()}, {"error", "No character called " + text} });
characterId = *resolved;
}
const LOT lot = body->value("lot", 0);
auto response = Database::Get()->GetTransfers(request->start, std::min<uint32_t>(request->length, 500), characterId, lot);
WithItemNames(response["data"]);
response["draw"] = request->draw;
JsonReply(reply, eHTTPStatusCode::OK, response);
});
Route(eHTTPMethod::POST, "/api/account/transfers", Perm("own_history"), "Trades and mail involving your own characters, newest first (DataTables)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto request = ParseDataTablesRequest(context.body);
if (!request) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto characters = Database::Get()->GetAccountCharacterIds(context.accountId);
auto response = Database::Get()->GetTransfersForCharacters(characters, request->start, std::min<uint32_t>(request->length, 100));
WithItemNames(response["data"]);
response["draw"] = request->draw;
JsonReply(reply, eHTTPStatusCode::OK, response);
});
Route(eHTTPMethod::POST, "/api/reports/objects/:id/restore", Perm("items_restore"),
"Mail a traced item back to a character. Body: {to (character name or ID), lot, count, note}. If the object ID is not held anywhere any more "
"the restored item keeps it; otherwise it gets a new one (so nothing is duplicated). Runs in the background: returns {requestId}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto itemId = PathId<LWOOBJID>(context.path, 3);
const auto body = ParseBody(context);
if (!itemId || *itemId == 0 || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid request");
const auto& to = (*body)["to"];
const auto recipient = ResolveCharacter(to.is_string() ? to.get<std::string>() : to.is_number_integer() ? to.dump() : "");
const auto info = recipient ? Database::Get()->GetCharacterInfo(*recipient) : std::nullopt;
if (!info) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "No such character to give it to");
// Like any item given out: only to accounts the actor may manage, their own characters only with self_items
if (!AuthorizeAccountAction(context, info->accountId, reply, eAccountAction::ITEMS)) return;
const LOT lot = body->value("lot", 0);
const int64_t count = body->value("count", 1);
if (lot <= 0 || ClientAssets::ItemName(lot).empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Unknown item");
if (count < 1 || count > 999) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Count must be 1 to 999");
std::string note = body->value("note", "");
if (note.size() > 300) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The note is too long");
const auto requestId = PlayerActions::Begin(context.accountId, std::chrono::minutes(10));
const auto id = *itemId;
const auto actor = context;
const auto target = *info;
Background::Run("restore:" + std::to_string(requestId), [id](GameDatabase& db) { return FindObjectRaw(db, id); },
[=](nlohmann::json raw, const std::string& error) {
if (!error.empty()) return PlayerActions::Finish(requestId, { false, "Checking where the item is failed: " + error });
// Still held somewhere: send a replacement with a new id, never a second copy of the same object
const bool stillExists = !raw.empty();
MailInfo mail;
mail.senderId = LWOOBJID_EMPTY;
mail.senderUsername = "[GM] " + actor.authenticatedUser;
mail.receiverId = target.id;
mail.recipient = target.name;
mail.subject = "An item returned to you";
mail.body = note.empty() ? "A moderator sent this item back to you." : note;
mail.timeSent = static_cast<uint64_t>(std::time(nullptr));
mail.itemID = stillExists ? LWOOBJID_EMPTY : id;
mail.itemSubkey = LWOOBJID_EMPTY;
mail.itemLOT = lot;
mail.itemCount = static_cast<int16_t>(count);
Database::Get()->InsertNewMail(mail);
const auto what = std::to_string(count) + "x " + ClientAssets::ItemName(lot) + " (object " + std::to_string(id) + ")";
Audit(actor, "restore_item", "Mailed " + what + " to " + target.name + (stillExists ? " as a replacement with a new ID; the original still exists" : " with its original ID"), AuditTarget::Character(target.id));
BroadcastTableChanged("mail", std::to_string(target.id));
PlayerActions::Finish(requestId, { true, "Mailed " + what + " to " + target.name +
(stillExists ? ". The original still exists, so this is a replacement with a new ID." : " with its original ID.") });
});
JsonSuccess(reply, { {"requestId", requestId} });
});
Route(eHTTPMethod::GET, "/api/reports/objects/:id", Perm("reports_view"),
"Trace an item from any of its object IDs: every trade, mail and inventory move it went through (an item gets a new ID at each), "
"followed back to its first recorded ID and forward to the latest, and where each ID is now. ?merges=1 also follows the history of "
"stacks it merged into. Runs in the background: returns {requestId}; the result's data (GET /api/actions/:id) has {item_id, lot, name, "
"first_id, latest_ids, history (each hop with role LINE/BRANCH/MERGE_IN, merge, gap), ids [{id, latest, checked, locations}], locations "
"(under any ID, each with its lot, name, character_version and new_id_at_login), duplicated (the same LOT twice under one ID), collision (different "
"LOTs share an ID: old data, not a dupe), resolves_on_login (every shared ID stops being shared once the characters holding it log in; each "
"shared entry in ids has login_fix {resolves, characters, logins_needed}), truncated, gaps}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto itemId = PathId<LWOOBJID>(context.path, 3);
if (!itemId || *itemId == 0) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid object ID");
const bool followMerges = QueryValue(context.queryString, "merges") == "1";
const auto requestId = PlayerActions::Begin(context.accountId, std::chrono::minutes(10));
const auto id = *itemId;
Background::Run("trace:" + std::to_string(requestId), [id, followMerges](GameDatabase& db) { return TraceRaw(db, id, followMerges); },
[requestId, id, followMerges](nlohmann::json raw, const std::string& error) {
if (!error.empty()) return PlayerActions::Finish(requestId, { false, "The search failed: " + error });
auto history = raw["hops"];
WithItemNames(history);
for (auto& hop : history) {
const auto zone = GetZoneInfo(hop.value("zone", 0u));
hop["zone_name"] = zone ? zone->name : "";
}
const auto names = CharacterNames();
std::set<std::string> latest;
for (const auto& value : raw["latest"]) latest.insert(value.get<std::string>());
nlohmann::json locations = nlohmann::json::array();
nlohmann::json ids = nlohmann::json::array();
// The traded LOT is what was traced; without a recorded hop, the first copy found
LOT lot = history.empty() ? 0 : history.back().value("lot", 0);
bool duplicated = false;
bool collision = false;
// Whether every id shared by several copies stops being shared once the characters holding them log in
bool resolvesOnLogin = true;
for (const auto& value : raw["ids"]) {
const auto idText = value.get<std::string>();
const bool checked = raw["locations"].contains(idText);
nlohmann::json here = nlohmann::json::array();
std::vector<EconomyScan::Location> found;
if (checked) {
for (const auto& entry : raw["locations"][idText]) {
const auto location = FromRawLocation(entry);
found.push_back(location);
if (lot == 0) lot = location.lot;
auto json = LocationJson(location, names);
json["item_id"] = idText;
here.push_back(json);
locations.push_back(std::move(json));
}
}
// Two copies of the same LOT are a dupe; different LOTs under one id are an old-data id collision
const auto groups = EconomyScan::Classify(found);
duplicated = duplicated || !groups.duplicates.empty();
collision = collision || groups.collision;
nlohmann::json entry{ {"id", idText}, {"latest", latest.contains(idText)}, {"checked", checked}, {"locations", here} };
if (found.size() > 1) {
entry["login_fix"] = LoginFixJson(found, names);
resolvesOnLogin = resolvesOnLogin && entry["login_fix"]["resolves"].get<bool>();
}
ids.push_back(std::move(entry));
}
size_t gaps = 0;
for (const auto& hop : history) gaps += hop["gap"].is_null() ? 0 : 1;
nlohmann::json latestIds = nlohmann::json::array();
for (const auto& value : raw["latest"]) latestIds.push_back(value);
PlayerActions::Finish(requestId, { true, "", {
{"item_id", std::to_string(id)}, {"lot", lot}, {"name", lot > 0 ? ClientAssets::ItemName(lot) : ""},
{"first_id", raw["first"]}, {"latest_ids", latestIds}, {"history", history}, {"ids", ids}, {"locations", locations},
{"duplicated", duplicated}, {"collision", collision}, {"resolves_on_login", (duplicated || collision) && resolvesOnLogin}, {"truncated", raw["truncated"]}, {"max_hops", TRACE_MAX_HOPS}, {"gaps", gaps}, {"merges_followed", followMerges}
} });
});
JsonSuccess(reply, { {"requestId", requestId} });
});
Route(eHTTPMethod::GET, "/api/reports/holders/:lot", Perm("reports_view"),
"Who holds an item right now, from saved characters and unclaimed mail. Runs in the background: returns {requestId}; "
"the result's data has {lot, name, total, holderCount, holders: [{character_id, character_name, count, places}]}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto lot = PathId<LOT>(context.path, 3);
if (!lot || *lot <= 0) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid LOT");
const auto requestId = PlayerActions::Begin(context.accountId, std::chrono::minutes(10));
const auto target = *lot;
Background::Run("holders:" + std::to_string(requestId), [target](GameDatabase& db) { return FindHoldersRaw(db, target); },
[requestId, target](nlohmann::json raw, const std::string& error) {
if (!error.empty()) return PlayerActions::Finish(requestId, { false, "The search failed: " + error });
const auto names = CharacterNames();
std::vector<nlohmann::json> holders;
uint64_t total = 0;
for (const auto& entry : raw) {
uint64_t count = 0;
for (const auto& [place, amount] : entry["places"].items()) count += amount.get<uint64_t>();
total += count;
const auto characterId = entry["id"].get<LWOOBJID>();
holders.push_back({ {"character_id", std::to_string(characterId)}, {"character_name", NameOf(names, characterId)}, {"count", count}, {"places", entry["places"]} });
}
std::ranges::sort(holders, [](const auto& a, const auto& b) { return a["count"].template get<uint64_t>() > b["count"].template get<uint64_t>(); });
const auto holderCount = holders.size();
if (holders.size() > 5000) holders.resize(5000);
PlayerActions::Finish(requestId, { true, "", {
{"lot", target}, {"name", ClientAssets::ItemName(target)}, {"total", total}, {"holderCount", holderCount}, {"holders", holders}
} });
});
JsonSuccess(reply, { {"requestId", requestId} });
});
Route(eHTTPMethod::GET, "/api/reports/map/zones", Perm("reports_view"),
"Zones with map events. Property zones have one instance per property, so they list each property (clone) under the zone, with clone 0 "
"for rows recorded before properties were told apart. Query: ?kind= (a map event kind from /api/reports/meta)&from=&to=",
[](HTTPReply& reply, const HTTPContext& context) {
const auto kind = MapKind(QueryValue(context.queryString, "kind"));
if (!kind) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "kind must be a map event kind (see /api/reports/meta)");
const auto [from, to] = RequestedDays(context);
const auto rows = Database::Get()->GetMapZones(*kind, from, to);
std::set<uint32_t> clones;
for (const auto& row : rows) clones.insert(row.value("clone", 0u));
const EconomyPlaces::Owners owners(clones);
// One entry per zone; a property zone's entry sums its properties and lists them
std::map<uint32_t, nlohmann::json> byZone;
nlohmann::json allProperties{ {"events", 0}, {"quantity", 0}, {"properties", 0} };
for (const auto& row : rows) {
const auto zoneId = row.value("zone", 0u);
const auto events = row.value("events", int64_t{ 0 }), quantity = row.value("quantity", int64_t{ 0 });
auto& zone = byZone[zoneId];
if (zone.is_null()) {
const auto info = GetZoneInfo(zoneId);
const bool property = EconomyPlaces::IsPropertyZone(zoneId);
zone = { {"zone", zoneId}, {"name", info ? info->name : "Zone " + std::to_string(zoneId)}, {"events", 0}, {"quantity", 0}, {"property", property} };
if (property) zone["properties"] = nlohmann::json::array();
}
zone["events"] = zone["events"].get<int64_t>() + events;
zone["quantity"] = zone["quantity"].get<int64_t>() + quantity;
if (!zone["property"].get<bool>()) continue;
auto property = owners.Info(zoneId, row.value("clone", 0u));
property["events"] = events;
property["quantity"] = quantity;
zone["properties"].push_back(std::move(property));
allProperties["events"] = allProperties["events"].get<int64_t>() + events;
allProperties["quantity"] = allProperties["quantity"].get<int64_t>() + quantity;
allProperties["properties"] = allProperties["properties"].get<int64_t>() + 1;
}
std::vector<nlohmann::json> zones;
for (auto& [id, zone] : byZone) zones.push_back(std::move(zone));
std::ranges::stable_sort(zones, [](const nlohmann::json& a, const nlohmann::json& b) { return a["events"].get<int64_t>() > b["events"].get<int64_t>(); });
JsonReply(reply, eHTTPStatusCode::OK, { {"from", from}, {"to", to}, {"zones", zones}, {"allProperties", allProperties} });
});
Route(eHTTPMethod::GET, "/api/reports/map/:zone", Perm("reports_view"),
"Map events for a zone per 4x4 unit cell, plus the top LOTs. On a property zone, &clone= picks one property (0: rows from before "
"properties were told apart; left out: every property together), and the reply adds the build area and, for one property, its owner and placed models. "
"Query: ?kind=&lot=&from=&to=&clone=",
[](HTTPReply& reply, const HTTPContext& context) {
const auto zone = PathId<uint32_t>(context.path, 3);
const auto kind = MapKind(QueryValue(context.queryString, "kind"));
if (!zone) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid zone");
if (!kind) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "kind must be a map event kind (see /api/reports/meta)");
const auto [from, to] = RequestedDays(context);
const LOT lot = GeneralUtils::TryParse<LOT>(QueryValue(context.queryString, "lot")).value_or(0);
const auto cloneText = QueryValue(context.queryString, "clone");
const auto clone = cloneText.empty() ? std::nullopt : GeneralUtils::TryParse<uint32_t>(cloneText);
if (!cloneText.empty() && !clone) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid clone");
auto lots = Database::Get()->GetMapLots(*zone, clone, *kind, from, to, 50);
WithItemNames(lots);
if (EconomyPlaces::IsPowerupKind(*kind)) {
for (auto& row : lots) row["type"] = EconomyPlaces::PowerupType(row.value("lot", 0));
}
const auto info = GetZoneInfo(*zone);
nlohmann::json response{
{"zone", *zone}, {"name", info ? info->name : ""}, {"from", from}, {"to", to}, {"lot", lot}, {"clone", clone ? nlohmann::json(*clone) : nlohmann::json()},
{"cellSize", static_cast<int>(IEconomyLedger::MAP_CELL_SIZE)}, {"lots", lots}, {"cells", Database::Get()->GetMapCells(*zone, clone, *kind, from, to, lot)}
};
if (EconomyPlaces::IsPropertyZone(*zone)) {
// Where owners may build, so events line up with the property
nlohmann::json property{ {"areas", ZonePropertyAreasJson(*zone).value_or(nlohmann::json())} };
if (clone) {
const EconomyPlaces::Owners owners({ *clone });
property["info"] = owners.Info(*zone, *clone);
const auto propertyId = GeneralUtils::TryParse<LWOOBJID>(property["info"].value("property_id", ""));
nlohmann::json models = nlohmann::json::array();
if (propertyId) {
for (const auto& model : Database::Get()->GetPropertyModels(*propertyId)) {
models.push_back({ {"lot", model.lot}, {"name", model.lot == 14 ? "Player-built model" : ClientAssets::ItemName(model.lot)},
{"x", model.position.x}, {"z", model.position.z} });
}
}
property["models"] = models;
}
response["property"] = property;
}
JsonReply(reply, eHTTPStatusCode::OK, response);
});
Route(eHTTPMethod::GET, "/api/reports/activity", Perm("reports_view"),
"What players did per day: map events of every kind and player statistics (per day and per place), and powerups by type. "
"Query: ?from=&to=&staff=1 (staff only applies to statistics)&place= (see /api/reports/places; empty: everywhere)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
const bool excludeStaff = ExcludeStaff(context);
const auto placeText = QueryValue(context.queryString, "place");
const auto place = EconomyPlaces::Parse(placeText);
if (!place) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "place must be empty, properties, <zone>, <zone>:<clone> or *:<clone>");
// Whether a (zone, clone) row is in the place, for the per-place breakdowns
const auto inPlace = [&place](uint32_t zone, uint32_t clone) {
if (!place->zones.empty() && std::ranges::find(place->zones, zone) == place->zones.end()) return false;
return !place->clone || *place->clone == clone;
};
auto statRows = Database::Get()->GetPlayerStatsPerZone(from, to, excludeStaff);
nlohmann::json mapRows = nlohmann::json::array();
// One pass over the range for every kind (a query per kind read it once each)
for (auto& row : Database::Get()->GetMapZonesAllKinds(from, to)) {
if (magic_enum::enum_cast<IEconomyLedger::eMapEvent>(row.value("kind", 0))) mapRows.push_back(std::move(row));
}
std::set<uint32_t> clones;
for (const auto* rows : { &statRows, &mapRows }) for (const auto& row : *rows) clones.insert(row.value("clone", 0u));
const EconomyPlaces::Owners owners(clones);
// Each row named for its place: the world, or the property (with its zone) on property zones
const auto describe = [&owners](nlohmann::json& row) {
const auto zoneId = row.value("zone", 0u);
const auto zone = GetZoneInfo(zoneId);
row["name"] = zone ? zone->name : "Zone " + std::to_string(zoneId);
if (!EconomyPlaces::IsPropertyZone(zoneId)) return;
const auto info = owners.Info(zoneId, row.value("clone", 0u));
row["property"] = true;
row["place"] = info["place"];
row["property_name"] = info["name"];
row["unknown"] = info["unknown"];
if (info.contains("property_id")) row["property_id"] = info["property_id"];
if (info.contains("owner_id")) { row["owner_id"] = info["owner_id"]; row["owner_name"] = info["owner_name"]; }
};
nlohmann::json statZones = nlohmann::json::array(), mapZones = nlohmann::json::array();
for (auto& row : statRows) if (inPlace(row.value("zone", 0u), row.value("clone", 0u))) { describe(row); statZones.push_back(std::move(row)); }
for (auto& row : mapRows) if (inPlace(row.value("zone", 0u), row.value("clone", 0u))) { describe(row); mapZones.push_back(std::move(row)); }
// Powerups per day and type, as the CDClient's behaviors say what each restores
nlohmann::json powerupDays = nlohmann::json::array();
for (const auto kind : { IEconomyLedger::eMapEvent::POWERUP_DROPS, IEconomyLedger::eMapEvent::POWERUP_PICKUPS }) {
std::map<std::pair<uint32_t, std::string>, int64_t> byDay;
for (const auto& row : Database::Get()->GetMapEventsByLot(kind, from, to, *place, 50000)) {
byDay[{ row.value("day", 0u), EconomyPlaces::PowerupType(row.value("lot", 0)) }] += row.value("events", int64_t{ 0 });
}
for (const auto& [key, events] : byDay) powerupDays.push_back({ {"day", key.first}, {"kind", static_cast<int>(kind)}, {"type", key.second}, {"events", events} });
}
JsonReply(reply, eHTTPStatusCode::OK, {
{"from", from}, {"to", to}, {"place", placeText},
{"mapDays", Database::Get()->GetMapEventsPerDay(from, to, *place)}, {"mapZones", mapZones},
{"statDays", Database::Get()->GetPlayerStatsPerDay(from, to, excludeStaff, *place)}, {"statZones", statZones},
{"powerupDays", powerupDays}
});
});
Route(eHTTPMethod::GET, "/api/reports/map/:zone/terrain", Perm("reports_view"), "A zone's terrain heights for drawing maps (needs client_location)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto zone = PathId<uint32_t>(context.path, 3);
if (!zone) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid zone");
const auto terrain = TerrainJson(*zone);
if (!terrain) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No terrain for this zone (is client_location set?)");
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::APPLICATION_JSON;
reply.message = *terrain;
reply.headers.push_back("Cache-Control: private, max-age=86400");
});
Route(eHTTPMethod::GET, "/api/reports/map/:zone/minimap/:tile", Perm("reports_view"), "One tile (1-based, row by row) of a zone's in-game minimap as PNG",
[](HTTPReply& reply, const HTTPContext& context) {
const auto zone = PathId<uint32_t>(context.path, 3);
const auto tile = PathId<uint32_t>(context.path, 5);
const auto info = zone ? GetZoneInfo(*zone) : std::nullopt;
const auto minimap = info ? FindMinimap(*info) : std::nullopt;
if (!minimap || !tile || *tile < 1 || *tile > minimap->tiles * minimap->tiles) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such minimap tile");
char name[32];
std::snprintf(name, sizeof(name), "/image_%04u.dds", *tile);
const auto png = ClientAssets::TextureAsPng(minimap->folder + name, 256);
if (!png) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Could not convert the minimap (is ImageMagick installed?)");
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::IMAGE_PNG;
reply.message = *png;
reply.headers.push_back("Cache-Control: private, max-age=86400");
});
// ---- CSV downloads (same filters as the JSON routes) ----
Route(eHTTPMethod::GET, "/api/reports/currency/csv", Perm("reports_view"), "Coins per day and source as CSV. Query: ?from=&to=&staff=1",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
CsvReply(reply, "coins_" + RangeSuffix(from, to), ToCsv(ReadableFlows(Database::Get()->GetCurrencyFlows(from, to, ExcludeStaff(context))),
{ {"date", "Date"}, {"source_name", "Source"}, {"gained", "Gained"}, {"spent", "Spent"} }));
});
Route(eHTTPMethod::GET, "/api/reports/uscore/csv", Perm("reports_view"), "U-score per day and source as CSV. Query: ?from=&to=&staff=1",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
CsvReply(reply, "uscore_" + RangeSuffix(from, to), ToCsv(ReadableFlows(Database::Get()->GetUScoreFlows(from, to, ExcludeStaff(context))),
{ {"date", "Date"}, {"source_name", "Source"}, {"gained", "Gained"}, {"lost", "Lost"} }));
});
Route(eHTTPMethod::GET, "/api/reports/items/csv", Perm("reports_view"), "Items created and destroyed per day and source as CSV. Query: ?from=&to=&lot=&staff=1",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
const LOT lot = GeneralUtils::TryParse<LOT>(QueryValue(context.queryString, "lot")).value_or(0);
CsvReply(reply, "items_" + (lot > 0 ? std::to_string(lot) + "_" : std::string()) + RangeSuffix(from, to),
ToCsv(ReadableFlows(Database::Get()->GetItemFlows(from, to, lot, ExcludeStaff(context))),
{ {"date", "Date"}, {"source_name", "Source"}, {"created", "Created"}, {"destroyed", "Destroyed"} }));
});
Route(eHTTPMethod::GET, "/api/reports/top_earners/csv", Perm("reports_view"), "Top earners as CSV. Query: ?from=&to=&limit= (up to 1000)&staff=1",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
const auto limit = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "limit")).value_or(100), 1, 1000);
CsvReply(reply, "top_earners_" + RangeSuffix(from, to), ToCsv(Database::Get()->GetTopEarners(from, to, limit, ExcludeStaff(context)),
{ {"character_id", "Character ID"}, {"name", "Character"}, {"gained", "Earned"}, {"spent", "Spent"} }));
});
Route(eHTTPMethod::GET, "/api/reports/top_items/csv", Perm("reports_view"), "Most created items as CSV. Query: ?from=&to=&limit= (up to 1000)&staff=1",
[](HTTPReply& reply, const HTTPContext& context) {
const auto [from, to] = RequestedDays(context);
const auto limit = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "limit")).value_or(100), 1, 1000);
auto rows = Database::Get()->GetTopItems(from, to, limit, ExcludeStaff(context));
WithItemNames(rows);
CsvReply(reply, "top_items_" + RangeSuffix(from, to), ToCsv(rows, { {"lot", "LOT"}, {"name", "Item"}, {"created", "Created"}, {"destroyed", "Destroyed"} }));
});
Route(eHTTPMethod::GET, "/api/reports/transfers/csv", Perm("reports_view"), "Trades and mail as CSV, newest first (up to 50,000). Query: ?character= (name or ID)&lot=",
[](HTTPReply& reply, const HTTPContext& context) {
auto characterText = QueryValue(context.queryString, "character");
if (characterText.empty()) characterText = QueryValue(context.queryString, "character_id");
const auto characterId = characterText.empty() ? 0 : ResolveCharacter(characterText).value_or(-1);
const auto lot = GeneralUtils::TryParse<LOT>(QueryValue(context.queryString, "lot")).value_or(0);
auto data = Database::Get()->GetTransfers(0, 50000, characterId, lot)["data"];
static const char* METHODS[] = { "", "Trade", "Mail sent", "Mail claimed" };
for (auto& row : data) {
const auto method = row.value("method", 0);
row["how"] = method >= 1 && method <= 3 ? METHODS[method] : "";
row["when"] = row.value("time", int64_t{ 0 });
}
WithItemNames(data);
CsvReply(reply, "transfers.csv", ToCsv(data, {
{"when", "Unix time"}, {"how", "How"}, {"lot", "LOT"}, {"name", "Item"}, {"count", "Count"}, {"coins", "Coins"},
{"from_character", "From ID"}, {"from_name", "From"}, {"to_character", "To ID"}, {"to_name", "To"},
{"item_id", "Object ID"}, {"new_item_id", "Received as"}, {"zone", "Zone"} }));
});
Route(eHTTPMethod::GET, "/api/reports/duplicates/csv", Perm("reports_view"), "The last duplicate scan's duplicated items and object ID collisions (different items sharing an ID, from old data) as CSV, one row per copy",
[](HTTPReply& reply, const HTTPContext&) {
nlohmann::json rows = nlohmann::json::array();
const auto addRows = [&rows](const nlohmann::json& groups, const std::string& kind) {
for (const auto& group : groups) {
for (const auto& copy : group["copies"]) {
rows.push_back({
{"kind", kind}, {"item_id", group["item_id"]}, {"lot", copy["lot"]}, {"name", copy.value("name", "")},
{"character_id", copy["character_id"]}, {"character_name", copy["character_name"]},
{"where", copy.value("where", "") == "mail" ? "mail " + copy.value("mail_id", "") : copy.value("inventory", "")}, {"count", copy["count"]},
{"version", copy.contains("character_version") ? nlohmann::json(copy["character_version"]) : nlohmann::json("")},
{"new_id", copy.value("new_id_at_login", false) ? "yes" : "no"},
{"resolves", group.contains("login_fix") && group["login_fix"].value("resolves", false) ? "yes" : "no"}
});
}
}
};
if (g_LastScan) {
addRows(g_LastScan->duplicates, "Duplicate");
addRows(g_LastScan->collisions, "ID collision");
}
CsvReply(reply, "duplicates.csv", ToCsv(rows, {
{"kind", "Kind"}, {"item_id", "Object ID"}, {"lot", "LOT"}, {"name", "Item"}, {"character_id", "Character ID"}, {"character_name", "Character"}, {"where", "Where"}, {"count", "Count"},
{"version", "Character version"}, {"new_id", "New ID at next login"}, {"resolves", "Resolves on login"} }));
});
Route(eHTTPMethod::GET, "/api/reports/duplicates", Perm("reports_view"), "Result of the last duplicate item scan: duplicates (the same LOT under one object ID in more than one place) and collisions "
"(different LOTs sharing an object ID, from old data; not dupes). Each copy has character_version (the save's <lvl cv>) and new_id_at_login; "
"each group has login_fix {resolves, characters, logins_needed}: whether the login migration that gives old saves new item IDs clears it",
[](HTTPReply& reply, const HTTPContext&) {
if (!g_LastScan) return JsonReply(reply, eHTTPStatusCode::OK, { {"time", 0} });
JsonReply(reply, eHTTPStatusCode::OK, ScanJson(*g_LastScan));
});
Route(eHTTPMethod::POST, "/api/reports/duplicates/scan", Perm("reports_view"),
"Scan every character and unclaimed mail for duplicated items and totals held per item. Runs in the background: returns {requestId}",
[](HTTPReply& reply, const HTTPContext& context) {
// Reads every character; a minute between scans keeps it from being run in a loop
if (g_LastScan && std::time(nullptr) - g_LastScan->time < 60) {
return JsonError(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, "A scan ran less than a minute ago");
}
const auto requestId = PlayerActions::Begin(context.accountId, std::chrono::minutes(30));
const auto actor = context;
const bool started = StartDuplicateScan([requestId, actor](const nlohmann::json& scan, const std::string& error) {
if (!error.empty()) return PlayerActions::Finish(requestId, { false, "The scan failed: " + error });
const auto count = scan["duplicateCount"].get<size_t>();
Audit(actor, "economy_duplicate_scan", std::to_string(count) + " duplicated item(s)");
PlayerActions::Finish(requestId, { true, "Scan finished: " + std::to_string(count) + " duplicated item(s)", scan });
});
if (!started) {
PlayerActions::Finish(requestId, { false, "A scan is already running" });
return JsonError(reply, eHTTPStatusCode::CONFLICT, "A scan is already running");
}
JsonSuccess(reply, { {"requestId", requestId}, {"message", "Scan started"} });
});
}