From 0a1e33d3d2deab83b1927ed323ffad7764cd5594 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 00:20:58 -0500 Subject: [PATCH] refactor(dashboard): reduce the CDClient browser to a raw table viewer Drops the analysis views (objects, loot odds, missions, skills, behavior trees, activities, zones), the name search and the 3D preview, with their API routes and CDClientRules.h. What stays: the table list, paging, sort, any-column search, column filters, and linked values that open the target table filtered to that ID. Old links such as /cdclient#/object/ (UGC page, world view) now open the Objects table filtered to that LOT. Check in the dashboard: CDClient Browser lists every table; open one, sort by a column, add a filter, search, page; click a LOT or loot matrix value; /cdclient#/object/1727 opens Objects id = 1727. Co-Authored-By: Claude Opus 5.5 --- dCommon/Permissions.cpp | 2 +- dDashboardServer/routes/CDClientBrowser.cpp | 982 +------------------- dDashboardServer/routes/CDClientBrowser.h | 6 +- dDashboardServer/routes/CDClientRules.h | 271 ------ dDashboardServer/routes/CDClientSchema.h | 35 +- dDashboardServer/static/js/cdclient.js | 592 +----------- dDashboardServer/templates/cdclient.jinja2 | 38 +- docs/Dashboard.md | 37 +- tests/dWebTests/CDClientSchemaTests.cpp | 106 --- 9 files changed, 48 insertions(+), 2021 deletions(-) delete mode 100644 dDashboardServer/routes/CDClientRules.h diff --git a/dCommon/Permissions.cpp b/dCommon/Permissions.cpp index 9f8e21aab..34671b848 100644 --- a/dCommon/Permissions.cpp +++ b/dCommon/Permissions.cpp @@ -109,7 +109,7 @@ namespace { { "permissions_manage", "Server", "Permissions", "Change what each GM level may do (always GM 9)", 9, true }, { "dev_message_inspector", "Developer tools", "Game message inspector", "Capture the game messages an online player sends and receives, live (every capture is audited)", 8 }, - { "dev_cdclient", "Developer tools", "CDClient browser", "Browse the game's CDClient data: objects and their components, loot, missions, skills, behaviors and activities", 8 }, + { "dev_cdclient", "Developer tools", "CDClient browser", "Read the game's CDClient tables as they are: page, sort, search and filter any table", 8 }, }; const std::map& Index() { diff --git a/dDashboardServer/routes/CDClientBrowser.cpp b/dDashboardServer/routes/CDClientBrowser.cpp index f9fde1451..69c4506c7 100644 --- a/dDashboardServer/routes/CDClientBrowser.cpp +++ b/dDashboardServer/routes/CDClientBrowser.cpp @@ -1,26 +1,11 @@ #include "CDClientBrowser.h" -#include -#include -#include -#include - -#include "BehaviorTemplate.h" #include "CDClientDatabase.h" -#include "CDClientRules.h" #include "CDClientSchema.h" -#include "ClientAssets.h" -#include "Database.h" #include "GameLabels.h" #include "GeneralUtils.h" -#include "InstanceLimits.h" -#include "Locale.h" #include "RouteUtils.h" #include "eHTTPMethod.h" -#include "eItemType.h" -#include "eMissionState.h" -#include "eMissionTaskType.h" -#include "eRacingTaskParam.h" using namespace RouteUtils; using namespace CDClientSchema; @@ -28,13 +13,6 @@ using json = nlohmann::json; namespace { constexpr uint32_t DEFAULT_PAGE = 50; - constexpr size_t REFERENCE_ROWS = 25; // rows shown per table in "used by" lists - constexpr size_t MAX_OBJECTS = 100; // objects listed in "dropped by" and similar lists - constexpr size_t SEARCH_RESULTS = 25; - constexpr size_t MAX_BEHAVIOR_NODES = 500; - constexpr size_t MAX_BEHAVIOR_DEPTH = 32; - constexpr size_t MAX_LOOT_ITEMS = 1000; // items listed per vendor loot table (the biggest loot tables have ~650) - constexpr size_t MAX_MATRICES = 100; // loot matrices whose odds are worked out for "comes from" lists // Read from the database itself once: every table and its columns const Schema& GetSchema() { @@ -60,17 +38,6 @@ namespace { return schema; } - // A row's value, or the fallback when it is missing or NULL (the CDClient has many NULLs) - int64_t Int(const json& row, const std::string& key, int64_t fallback = 0) { - const auto it = row.find(key); - return it != row.end() && it->is_number() ? it->get() : fallback; - } - - double Real(const json& row, const std::string& key) { - const auto it = row.find(key); - return it != row.end() && it->is_number() ? it->get() : 0.0; - } - std::string Text(const json& row, const std::string& key, const std::string& fallback = "") { const auto it = row.find(key); return it != row.end() && it->is_string() ? it->get() : fallback; @@ -102,7 +69,7 @@ namespace { return row; } - // Rows of a fixed query (only ever SQL written here, with values bound) + // Rows of a query built by BuildRowQuery (only tables and columns from the schema, values bound) json Rows(const std::string& sql, const std::vector& params, size_t max) { auto statement = CDClientDatabase::CreatePreppedStmt(sql); Bind(statement, params); @@ -122,114 +89,6 @@ namespace { return query.eof() ? 0 : query.getInt64Field(0); } - // Rows of `table` whose `column` equals `value` (both from the schema) - json RowsWhere(const Table& table, const Column& column, int64_t value, size_t max) { - return Rows("SELECT * FROM " + Quote(table.name) + " WHERE " + Quote(column.name) + " = ? LIMIT ?;", { value, static_cast(max) }, max); - } - - json RowsWhere(std::string_view tableName, std::string_view columnName, int64_t value, size_t max) { - const auto* table = GetSchema().Find(tableName); - const auto* column = table ? table->Find(columnName) : nullptr; - return column ? RowsWhere(*table, *column, value, max) : json::array(); - } - - std::string Phrase(const std::string& key) { - return Locale::GetPhrase(key); - } - - // The locale's texts for a row: __ -> {column: text} - json Localized(const std::string& table, int64_t id) { - const auto prefix = table + "_" + std::to_string(id) + "_"; - json texts = json::object(); - for (const auto& key : Locale::GetPhraseIdsWithPrefix(prefix)) texts[key.substr(prefix.size())] = Locale::GetPhrase(key); - return texts; - } - - std::string Named(const std::string& phrase, const std::string& fallback) { - return phrase.empty() ? fallback : phrase; - } - - // A readable name for what a link points at - std::string LinkLabel(eLink link, int64_t id) { - const auto text = std::to_string(id); - switch (link) { - case eLink::OBJECT: return ClientAssets::ItemName(static_cast(id)); - case eLink::MISSION: return Named(Phrase("Missions_" + text + "_name"), "Mission " + text); - case eLink::SKILL: return Named(Phrase("SkillBehavior_" + text + "_name"), "Skill " + text); - case eLink::ACTIVITY: return Named(Phrase("Activities_" + text + "_ActivityName"), "Activity " + text); - case eLink::ZONE: return Named(Phrase("ZoneTable_" + text + "_DisplayDescription"), "Zone " + text); - case eLink::EMOTE: { - // Most emotes have no text in the locale, only an animation - if (const auto& phrase = Phrase("Emotes_" + text + "_outputText"); !phrase.empty()) return phrase; - const auto rows = RowsWhere("Emotes", "id", id, 1); - return Named(rows.empty() ? "" : Text(rows[0], "animationName"), "Emote " + text); - } - default: return GameLabels::Words(magic_enum::enum_name(link)) + " " + text; - } - } - - // Whether a link's target row is in the CDClient - bool Exists(eLink link, int64_t id) { - const auto target = Target(link); - return !RowsWhere(target.table, target.column, id, 1).empty(); - } - - json Ref(eLink link, int64_t id) { - return { {"link", LinkName(link)}, {"id", id}, {"name", LinkLabel(link, id)} }; - } - - // Component table -> the component types stored in it - const std::map>& ComponentTypesByTable() { - static const auto byTable = [] { - std::map> map; - for (const auto type : magic_enum::enum_values()) { - if (const auto* table = ComponentTable(GetSchema(), type)) map[table->name].push_back(static_cast(type)); - } - return map; - }(); - return byTable; - } - - std::string TypeList(const std::vector& types) { - std::string list; - for (const auto type : types) list += (list.empty() ? "" : ",") + std::to_string(type); - return list; - } - - /** - * Objects with a component row in `table` matching `condition` (SQL about c, the component row; values bound). - * Only for component tables; others give nothing. - */ - json ObjectsWithComponent(const std::string& table, const std::string& condition, const std::vector& params) { - const auto types = ComponentTypesByTable().find(table); - if (types == ComponentTypesByTable().end()) return json::array(); - json objects = json::array(); - // The type list is made of numbers from the enum, so it can be part of the SQL - const auto rows = Rows("SELECT DISTINCT cr.id AS lot FROM ComponentsRegistry cr JOIN " + Quote(table) + " c ON c.id = cr.component_id " - "WHERE cr.component_type IN (" + TypeList(types->second) + ") AND " + condition + " LIMIT " + std::to_string(MAX_OBJECTS) + ";", params, MAX_OBJECTS); - for (const auto& row : rows) objects.push_back(Ref(eLink::OBJECT, Int(row, "lot"))); - return objects; - } - - /** - * Everything pointing at `id` through a `link` column: per (table, column) how many rows, the first few, and for - * component tables the objects that have them. - */ - json References(eLink link, int64_t id) { - json references = json::array(); - for (const auto& [table, column] : ColumnsLinkingTo(GetSchema(), link)) { - const auto where = " WHERE " + Quote(column->name) + " = ?"; - const auto count = Count("SELECT COUNT(*) FROM " + Quote(table->name) + where + ";", { id }); - if (count == 0) continue; - references.push_back({ - {"table", table->name}, {"column", column->name}, {"count", count}, - {"rows", RowsWhere(*table, *column, id, REFERENCE_ROWS)}, - {"objects", ObjectsWithComponent(table->name, "c." + Quote(column->name) + " = ?", { id })} - }); - } - return references; - } - json Columns(const Table& table) { json columns = json::array(); for (const auto& column : table.columns) { @@ -243,798 +102,6 @@ namespace { const auto name = magic_enum::enum_name(static_cast(type)); return name.empty() ? "Type " + std::to_string(type) : GameLabels::Words(name); } - - // ---- Models ---- - - bool PlainName(std::string_view name) { - return !name.empty() && name.size() < 128 && std::ranges::all_of(name, [](char c) { - return std::isalnum(static_cast(c)) || c == '_' || c == '-' || c == '.'; - }); - } - - /** - * LXFML to preview an object, and where it came from: brick-built objects have one in res/BrickModels named after - * their render asset (in the render component's LXFMLFolder, if any); a single LEGO brick (BrickIDTable) is drawn - * as that one brick. - */ - std::optional> ObjectLxfml(LOT lot) { - const auto render = Rows("SELECT rc.render_asset, rc.LXFMLFolder FROM ComponentsRegistry cr JOIN RenderComponent rc ON rc.id = cr.component_id " - "WHERE cr.component_type = ? AND cr.id = ? LIMIT 1;", { static_cast(eReplicaComponentType::RENDER), static_cast(lot) }, 1); - if (!render.empty()) { - std::string asset = Text(render[0], "render_asset"); - std::replace(asset.begin(), asset.end(), '\\', '/'); - asset = asset.substr(asset.rfind('/') + 1); - asset = Lower(asset.substr(0, asset.rfind('.'))); - const std::string folder = Lower(Text(render[0], "LXFMLFolder")); - if (PlainName(asset)) { - std::vector candidates; - if (PlainName(folder)) candidates.push_back("BrickModels/" + folder + "/" + asset + ".lxfml"); - candidates.push_back("BrickModels/" + asset + ".lxfml"); - for (const auto& path : candidates) { - if (auto data = ClientAssets::ReadResFile(path)) return std::make_pair(std::move(*data), path); - } - if (const auto found = ClientAssets::FindResFile("BrickModels", asset + ".lxfml")) { - if (auto data = ClientAssets::ReadResFile(*found)) return std::make_pair(std::move(*data), *found); - } - } - } - const auto brick = Rows("SELECT LEGOBrickID FROM BrickIDTable WHERE NDObjectID = ? LIMIT 1;", { static_cast(lot) }, 1); - if (brick.empty()) return std::nullopt; - const auto design = std::to_string(Int(brick[0], "LEGOBrickID")); - return std::make_pair( - "\n\n", - "LEGO brick " + design); - } - - // ---- Loot, as the server rolls it (CDClientRules) ---- - - constexpr auto ITEM_COMPONENT = static_cast(eReplicaComponentType::ITEM); - - /** - * Items' rarities as RollLootMatrix looks them up: the item component's rarity; an object without one gets item - * component 0's (what GetByIDAndType's default finds), or 0. Read once: the CDClient has no indexes. - */ - struct Rarities { - std::map byLot; - int32_t withoutComponent{}; - }; - - const Rarities& ItemRarities() { - static const Rarities rarities = [] { - Rarities loaded; - for (const auto& row : Rows("SELECT cr.id, ic.rarity FROM ComponentsRegistry cr JOIN ItemComponent ic ON ic.id = cr.component_id WHERE cr.component_type = ?;", - { ITEM_COMPONENT }, SIZE_MAX)) { - loaded.byLot.emplace(Int(row, "id"), static_cast(Int(row, "rarity"))); - } - const auto none = Rows("SELECT rarity FROM ItemComponent WHERE id = 0 LIMIT 1;", {}, 1); - if (!none.empty()) loaded.withoutComponent = static_cast(Int(none[0], "rarity")); - return loaded; - }(); - return rarities; - } - - int32_t RollRarity(int64_t lot) { - const auto& rarities = ItemRarities(); - const auto it = rarities.byLot.find(lot); - return it == rarities.byLot.end() ? rarities.withoutComponent : it->second; - } - - // An object as a link, with its item rarity when it is an item - json ItemRef(int64_t lot) { - auto ref = Ref(eLink::OBJECT, lot); - const auto& rarities = ItemRarities(); - if (const auto it = rarities.byLot.find(lot); it != rarities.byLot.end()) ref["rarity"] = it->second; - return ref; - } - - /** - * A loot table's rows by one of its columns, all read once in table order: odds for an item used by hundreds of - * matrices would otherwise scan the unindexed tables hundreds of times. - */ - using Grouped = std::map>; - - Grouped GroupRows(const std::string& sql, const std::string& key) { - Grouped grouped; - for (auto& row : Rows(sql, {}, SIZE_MAX)) grouped[Int(row, key)].push_back(std::move(row)); - return grouped; - } - - const std::vector& Group(const Grouped& grouped, int64_t index) { - static const std::vector none; - const auto it = grouped.find(index); - return it == grouped.end() ? none : it->second; - } - - const std::vector& LootMatrixRows(int64_t index) { - static const auto grouped = GroupRows("SELECT * FROM LootMatrix;", "LootMatrixIndex"); - return Group(grouped, index); - } - - const std::vector& LootTableRows(int64_t index) { - static const auto grouped = GroupRows("SELECT * FROM LootTable;", "LootTableIndex"); - return Group(grouped, index); - } - - // The rarity rows in the order the server walks them (CDRarityTableTable: randmax descending) - std::vector RarityRows(int64_t index) { - static const auto grouped = GroupRows("SELECT RarityTableIndex, randmax, rarity FROM RarityTable ORDER BY randmax DESC;", "RarityTableIndex"); - std::vector rows; - for (const auto& row : Group(grouped, index)) rows.push_back({ Real(row, "randmax"), static_cast(Int(row, "rarity")) }); - return rows; - } - - // One loot matrix entry as a drop rolls it: the loot table's items with their rarity and chances - struct EntryOdds { - std::vector rarities; - std::vector perDrop; - std::vector odds; - }; - - EntryOdds RollEntry(const json& row) { - EntryOdds entry; - const auto& lootRows = LootTableRows(Int(row, "LootTableIndex")); - for (const auto& loot : lootRows) entry.rarities.push_back(RollRarity(Int(loot, "itemid"))); - entry.perDrop = CDClientRules::ChancePerDrop(entry.rarities, RarityRows(Int(row, "RarityTableIndex"))); - for (const auto chance : entry.perDrop) { - entry.odds.push_back(CDClientRules::Odds(Real(row, "percent"), static_cast(Int(row, "minToDrop")), static_cast(Int(row, "maxToDrop")), chance)); - } - return entry; - } - - // Per item of a loot matrix, the chance of getting it at least once and how many on average (entries roll independently) - std::map MatrixOdds(int64_t index) { - std::map> totals; // lot: chance of none, expected count - for (const auto& row : LootMatrixRows(index)) { - const auto entry = RollEntry(row); - const auto& lootRows = LootTableRows(Int(row, "LootTableIndex")); - for (size_t i = 0; i < lootRows.size(); i++) { - auto& total = totals.try_emplace(Int(lootRows[i], "itemid"), 1.0, 0.0).first->second; - total.first *= 1.0 - entry.odds[i].atLeastOne; - total.second += entry.odds[i].expected; - } - } - std::map odds; - for (const auto& [lot, total] : totals) odds[lot] = { 1.0 - total.first, total.second }; - return odds; - } - - /** - * A loot matrix as a drop rolls it (RollLootMatrix, for smashables, activities and packages): per entry its chance, - * how many drops, the rarity each drop rolls and each item's chances; and per item the chance of getting it at - * least once from the whole matrix and how many on average. - */ - json LootMatrix(int64_t index) { - json entries = json::array(); - for (const auto& row : LootMatrixRows(index)) { - const auto entry = RollEntry(row); - const auto& lootRows = LootTableRows(Int(row, "LootTableIndex")); - json rolled = json::array(); - for (const auto& [rarity, chance] : CDClientRules::RarityChances(RarityRows(Int(row, "RarityTableIndex")))) rolled.push_back({ {"rarity", rarity}, {"chance", chance} }); - json items = json::array(); - for (size_t i = 0; i < lootRows.size(); i++) { - items.push_back({ {"item", ItemRef(Int(lootRows[i], "itemid"))}, {"rarity", entry.rarities[i]}, {"missionDrop", Int(lootRows[i], "MissionDrop") == 1}, - {"perDrop", entry.perDrop[i]}, {"chance", entry.odds[i].atLeastOne}, {"expected", entry.odds[i].expected} }); - } - std::stable_sort(items.begin(), items.end(), [](const json& a, const json& b) { return a["rarity"].get() > b["rarity"].get(); }); - entries.push_back({ {"lootTable", row["LootTableIndex"]}, {"rarityTable", row["RarityTableIndex"]}, {"percent", row["percent"]}, - {"minToDrop", row["minToDrop"]}, {"maxToDrop", row["maxToDrop"]}, {"flagID", row.contains("flagID") ? row["flagID"] : json(nullptr)}, - {"rolledRarity", rolled}, {"items", items} }); - } - json items = json::array(); - for (const auto& [lot, odds] : MatrixOdds(index)) items.push_back({ {"item", ItemRef(lot)}, {"chance", odds.atLeastOne}, {"expected", odds.expected} }); - std::stable_sort(items.begin(), items.end(), [](const json& a, const json& b) { return a["chance"].get() > b["chance"].get(); }); - return { {"entries", entries}, {"items", items} }; - } - - // What an item costs at a vendor with this buy scalar (VendorComponent::Buy); coins are null when the scalar is 0, - // which means the world's vendor_buy_multiplier - json VendorPrice(const json& item, double buyScalar) { - json price{ {"baseValue", item.value("baseValue", json(nullptr))}, {"coins", nullptr}, {"altCurrency", nullptr}, {"costs", json::array()} }; - const auto baseValue = Int(item, "baseValue", -1); - if (buyScalar != 0.0 && baseValue >= 0) price["coins"] = static_cast(std::floor(baseValue * buyScalar)); - const auto currency = Int(item, "currencyLOT"); - if (currency > 0 && ItemRarities().byLot.contains(currency)) { - price["altCurrency"] = { {"item", ItemRef(currency)}, {"base", Int(item, "altCurrencyCost")}, - {"count", buyScalar != 0.0 ? json(static_cast(std::floor(Int(item, "altCurrencyCost") * buyScalar))) : json(nullptr)} }; - } - // currencyCosts "lot:count,lot:count" (CDItemComponentTable::ParseCraftingCurrencies), taken as they are - for (const auto& part : GeneralUtils::SplitString(Text(item, "currencyCosts"), ',')) { - const auto pair = GeneralUtils::SplitString(part, ':'); - if (pair.size() != 2) continue; - const auto lot = GeneralUtils::TryParse(pair[0]); - if (lot) price["costs"].push_back({ {"item", ItemRef(*lot)}, {"count", GeneralUtils::TryParse(pair[1]).value_or(0)} }); - } - return price; - } - - /** - * What a vendor sells (VendorComponent::RefreshInventory): per loot matrix entry the whole loot table when its - * minToDrop or maxToDrop is 0, otherwise that many of its items at random, picked again every refresh. Items - * without an item component are never stocked. - */ - json VendorStock(int64_t matrix, double buyScalar) { - json entries = json::array(); - for (const auto& row : LootMatrixRows(matrix)) { - const auto tableIndex = Int(row, "LootTableIndex"); - const auto minDrops = static_cast(Int(row, "minToDrop")); - const auto maxDrops = static_cast(Int(row, "maxToDrop")); - const auto items = Rows("SELECT lt.itemid, lt.sortPriority, cr.component_id AS itemComponent, ic.baseValue, ic.currencyLOT, ic.altCurrencyCost, ic.currencyCosts " - "FROM LootTable lt LEFT JOIN ComponentsRegistry cr ON cr.id = lt.itemid AND cr.component_type = ? LEFT JOIN ItemComponent ic ON ic.id = cr.component_id " - "WHERE lt.LootTableIndex = ? ORDER BY lt.sortPriority;", { ITEM_COMPONENT, tableIndex }, MAX_LOOT_ITEMS); - const auto chance = CDClientRules::VendorStockChance(minDrops, maxDrops, items.size()); - json list = json::array(); - for (const auto& item : items) { - const bool sold = !item["itemComponent"].is_null(); - list.push_back({ {"item", ItemRef(Int(item, "itemid"))}, {"chance", sold ? chance : 0.0}, {"sold", sold}, {"price", VendorPrice(item, buyScalar)} }); - } - entries.push_back({ {"lootTable", tableIndex}, {"all", minDrops == 0 || maxDrops == 0}, {"minToDrop", minDrops}, {"maxToDrop", maxDrops}, {"items", list} }); - } - return entries; - } - - // Coins a destructible drops (Entity: CurrencyTable at its currency index and level) or an activity gives (level 1) - json Coins(int64_t currencyIndex, int64_t level) { - const auto rows = Rows("SELECT minvalue, maxvalue FROM CurrencyTable WHERE currencyIndex = ? AND npcminlevel = ? LIMIT 1;", { currencyIndex, level }, 1); - if (rows.empty()) return nullptr; - return { {"min", Int(rows[0], "minvalue")}, {"max", Int(rows[0], "maxvalue")} }; - } - - // ---- Views ---- - - // A number that can be any of these kinds, as the first that has it; just the number when none does - json Resolve(const std::vector& kinds, int64_t id) { - for (const auto kind : kinds) { - if (Exists(kind, id)) return kind == eLink::OBJECT ? ItemRef(id) : Ref(kind, id); - } - return { {"id", id} }; - } - - json LootTableView(int64_t index) { - json items = json::array(); - for (const auto& row : LootTableRows(index)) { - const auto lot = Int(row, "itemid"); - items.push_back({ {"item", ItemRef(lot)}, {"rarity", RollRarity(lot)}, {"missionDrop", Int(row, "MissionDrop") == 1}, {"sortPriority", row["sortPriority"]} }); - } - if (items.empty()) return nullptr; - std::stable_sort(items.begin(), items.end(), [](const json& a, const json& b) { return a["rarity"].get() > b["rarity"].get(); }); - return { {"id", index}, {"items", items}, {"usedBy", References(eLink::LOOT_TABLE, index)} }; - } - - std::string EnumWords(std::string_view name, int64_t value) { - return name.empty() ? std::to_string(value) : GameLabels::Words(name); - } - - // The first row of an object's component of this type, if it has one - std::optional ComponentRow(LOT lot, eReplicaComponentType type) { - const auto* table = ComponentTable(GetSchema(), type); - if (!table || !table->Find("id")) return std::nullopt; - const auto rows = Rows("SELECT c.* FROM ComponentsRegistry cr JOIN " + Quote(table->name) + " c ON c.id = cr.component_id WHERE cr.id = ? AND cr.component_type = ? LIMIT 1;", - { static_cast(lot), static_cast(type) }, 1); - if (rows.empty()) return std::nullopt; - return rows[0]; - } - - // An item's own details: what kind, where it goes, what's in it, what it needs - json ItemDetails(LOT lot, const json& item) { - const auto type = Int(item, "itemType", -1); - json details{ {"rarity", item["rarity"]}, {"itemType", type}, {"itemTypeName", EnumWords(magic_enum::enum_name(static_cast(type)), type)}, - {"stackSize", item["stackSize"]}, {"baseValue", item["baseValue"]}, {"equipLocation", item["equipLocation"]}, {"subItems", json::array()}, - {"price", VendorPrice(item, 1.0)}, {"commendation", nullptr}, {"preconditions", json::array()} }; - // Proxy items equipped with it, read as InventoryComponent does: spaces dropped, then split at commas - auto subItems = Text(item, "subItems"); - std::erase_if(subItems, [](unsigned char c) { return std::isspace(c); }); - for (const auto& sub : GeneralUtils::SplitString(subItems, ',')) { - if (const auto subLot = GeneralUtils::TryParse(sub)) details["subItems"].push_back(ItemRef(*subLot)); - } - if (Int(item, "commendationLOT") > 0) details["commendation"] = { {"item", ItemRef(Int(item, "commendationLOT"))}, {"count", Int(item, "commendationCost")} }; - // reqPrecondition lists Preconditions rows; show each with the reason the client gives when it isn't met - for (const auto id : PrerequisiteMissions(Text(item, "reqPrecondition"))) { - details["preconditions"].push_back({ {"id", id}, {"reason", Phrase("Preconditions_" + std::to_string(id) + "_FailureReason")} }); - } - const auto info = ClientAssets::ItemInfo(lot); - details["stats"] = info.value("stats", json(nullptr)); - details["set"] = info.value("set", json(nullptr)); - return details; - } - - // The skills on an object (ObjectSkills) with what they cost and do - json ObjectSkills(LOT lot) { - json skills = json::array(); - for (const auto& row : Rows("SELECT os.skillID, os.castOnType, os.AICombatWeight, sb.behaviorID, sb.imaginationcost, sb.cooldown, sb.cooldowngroup, sb.skillIcon " - "FROM ObjectSkills os LEFT JOIN SkillBehavior sb ON sb.skillID = os.skillID WHERE os.objectTemplate = ?;", { static_cast(lot) }, 100)) { - auto skill = row; - skill["skill"] = Ref(eLink::SKILL, Int(row, "skillID")); - skill["description"] = Phrase("SkillBehavior_" + std::to_string(Int(row, "skillID")) + "_descriptionUI"); - skills.push_back(std::move(skill)); - } - return skills; - } - - /** - * Mission tasks that count an object, mission, skill... : tasks whose targets (as MissionTask::Progress compares - * them, see CDClientRules::MeaningOf) include `id` as that kind. - */ - json TasksCounting(eLink link, int64_t id) { - json tasks = json::array(); - const auto text = std::to_string(id); - for (const auto& task : Rows("SELECT * FROM MissionTasks WHERE target = ? OR targetGroup LIKE ? ESCAPE '\\';", { id, "%" + text + "%" }, 500)) { - const auto type = static_cast(Int(task, "taskType", -1)); - const auto meaning = CDClientRules::MeaningOf(type); - if (std::ranges::find(meaning.target, link) == meaning.target.end()) continue; - // Numbers that can be more than one kind count as the first kind that has them - if (meaning.target.size() > 1 && Resolve(meaning.target, id).value("link", "") != LinkName(link)) continue; - bool counts = meaning.targetUsed && Int(task, "target") == id; - if (!counts && meaning.targetGroupIds) { - const auto group = CDClientRules::NumberList(Text(task, "targetGroup")); - counts = std::ranges::find(group, id) != group.end(); - } - if (!counts) continue; - tasks.push_back({ {"mission", Ref(eLink::MISSION, Int(task, "id"))}, {"uid", task["uid"]}, {"taskType", EnumWords(magic_enum::enum_name(type), Int(task, "taskType", -1))}, - {"targetValue", task["targetValue"]} }); - } - return tasks; - } - - // Missions giving an item as a reward, first time or on repeats (Mission::YieldRewards) - json MissionsRewarding(LOT lot) { - json missions = json::array(); - std::string condition; - for (int i = 1; i <= 4; i++) { - const auto slot = std::to_string(i); - condition += (i > 1 ? " OR " : "") + std::string("reward_item") + slot + " = ?1 OR reward_item" + slot + "_repeatable = ?1"; - } - for (const auto& row : Rows("SELECT * FROM Missions WHERE " + condition + " LIMIT 200;", { static_cast(lot) }, 200)) { - for (int i = 1; i <= 4; i++) { - const auto slot = std::to_string(i); - if (Int(row, "reward_item" + slot) == lot) missions.push_back({ {"mission", Ref(eLink::MISSION, Int(row, "id"))}, {"count", std::max(Int(row, "reward_item" + slot + "_count"), 1)}, {"repeat", false}, {"choice", Int(row, "isChoiceReward") == 1} }); - if (Int(row, "reward_item" + slot + "_repeatable") == lot) missions.push_back({ {"mission", Ref(eLink::MISSION, Int(row, "id"))}, {"count", std::max(Int(row, "reward_item" + slot + "_repeat_count"), 1)}, {"repeat", true}, {"choice", Int(row, "isChoiceReward") == 1} }); - } - } - return missions; - } - - /** - * Where an object comes from through loot matrices: per table and column pointing at matrices that have it, each - * matrix with its chance (a drop's, or a vendor's chance of stocking it) and the objects or rows using it. - */ - json Sources(LOT lot) { - const auto& schema = GetSchema(); - const auto* vendorTable = ComponentTable(schema, eReplicaComponentType::VENDOR); - const auto matrices = Rows("SELECT DISTINCT lm.LootMatrixIndex, lm.LootTableIndex, lm.minToDrop, lm.maxToDrop FROM LootMatrix lm " - "JOIN LootTable lt ON lt.LootTableIndex = lm.LootTableIndex WHERE lt.itemid = ?;", { static_cast(lot) }, SIZE_MAX); - std::string withItem; // the matrices as an SQL list: numbers read from the database - for (const auto& row : matrices) withItem += (withItem.empty() ? "" : ",") + std::to_string(Int(row, "LootMatrixIndex")); - if (withItem.empty()) return json::array(); - - // The chance per matrix, worked out once however many tables use it (up to MAX_MATRICES of them) - std::map drops; - const auto dropChance = [&](int64_t matrix) -> const json& { - if (const auto it = drops.find(matrix); it != drops.end()) return it->second; - json chance = json::object(); - if (drops.size() < MAX_MATRICES) { - const auto odds = MatrixOdds(matrix); - if (const auto it = odds.find(lot); it != odds.end()) chance = { {"chance", it->second.atLeastOne}, {"expected", it->second.expected} }; - } - return drops[matrix] = chance; - }; - // A vendor's chance of having it in stock, from the entries whose table has it - const auto stockChance = [&](int64_t matrix) { - double missing = 1.0; - for (const auto& row : matrices) { - if (Int(row, "LootMatrixIndex") != matrix) continue; - const auto size = LootTableRows(Int(row, "LootTableIndex")).size(); - missing *= 1.0 - CDClientRules::VendorStockChance(static_cast(Int(row, "minToDrop")), static_cast(Int(row, "maxToDrop")), size); - } - return 1.0 - missing; - }; - - json sources = json::array(); - for (const auto& [table, column] : ColumnsLinkingTo(schema, eLink::LOOT_MATRIX)) { - if (table->name == "LootMatrix" || table->name == "LootMatrixIndex") continue; - const bool vendor = vendorTable && table == vendorTable; - const auto types = ComponentTypesByTable().find(table->name); - // Per matrix, the objects whose component uses it: one query for the table, not one per matrix - std::map users; - if (types != ComponentTypesByTable().end()) { - for (const auto& row : Rows("SELECT DISTINCT c." + Quote(column->name) + " AS matrix, cr.id AS lot FROM ComponentsRegistry cr JOIN " + Quote(table->name) + - " c ON c.id = cr.component_id WHERE cr.component_type IN (" + TypeList(types->second) + ") AND c." + Quote(column->name) + " IN (" + withItem + ");", {}, SIZE_MAX)) { - auto& objects = users.try_emplace(Int(row, "matrix"), json::array()).first->second; - if (objects.size() < MAX_OBJECTS) objects.push_back(ItemRef(Int(row, "lot"))); - } - } else { - for (const auto& row : Rows("SELECT * FROM " + Quote(table->name) + " WHERE " + Quote(column->name) + " IN (" + withItem + ");", {}, SIZE_MAX)) { - auto& rows = users.try_emplace(Int(row, column->name), json::array()).first->second; - if (rows.size() < REFERENCE_ROWS) rows.push_back(row); - } - } - json list = json::array(); - for (const auto& [matrix, used] : users) { - json entry{ {"matrix", matrix}, {"objects", types != ComponentTypesByTable().end() ? used : json::array()}, {"rows", types != ComponentTypesByTable().end() ? json::array() : used} }; - if (vendor) entry["chance"] = stockChance(matrix); - else entry.update(dropChance(matrix)); - list.push_back(std::move(entry)); - } - if (list.empty()) continue; - std::stable_sort(list.begin(), list.end(), [](const json& a, const json& b) { return a.value("chance", 0.0) > b.value("chance", 0.0); }); - sources.push_back({ {"table", table->name}, {"column", column->name}, {"vendor", vendor}, {"matrices", list} }); - } - return sources; - } - - json ObjectView(LOT lot) { - const auto object = RowsWhere("Objects", "id", lot, 1); - if (object.empty()) return nullptr; - const auto& schema = GetSchema(); - using enum eReplicaComponentType; - - json components = json::array(); - json loot = json::array(); - for (const auto& entry : RowsWhere("ComponentsRegistry", "id", lot, 200)) { - const auto type = Int(entry, "component_type"); - const auto componentId = Int(entry, "component_id"); - const auto* table = ComponentTable(schema, static_cast(type)); - json component{ {"type", type}, {"typeName", ComponentTypeName(type)}, {"componentId", componentId}, {"table", table ? json(table->name) : json(nullptr)}, {"rows", json::array()} }; - if (table && table->Find("id")) { - component["rows"] = RowsWhere(*table, *table->Find("id"), componentId, 50); - component["columns"] = Columns(*table); - // What this object drops or contains: loot matrices its component rows point at (a vendor's are its stock, below) - if (static_cast(type) != VENDOR) { - for (const auto& column : table->columns) { - if (LinkFor(schema, *table, column) != eLink::LOOT_MATRIX) continue; - for (const auto& row : component["rows"]) { - const auto matrix = Int(row, column.name); - if (matrix > 0) loot.push_back({ {"table", table->name}, {"column", column.name}, {"matrix", matrix}, {"odds", LootMatrix(matrix)} }); - } - } - } - } - components.push_back(std::move(component)); - } - - json view{ - {"lot", lot}, - {"name", ClientAssets::ItemName(lot)}, - {"description", Phrase("Objects_" + std::to_string(lot) + "_description")}, - {"row", object[0]}, - {"columns", Columns(*schema.Find("Objects"))}, - {"localized", Localized("Objects", lot)}, - {"icon", !ClientAssets::IconPathForLot(lot).empty()}, - {"components", components}, - {"loot", loot}, - {"skills", ObjectSkills(lot)}, - {"sources", Sources(lot)}, - {"rewardedBy", MissionsRewarding(lot)}, - {"countedBy", TasksCounting(eLink::OBJECT, lot)}, - }; - - if (const auto item = ComponentRow(lot, ITEM)) view["item"] = ItemDetails(lot, *item); - if (const auto destructible = ComponentRow(lot, BUFF)) { - view["destructible"] = { {"life", (*destructible)["life"]}, {"armor", (*destructible)["armor"]}, {"imagination", (*destructible)["imagination"]}, - {"level", (*destructible)["level"]}, {"faction", (*destructible)["faction"]}, {"isSmashable", (*destructible)["isSmashable"]}, - {"coins", Coins(Int(*destructible, "CurrencyIndex"), Int(*destructible, "level"))} }; - } - if (const auto vendor = ComponentRow(lot, VENDOR)) { - const auto buyScalar = Real(*vendor, "buyScalar"); - view["vendor"] = { {"buyScalar", buyScalar}, {"sellScalar", (*vendor)["sellScalar"]}, {"refreshTimeSeconds", (*vendor)["refreshTimeSeconds"]}, - {"matrix", (*vendor)["LootMatrixIndex"]}, {"stock", VendorStock(Int(*vendor, "LootMatrixIndex"), buyScalar)} }; - } - if (const auto collectible = ComponentRow(lot, COLLECTIBLE); collectible && Int(*collectible, "requirement_mission") > 0) { - view["collectibleMission"] = Ref(eLink::MISSION, Int(*collectible, "requirement_mission")); - } - // What it starts with (InventoryComponent) and the missions it gives or takes (MissionNPCComponent) - if (const auto* table = ComponentTable(schema, INVENTORY); table && table->Find("itemid")) { - json inventory = json::array(); - for (const auto& row : Rows("SELECT c.itemid, c.count, c.equip FROM ComponentsRegistry cr JOIN " + Quote(table->name) + " c ON c.id = cr.component_id " - "WHERE cr.id = ? AND cr.component_type = ?;", { static_cast(lot), static_cast(INVENTORY) }, 200)) { - inventory.push_back({ {"item", ItemRef(Int(row, "itemid"))}, {"count", row["count"]}, {"equip", Int(row, "equip") == 1} }); - } - if (!inventory.empty()) view["inventory"] = inventory; - } - if (const auto* table = ComponentTable(schema, MISSION_OFFER); table && table->Find("missionID")) { - json missions = json::array(); - for (const auto& row : Rows("SELECT c.missionID, c.offersMission, c.acceptsMission FROM ComponentsRegistry cr JOIN " + Quote(table->name) + " c ON c.id = cr.component_id " - "WHERE cr.id = ? AND cr.component_type = ?;", { static_cast(lot), static_cast(MISSION_OFFER) }, 500)) { - missions.push_back({ {"mission", Ref(eLink::MISSION, Int(row, "missionID"))}, {"offers", Int(row, "offersMission") == 1}, {"accepts", Int(row, "acceptsMission") == 1} }); - } - if (!missions.empty()) view["missions"] = missions; - } - - // Its own ComponentsRegistry rows are listed above as its components - auto usedBy = References(eLink::OBJECT, lot); - usedBy.erase(std::remove_if(usedBy.begin(), usedBy.end(), [](const json& r) { return r["table"] == "ComponentsRegistry"; }), usedBy.end()); - view["usedBy"] = usedBy; - const auto model = ObjectLxfml(lot); - view["model"] = model ? json(model->second) : json(nullptr); - return view; - } - - json MissionView(int64_t id) { - const auto mission = RowsWhere("Missions", "id", id, 1); - if (mission.empty()) return nullptr; - const auto& row = mission[0]; - - json tasks = json::array(); - for (const auto& task : RowsWhere("MissionTasks", "id", id, 50)) { - const auto type = Int(task, "taskType", -1); - const auto meaning = CDClientRules::MeaningOf(static_cast(type)); - json entry{ {"uid", task["uid"]}, {"taskType", type}, {"taskTypeName", EnumWords(magic_enum::enum_name(static_cast(type)), type)}, - {"description", Phrase("MissionTasks_" + std::to_string(Int(task, "uid")) + "_description")}, - {"targetValue", task["targetValue"]}, {"progressed", meaning.progressed}, {"iconID", task["IconID"]}, - {"targets", json::array()}, {"targetText", nullptr}, {"parameters", json::array()}, {"racingParameter", nullptr}, {"row", task} }; - std::vector targets; - if (meaning.targetUsed) targets.push_back(Int(task, "target")); - if (meaning.targetGroupIds) for (const auto number : CDClientRules::NumberList(Text(task, "targetGroup"))) targets.push_back(number); - for (const auto target : targets) { - // MissionTask skips a target it can't read and -1 never matches; 0 is only ever progressed by the "value 0" tasks - if (target > 0 || (target == 0 && meaning.target.empty())) entry["targets"].push_back(Resolve(meaning.target, target)); - } - if (meaning.targetGroupText && !Text(task, "targetGroup").empty()) entry["targetText"] = Text(task, "targetGroup"); - const auto parameters = CDClientRules::NumberList(Text(task, "taskParam1")); - if (meaning.racingParameter && !parameters.empty()) { - entry["racingParameter"] = EnumWords(magic_enum::enum_name(static_cast(parameters[0])), parameters[0]); - } else if (!meaning.parameters.empty()) { - for (const auto parameter : parameters) entry["parameters"].push_back(Resolve(meaning.parameters, parameter)); - } - tasks.push_back(std::move(entry)); - } - - // Mission::YieldRewards: the first completion gives the items (a count of 0 gives 1) and the stat rewards; later - // ones only the repeat items and coins. A choice reward gives only the item picked. - json rewards{ {"items", json::array()}, {"repeatItems", json::array()}, {"emotes", json::array()}, {"stats", json::array()} }; - for (int i = 1; i <= 4; i++) { - const auto slot = std::to_string(i); - const auto item = Int(row, "reward_item" + slot); - const auto count = Int(row, "reward_item" + slot + "_count"); - if (item > 0 && count >= 0) rewards["items"].push_back({ {"item", ItemRef(item)}, {"count", std::max(count, 1)} }); - const auto repeatItem = Int(row, "reward_item" + slot + "_repeatable"); - const auto repeatCount = Int(row, "reward_item" + slot + "_repeat_count"); - if (repeatItem > 0 && repeatCount >= 0) rewards["repeatItems"].push_back({ {"item", ItemRef(repeatItem)}, {"count", std::max(repeatCount, 1)} }); - const auto emote = Int(row, i == 1 ? "reward_emote" : "reward_emote" + slot); - if (emote > 0) rewards["emotes"].push_back(Ref(eLink::EMOTE, emote)); - } - // The other reward_ columns the server gives the first time (inventory space, health, imagination, ...), as named - for (const auto& [key, value] : row.items()) { - if (!key.starts_with("reward_max") && key != "reward_bankinventory") continue; - if (value.is_number() && value.get() > 0) rewards["stats"].push_back({ {"column", key}, {"value", value} }); - } - - json prerequisites = json::array(); - for (const auto& term : CDClientRules::ParsePrerequisites(Text(row, "prereqMissionID"))) { - prerequisites.push_back({ {"mission", term.mission ? Ref(eLink::MISSION, term.mission) : json(nullptr)}, {"state", term.state}, - {"stateName", term.state ? json(EnumWords(magic_enum::enum_name(static_cast(term.state)), term.state)) : json(nullptr)}, {"or", term.orRest} }); - } - - // Missions that need this one: prereqMissionID is text, so find candidates and check each properly - json unlocks = json::array(); - for (const auto& candidate : Rows("SELECT id, prereqMissionID FROM Missions WHERE prereqMissionID LIKE ? ESCAPE '\\';", { "%" + std::to_string(id) + "%" }, 500)) { - const auto terms = CDClientRules::ParsePrerequisites(Text(candidate, "prereqMissionID")); - if (std::ranges::any_of(terms, [&](const auto& term) { return static_cast(term.mission) == id; })) unlocks.push_back(Ref(eLink::MISSION, Int(candidate, "id"))); - } - - return { - {"id", id}, - {"name", LinkLabel(eLink::MISSION, id)}, - {"row", row}, - {"columns", Columns(*GetSchema().Find("Missions"))}, - {"localized", Localized("Missions", id)}, - {"text", Localized("MissionText", id)}, - {"tasks", tasks}, - {"rewards", rewards}, - {"prerequisites", prerequisites}, - {"unlocks", unlocks}, - {"countedBy", TasksCounting(eLink::MISSION, id)}, - {"offeredBy", ObjectsWithComponent("MissionNPCComponent", "c.missionID = ? AND c.offersMission = 1", { id })}, - {"acceptedBy", ObjectsWithComponent("MissionNPCComponent", "c.missionID = ? AND c.acceptsMission = 1", { id })}, - {"usedBy", References(eLink::MISSION, id)} - }; - } - - // A template's name from the server's enum (BehaviorTemplate.h), or the CDClient's own name for one it doesn't know - std::string TemplateName(int64_t templateId, const std::string& clientName) { - const auto name = magic_enum::enum_name(static_cast(templateId)); - if (!name.empty()) return GameLabels::Words(name); - return clientName.empty() ? "Template " + std::to_string(templateId) : clientName; - } - - /** - * A behavior and everything under it, read a level at a time: {root, nodes: {id: {template, effect, parameters: - * [{name, value, child}]}}, truncated}. A behavior used twice is listed once. - */ - json BehaviorTree(int64_t root) { - json nodes = json::object(); - std::set seen{ root }; - std::vector level{ root }; - bool truncated = false; - for (size_t depth = 0; !level.empty(); depth++) { - if (depth >= MAX_BEHAVIOR_DEPTH || seen.size() > MAX_BEHAVIOR_NODES) { - truncated = true; - break; - } - std::string placeholders; - std::vector ids; - for (const auto id : level) { - placeholders += placeholders.empty() ? "?" : ",?"; - ids.emplace_back(id); - } - for (const auto& row : Rows("SELECT bt.behaviorID, bt.templateID, btn.name AS clientName, bt.effectID, bt.effectHandle FROM BehaviorTemplate bt " - "LEFT JOIN BehaviorTemplateName btn ON btn.templateID = bt.templateID WHERE bt.behaviorID IN (" + placeholders + ");", ids, level.size())) { - auto node = row; - node["templateName"] = TemplateName(Int(row, "templateID"), Text(row, "clientName")); - node["parameters"] = json::array(); - nodes[std::to_string(Int(row, "behaviorID"))] = std::move(node); - } - - std::vector next; - for (const auto& row : Rows("SELECT behaviorID, parameterID, value FROM BehaviorParameter WHERE behaviorID IN (" + placeholders + ") ORDER BY behaviorID, parameterID;", - ids, MAX_BEHAVIOR_NODES * 40)) { - const auto key = std::to_string(Int(row, "behaviorID")); - if (!nodes.contains(key)) continue; - const std::string name = Text(row, "parameterID"); - const double value = Real(row, "value"); - json parameter{ {"name", name}, {"value", value}, {"child", nullptr} }; - if (IsChildBehaviorParameter(name) && value > 0 && std::floor(value) == value) { - const auto child = static_cast(value); - parameter["child"] = child; - if (seen.insert(child).second) next.push_back(child); - } - nodes[key]["parameters"].push_back(std::move(parameter)); - } - level = std::move(next); - } - return { {"root", root}, {"nodes", nodes}, {"truncated", truncated} }; - } - - json SkillView(int64_t id) { - const auto skill = RowsWhere("SkillBehavior", "skillID", id, 1); - if (skill.empty()) return nullptr; - json objects = json::array(); - for (const auto& row : Rows("SELECT objectTemplate, castOnType FROM ObjectSkills WHERE skillID = ? LIMIT ?;", { id, static_cast(MAX_OBJECTS) }, MAX_OBJECTS)) { - objects.push_back({ {"object", ItemRef(Int(row, "objectTemplate"))}, {"castOnType", row["castOnType"]} }); - } - const auto behavior = Int(skill[0], "behaviorID"); - return { - {"id", id}, - {"name", LinkLabel(eLink::SKILL, id)}, - {"description", Phrase("SkillBehavior_" + std::to_string(id) + "_descriptionUI")}, - {"row", skill[0]}, - {"columns", Columns(*GetSchema().Find("SkillBehavior"))}, - {"localized", Localized("SkillBehavior", id)}, - {"objects", objects}, - {"countedBy", TasksCounting(eLink::SKILL, id)}, - {"tree", behavior > 0 && Exists(eLink::BEHAVIOR, behavior) ? BehaviorTree(behavior) : json(nullptr)}, - {"usedBy", References(eLink::SKILL, id)} - }; - } - - json ActivityView(int64_t id) { - const auto activity = RowsWhere("Activities", "ActivityID", id, 1); - if (activity.empty()) return nullptr; - const auto& row = activity[0]; - // ActivityRewards in table order: an activity instance rewards the first row, scripts that rate a result - // (Loot::DropActivityLoot) the one with the highest activityRating at or below it; coins at level 1 - json rewards = json::array(); - for (const auto& reward : RowsWhere("ActivityRewards", "objectTemplate", id, 50)) { - const auto matrix = Int(reward, "LootMatrixIndex"); - rewards.push_back({ {"rating", reward["activityRating"]}, {"challengeRating", reward["ChallengeRating"]}, {"description", reward["description"]}, - {"matrix", matrix}, {"coins", Coins(Int(reward, "CurrencyIndex"), 1)}, {"odds", matrix > 0 ? LootMatrix(matrix) : json(nullptr)} }); - } - const auto zone = Int(row, "instanceMapID"); - const auto cost = Int(row, "optionalCostLOT"); - return { - {"id", id}, - {"name", LinkLabel(eLink::ACTIVITY, id)}, - {"row", row}, - {"columns", Columns(*GetSchema().Find("Activities"))}, - {"localized", Localized("Activities", id)}, - {"zone", zone > 0 ? Ref(eLink::ZONE, zone) : json(nullptr)}, - {"cost", cost > 0 && Int(row, "optionalCostCount") > 0 ? json{ {"item", ItemRef(cost)}, {"count", row["optionalCostCount"]} } : json(nullptr)}, - {"rewards", rewards}, - {"countedBy", TasksCounting(eLink::ACTIVITY, id)}, - {"usedBy", References(eLink::ACTIVITY, id)} - }; - } - - /** - * A zone: its row, names, the player caps new instances get (InstanceManager::GetSoftCap/GetHardCap: an override - * set on the Instances page, else the ZoneTable's), and what is played or launched there. - */ - json ZoneView(int64_t id, bool withOverrides) { - const auto zone = RowsWhere("ZoneTable", "zoneID", id, 1); - if (zone.empty()) return nullptr; - const auto& row = zone[0]; - // As InstanceLoad reads them: NULL caps count as 8 and 12 - const InstanceLimits::Caps client{ static_cast(Int(row, "population_soft_cap", 8)), static_cast(Int(row, "population_hard_cap", 12)) }; - std::optional softOverride, hardOverride; - if (withOverrides) { - for (const auto& limit : Database::Get()->GetZoneLimits()) { - if (limit.zoneId != id) continue; - softOverride = limit.softCap; - hardOverride = limit.hardCap; - } - } - const auto effective = InstanceLimits::Effective(softOverride, hardOverride, client); - const json caps{ {"clientSoft", client.soft}, {"clientHard", client.hard}, {"soft", effective.soft}, {"hard", effective.hard}, {"overridesShown", withOverrides}, - {"softOverride", softOverride ? json(*softOverride) : json(nullptr)}, {"hardOverride", hardOverride ? json(*hardOverride) : json(nullptr)} }; - const auto control = Int(row, "zoneControlTemplate"); - return { - {"id", id}, - {"name", LinkLabel(eLink::ZONE, id)}, - {"row", row}, - {"columns", Columns(*GetSchema().Find("ZoneTable"))}, - {"localized", Localized("ZoneTable", id)}, - {"caps", caps}, - {"control", control > 0 ? ItemRef(control) : json(nullptr)}, - {"usedBy", References(eLink::ZONE, id)} - }; - } - - // ---- Search ---- - - // id -> localized name for the kinds whose names are only in the locale - const std::vector>& LocaleNames(const std::string& prefix, const std::string& suffix) { - static std::map>> cache; - auto& names = cache[prefix + suffix]; - if (!names.empty()) return names; - for (const auto& key : Locale::GetPhraseIdsWithPrefix(prefix)) { - if (!key.ends_with(suffix) || key.size() <= prefix.size() + suffix.size()) continue; - const auto id = GeneralUtils::TryParse(key.substr(prefix.size(), key.size() - prefix.size() - suffix.size())); - if (id) names.emplace_back(*id, Locale::GetPhrase(key)); - } - std::ranges::sort(names); - return names; - } - - json SearchLocale(eLink link, const std::string& prefix, const std::string& suffix, const std::string& text) { - json results = json::array(); - const auto wanted = Lower(text); - for (const auto& [id, name] : LocaleNames(prefix, suffix)) { - if (Lower(name).find(wanted) == std::string::npos) continue; - results.push_back({ {"link", LinkName(link)}, {"id", id}, {"name", name} }); - if (results.size() >= SEARCH_RESULTS) break; - } - return results; - } - - json Search(const std::string& text) { - json results{ {"exact", json::array()}, {"objects", json::array()}, {"missions", json::array()}, {"skills", json::array()}, {"activities", json::array()}, - {"zones", json::array()} }; - if (const auto id = GeneralUtils::TryParse(text)) { - for (const auto link : magic_enum::enum_values()) { - if (Exists(link, *id)) results["exact"].push_back(Ref(link, *id)); - } - return results; - } - // Objects by the name players see (the locale), then by their names in the CDClient - std::set found; - for (const auto& ref : SearchLocale(eLink::OBJECT, "Objects_", "_name", text)) { - found.insert(ref["id"].get()); - results["objects"].push_back(ItemRef(ref["id"].get())); - } - for (const auto& row : Rows("SELECT id FROM Objects WHERE name LIKE ? ESCAPE '\\' OR displayName LIKE ? ESCAPE '\\' ORDER BY id LIMIT ?;", - { "%" + LikeEscape(text) + "%", "%" + LikeEscape(text) + "%", static_cast(SEARCH_RESULTS) }, SEARCH_RESULTS)) { - if (results["objects"].size() >= SEARCH_RESULTS) break; - if (found.insert(Int(row, "id")).second) results["objects"].push_back(ItemRef(Int(row, "id"))); - } - results["missions"] = SearchLocale(eLink::MISSION, "Missions_", "_name", text); - results["skills"] = SearchLocale(eLink::SKILL, "SkillBehavior_", "_name", text); - results["activities"] = SearchLocale(eLink::ACTIVITY, "Activities_", "_ActivityName", text); - results["zones"] = SearchLocale(eLink::ZONE, "ZoneTable_", "_DisplayDescription", text); - return results; - } - - // ---- Routes ---- - - template - void ViewRoute(const std::string& path, const std::string& description, View view) { - Route(eHTTPMethod::GET, path, Perm("dev_cdclient"), description, [view](HTTPReply& reply, const HTTPContext& context) { - const auto id = PathId(context.path, 3); - if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Give a number"); - auto result = view(*id); - if (result.is_null()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Not in the CDClient"); - JsonReply(reply, eHTTPStatusCode::OK, result); - }); - } } void RegisterCDClientBrowserRoutes() { @@ -1043,7 +110,7 @@ void RegisterCDClientBrowserRoutes() { RenderPage(reply, context, "cdclient.jinja2", "cdclient"); }); - Route(eHTTPMethod::GET, "/api/cdclient/schema", Perm("dev_cdclient"), "Every CDClient table with its columns and what each column links to", + Route(eHTTPMethod::GET, "/api/cdclient/schema", Perm("dev_cdclient"), "Every CDClient table with its columns and the table each linking column points at", [](HTTPReply& reply, const HTTPContext&) { static const auto schema = [] { json tables = json::array(); @@ -1097,49 +164,4 @@ void RegisterCDClientBrowserRoutes() { {"rows", Rows(built->select, built->params, MAX_ROWS)} }); }); - - Route(eHTTPMethod::GET, "/api/cdclient/search", Perm("dev_cdclient"), "Find objects, missions, skills, activities and zones by name, or anything by ID. Query: q", - [](HTTPReply& reply, const HTTPContext& context) { - auto text = QueryValue(context.queryString, "q"); - text.erase(0, text.find_first_not_of(" \t")); - text.erase(text.find_last_not_of(" \t") + 1); - if (text.size() < 2 && !GeneralUtils::TryParse(text)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type at least 2 letters, or an ID"); - JsonReply(reply, eHTTPStatusCode::OK, Search(text)); - }); - - ViewRoute("/api/cdclient/objects/:id", "An object as the game uses it: its components (item, health and drops, skills, vendor stock, " - "starting inventory, missions), where it comes from with the chances, the missions that reward or count it, and what else uses it", - [](int64_t id) { return ObjectView(static_cast(id)); }); - ViewRoute("/api/cdclient/missions/:id", "A mission: tasks with their targets, rewards, texts, prerequisites as the server reads them, what it unlocks and who offers it", MissionView); - ViewRoute("/api/cdclient/skills/:id", "A skill: its costs, behavior tree, the objects that have it and what uses it", SkillView); - ViewRoute("/api/cdclient/behaviors/:id", "A behavior tree with every behavior's template, effect and parameters", [](int64_t id) { - return Exists(eLink::BEHAVIOR, id) ? json{ {"tree", BehaviorTree(id)}, {"usedBy", References(eLink::BEHAVIOR, id)} } : json(nullptr); - }); - ViewRoute("/api/cdclient/activities/:id", "An activity: where it is played, what it costs, its rewards per rating with drop chances, and what uses it", ActivityView); - ViewRoute("/api/cdclient/loot_matrix/:id", "A loot matrix with the chance of each item dropping, as the server rolls it, and what uses it", [](int64_t id) { - auto odds = LootMatrix(id); - return odds["entries"].empty() ? json(nullptr) : json{ {"id", id}, {"odds", odds}, {"usedBy", References(eLink::LOOT_MATRIX, id)} }; - }); - ViewRoute("/api/cdclient/loot_table/:id", "A loot table's items with their rarity and the loot matrices that use it", LootTableView); - - Route(eHTTPMethod::GET, "/api/cdclient/zones/:id", Perm("dev_cdclient"), "A zone: its names, the player caps new instances get (with any override set on the Instances page) and what uses it", - [](HTTPReply& reply, const HTTPContext& context) { - const auto id = PathId(context.path, 3); - if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Give a number"); - // The overrides are the Instances page's, so only for those who can see it - auto result = ZoneView(*id, Can(context, "health_view")); - if (result.is_null()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Not in the CDClient"); - JsonReply(reply, eHTTPStatusCode::OK, result); - }); - - Route(eHTTPMethod::GET, "/api/cdclient/objects/:id/lxfml", Perm("dev_cdclient"), "An object's model as LXFML for the 3D preview, when the client has one", - [](HTTPReply& reply, const HTTPContext& context) { - const auto lot = PathId(context.path, 3); - const auto model = lot ? ObjectLxfml(*lot) : std::nullopt; - if (!model) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No brick model for this object (is client_location set?)"); - reply.status = eHTTPStatusCode::OK; - reply.message = model->first; - reply.contentType = eContentType::APPLICATION_OCTET_STREAM; - reply.headers.push_back("Cache-Control: private, max-age=86400"); - }); } diff --git a/dDashboardServer/routes/CDClientBrowser.h b/dDashboardServer/routes/CDClientBrowser.h index fe5d5d482..171c4bfbd 100644 --- a/dDashboardServer/routes/CDClientBrowser.h +++ b/dDashboardServer/routes/CDClientBrowser.h @@ -1,8 +1,8 @@ #pragma once /** - * The CDClient browser (developer tool): search and read the game's CDClient database. Any table can be paged and - * filtered by its columns, and objects, loot matrices and tables, missions, skills, behaviors and activities have - * their own views that follow the links between tables (see CDClientSchema.h). Read-only; the browser never sends SQL. + * The CDClient browser (developer tool): a raw viewer for the game's CDClient database. Any table can be paged, sorted, + * searched and filtered by its columns; columns that point at other tables (see CDClientSchema.h) link to the rows they + * point at. Read-only; the browser never sends SQL. */ void RegisterCDClientBrowserRoutes(); diff --git a/dDashboardServer/routes/CDClientRules.h b/dDashboardServer/routes/CDClientRules.h deleted file mode 100644 index e543496c5..000000000 --- a/dDashboardServer/routes/CDClientRules.h +++ /dev/null @@ -1,271 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "CDClientSchema.h" -#include "eMissionTaskType.h" - -/** - * What the server makes of CDClient rows, for the CDClient browser: drop chances as dGame/dUtilities/Loot.cpp rolls - * them, vendor stock as VendorComponent fills it, mission prerequisites as MissionPrerequisites evaluates them and what - * a mission task's numbers are as MissionTask::Progress compares them. Each follows the server code it names, so when - * that code changes, this should too. Pure, so it is unit tested. - */ -namespace CDClientRules { - // ---- Loot ---- - - struct RarityRow { - double randmax{}; - int32_t rarity{}; - }; - - /** - * The chance of each rarity a drop rolls (RollLootMatrix). The rows are walked in the order the server loads them - * (randmax descending) and a roll in [0, 1) takes the rarity of the last row whose randmax is at or above it, so a - * row gets the span between its randmax and the next lower one. A roll above every row keeps the starting rarity 1. - */ - inline std::vector> RarityChances(const std::vector& rows) { - std::vector> chances; - const auto add = [&](int32_t rarity, double chance) { - if (chance <= 0.0) return; - const auto it = std::ranges::find_if(chances, [&](const auto& c) { return c.first == rarity; }); - if (it == chances.end()) chances.emplace_back(rarity, chance); - else it->second += chance; - }; - if (rows.empty()) { - add(1, 1.0); - return chances; - } - add(1, 1.0 - std::clamp(rows.front().randmax, 0.0, 1.0)); - for (size_t i = 0; i < rows.size(); i++) { - const double top = std::clamp(rows[i].randmax, 0.0, 1.0); - const double bottom = i + 1 < rows.size() ? std::clamp(rows[i + 1].randmax, 0.0, 1.0) : 0.0; - add(rows[i].rarity, top - bottom); - } - return chances; - } - - /** - * Which entries of a loot table a drop picks from (one of them, evenly) for a rolled rarity, as RollLootMatrix - * does: the table is sorted by item rarity, highest first (CDLootTableTable), items above the rolled rarity are - * skipped, items of it are taken, and while none of it has been found a lower rarity is taken and becomes the rarity - * looked for. `rarities` are the items' rarities (0 without an item component) in any order; indexes into it. - */ - inline std::vector Candidates(const std::vector& rarities, int32_t rolled) { - std::vector order(rarities.size()); - for (size_t i = 0; i < order.size(); i++) order[i] = i; - std::ranges::stable_sort(order, [&](size_t a, size_t b) { return rarities[a] > rarities[b]; }); - std::vector picked; - int32_t maxRarity = rolled; - bool found = false; - for (const auto index : order) { - const auto rarity = rarities[index]; - if (rarity == maxRarity) { - picked.push_back(index); - found = true; - } else if (rarity < maxRarity && !found) { - picked.push_back(index); - maxRarity = rarity; - } - } - return picked; - } - - // The chance that one drop from a loot table is each of its entries, given the rarity table it is rolled with - inline std::vector ChancePerDrop(const std::vector& rarities, const std::vector& rows) { - std::vector chances(rarities.size(), 0.0); - for (const auto& [rarity, chance] : RarityChances(rows)) { - const auto picked = Candidates(rarities, rarity); - for (const auto index : picked) chances[index] += chance / picked.size(); - } - return chances; - } - - struct DropOdds { - double atLeastOne{}; // chance of getting it at least once - double expected{}; // how many on average - }; - - /** - * Odds for one item of a loot matrix entry: the entry rolls with `percent`, then drops a count between minDrops and - * maxDrops (evenly), each drop being the item with `perDrop`. Before any live event's loot bonus. - */ - inline DropOdds Odds(double percent, int32_t minDrops, int32_t maxDrops, double perDrop) { - percent = std::clamp(percent, 0.0, 1.0); - minDrops = std::max(minDrops, 0); - maxDrops = std::max(maxDrops, minDrops); - double missAll = 0.0; - const int32_t counts = maxDrops - minDrops + 1; - for (int32_t n = minDrops; n <= maxDrops; n++) missAll += std::pow(1.0 - perDrop, n) / counts; - return { percent * (1.0 - missAll), percent * (minDrops + maxDrops) / 2.0 * perDrop }; - } - - /** - * The chance a vendor has an entry's item for sale (VendorComponent::RefreshInventory): with minToDrop or - * maxToDrop 0 it sells the whole loot table, otherwise that many of its items at random (chance and rarity play no - * part), picked again every refresh. - */ - inline double VendorStockChance(int32_t minDrops, int32_t maxDrops, size_t tableSize) { - if (minDrops == 0 || maxDrops == 0) return 1.0; - if (tableSize == 0) return 0.0; - maxDrops = std::max(maxDrops, minDrops); - return std::min(1.0, (minDrops + maxDrops) / 2.0 / static_cast(tableSize)); - } - - // ---- Missions ---- - - /** - * Missions.prereqMissionID as MissionPrerequisites reads it: a mission (with an optional ":state" it must be in, - * otherwise it must be complete), then "|" (or) or "," "&" "(" (and) joining it to all the rest, which is read the - * same way; ")" and spaces are skipped. So "1|2,3" is 1 or (2 and 3), and brackets don't group. A term without a - * mission (mission 0) is always met. - */ - struct PrerequisiteTerm { - uint32_t mission{}; - uint32_t state{}; // 0: must be complete - bool orRest{}; // joined to the rest by "or" (otherwise "and"); the last term has no rest - }; - - inline std::vector ParsePrerequisites(std::string_view text) { - std::vector terms; - while (true) { - PrerequisiteTerm term; - bool inState = false, more = false; - size_t i = 0; - // Numbers too long for an ID are cut short rather than overflowing - const auto append = [](uint32_t& value, char c) { if (value < 100000000u) value = value * 10 + (c - '0'); }; - for (; i < text.size(); i++) { - const char c = text[i]; - if (c == '|' || c == ',' || c == '&' || c == '(') { - term.orRest = c == '|'; - more = true; - break; - } - if (c == ':') inState = true; - else if (c >= '0' && c <= '9') append(inState ? term.state : term.mission, c); - } - if (!more) { - term.orRest = false; - terms.push_back(term); - break; - } - terms.push_back(term); - text.remove_prefix(i + 1); - } - // Terms without a mission are always met, so "and" ones change nothing ("(" starts one): leave them out - std::vector kept; - for (size_t i = 0; i < terms.size(); i++) { - const bool last = i + 1 == terms.size(); - const bool drop = terms[i].mission == 0 && terms.size() > 1 && (last ? !kept.empty() && !kept.back().orRest : !terms[i].orRest); - if (!drop) kept.push_back(terms[i]); - } - if (!kept.empty()) kept.back().orRest = false; - return kept; - } - - /** - * What a mission task's numbers are (MissionTask::Progress, and what the server passes to it): `target` and the - * comma separated targetGroup are compared with the value the task is progressed with, taskParam1's numbers with - * the value or the associate. Each list names what the numbers can be; empty means a number the browser can't link. - */ - struct TaskMeaning { - bool progressed{ true }; // false: Progress logs the type as invalid and never counts it - std::vector target; - bool targetGroupIds{}; // targetGroup holds more IDs like target - bool targetGroupText{}; // targetGroup is compared as text (a point of interest, a score name) - bool targetUsed{ true }; // false: target plays no part - std::vector parameters; - bool racingParameter{}; // taskParam1's first number is an eRacingTaskParam - }; - - inline TaskMeaning MeaningOf(eMissionTaskType type) { - using enum eMissionTaskType; - using Link = CDClientSchema::eLink; - TaskMeaning meaning; - switch (type) { - // Progressed with the LOT of what was smashed, scripted, collected, interacted with, tamed or gathered - case SMASH: case SCRIPT: case COLLECTION: case INTERACT: case PET_TAMING: case GATHER: - meaning.target = { Link::OBJECT }; - meaning.targetGroupIds = true; - break; - // Only `target` is compared (GetTarget) - case TALK_TO_NPC: case USE_ITEM: - meaning.target = { Link::OBJECT }; - break; - // The emote is in taskParam1; `target` is the LOT it was done at - case EMOTE: - meaning.target = { Link::OBJECT }; - meaning.parameters = { Link::EMOTE }; - break; - // The skill is in taskParam1; the targets are the LOT it hit - case USE_SKILL: - meaning.target = { Link::OBJECT }; - meaning.targetGroupIds = true; - meaning.parameters = { Link::SKILL }; - break; - // An activity ID, or the LOT of a quick build or cannon - case ACTIVITY: - meaning.target = { Link::ACTIVITY, Link::OBJECT }; - meaning.targetGroupIds = true; - break; - // The minigame's activity ID (or its LOT without one); targetGroup names the score, targetValue is the least - case PERFORM_ACTIVITY: - meaning.target = { Link::ACTIVITY, Link::OBJECT }; - meaning.targetGroupText = true; - break; - // Only the point of interest's name counts - case EXPLORE: - meaning.targetUsed = false; - meaning.targetGroupText = true; - break; - case META: - meaning.target = { Link::MISSION }; - meaning.targetGroupIds = true; - break; - // Progressed with the skill ID of the powerup - case POWERUP: - meaning.target = { Link::SKILL }; - meaning.targetGroupIds = true; - break; - // Player flag IDs - case PLAYER_FLAG: - // Progressed with 0 and the reputation earned as the count - case EARN_REPUTATION: - case VISIT_PROPERTY: - meaning.targetGroupIds = true; - break; - // Targets are zones, tracks or missions depending on the racing parameter - case RACING: - meaning.targetGroupIds = true; - meaning.racingParameter = true; - break; - // Any progress counts - case PLACE_MODEL: case ADD_BEHAVIOR: case DONATION: - meaning.targetUsed = false; - break; - default: - meaning.progressed = false; - meaning.targetUsed = false; - break; - } - return meaning; - } - - // Numbers in a comma separated list, as MissionTask reads targetGroup and taskParam1 (anything else is skipped) - inline std::vector NumberList(std::string_view text) { - std::vector numbers; - while (!text.empty()) { - const auto comma = text.find(','); - // TryParse skips leading spaces but not trailing ones, so "1 ,2" loses the 1 on the server too - if (const auto number = GeneralUtils::TryParse(text.substr(0, comma))) numbers.push_back(*number); - if (comma == std::string_view::npos) break; - text.remove_prefix(comma + 1); - } - return numbers; - } -} diff --git a/dDashboardServer/routes/CDClientSchema.h b/dDashboardServer/routes/CDClientSchema.h index 5f46638eb..23f470b37 100644 --- a/dDashboardServer/routes/CDClientSchema.h +++ b/dDashboardServer/routes/CDClientSchema.h @@ -83,7 +83,7 @@ namespace CDClientSchema { // ---- Links between tables ---- - // What a column's values point at. The browser has a page for each. + // What a column's values point at. The browser links such values to the rows they point at. enum class eLink : uint8_t { OBJECT, LOOT_MATRIX, LOOT_TABLE, RARITY_TABLE, BEHAVIOR, SKILL, MISSION, ACTIVITY, ICON, ZONE, EMOTE }; struct LinkTarget { @@ -175,18 +175,7 @@ namespace CDClientSchema { return std::nullopt; } - // Every (table, column) that points at `link`, for "used by" lists - inline std::vector> ColumnsLinkingTo(const Schema& schema, eLink link) { - std::vector> columns; - for (const auto& [name, table] : schema.Tables()) { - for (const auto& column : table.columns) { - if (LinkFor(schema, table, column) == link) columns.emplace_back(&table, &column); - } - } - return columns; - } - - // The link's name for the page ("loot_matrix") + // The link's name as the browser sends it ("loot_matrix") inline std::string LinkName(eLink link) { return Lower(magic_enum::enum_name(link)); } @@ -372,24 +361,4 @@ namespace CDClientSchema { if (name.find("faction") != std::string::npos) return false; return name.starts_with("behavior") || name.starts_with("on_") || name.find("action") != std::string::npos; } - - /** - * Mission IDs in Missions.prereqMissionID, which lists them separated by commas, pipes, ampersands or spaces, - * sometimes with a ":state" suffix or in brackets ("(1,2)|3", "123:4"). - */ - inline std::vector PrerequisiteMissions(std::string_view text) { - std::vector ids; - for (size_t i = 0; i < text.size();) { - if (!std::isdigit(static_cast(text[i]))) { i++; continue; } - size_t end = i; - while (end < text.size() && std::isdigit(static_cast(text[end]))) end++; - const bool isState = i > 0 && text[i - 1] == ':'; - if (!isState && end - i <= 9) { - const auto id = static_cast(std::stol(std::string(text.substr(i, end - i)))); - if (std::ranges::find(ids, id) == ids.end()) ids.push_back(id); - } - i = end; - } - return ids; - } } diff --git a/dDashboardServer/static/js/cdclient.js b/dDashboardServer/static/js/cdclient.js index 97b47118d..076af9aaa 100644 --- a/dDashboardServer/static/js/cdclient.js +++ b/dDashboardServer/static/js/cdclient.js @@ -1,33 +1,24 @@ /** - * The CDClient Browser page. Everything is addressed by the hash, so views can be linked and the back button works: - * #/ search and the list of tables - * #/search/ search results + * The CDClient Browser page: a raw table viewer. Everything is addressed by the hash, so views can be linked and the + * back button works: + * #/ the list of tables * #/table/?... a table's rows (start, order, dir, q, filters as JSON [{column, op, value}]) - * #// object, mission, skill, behavior, activity, zone, loot_matrix, loot_table - * Which columns link where comes from /api/cdclient/schema; links without their own view open the target table - * filtered to that value. + * #// shorthand for the target table filtered to that ID (#/object/1727 is Objects id = 1727) + * Which columns link where comes from /api/cdclient/schema; a linked value opens the target table filtered to it. */ (function () { 'use strict'; - var VIEWS = ['object', 'mission', 'skill', 'behavior', 'activity', 'zone', 'loot_matrix', 'loot_table']; var PAGE = 50; + var OPS = ['=', '!=', '<', '<=', '>', '>=', 'contains', 'starts', 'null', 'notnull']; var nf = new Intl.NumberFormat(); var view = document.getElementById('view'); var schema = null; - var viewer = null; function api404(d) { return d && d.success === false; } // ---- links ---- - function hashFor(link, id) { - if (VIEWS.indexOf(link) !== -1) return '#/' + link + '/' + id; - var target = schema && schema.links[link]; - if (!target) return null; - return tableHash(target.table, { filters: [{ column: target.column, op: '=', value: String(id) }] }); - } - function tableHash(table, params) { var query = []; Object.keys(params || {}).forEach(function (k) { @@ -38,20 +29,15 @@ return '#/table/' + encodeURIComponent(table) + (query.length ? '?' + query.join('&') : ''); } - function refLink(ref) { - var href = hashFor(ref.link, ref.id); - var text = esc(ref.name) + ' ' + esc(ref.id) + ''; - return href ? '' + text + '' : text; - } - - function chips(refs) { - if (!refs || !refs.length) return 'None'; - return refs.map(function (r) { return '' + refLink(r) + ''; }).join(''); + // The rows a link points at: its target table filtered to the ID + function hashFor(link, id) { + var target = schema && schema.links[link]; + if (!target) return null; + return tableHash(target.table, { filters: [{ column: target.column, op: '=', value: String(id) }] }); } function componentTable(type) { - var match = schema.componentTypes.filter(function (c) { return c.type === type; })[0]; - return match ? match : null; + return schema.componentTypes.filter(function (c) { return c.type === type; })[0] || null; } // One cell: linked when its column points somewhere @@ -77,55 +63,8 @@ return text; } - function columnsOf(table) { - var t = schema.tables.filter(function (x) { return x.name === table; })[0]; - return t ? t.columns : []; - } - - // A row as a two-column table - function keyValues(row, columns, table) { - columns = columns || Object.keys(row).map(function (k) { return { name: k }; }); - return '
' + columns.map(function (c) { - return ''; - }).join('') + '
' + esc(c.name) + (c.link ? ' ' + esc(c.link) + '' : '') + '' + cell(row[c.name], c, row, table) + '
'; - } - - // Rows as a table, with the schema's links - function rowsTable(rows, table, columns) { - if (!rows.length) return '
No rows.
'; - columns = columns || (table ? columnsOf(table) : null) || []; - if (!columns.length) columns = Object.keys(rows[0]).map(function (k) { return { name: k }; }); - return '
' + - columns.map(function (c) { return ''; }).join('') + '' + - rows.map(function (row) { - return '' + columns.map(function (c) { return ''; }).join('') + ''; - }).join('') + '
' + esc(c.name) + '
' + cell(row[c.name], c, row, table) + '
'; - } - - function card(title, body, extra) { - return '
' + title + '
' + - (extra || '') + '
' + body + '
'; - } - - function localized(texts) { - var keys = Object.keys(texts || {}); - if (!keys.length) return ''; - return card('Texts (locale)', '' + keys.map(function (k) { - return ''; - }).join('') + '
' + esc(k) + '' + esc(texts[k]) + '
'); - } - - // "Used by": everything pointing here, per table and column - function usedBy(references, link, id) { - if (!references || !references.length) return card('Used by', 'Nothing in the CDClient points here.'); - return card('Used by', references.map(function (r) { - var href = tableHash(r.table, { filters: [{ column: r.column, op: '=', value: String(id) }] }); - return '
' + esc(r.table) + '.' + esc(r.column) + ' ' + nf.format(r.count) + '' + - (r.objects && r.objects.length ? ' on ' + r.objects.length + ' object(s)' : '') + '' + - (r.objects && r.objects.length ? '
' + chips(r.objects) + '
' : '') + - rowsTable(r.rows, r.table) + (r.count > r.rows.length ? '
First ' + r.rows.length + ' of ' + nf.format(r.count) + '; see all.
' : '') + - '
'; - }).join('')); + function card(title, body) { + return '
' + title + '
' + body + '
'; } function header(title, subtitle) { @@ -136,10 +75,6 @@ view.innerHTML = '
' + esc(message) + '
'; } - function disposeViewer() { - if (viewer) { viewer.dispose(); viewer = null; } - } - // ---- views ---- function home() { @@ -149,29 +84,20 @@ ''); - document.getElementById('tableFilter').addEventListener('input', function (e) { - var text = e.target.value.trim().toLowerCase(); + var filter = document.getElementById('tableFilter'); + filter.addEventListener('input', function () { + var text = filter.value.trim().toLowerCase(); document.querySelectorAll('#tableLinks a').forEach(function (a) { a.classList.toggle('d-none', text && a.dataset.name.indexOf(text) === -1); }); }); - } - - function search(text) { - document.getElementById('searchInput').value = text; - api.get('/api/cdclient/search?q=' + encodeURIComponent(text)).then(function (d) { - if (api404(d)) return showError(d.error); - var sections = [['Exact ID', d.exact], ['Objects', d.objects], ['Missions', d.missions], ['Skills', d.skills], ['Activities', d.activities], ['Zones', d.zones]]; - var found = sections.filter(function (s) { return s[1].length; }); - if (found.length === 1 && found[0][1].length === 1) { window.location.hash = hashFor(found[0][1][0].link, found[0][1][0].id); return; } - view.innerHTML = header('Results for "' + esc(text) + '"') + (found.length ? found.map(function (s) { - return card(s[0], '
' + s[1].map(function (r) { - return '
' + esc(r.link) + '' + (r.link === 'object' ? item(r) : refLink(r)) + '
'; - }).join('') + '
'); - }).join('') : '
Nothing found.
'); + // Enter opens the only table left + filter.addEventListener('keydown', function (e) { + if (e.key !== 'Enter') return; + var shown = document.querySelectorAll('#tableLinks a:not(.d-none)'); + if (shown.length === 1) window.location.hash = shown[0].getAttribute('href'); }); + filter.focus(); } - var OPS = ['=', '!=', '<', '<=', '>', '>=', 'contains', 'starts', 'null', 'notnull']; - function tableView(name, params) { var start = parseInt(params.start || '0', 10) || 0; var filters = []; @@ -193,7 +119,7 @@ var arrow = state.order === c.name ? (state.dir === 'desc' ? ' ↓' : ' ↑') : ''; return '' + esc(c.name) + arrow + ''; }).join('') + ''; - view.innerHTML = header(esc(name), nf.format(d.total) + ' matching rows') + + view.innerHTML = header('Tables / ' + esc(name), nf.format(d.total) + ' matching rows') + '
' + '
' + '
' + @@ -231,454 +157,9 @@ }); } - // ---- game values ---- - - // A chance as a percentage with the digits that matter (0.4%, 12.5%, 100%) - function pct(chance) { - var p = (chance || 0) * 100; - if (p === 0) return '0%'; - if (p >= 99.95) return '100%'; - return (p >= 10 ? p.toFixed(1) : p >= 1 ? p.toFixed(2) : +p.toPrecision(2)) + '%'; - } - - function num(value) { - return typeof value === 'number' ? nf.format(+value.toPrecision(7)) : esc(value === null || value === undefined ? '' : value); - } - - // Rarity as the game shows it: 1 to 4 stars, coloured - function rarity(value) { - if (!value || value < 1) return ''; - return ' ' + '\u2605'.repeat(Math.min(value, 5)) + ''; - } - - function icon(lot) { - return ''; - } - - function iconById(id, size) { - if (!id || id <= 0) return ''; - return ''; - } - - // An object with its icon and rarity; other links as they are - function item(ref) { - if (!ref) return '-'; - if (ref.link !== 'object') return ref.link ? refLink(ref) : '' + esc(ref.id) + ''; - return '' + icon(ref.id) + esc(ref.name) + '' + rarity(ref.rarity) + ' ' + esc(ref.id) + ''; - } - - function items(refs, count) { - if (!refs || !refs.length) return 'None'; - return refs.map(function (r) { return '
' + item(r.item || r) + (count && r.count !== undefined ? ' × ' + num(r.count) : '') + '
'; }).join(''); - } - - function none(text) { return '' + (text || 'None') + ''; } - - function raw(title, body) { - return '
' + title + '
' + body + '
'; - } - - // Like raw, but the body is only built when opened (loot lists can run to thousands of rows) - var lazy = {}, lazyCount = 0; - function later(title, render) { - lazy[++lazyCount] = render; - return '
' + title + '
'; - } - - view.addEventListener('toggle', function (e) { - var details = e.target; - if (!details.open || !details.dataset || !lazy[details.dataset.lazy]) return; - details.lastElementChild.innerHTML = lazy[details.dataset.lazy](); - delete lazy[details.dataset.lazy]; - }, true); - - function table(head, rows, cls) { - return '
' + head.map(function (h) { - return '' + h.text + ''; - }).join('') + '' + rows.join('') + '
'; - } - - function stat(label, value) { - return '
' + label + '
' + value + '
'; - } - - // Mission tasks that count something - function countedBy(tasks) { - if (!tasks || !tasks.length) return ''; - return card('Counted by mission tasks', tasks.map(function (t) { - return '
' + refLink(t.mission) + ' ' + esc(t.taskType) + '' + - (t.targetValue ? ' × ' + num(t.targetValue) + '' : '') + '
'; - }).join('')); - } - - /** - * A loot matrix as the server rolls it: each item's chance of dropping at least once and how many on average, and - * under that how each entry rolls. `per` names what one roll is (a smash, an opened package, an activity reward). - */ - function lootOdds(odds, per) { - if (!odds || !odds.entries || !odds.entries.length) return none('No loot matrix rows'); - var rows = odds.items.map(function (i) { - return '' + item(i.item) + '' + pct(i.chance) + '' + num(+i.expected.toPrecision(3)) + ''; - }); - var shown = rows.length > 25 ? rows.slice(0, 25) : rows; - var html = table([{ text: 'Item' }, { text: 'Chance per ' + esc(per), num: true }, { text: 'Average count', num: true }], shown, 'cdc-odds'); - if (rows.length > shown.length) html += later('All ' + rows.length + ' items', function () { return table([{ text: 'Item' }, { text: 'Chance', num: true }, { text: 'Average', num: true }], rows, 'cdc-odds'); }); - html += later('How it rolls (' + odds.entries.length + (odds.entries.length === 1 ? ' entry' : ' entries') + ')', function () { return odds.entries.map(function (e) { - return '
Loot table ' + esc(e.lootTable) + ': rolls with ' + pct(e.percent) + ', then ' + - (e.minToDrop === e.maxToDrop ? esc(e.minToDrop) : esc(e.minToDrop) + '–' + esc(e.maxToDrop)) + ' drop(s)' + - (e.flagID ? ' (flagID ' + esc(e.flagID) + ' in the row; the server\'s roll doesn\'t check it)' : '') + '
' + - '
Each drop rolls a rarity (rarity table ' + esc(e.rarityTable) + '): ' + - e.rolledRarity.map(function (r) { return rarity(r.rarity).trim() + ' ' + pct(r.chance); }).join(', ') + - ', then picks evenly among the items of that rarity (or the next lower one the table has).
' + - table([{ text: 'Item' }, { text: 'Per drop', num: true }, { text: 'Chance', num: true }, { text: '' }], e.items.map(function (i) { - return '' + item(i.item) + '' + pct(i.perDrop) + '' + pct(i.chance) + '' + - (i.missionDrop ? 'mission item' : '') + ''; - }), 'cdc-odds') + '
'; - }).join(''); }) + '
Chances before any live event loot bonus. Mission items drop only for players whose mission needs them.
'; - return html; - } - - // ---- views ---- - - function objectView(lot) { - api.get('/api/cdclient/objects/' + lot).then(function (d) { - if (api404(d)) return showError(d.error); - var sub = [esc(d.row.type || '')]; - if (d.item) sub.push(esc(d.item.itemTypeName)); - var html = header('' + esc(d.name) + - (d.item ? rarity(d.item.rarity) : '') + ' ' + lot + '', sub.filter(Boolean).join(' · ') + - (d.row._internalNotes ? ' · ' + esc(d.row._internalNotes) : '')); - if (d.description) html += '

' + esc(d.description) + '

'; - - var left = '', right = ''; - if (d.item) left += itemCard(d.item); - if (d.destructible) left += destructibleCard(d.destructible); - d.loot.forEach(function (l) { - left += card('Drops (' + esc(l.table) + ', loot matrix ' + esc(l.matrix) + ')', - lootOdds(l.odds, 'roll')); - }); - if (d.vendor) left += vendorCard(d.vendor); - if (d.skills.length) left += skillsCard(d.skills); - if (d.inventory) left += card('Starts with (inventory component)', d.inventory.map(function (i) { - return '
' + item(i.item) + ' × ' + num(i.count) + (i.equip ? ' equipped' : '') + '
'; - }).join('')); - if (d.missions) left += card('Missions here', d.missions.map(function (m) { - return '
' + refLink(m.mission) + (m.offers ? ' offers' : '') + (m.accepts ? ' takes turn-ins' : '') + '
'; - }).join('')); - if (d.collectibleMission) left += card('Collectible', 'Needs ' + refLink(d.collectibleMission)); - - right += d.model - ? card('Model', '
' + - '
' + esc(d.model) + '
') - : ''; - right += sourcesCard(d.sources); - if (d.rewardedBy.length) right += card('Mission rewards', d.rewardedBy.map(function (r) { - return '
' + refLink(r.mission) + ' × ' + num(r.count) + (r.repeat ? ' on repeats' : '') + - (r.choice ? ' one of a choice' : '') + '
'; - }).join('')); - right += countedBy(d.countedBy); - right += card('Components', d.components.length ? d.components.map(function (c) { - var title = '' + esc(c.typeName) + ' type ' + c.type + - ', id ' + c.componentId + (c.table ? ' in ' + esc(c.table) + '' : ', no table') + ''; - // Nothing to unfold without rows (components with no table of their own) - if (!c.rows.length) return '
' + title + '
'; - return '
' + title + '' + - (c.rows.length === 1 ? keyValues(c.rows[0], c.columns, c.table) : rowsTable(c.rows, c.table, c.columns)) + '
'; - }).join('') : none()); - right += card('Objects row', raw('All columns', keyValues(d.row, d.columns, 'Objects'))) + localized(d.localized); - - html += '
' + left + '
' + right + '
'; - html += usedBy(d.usedBy, 'object', lot); - view.innerHTML = html; - var button = document.getElementById('showModel'); - if (button) button.addEventListener('click', function () { showModel(lot, d.name); }); - }); - } - - function itemCard(i) { - var rows = [ - ['Type', esc(i.itemTypeName) + ' (' + esc(i.itemType) + ')'], - ['Equips to', i.equipLocation ? '' + esc(i.equipLocation) + '' : none('Not equipped')], - ['Stack size', num(i.stackSize)], - ['Base value', i.baseValue >= 0 ? num(i.baseValue) + ' coins (a vendor charges it × its buy scalar)' : none('No coin price')] - ]; - if (i.price.altCurrency) rows.push(['Also costs', item(i.price.altCurrency.item) + ' × ' + num(i.price.altCurrency.base) + ' (× buy scalar)']); - if (i.price.costs.length) rows.push(['Crafting costs', items(i.price.costs, true)]); - if (i.commendation) rows.push(['Commendation cost', item(i.commendation.item) + ' × ' + num(i.commendation.count)]); - if (i.subItems.length) rows.push(['Equips with it', items(i.subItems)]); - if (i.preconditions.length) rows.push(['Preconditions', i.preconditions.map(function (p) { - return '
' + esc(p.id) + ' ' + esc(p.reason) + '
'; - }).join('')]); - var stats = i.stats || {}; - var bonuses = ['life', 'armor', 'imagination'].filter(function (k) { return stats[k]; }).map(function (k) { return '+' + esc(stats[k]) + ' ' + k; }); - if (bonuses.length) rows.push(['Stats', bonuses.join(', ')]); - (stats.skills || []).forEach(function (s) { rows.push(['Skill', iconById(s.icon) + '' + esc(s.description) + '']); }); - if (i.set) rows.push(['Item set', iconById(i.set.icon) + esc(i.set.name || 'Set ' + i.set.id) + ' rank ' + esc(i.set.rank) + '' + - i.set.bonuses.map(function (b) { - var parts = ['life', 'armor', 'imagination'].filter(function (k) { return b.stats[k]; }).map(function (k) { return '+' + esc(b.stats[k]) + ' ' + k; }) - .concat((b.stats.skills || []).map(function (s) { return esc(s.description); })); - return '
' + esc(b.pieces) + ' pieces: ' + parts.join(', ') + '
'; - }).join('')]); - return card('Item', '' + rows.map(function (r) { - return ''; - }).join('') + '
' + r[0] + '' + r[1] + '
'); - } - - function destructibleCard(x) { - return card('Health and smashing (destructible component)', '
' + - stat('Life', num(x.life)) + stat('Armor', num(x.armor)) + stat('Imagination', num(x.imagination)) + stat('Level', num(x.level)) + '
' + - '
Coins when smashed: ' + (x.coins ? num(x.coins.min) + '–' + num(x.coins.max) : none()) + - ' (currency table at its level)
' + - '
Faction ' + esc(x.faction) + (x.isSmashable ? ' · smashable' : '') + '
'); - } - - function vendorCard(v) { - var scalar = v.buyScalar ? 'prices are the base value × ' + num(v.buyScalar) : 'buy scalar 0: prices use the world\'s vendor_buy_multiplier'; - return card('Sells (vendor component, loot matrix ' + esc(v.matrix) + ')', - '
' + scalar + '; buys back at × ' + num(v.sellScalar) + - (v.refreshTimeSeconds > 0 ? '; restocks every ' + num(v.refreshTimeSeconds / 60) + ' min' : '') + '
' + - v.stock.map(function (e) { - return '
Loot table ' + esc(e.lootTable) + ': ' + - (e.all ? 'all of it' : (e.minToDrop === e.maxToDrop ? esc(e.minToDrop) : esc(e.minToDrop) + '–' + esc(e.maxToDrop)) + ' of its ' + e.items.length + ' items at random') + '
' + - table([{ text: 'Item' }, { text: 'Price', num: true }, { text: 'In stock', num: true }], e.items.map(function (i) { - var price = i.price.coins !== null ? num(i.price.coins) + ' coins' : ''; - if (i.price.altCurrency) price += (price ? ' + ' : '') + num(i.price.altCurrency.count !== null ? i.price.altCurrency.count : i.price.altCurrency.base) + ' ' + esc(i.price.altCurrency.item.name); - i.price.costs.forEach(function (c) { price += (price ? ' + ' : '') + num(c.count) + ' ' + esc(c.item.name); }); - return '' + item(i.item) + '' + (price || none('-')) + '' + (i.sold ? pct(i.chance) : 'never') + ''; - }), 'cdc-odds') + '
'; - }).join('')); - } - - function skillsCard(skills) { - return card('Skills', table([{ text: 'Skill' }, { text: 'Cost', num: true }, { text: 'Cooldown', num: true }, { text: 'castOnType (raw)', num: true }], - skills.map(function (s) { - return '' + iconById(s.skillIcon) + refLink(s.skill) + (s.description ? '
' + esc(s.description) + '
' : '') + - (s.behaviorID ? '' : '') + '' + - '' + (s.imaginationcost ? num(s.imaginationcost) + ' imagination' : '') + '' + (s.cooldown ? num(s.cooldown) + ' s' : '') + '' + esc(s.castOnType) + ''; - }))); - } - - // Where an object comes from: drops, vendors, packages, activity rewards; with the chances - function sourcesCard(sources) { - if (!sources.length) return ''; - return card('Comes from', sources.map(function (s) { - var columns = columnsOf(s.table); - var rows = s.matrices.map(function (m) { - // Objects with the component, or the rows themselves (with their links) for other tables - var users = m.objects.slice(0, 5).map(function (o) { return '
' + item(o) + '
'; }).join('') + - (m.objects.length > 5 ? '
and ' + (m.objects.length - 5) + ' more
' : '') + - (m.rows || []).map(function (r) { - return '
' + columns.filter(function (c) { return r[c.name] !== null && r[c.name] !== '' && r[c.name] !== undefined; }).map(function (c) { - return '' + esc(c.name) + ' ' + cell(r[c.name], c, r, s.table); - }).join(' · ') + '
'; - }).join(''); - return '' + esc(m.matrix) + '' + - (m.chance !== undefined ? pct(m.chance) : '?') + - '' + (m.expected !== undefined ? num(+m.expected.toPrecision(3)) : '') + '' + (users || none()) + ''; - }); - var head = [{ text: 'Loot matrix' }, { text: s.vendor ? 'In stock' : 'Chance', num: true }, { text: s.vendor ? '' : 'Average', num: true }, { text: 'Used by' }]; - return '
Through ' + esc(s.table) + '.' + esc(s.column) + ' (' + rows.length + ')
' + - table(head, rows.slice(0, 10), 'cdc-odds') + - (rows.length > 10 ? later('All ' + rows.length, function () { return table(head, rows, 'cdc-odds'); }) : '') + '
'; - }).join('') + '
Chance per smash, opened package or activity reward, before live event bonuses; for vendors, the chance it is in stock.
'); - } - - function showModel(lot, name) { - var container = document.getElementById('modelViewer'); - container.textContent = 'Loading…'; - import('/js/lddviewer.js').then(function (module) { - disposeViewer(); - viewer = module.createViewer(container, {}); - return viewer.load([{ id: lot, name: name, lot: lot, position: [0, 0, 0], rotation: [0, 0, 0, 1], url: '/api/cdclient/objects/' + lot + '/lxfml' }], 1); - }).catch(function (e) { container.textContent = 'The 3D view could not load: ' + e.message; }); - } - - // Prerequisites as the server reads them: each term joined to all the rest, so "a or b and c" is a or (b and c) - function prerequisites(terms, text) { - if (!terms.length || (terms.length === 1 && !terms[0].mission)) return none('None'); - function term(t) { - if (!t.mission) return 'always met'; - return '' + refLink(t.mission) + (t.stateName ? ' in state ' + esc(t.stateName) : ' complete') + ''; - } - function from(i) { - var t = terms[i]; - if (i === terms.length - 1) return term(t); - var rest = from(i + 1); - // Brackets where the rest joins differently than this term does - if (i + 1 < terms.length - 1 && terms[i + 1].or !== t.or) rest = '( ' + rest + ' )'; - return term(t) + ' ' + (t.or ? 'or' : 'and') + ' ' + rest; - } - return '
' + from(0) + '
As the server reads ' + esc(text) + ': ' + - 'each mission is joined to everything after it, and brackets don\'t group.
'; - } - - function taskCard(tasks) { - if (!tasks.length) return card('Tasks', none()); - return card('Tasks', tasks.map(function (t) { - var parts = []; - if (t.targets.length) parts.push('
Targets: ' + t.targets.map(item).join(', ') + '
'); - if (t.targetText) parts.push('
Target group: ' + esc(t.targetText) + '
'); - if (t.parameters.length) parts.push('
Parameters: ' + t.parameters.map(item).join(', ') + '
'); - if (t.racingParameter) parts.push('
Racing task: ' + esc(t.racingParameter) + '
'); - return '
' + iconById(t.iconID) + '' + esc(t.taskTypeName) + ' ' + - '× ' + num(t.targetValue) + ' ' + esc(t.description || '') + - (t.progressed ? '' : ' the server never progresses this type') + - parts.join('') + raw('MissionTasks row (uid ' + esc(t.uid) + ')', keyValues(t.row, columnsOf('MissionTasks'), 'MissionTasks')) + '
'; - }).join('')); - } - - function rewardsCard(r, row) { - var choice = row.isChoiceReward ? ' (the player picks one)' : ''; - var first = '
First completion' + choice + '
' + (r.items.length ? items(r.items, true) : ''); - var extras = []; - if (row.reward_currency > 0) extras.push(num(row.reward_currency) + ' coins'); - if (row.LegoScore > 0) extras.push(num(row.LegoScore) + ' U-score'); - if (row.reward_reputation > 0) extras.push(num(row.reward_reputation) + ' reputation'); - first += extras.length ? '
' + extras.join(', ') + '
' : ''; - if (r.emotes.length) first += '
Emotes: ' + r.emotes.map(refLink).join(', ') + '
'; - if (r.stats.length) first += r.stats.map(function (s) { return '
' + esc(s.column) + ' +' + num(s.value) + '
'; }).join(''); - if (!r.items.length && !extras.length && !r.emotes.length && !r.stats.length) first += none(); - var html = first; - if (row.repeatable) { - var again = []; - if (row.reward_currency_repeatable > 0) again.push(num(row.reward_currency_repeatable) + ' coins'); - if (row.LegoScore > 0) again.push(num(row.LegoScore) + ' U-score'); - if (row.reward_reputation > 0) again.push(num(row.reward_reputation) + ' reputation'); - html += '
Each repeat' + choice + '
' + items(r.repeatItems, true) + (again.length ? '
' + again.join(', ') + '
' : ''); - } - return card('Rewards', html + '
At the level cap U-score is paid as coins.
'); - } - - function missionView(id) { - api.get('/api/cdclient/missions/' + id).then(function (d) { - if (api404(d)) return showError(d.error); - var row = d.row; - var badges = [row.isMission ? 'Mission' : 'Achievement']; - if (row.repeatable) badges.push('Repeatable' + (row.cooldownTime > 0 ? ' every ' + num(row.cooldownTime) + ' min' : '')); - if (row.time_limit > 0) badges.push('time_limit ' + num(row.time_limit)); - var html = header(iconById(row.missionIconID, 40) + esc(d.name) + ' ' + id + '', - esc([row.defined_type, row.defined_subtype].filter(Boolean).join(' / ')) + ' ' + badges.map(function (b) { return '' + b + ''; }).join(' ')); - html += '
'; - html += taskCard(d.tasks) + rewardsCard(d.rewards, row); - html += card('Prerequisites', prerequisites(d.prerequisites, row.prereqMissionID || '')); - html += card('Unlocks', chips(d.unlocks)) + countedBy(d.countedBy); - html += '
'; - html += card('Offered by', chips(d.offeredBy)) + card('Turned in to', chips(d.acceptedBy)); - html += localized(d.text) + localized(d.localized) + card('Missions row', raw('All columns', keyValues(row, d.columns, 'Missions'))) + '
'; - html += usedBy(d.usedBy, 'mission', id); - view.innerHTML = html; - }); - } - - // A behavior tree, nested; each behavior with its template (from the server's enum) and parameters - function behaviorTree(tree) { - var shown = {}; - function node(behaviorId, label) { - var n = tree.nodes[String(behaviorId)]; - var title = (label ? '' + esc(label) + ': ' : '') + - '' + esc(behaviorId) + ' '; - if (!n) return '
' + title + 'not in BehaviorTemplate' + (tree.truncated ? ' (or past the depth shown)' : '') + '
'; - title += '' + esc(n.templateName) + ' template ' + esc(n.templateID) + '' + - (n.effectID ? ' effect ' + esc(n.effectID) + (n.effectHandle ? ' ' + esc(n.effectHandle) : '') + '' : ''); - if (shown[behaviorId]) return '
' + title + ' (shown above)
'; - shown[behaviorId] = true; - var values = n.parameters.filter(function (p) { return p.child === null; }); - var children = n.parameters.filter(function (p) { return p.child !== null; }); - return '
' + title + '' + - (values.length ? '' + values.map(function (p) { - return ''; - }).join('') + '
' + esc(p.name) + '' + num(p.value) + '
' : '') + - children.map(function (p) { return node(p.child, p.name); }).join('') + '
'; - } - return '
' + node(tree.root, '') + '
' + (tree.truncated ? '
Cut short: too big to show whole.
' : ''); - } - - function skillView(id) { - api.get('/api/cdclient/skills/' + id).then(function (d) { - if (api404(d)) return showError(d.error); - var row = d.row; - var html = header(iconById(row.skillIcon, 40) + esc(d.name) + ' ' + id + '', esc(d.description || '')); - html += '
' + stat('Imagination cost', num(row.imaginationcost || 0)) + stat('Cooldown', num(row.cooldown || 0) + ' s') + - stat('Cooldown group', num(row.cooldowngroup || 0)) + '
'; - html += '
' + card('Behavior tree' + (row.behaviorID ? ' ' + esc(row.behaviorID) + '' : ''), - d.tree ? behaviorTree(d.tree) : none('No behavior')) + '
'; - html += card('On objects', d.objects.length ? table([{ text: 'Object' }, { text: 'castOnType (raw)', num: true }], d.objects.map(function (o) { - return '' + item(o.object) + '' + esc(o.castOnType) + ''; - })) : none()); - html += countedBy(d.countedBy) + localized(d.localized) + card('SkillBehavior row', raw('All columns', keyValues(row, d.columns, 'SkillBehavior'))) + '
'; - view.innerHTML = html + usedBy(d.usedBy, 'skill', id); - }); - } - - function behaviorView(id) { - api.get('/api/cdclient/behaviors/' + id).then(function (d) { - if (api404(d)) return showError(d.error); - var count = Object.keys(d.tree.nodes).length; - view.innerHTML = header('Behavior ' + id, nf.format(count) + (count === 1 ? ' behavior' : ' behaviors') + ' in this tree') + - card('Tree', behaviorTree(d.tree)) + usedBy(d.usedBy, 'behavior', id); - }); - } - - function activityView(id) { - api.get('/api/cdclient/activities/' + id).then(function (d) { - if (api404(d)) return showError(d.error); - var row = d.row; - var html = header(esc(d.name) + ' ' + id + '', d.zone ? 'Played in ' + refLink(d.zone) : ''); - html += '

Costs to play: ' + (d.cost ? item(d.cost.item) + ' × ' + num(d.cost.count) : none('nothing')) + '

'; - var rewards = d.rewards.map(function (r, i) { - return '
Rating ' + esc(r.rating) + '' + (i === 0 ? ' first row' : '') + - (r.description ? ' ' + esc(r.description) + '' : '') + '
' + - '
Coins: ' + (r.coins ? num(r.coins.min) + '–' + num(r.coins.max) : none()) + (r.matrix > 0 ? ' · loot matrix ' + esc(r.matrix) + '' : '') + '
' + - (r.odds ? raw('Drops', lootOdds(r.odds, 'reward')) : '') + '
'; - }).join(''); - html += '
' + card('Rewards', (rewards || none()) + '
Scripts that rate a result give the row with the highest rating at or below it; ' + - 'activity instances give the first row. Coins come from the currency table at level 1.
') + '
'; - html += countedBy(d.countedBy) + localized(d.localized) + card('Activities row', raw('All columns', keyValues(row, d.columns, 'Activities'))) + '
'; - view.innerHTML = html + usedBy(d.usedBy, 'activity', id); - }); - } - - function zoneView(id) { - api.get('/api/cdclient/zones/' + id).then(function (d) { - if (api404(d)) return showError(d.error); - var c = d.caps; - var override = function (value) { return value !== null ? ' override' : ''; }; - var html = header(esc(d.name) + ' ' + id + '', esc(d.row.mapFolder || '') + (d.row.zoneName ? ' · ' + esc(d.row.zoneName) : '')); - html += '
'; - html += card('Players per instance', '
' + stat('Soft cap' + override(c.softOverride), num(c.soft)) + stat('Hard cap' + override(c.hardOverride), num(c.hard)) + '
' + - '
The master sends players to a running instance while it has fewer than the soft cap (friends joining them up to the hard cap), ' + - 'otherwise starts a new one. The ZoneTable says ' + num(c.clientSoft) + ' and ' + num(c.clientHard) + '.' + - (c.overridesShown ? '' : ' Overrides set on the Instances page aren\'t shown without access to it.') + ' Instances
'); - html += card('Zone control object', d.control ? item(d.control) : none('None set: the server spawns its default zone control object')); - html += '
' + localized(d.localized) + card('ZoneTable row', raw('All columns', keyValues(d.row, d.columns, 'ZoneTable'))) + '
'; - view.innerHTML = html + usedBy(d.usedBy, 'zone', id); - }); - } - - function lootMatrixView(id) { - api.get('/api/cdclient/loot_matrix/' + id).then(function (d) { - if (api404(d)) return showError(d.error); - view.innerHTML = header('Loot matrix ' + id) + card('Drops', lootOdds(d.odds, 'roll')) + usedBy(d.usedBy, 'loot_matrix', id); - }); - } - - function lootTableView(id) { - api.get('/api/cdclient/loot_table/' + id).then(function (d) { - if (api404(d)) return showError(d.error); - view.innerHTML = header('Loot table ' + id, nf.format(d.items.length) + ' items, highest rarity first as the server sorts them') + - card('Items', table([{ text: 'Item' }, { text: 'Rarity', num: true }, { text: '' }], d.items.map(function (i) { - return '' + item(i.item) + '' + (rarity(i.rarity) || none('0')) + '' + - (i.missionDrop ? 'mission item' : '') + ''; - }), 'cdc-odds') + '
The chance of each depends on the loot matrix and rarity table it is rolled with: open one of the matrices below.
') + - usedBy(d.usedBy, 'loot_table', id); - }); - } - // ---- routing ---- function route() { - disposeViewer(); - lazy = {}; var hash = window.location.hash.replace(/^#\/?/, ''); var queryAt = hash.indexOf('?'); var path = (queryAt === -1 ? hash : hash.slice(0, queryAt)).split('/').map(decodeURIComponent); @@ -689,28 +170,15 @@ }); view.innerHTML = '
Loading…
'; window.scrollTo(0, 0); + if (path[0] === '') return home(); + if (path[0] === 'table') return tableView(path[1], params); + // #/object/1727 and the like: the rows it names, without adding a history entry var id = parseInt(path[1], 10); - switch (path[0]) { - case '': return home(); - case 'search': return search(path.slice(1).join('/')); - case 'table': return tableView(path[1], params); - case 'object': return objectView(id); - case 'mission': return missionView(id); - case 'skill': return skillView(id); - case 'behavior': return behaviorView(id); - case 'activity': return activityView(id); - case 'zone': return zoneView(id); - case 'loot_matrix': return lootMatrixView(id); - case 'loot_table': return lootTableView(id); - default: return showError('Nothing here.'); - } + var target = !isNaN(id) && hashFor(path[0], id); + if (target) return window.location.replace(target); + showError('Nothing here.'); } - document.getElementById('searchForm').addEventListener('submit', function (e) { - e.preventDefault(); - var text = document.getElementById('searchInput').value.trim(); - if (text) window.location.hash = '#/search/' + encodeURIComponent(text); - }); window.addEventListener('hashchange', route); api.get('/api/cdclient/schema').then(function (d) { diff --git a/dDashboardServer/templates/cdclient.jinja2 b/dDashboardServer/templates/cdclient.jinja2 index 34ce56810..2bf1d4ba3 100644 --- a/dDashboardServer/templates/cdclient.jinja2 +++ b/dDashboardServer/templates/cdclient.jinja2 @@ -4,57 +4,23 @@ {% block css %} {% endblock %} {% block content %}
-
-

CDClient Browser

- - - - -
+

CDClient Browser

Loading…
{% endblock %} {% block scripts %} - - {% endblock %} diff --git a/docs/Dashboard.md b/docs/Dashboard.md index bf62e90f9..f0cda8fad 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -1454,36 +1454,15 @@ world server later only apply to new captures. ### CDClient browser -**CDClient Browser** (`dev_cdclient`) searches and reads the game's CDClient database (`resServer/CDServer.sqlite`). -Besides the raw rows, each kind of thing has a view that shows it the way the server uses it: +**CDClient Browser** (`dev_cdclient`) is a raw viewer for the game's CDClient database (`resServer/CDServer.sqlite`), +for checking the values the server reads. It lists every table; each table is paged (50 rows), sortable by any column, +searchable across all columns and filterable by column (`=`, `!=`, `<`, `<=`, `>`, `>=`, contains, starts with, null, +not null). Values that point at other rows (a LOT, a loot matrix, a skill, a mission, a zone, an emote, ...) link to +the target table filtered to that ID; in `ComponentsRegistry`, `component_type` shows the type's name and +`component_id` links to the component's row. The view is in the URL, so it can be linked; `/cdclient#/object/` +(or `mission`, `skill`, `loot_matrix`, ...) opens the target table filtered to that ID. -- **Search** by name (objects by the names players see, missions, skills, activities, zones) or by ID (anything with - that ID). -- **Tables**: every table in the database, paged, sortable, searchable and filterable by column. Values that point at - other rows (a LOT, a loot matrix, a skill, a mission, a zone, an emote, ...) are links. -- **Objects**: icon, rarity and description; for items their type, equip slot, stack size, value, other currencies and - crafting costs, stat bonuses, skills and item set; for destructibles their life, armor, imagination, level and coin - drop; what they drop or contain, with each item's chance; a vendor's stock with prices and how it restocks; skills; - starting inventory; the missions an NPC offers or takes. Then where the object comes from (smashables, packages, - activity rewards, vendors, each with its chance), the missions that reward it and the mission tasks that count it. - Objects built from bricks can be previewed in 3D (from the client's `BrickModels`, so `client_location` must be - set); objects drawn from `.nif` meshes can't. -- **Loot**: chances are worked out as `Loot.cpp` rolls them: each loot matrix entry rolls with its percent, then drops - its count of items, each rolling a rarity from the rarity table (the lowest `randmax` at or above the roll) and - picking evenly among the loot table's items of that rarity, or the next lower rarity it has. The page shows, per - item, the chance of getting it at least once and how many on average, before live event bonuses. A vendor sells all - of an entry's table when its min or max is 0, otherwise that many of its items at random, whatever the rarity. -- **Missions**: tasks with their targets and parameters resolved to what `MissionTask` compares them with (objects, - missions, skills, emotes, activities), rewards for the first completion and for repeats, prerequisites as the server - evaluates them (each mission joined to everything after it, so brackets don't group), who offers and takes it, what - it unlocks and the achievements that count it. -- **Skills** with their cost, cooldown, behavior tree and the objects that have them; **behavior trees** with each - behavior's template (named from the server's `BehaviorTemplate` enum), effect and parameters. -- **Activities**: where they are played, what they cost, and their rewards per rating with coins and drop chances. -- **Zones**: names, the player caps new instances get (the ZoneTable's, or an override set on the Instances page, - shown to those who can see that page) and what uses the zone. - -Numbers the server has no names for (a skill's `castOnType`) or doesn't read (a loot matrix's `flagID`) are shown raw. +It shows values only. For worked-out views (loot odds, mission rewards, behavior trees) use lu-explorer. It is read-only. Tables and columns come from the database itself; the page never sends SQL, only which table, column and value to look at. diff --git a/tests/dWebTests/CDClientSchemaTests.cpp b/tests/dWebTests/CDClientSchemaTests.cpp index f09dbab2e..94da644da 100644 --- a/tests/dWebTests/CDClientSchemaTests.cpp +++ b/tests/dWebTests/CDClientSchemaTests.cpp @@ -1,6 +1,5 @@ #include -#include "CDClientRules.h" #include "CDClientSchema.h" using namespace CDClientSchema; @@ -61,9 +60,6 @@ TEST(CDClientSchemaTest, Links) { EXPECT_FALSE(LinkFor(schema, T(schema, "ObjectBehaviors"), C(schema, "ObjectBehaviors", "BehaviorID"))); // No target table in the schema: no link EXPECT_FALSE(LinkFor(schema, T(schema, "ObjectSkills"), C(schema, "ObjectSkills", "skillID"))); - - const auto toMatrices = ColumnsLinkingTo(schema, eLink::LOOT_MATRIX); - EXPECT_EQ(toMatrices.size(), 2u); // ActivityRewards and DestructibleComponent } TEST(CDClientSchemaTest, ComponentTables) { @@ -170,105 +166,3 @@ TEST(CDClientSchemaTest, ChildBehaviorParameters) { EXPECT_FALSE(IsChildBehaviorParameter("isEnemyFaction")); EXPECT_FALSE(IsChildBehaviorParameter("radius")); } - -TEST(CDClientSchemaTest, PrerequisiteMissions) { - EXPECT_EQ(PrerequisiteMissions("815|812|813"), (std::vector{ 815, 812, 813 })); - EXPECT_EQ(PrerequisiteMissions("(509|229)&(812|813)"), (std::vector{ 509, 229, 812, 813 })); - EXPECT_EQ(PrerequisiteMissions("1296, (1684 | 1680)"), (std::vector{ 1296, 1684, 1680 })); - // ":2" is a mission state, not a mission - EXPECT_EQ(PrerequisiteMissions("236:2"), (std::vector{ 236 })); - EXPECT_TRUE(PrerequisiteMissions("").empty()); -} - -TEST(CDClientRulesTest, RarityChances) { - using namespace CDClientRules; - // RarityTable 2 as the server loads it (randmax descending): a roll takes the lowest row at or above it - const std::vector rows{ { 1.0, 4 }, { 0.95, 3 }, { 0.75, 2 }, { 0.5, 1 } }; - const auto chances = RarityChances(rows); - ASSERT_EQ(chances.size(), 4u); - EXPECT_EQ(chances[0].first, 4); - EXPECT_NEAR(chances[0].second, 0.05, 1e-9); - EXPECT_NEAR(chances[1].second, 0.2, 1e-9); - EXPECT_NEAR(chances[2].second, 0.25, 1e-9); - EXPECT_EQ(chances[3].first, 1); - EXPECT_NEAR(chances[3].second, 0.5, 1e-9); - - // A roll above every row keeps rarity 1 - const auto partial = RarityChances({ { 0.9, 2 }, { 0.4, 1 } }); - ASSERT_EQ(partial.size(), 2u); - EXPECT_EQ(partial[0].first, 1); - EXPECT_NEAR(partial[0].second, 0.1 + 0.4, 1e-9); - EXPECT_NEAR(partial[1].second, 0.5, 1e-9); -} - -TEST(CDClientRulesTest, Candidates) { - using namespace CDClientRules; - // Items of the rolled rarity - EXPECT_EQ(Candidates({ 1, 3, 4, 3 }, 3), (std::vector{ 1, 3 })); - // None of it: the next lower rarity - EXPECT_EQ(Candidates({ 4, 3, 3, 1 }, 2), (std::vector{ 3 })); - // A lower rarity found alone doesn't stop the search: the server keeps taking lower ones until two match - EXPECT_EQ(Candidates({ 3, 1, 1 }, 4), (std::vector{ 0, 1, 2 })); - EXPECT_TRUE(Candidates({ 4 }, 2).empty()); -} - -TEST(CDClientRulesTest, ChancesAndOdds) { - using namespace CDClientRules; - const std::vector rows{ { 1.0, 4 }, { 0.95, 3 }, { 0.75, 2 }, { 0.5, 1 } }; - const auto perDrop = ChancePerDrop({ 1, 1, 2, 3, 4 }, rows); - EXPECT_NEAR(perDrop[0], 0.25, 1e-9); - EXPECT_NEAR(perDrop[2], 0.25, 1e-9); - EXPECT_NEAR(perDrop[3], 0.2, 1e-9); - EXPECT_NEAR(perDrop[4], 0.05, 1e-9); - double total = 0; - for (const auto chance : perDrop) total += chance; - EXPECT_NEAR(total, 1.0, 1e-9); - - const auto once = Odds(0.5, 1, 1, 0.25); - EXPECT_NEAR(once.atLeastOne, 0.125, 1e-9); - EXPECT_NEAR(once.expected, 0.125, 1e-9); - const auto twice = Odds(1.0, 2, 2, 0.5); - EXPECT_NEAR(twice.atLeastOne, 0.75, 1e-9); - EXPECT_NEAR(twice.expected, 1.0, 1e-9); - // 1 to 3 drops: 1 - (0.5 + 0.25 + 0.125) / 3 - EXPECT_NEAR(Odds(1.0, 1, 3, 0.5).atLeastOne, 1.0 - 0.875 / 3.0, 1e-9); - - EXPECT_EQ(VendorStockChance(0, 3, 10), 1.0); - EXPECT_NEAR(VendorStockChance(2, 4, 10), 0.3, 1e-9); - EXPECT_EQ(VendorStockChance(5, 5, 2), 1.0); -} - -TEST(CDClientRulesTest, Prerequisites) { - using namespace CDClientRules; - const auto terms = [](std::string_view text) { - std::string out; - for (const auto& term : ParsePrerequisites(text)) { - out += std::to_string(term.mission) + (term.state ? ":" + std::to_string(term.state) : "") + (term.orRest ? " or " : " and "); - } - return out; - }; - // Right to left, brackets don't group: 509 or (229 and (812 or 813)) - EXPECT_EQ(terms("(509|229)&(812|813)"), "509 or 229 and 812 or 813 and "); - EXPECT_EQ(terms("1|2,3"), "1 or 2 and 3 and "); - EXPECT_EQ(terms("1170:2"), "1170:2 and "); - EXPECT_EQ(terms("1882:10 | 1882:2"), "1882:10 or 1882:2 and "); - // Nothing: always met; a trailing "|" makes the whole thing always met, a trailing "," changes nothing - EXPECT_EQ(terms(""), "0 and "); - EXPECT_EQ(terms("5|"), "5 or 0 and "); - EXPECT_EQ(terms("5,"), "5 and "); -} - -TEST(CDClientRulesTest, TaskMeanings) { - using namespace CDClientRules; - EXPECT_EQ(MeaningOf(eMissionTaskType::SMASH).target, (std::vector{ eLink::OBJECT })); - EXPECT_TRUE(MeaningOf(eMissionTaskType::SMASH).targetGroupIds); - EXPECT_EQ(MeaningOf(eMissionTaskType::META).target, (std::vector{ eLink::MISSION })); - EXPECT_FALSE(MeaningOf(eMissionTaskType::TALK_TO_NPC).targetGroupIds); - EXPECT_EQ(MeaningOf(eMissionTaskType::USE_SKILL).parameters, (std::vector{ eLink::SKILL })); - EXPECT_TRUE(MeaningOf(eMissionTaskType::RACING).racingParameter); - EXPECT_FALSE(MeaningOf(eMissionTaskType::BUY).progressed); - - // As MissionTask reads targetGroup: leading spaces are fine, anything else that isn't a number is skipped - EXPECT_EQ(NumberList("1,2, 3,-1,x, 4 "), (std::vector{ 1, 2, 3 })); - EXPECT_TRUE(NumberList("").empty()); -}