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()); -}