diff --git a/dDashboardServer/routes/UgcAssemblies.h b/dDashboardServer/routes/UgcAssemblies.h new file mode 100644 index 000000000..a2c2508e8 --- /dev/null +++ b/dDashboardServer/routes/UgcAssemblies.h @@ -0,0 +1,155 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "IUgcLookup.h" +#include "UgcKeys.h" + +/** + * Cars and rockets as the /ugc page lists them: one assembly per combination of modules (UgcModularKey::Normalize), + * however many builds (ugc_modular_build rows) use it, since the UGC server makes one icon per combination. Pure (the + * rows and the modules' CDClient data come in), so it can be unit tested; routes/UgcRoutes.cpp runs it. + */ +namespace UgcAssemblies { + struct ModuleInfo { + int32_t buildType{ -1 }; // ModuleComponent.buildType, -1 when the LOT isn't a module + std::string name; // Objects.displayName, else name + }; + + struct Assembly { + std::string key; + std::vector lots; + int32_t buildType{ -1 }; // of the first module that is one + std::vector builds; // newest first + std::set owners; // creator characters + IUgc::eProcessState state{ IUgc::eProcessState::PENDING }; + LWOOBJID iconBuild{}; // a build whose icon is made (the newest made one, else the newest) + std::string error; // a failed build's error, when none is made + }; + + enum class eSort : uint8_t { NEWEST, OLDEST, REFERENCES, NAME }; + + inline std::optional ParseSort(std::string_view text) { + if (text.empty() || text == "newest") return eSort::NEWEST; + if (text == "oldest") return eSort::OLDEST; + if (text == "references" || text == "uses") return eSort::REFERENCES; + if (text == "name") return eSort::NAME; + return std::nullopt; + } + + inline std::string Lower(std::string_view text) { + std::string out(text); + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return out; + } + + // The builds grouped by their combination; builds without modules are left out + inline std::vector Group(const std::vector& builds, const std::map& modules) { + std::map byKey; + for (const auto& build : builds) { + auto key = UgcModularKey::Normalize(build.detail); + if (key.empty()) continue; + auto& assembly = byKey[key]; + if (assembly.key.empty()) { + assembly.key = key; + assembly.lots = UgcModularKey::Lots(build.detail); + std::sort(assembly.lots.begin(), assembly.lots.end()); + assembly.lots.erase(std::unique(assembly.lots.begin(), assembly.lots.end()), assembly.lots.end()); + for (const auto lot : assembly.lots) { + const auto it = modules.find(lot); + if (it != modules.end() && it->second.buildType >= 0) { + assembly.buildType = it->second.buildType; + break; + } + } + } + assembly.builds.push_back(build.id); + if (build.characterId != LWOOBJID_EMPTY) assembly.owners.insert(build.characterId); + } + // The combination's state is its icon's: made when any build of it is, else waiting, failed or empty + std::map byId; + for (const auto& build : builds) byId[build.id] = &build; + std::vector out; + for (auto& [_, assembly] : byKey) { + std::sort(assembly.builds.rbegin(), assembly.builds.rend()); + bool made = false, waiting = false, failed = false; + for (const auto id : assembly.builds) { + const auto& build = *byId[id]; + if (build.state == IUgc::eProcessState::DONE && !made) { + made = true; + assembly.iconBuild = id; + } + waiting = waiting || build.state == IUgc::eProcessState::PENDING; + if (build.state == IUgc::eProcessState::FAILED && !failed) { + failed = true; + assembly.error = build.error; + } + } + if (!made) assembly.iconBuild = assembly.builds.front(); + assembly.state = made ? IUgc::eProcessState::DONE : waiting ? IUgc::eProcessState::PENDING : failed ? IUgc::eProcessState::FAILED : IUgc::eProcessState::EMPTY; + if (made) assembly.error.clear(); + out.push_back(std::move(assembly)); + } + return out; + } + + struct Filter { + std::optional buildType; + std::optional state; + std::optional> builds; // only assemblies using one of these builds (a database search's matches) + std::string moduleText; // or: a module's name contains this (any case) + std::optional moduleLot; // or: this module is in it + }; + + inline bool Matches(const Assembly& assembly, const Filter& filter, const std::map& modules) { + if (filter.buildType && assembly.buildType != *filter.buildType) return false; + if (filter.state && assembly.state != *filter.state) return false; + const bool searching = filter.builds || !filter.moduleText.empty() || filter.moduleLot; + if (!searching) return true; + if (filter.builds) { + for (const auto id : assembly.builds) { + if (filter.builds->contains(id)) return true; + } + } + if (filter.moduleLot && std::find(assembly.lots.begin(), assembly.lots.end(), *filter.moduleLot) != assembly.lots.end()) return true; + if (!filter.moduleText.empty()) { + const auto wanted = Lower(filter.moduleText); + for (const auto lot : assembly.lots) { + const auto it = modules.find(lot); + if (it != modules.end() && Lower(it->second.name).find(wanted) != std::string::npos) return true; + } + } + return false; + } + + // The first module's name (what "name" sorts by) + inline std::string Name(const Assembly& assembly, const std::map& modules) { + for (const auto lot : assembly.lots) { + const auto it = modules.find(lot); + if (it != modules.end() && !it->second.name.empty()) return it->second.name; + } + return assembly.key; + } + + inline void Sort(std::vector& list, eSort sort, const std::map& modules) { + std::stable_sort(list.begin(), list.end(), [&](const Assembly& a, const Assembly& b) { + switch (sort) { + case eSort::OLDEST: return a.builds.back() < b.builds.back(); + case eSort::REFERENCES: return a.builds.size() != b.builds.size() ? a.builds.size() > b.builds.size() : a.builds.front() > b.builds.front(); + case eSort::NAME: { + const auto an = Lower(Name(a, modules)), bn = Lower(Name(b, modules)); + return an != bn ? an < bn : a.key < b.key; + } + default: return a.builds.front() > b.builds.front(); + } + }); + } +} diff --git a/dDashboardServer/routes/UgcLinks.cpp b/dDashboardServer/routes/UgcLinks.cpp index e9bf75f5f..55efeae79 100644 --- a/dDashboardServer/routes/UgcLinks.cpp +++ b/dDashboardServer/routes/UgcLinks.cpp @@ -87,6 +87,46 @@ namespace { } namespace UgcLinks { + std::map Whereabouts(const std::vector& entries) { + std::vector ids, modularIds; + std::set models, modular; + for (const auto& entry : entries) { + ids.push_back(entry.id); + if (entry.kind == eUgcKind::MODEL) { + models.insert(entry.id); + } else { + modular.insert(entry.id); + modularIds.push_back(entry.id); + } + } + std::map where; + for (const auto& p : Database::Get()->GetUgcPlacements(ids)) { + where[p.ugcId].push_back({ { "type", "property" }, { "propertyId", std::to_string(p.propertyId) }, { "propertyName", p.propertyName }, + { "ownerId", std::to_string(p.ownerId) }, { "ownerName", p.ownerName }, { "zoneId", p.zoneId }, { "modelId", std::to_string(p.modelId) }, + { "lot", p.lot }, { "modelName", p.modelName }, { "modelDescription", p.modelDescription } }); + } + for (const auto& mail : Database::Get()->GetUgcMail(modularIds, MODEL_ITEM_LOT)) { + if (const auto creation = MailCreation(mail, models, modular)) { + where[creation->second].push_back({ { "type", "mail" }, { "mailId", std::to_string(mail.id) }, { "characterId", std::to_string(mail.receiverId) }, + { "characterName", mail.receiverName } }); + } + } + // Not placed or mailed: most are still with whoever made them + std::set creators; + for (const auto& entry : entries) { + if (!where.contains(entry.id) && entry.characterId != LWOOBJID_EMPTY && creators.size() < MAX_INVENTORIES) creators.insert(entry.characterId); + } + for (const auto creator : creators) { + for (const auto& link : CharacterCreations(creator)) { + const bool wanted = link.kind == eUgcKind::MODEL ? models.contains(link.ugcId) : modular.contains(link.ugcId); + if (!wanted) continue; + where[link.ugcId].push_back({ { "type", "inventory" }, { "characterId", std::to_string(creator) }, { "inventory", link.item.inventory }, + { "itemId", std::to_string(link.item.itemId) }, { "lot", link.item.lot } }); + } + } + return where; + } + void RegisterRoutes() { Route(eHTTPMethod::GET, "/ugc_search", Perm("properties_view"), "Find players' models, cars and rockets and where they are", [](HTTPReply& reply, const HTTPContext& context) { @@ -105,44 +145,7 @@ namespace UgcLinks { const auto limit = std::clamp(GeneralUtils::TryParse(QueryValue(context.queryString, "limit")).value_or(SEARCH_LIMIT), 1u, SEARCH_LIMIT); const auto entries = Database::Get()->SearchUgc(search, limit); - std::vector ids, modularIds; - std::set models, modular; - for (const auto& entry : entries) { - ids.push_back(entry.id); - if (entry.kind == eUgcKind::MODEL) { - models.insert(entry.id); - } else { - modular.insert(entry.id); - modularIds.push_back(entry.id); - } - } - std::map, nlohmann::json> where; - for (const auto& p : Database::Get()->GetUgcPlacements(ids)) { - const auto kind = models.contains(p.ugcId) ? eUgcKind::MODEL : eUgcKind::MODULAR; - where[{ kind, p.ugcId }].push_back({ { "type", "property" }, { "propertyId", std::to_string(p.propertyId) }, { "propertyName", p.propertyName }, - { "ownerId", std::to_string(p.ownerId) }, { "ownerName", p.ownerName }, { "zoneId", p.zoneId }, { "modelId", std::to_string(p.modelId) }, - { "lot", p.lot }, { "modelName", p.modelName }, { "modelDescription", p.modelDescription } }); - } - for (const auto& mail : Database::Get()->GetUgcMail(modularIds, MODEL_ITEM_LOT)) { - if (const auto creation = MailCreation(mail, models, modular)) { - where[*creation].push_back({ { "type", "mail" }, { "mailId", std::to_string(mail.id) }, { "characterId", std::to_string(mail.receiverId) }, - { "characterName", mail.receiverName } }); - } - } - // Not placed or mailed: most are still with whoever made them - std::set creators; - for (const auto& entry : entries) { - if (!where.contains({ entry.kind, entry.id }) && entry.characterId != LWOOBJID_EMPTY && creators.size() < MAX_INVENTORIES) creators.insert(entry.characterId); - } - for (const auto creator : creators) { - for (const auto& link : CharacterCreations(creator)) { - const std::pair key{ link.kind, link.ugcId }; - const bool wanted = link.kind == eUgcKind::MODEL ? models.contains(link.ugcId) : modular.contains(link.ugcId); - if (!wanted) continue; - where[key].push_back({ { "type", "inventory" }, { "characterId", std::to_string(creator) }, { "inventory", link.item.inventory }, - { "itemId", std::to_string(link.item.itemId) }, { "lot", link.item.lot } }); - } - } + auto where = UgcLinks::Whereabouts(entries); nlohmann::json items = nlohmann::json::array(); for (const auto& entry : entries) { @@ -152,7 +155,7 @@ namespace UgcLinks { item["characterName"] = entry.characterName; item["accountId"] = entry.accountId; item["accountName"] = entry.accountName; - const auto found = where.find({ entry.kind, entry.id }); + const auto found = where.find(entry.id); item["where"] = found == where.end() ? nlohmann::json::array() : found->second; items.push_back(std::move(item)); } diff --git a/dDashboardServer/routes/UgcLinks.h b/dDashboardServer/routes/UgcLinks.h index 44c9e7026..d23d85767 100644 --- a/dDashboardServer/routes/UgcLinks.h +++ b/dDashboardServer/routes/UgcLinks.h @@ -1,7 +1,17 @@ #pragma once +#include +#include + +#include "IUgcLookup.h" +#include "json.hpp" + // Finding players' creations and showing them where they are: the UGC search, and the UGC server's icons and models on // the property and character pages (for whoever may view those pages) namespace UgcLinks { void RegisterRoutes(); + + // Web thread: where each of these creations is (by id): placed on properties, attached to mails, or in its creator's + // inventories (looked in only for those not placed or mailed, and for at most a few creators). [{type: property|mail|inventory, ...}] + std::map Whereabouts(const std::vector& entries); } diff --git a/dDashboardServer/routes/UgcLookup.h b/dDashboardServer/routes/UgcLookup.h index a56edc9a4..8f017561b 100644 --- a/dDashboardServer/routes/UgcLookup.h +++ b/dDashboardServer/routes/UgcLookup.h @@ -76,7 +76,7 @@ namespace UgcLookup { static const std::map FIELDS = { { "id", UgcSearch::eField::ID }, { "owner", UgcSearch::eField::OWNER }, { "character", UgcSearch::eField::OWNER }, { "account", UgcSearch::eField::OWNER }, { "property", UgcSearch::eField::PROPERTY }, { "model", UgcSearch::eField::MODEL }, - { "name", UgcSearch::eField::MODEL }, { "lot", UgcSearch::eField::LOT }, + { "name", UgcSearch::eField::MODEL }, { "lot", UgcSearch::eField::LOT }, { "module", UgcSearch::eField::LOT }, }; const auto field = FIELDS.find(key); if (field != FIELDS.end()) { diff --git a/dDashboardServer/routes/UgcRoutes.cpp b/dDashboardServer/routes/UgcRoutes.cpp index a0d60d9c8..23790ef56 100644 --- a/dDashboardServer/routes/UgcRoutes.cpp +++ b/dDashboardServer/routes/UgcRoutes.cpp @@ -10,7 +10,10 @@ #include "NifFile.h" #include "SettingsCatalog.h" #include "SettingsHistory.h" +#include "UgcAssemblies.h" #include "UgcFetch.h" +#include "UgcLinks.h" +#include "UgcLookup.h" #include "UgcKeys.h" #include "Workers.h" #include "Game.h" @@ -129,6 +132,76 @@ namespace { return kinds; } + // Web thread: every module in the client's data (ModuleComponent) with its build type and name, read once + const std::map& Modules() { + static std::map modules; + static bool loaded = false; + if (loaded) return modules; + loaded = true; + auto result = CDClientDatabase::ExecuteQuery("SELECT cr.id AS lot, m.buildType AS buildType, o.name AS name, o.displayName AS displayName FROM ComponentsRegistry cr " + "JOIN ModuleComponent m ON m.id = cr.component_id LEFT JOIN Objects o ON o.id = cr.id WHERE cr.component_type = 28;"); + while (!result.eof()) { + std::string name = result.getStringField("displayName", ""); + if (name.empty()) name = result.getStringField("name", ""); + modules[static_cast(result.getIntField("lot", 0))] = { result.getIntField("buildType", -1), name }; + result.nextRow(); + } + return modules; + } + + // The /ugc search box: "state:" and "kind:" (or "type:") words are filters of their own, the rest is a UGC search + // (UgcLookup::ParseQuery: "owner:", "account:", "property:", "name:", "lot:"/"module:", "id:", or plain text) + struct ListSearch { + IUgcLookup::UgcSearch search; + std::optional state; + std::string kind; + std::string text; // what went to ParseQuery + }; + ListSearch ParseListSearch(const std::string& input) { + ListSearch out; + std::string rest; + size_t start = 0; + while (start < input.size()) { + auto end = input.find(' ', start); + if (end == std::string::npos) end = input.size(); + const auto word = input.substr(start, end - start); + const auto lower = UgcAssemblies::Lower(word); + if (lower.starts_with("state:") && IUgc::ParseProcessState(lower.substr(6))) out.state = IUgc::ParseProcessState(lower.substr(6)); + else if (lower.starts_with("kind:") && lower.size() > 5) out.kind = lower.substr(5); + else if (lower.starts_with("type:") && lower.size() > 5) out.kind = lower.substr(5); + else if (!word.empty()) rest += (rest.empty() ? "" : " ") + word; + start = end + 1; + } + out.text = rest; + out.search = UgcLookup::ParseQuery(rest); + return out; + } + + nlohmann::json ModulesJson(const std::vector& lots) { + nlohmann::json out = nlohmann::json::array(); + const auto& modules = Modules(); + for (const auto lot : lots) { + const auto it = modules.find(lot); + out.push_back({ { "lot", lot }, { "name", it != modules.end() ? it->second.name : std::string() }, { "icon", "/api/icon/" + std::to_string(lot) } }); + } + return out; + } + + nlohmann::json EntryJson(const IUgcLookup::UgcEntry& entry) { + return { { "id", std::to_string(entry.id) }, { "characterId", std::to_string(entry.characterId) }, { "characterName", entry.characterName }, + { "accountId", entry.accountId }, { "accountName", entry.accountName }, { "state", IUgc::ProcessStateName(entry.state) }, { "attempts", entry.attempts }, + { "processedAt", entry.processedAt }, { "error", entry.error }, { "bakeAo", entry.bakeAo }, { "processAfter", entry.processAfter }, + { "detail", entry.detail }, { "bricks", entry.bricks }, { "triangles", entry.triangles } }; + } + + // Web thread: every car and rocket build (the assemblies are made from them) + std::vector AllBuilds(const IUgcLookup::UgcSearch& search = {}) { + IUgcLookup::UgcListQuery query; + query.search = search; + query.limit = 1000000; + return Database::Get()->ListUgc(IUgcLookup::eUgcKind::MODULAR, query).first; + } + // Web thread: the values stored for a target, or null nlohmann::json StoredValues(const std::string& target) { const auto stored = Database::Get()->GetUgcIconSettings(target); @@ -165,35 +238,129 @@ namespace UgcRoutes { [](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "ugc.jinja2", "ugc"); }); Route(eHTTPMethod::GET, "/api/ugc", Perm("properties_view"), - "Processing state: {counts: {model, modular: {pending, done, failed}}, items: [{id, characterId, characterName, state, attempts, processedAt, " - "error, bakeAo, modules}], ugcPublicUrl, canManage}. Query: ?kind=model|modular&state=pending|done|failed&search=(id or owner name)&page=&size=", + "A page of player models (kind=model) or of car and rocket assemblies (kind=modular: one per combination of modules, however many builds use " + "it). Query: q= (\"state:\", \"kind:\"/\"type:\" (a build type, e.g. build6), \"owner:\", \"account:\", \"property:\", \"name:\", " + "\"lot:\"/\"module:\" (a LOT or a module's name), \"id:\", or plain text across names, owners and ids), state=, type=, sort=newest|oldest|owner|name|" + "bricks|triangles (models) or newest|oldest|references|name (assemblies), page= (from 0), size= (1-200). {items, total, page, size, counts, " + "kinds, ugcPublicUrl, canManage}", [](HTTPReply& reply, const HTTPContext& context) { const bool modular = QueryValue(context.queryString, "kind") == "modular"; - const auto state = ParseState(QueryValue(context.queryString, "state")); + auto parsed = ParseListSearch(QueryValue(context.queryString, "q").substr(0, 100)); + if (const auto state = IUgc::ParseProcessState(QueryValue(context.queryString, "state"))) parsed.state = state; + if (const auto type = QueryValue(context.queryString, "type"); !type.empty()) parsed.kind = type; const auto page = GeneralUtils::TryParse(QueryValue(context.queryString, "page")).value_or(0); - const auto search = QueryValue(context.queryString, "search").substr(0, 64); const auto size = std::clamp(GeneralUtils::TryParse(QueryValue(context.queryString, "size")).value_or(PAGE_SIZE), 1u, 200u); - const auto list = modular ? Database::Get()->GetModularBuildProcessList(state, search, page * size, size + 1) - : Database::Get()->GetUgcProcessList(state, search, page * size, size + 1); - // How many builds share each combination of modules (they share one icon) - std::map reuse; - if (modular) { - for (const auto& [ldf, count] : Database::Get()->GetModularBuildConfigCounts()) reuse[UgcModularKey::Normalize(ldf)] += count; - } + const auto sortText = QueryValue(context.queryString, "sort"); nlohmann::json items = nlohmann::json::array(); - for (size_t i = 0; i < list.size() && i < size; i++) { - const auto& info = list[i]; - items.push_back({ { "id", std::to_string(info.id) }, { "characterId", std::to_string(info.characterId) }, { "characterName", info.characterName }, - { "state", StateName(info.state) }, { "attempts", info.attempts }, { "processedAt", info.processedAt }, { "error", info.error }, - { "bakeAo", info.bakeAo }, { "modules", info.details }, { "processAfter", info.processAfter } }); - if (modular) { - const auto key = UgcModularKey::Normalize(info.details); - items.back()["combination"] = key; - items.back()["sharedBy"] = reuse[key]; + uint64_t total = 0; + const auto kinds = IconKinds(); + if (!modular) { + IUgcLookup::UgcListQuery query; + query.search = parsed.search; + query.state = parsed.state; + static const std::map SORTS = { { "newest", IUgcLookup::eSort::NEWEST }, { "oldest", IUgcLookup::eSort::OLDEST }, + { "owner", IUgcLookup::eSort::OWNER }, { "name", IUgcLookup::eSort::NAME }, { "bricks", IUgcLookup::eSort::BRICKS }, { "triangles", IUgcLookup::eSort::TRIANGLES } }; + if (const auto it = SORTS.find(sortText); it != SORTS.end()) query.sort = it->second; + query.offset = page * size; + query.limit = size; + const auto [entries, count] = Database::Get()->ListUgc(IUgcLookup::eUgcKind::MODEL, query); + total = count; + for (const auto& entry : entries) items.push_back(EntryJson(entry)); + } else { + UgcAssemblies::Filter filter; + filter.state = parsed.state; + if (!parsed.kind.empty()) { + for (const auto& k : kinds) { + if (k.contains("buildType") && (k["kind"] == parsed.kind || UgcAssemblies::Lower(k["label"].get()).find(parsed.kind) != std::string::npos)) { + filter.buildType = k["buildType"].get(); + break; + } + } + if (!filter.buildType) filter.buildType = -2; // no such type: nothing + } + const auto& modules = Modules(); + if (!parsed.search.text.empty() || parsed.search.number) { + using eField = IUgcLookup::UgcSearch::eField; + const auto field = parsed.search.field; + if (field != eField::LOT) { + std::set matched; + for (const auto& build : AllBuilds(parsed.search)) matched.insert(build.id); + filter.builds = matched; + } + if (field == eField::ANY || field == eField::LOT) { + filter.moduleText = parsed.search.text; + if (parsed.search.number) filter.moduleLot = static_cast(*parsed.search.number); + } + } + auto assemblies = UgcAssemblies::Group(AllBuilds(), modules); + std::erase_if(assemblies, [&](const auto& a) { return !UgcAssemblies::Matches(a, filter, modules); }); + UgcAssemblies::Sort(assemblies, UgcAssemblies::ParseSort(sortText).value_or(UgcAssemblies::eSort::NEWEST), modules); + total = assemblies.size(); + for (size_t i = static_cast(page) * size; i < assemblies.size() && i < static_cast(page + 1) * size; i++) { + const auto& a = assemblies[i]; + std::string label, kind; + for (const auto& k : kinds) { + if (k.contains("buildType") && k["buildType"] == a.buildType) { + label = k["label"]; + kind = k["kind"]; + } + } + std::string ldf = a.key; + std::replace(ldf.begin(), ldf.end(), '-', '+'); + items.push_back({ { "id", a.key }, { "key", a.key }, { "modules", ldf }, { "moduleList", ModulesJson(a.lots) }, { "buildType", a.buildType }, + { "kind", kind }, { "kindLabel", label }, { "state", IUgc::ProcessStateName(a.state) }, { "error", a.error }, { "uses", a.builds.size() }, + { "owners", a.owners.size() }, { "iconBuild", std::to_string(a.iconBuild) }, { "newestBuild", std::to_string(a.builds.front()) }, + { "storageId", std::to_string(UgcModularKey::StorageId(a.key)) } }); } } JsonSuccess(reply, { { "counts", { { "model", Counts(Database::Get()->GetUgcProcessCounts()) }, { "modular", Counts(Database::Get()->GetModularBuildProcessCounts()) } } }, - { "items", items }, { "more", list.size() > size }, { "ugcPublicUrl", Game::config->GetValue("ugc_public_url") }, { "canManage", Can(context, "ugc_manage") } }); + { "items", items }, { "total", total }, { "page", page }, { "size", size }, { "more", static_cast(page + 1) * size < total }, { "kinds", kinds }, + { "ugcPublicUrl", Game::config->GetValue("ugc_public_url") }, { "canManage", Can(context, "ugc_manage") } }); + }); + + Route(eHTTPMethod::GET, "/api/ugc/assembly/builds", Perm("properties_view"), + "The builds (ugc_modular_build rows) that use a combination of modules: {items: [{id, characterId, characterName, accountId, accountName, state, " + "attempts, processedAt, error, where: [{type: property|mail|inventory, ...}]}], total, page, size}. Query: modules= (the combination), q= (owner, " + "account, property or id, as the list's search), page=, size=, build= (a build to show: the page holding it is given)", + [](HTTPReply& reply, const HTTPContext& context) { + auto asked = QueryValue(context.queryString, "modules"); + std::replace(asked.begin(), asked.end(), '-', '+'); + const auto key = UgcModularKey::Normalize(asked); + if (key.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "No modules"); + const auto parsed = ParseListSearch(QueryValue(context.queryString, "q").substr(0, 100)); + auto builds = AllBuilds(parsed.search); + std::erase_if(builds, [&](const auto& b) { return UgcModularKey::Normalize(b.detail) != key || (parsed.state && b.state != *parsed.state); }); + std::sort(builds.begin(), builds.end(), [](const auto& a, const auto& b) { return a.id > b.id; }); + const auto size = std::clamp(GeneralUtils::TryParse(QueryValue(context.queryString, "size")).value_or(25), 1u, 200u); + auto page = GeneralUtils::TryParse(QueryValue(context.queryString, "page")).value_or(0); + if (const auto wanted = GeneralUtils::TryParse(QueryValue(context.queryString, "build"))) { + const auto it = std::find_if(builds.begin(), builds.end(), [&](const auto& b) { return b.id == *wanted; }); + if (it != builds.end()) page = static_cast((it - builds.begin()) / size); + } + std::vector shown; + for (size_t i = static_cast(page) * size; i < builds.size() && i < static_cast(page + 1) * size; i++) shown.push_back(builds[i]); + const auto where = UgcLinks::Whereabouts(shown); + nlohmann::json items = nlohmann::json::array(); + for (const auto& build : shown) { + auto item = EntryJson(build); + const auto found = where.find(build.id); + item["where"] = found == where.end() ? nlohmann::json::array() : found->second; + items.push_back(std::move(item)); + } + JsonSuccess(reply, { { "items", items }, { "total", builds.size() }, { "page", page }, { "size", size }, { "key", key } }); + }); + + Route(eHTTPMethod::GET, "/api/ugc/assembly/of/:id", Perm("properties_view"), + "The assembly (combination of modules) a car or rocket build uses: {key, modules}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto id = PathId(context.path, 4); + if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id"); + const auto info = Database::Get()->GetModularBuildProcessInfo(*id); + if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such build"); + const auto key = UgcModularKey::Normalize(info->details); + std::string ldf = key; + std::replace(ldf.begin(), ldf.end(), '-', '+'); + JsonSuccess(reply, { { "key", key }, { "modules", ldf }, { "moduleList", ModulesJson(UgcModularKey::Lots(ldf)) } }); }); Route(eHTTPMethod::GET, "/api/ugc/:id/lxfml", Perm("properties_view"), "A player model's LXFML", diff --git a/dDatabase/GameDatabase/ITables/IUgc.h b/dDatabase/GameDatabase/ITables/IUgc.h index 4113b02bf..bbcc81e08 100644 --- a/dDatabase/GameDatabase/ITables/IUgc.h +++ b/dDatabase/GameDatabase/ITables/IUgc.h @@ -92,6 +92,9 @@ public: // whether lighting was baked in; processed_at becomes now virtual void SetUgcModelProcessed(const LWOOBJID id, const eProcessState state, const uint32_t attempts, const std::string_view error, const bool bakeAo) = 0; + // What the UGC server counted when it made a model: its bricks and the most detailed mesh's triangles + virtual void SetUgcModelStats(const LWOOBJID id, const uint32_t bricks, const uint32_t triangles) = 0; + virtual std::optional GetUgcProcessInfo(const LWOOBJID id) = 0; // Sets models back to pending with no attempts: one (`id`), or all of them (`id` nullopt; only the failed ones diff --git a/dDatabase/GameDatabase/ITables/IUgcLookup.h b/dDatabase/GameDatabase/ITables/IUgcLookup.h index 7b3801259..261779e5b 100644 --- a/dDatabase/GameDatabase/ITables/IUgcLookup.h +++ b/dDatabase/GameDatabase/ITables/IUgcLookup.h @@ -30,6 +30,12 @@ public: IUgc::eProcessState state{}; std::string error; // why the UGC server failed, if it did std::string detail; // models: the file name it was uploaded as; modular builds: the modules + uint32_t attempts{}; + int64_t processedAt{}; // Unix seconds of the UGC server's last attempt, 0 for none + int64_t processAfter{}; // models: the end of the quiet period after a save + bool bakeAo{}; + uint32_t bricks{}; // models: counted by the UGC server when it made them (0: not yet) + uint32_t triangles{}; // models: of the made mesh's most detailed level (0: not yet) }; // What SearchUgc matches. A number (when set) is matched against ids; text against names @@ -47,6 +53,27 @@ public: std::optional number; // matched exactly against ids }; + enum class eSort : uint8_t { + NEWEST, // the highest id first (ids are handed out in order) + OLDEST, + OWNER, // the creator's character name + NAME, // models: the upload's file name; modular builds: the modules + BRICKS, // models: the most bricks first + TRIANGLES, // models: the most triangles first + }; + + // A page of one kind: all of them or those matching the search (the same matching as SearchUgc), in a state or any + struct UgcListQuery { + UgcSearch search; + std::optional state; + eSort sort{ eSort::NEWEST }; + uint32_t offset{}; + uint32_t limit{ 50 }; + }; + + // The page and how many match in all + virtual std::pair, uint64_t> ListUgc(const eUgcKind kind, const UgcListQuery& query) = 0; + // Up to `limit` of each kind, the newest first virtual std::vector SearchUgc(const UgcSearch& search, const uint32_t limit) = 0; diff --git a/dDatabase/GameDatabase/ITables/UgcLookupSql.h b/dDatabase/GameDatabase/ITables/UgcLookupSql.h index 0ffa132a6..9e8c21f8d 100644 --- a/dDatabase/GameDatabase/ITables/UgcLookupSql.h +++ b/dDatabase/GameDatabase/ITables/UgcLookupSql.h @@ -19,15 +19,45 @@ namespace UgcLookupSql { return list; } - // The columns every entry query selects, for models (ugc AS u) or modular builds (ugc_modular_build AS b) - inline std::string Select(bool modular) { + // The tables every entry query reads, for models (ugc AS u) or modular builds (ugc_modular_build AS b) + inline std::string From(bool modular) { return modular + ? "FROM ugc_modular_build AS b LEFT JOIN charinfo AS c ON c.id = b.character_id LEFT JOIN accounts AS a ON a.id = c.account_id " + : "FROM ugc AS u LEFT JOIN charinfo AS c ON c.id = u.character_id LEFT JOIN accounts AS a ON a.id = u.account_id "; + } + + // The columns every entry query selects + inline std::string Select(bool modular) { + return (modular ? "SELECT b.ugc_id AS id, b.character_id, c.name AS character_name, COALESCE(c.account_id, 0) AS account_id, a.name AS account_name, " - "b.is_optimized, b.process_error, b.ldf_config AS detail FROM ugc_modular_build AS b " - "LEFT JOIN charinfo AS c ON c.id = b.character_id LEFT JOIN accounts AS a ON a.id = c.account_id " + "b.is_optimized, b.process_error, b.ldf_config AS detail, b.process_attempts, b.processed_at, 0 AS process_after, 0 AS bake_ao, " + "0 AS brick_count, 0 AS triangle_count " : "SELECT u.id, u.character_id, c.name AS character_name, u.account_id, a.name AS account_name, " - "u.is_optimized, u.process_error, u.filename AS detail FROM ugc AS u " - "LEFT JOIN charinfo AS c ON c.id = u.character_id LEFT JOIN accounts AS a ON a.id = u.account_id "; + "u.is_optimized, u.process_error, u.filename AS detail, u.process_attempts, u.processed_at, u.process_after, u.bake_ao, " + "u.brick_count, u.triangle_count ") + From(modular); + } + + // Whether a search has anything to match (else a list is of everything) + inline bool Searching(const IUgcLookup::UgcSearch& search) { return !search.text.empty() || search.number.has_value(); } + + // A list's state filter and order (after Where, or "WHERE 1=1 " when not searching) + inline std::string ListFilter(const IUgcLookup::UgcListQuery& query, bool modular) { + const std::string table = modular ? "b." : "u."; + std::string sql = query.state ? "AND " + table + "is_optimized = " + std::to_string(static_cast(*query.state)) + " " : ""; + return sql; + } + + inline std::string ListOrder(const IUgcLookup::UgcListQuery& query, bool modular) { + using eSort = IUgcLookup::eSort; + const std::string id = modular ? "b.ugc_id" : "u.id"; + switch (query.sort) { + case eSort::OLDEST: return "ORDER BY " + id + " ASC "; + case eSort::OWNER: return "ORDER BY c.name ASC, " + id + " DESC "; + case eSort::NAME: return std::string("ORDER BY ") + (modular ? "b.ldf_config" : "u.filename") + " ASC, " + id + " DESC "; + case eSort::BRICKS: return modular ? "ORDER BY " + id + " DESC " : "ORDER BY u.brick_count DESC, u.id DESC "; + case eSort::TRIANGLES: return modular ? "ORDER BY " + id + " DESC " : "ORDER BY u.triangle_count DESC, u.id DESC "; + default: return "ORDER BY " + id + " DESC "; + } } // WHERE for a search; binds the search's %text% TEXT_BINDS times diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h index 350e703ee..5469eba1e 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h @@ -222,6 +222,7 @@ public: // IUgcLookup std::vector SearchUgc(const UgcSearch& search, const uint32_t limit) override; + std::pair, uint64_t> ListUgc(const eUgcKind kind, const UgcListQuery& query) override; std::vector GetUgcEntries(const std::vector& ids) override; std::vector GetUgcPlacements(const std::vector& ugcIds) override; std::vector GetUgcMail(const std::vector& subkeys, const LOT modelItemLot) override; @@ -427,6 +428,7 @@ public: void SetUgcIconSettings(const std::string_view target, const std::string_view params) override; void DeleteUgcIconSettings(const std::string_view target) override; void SetUgcModelProcessed(const LWOOBJID id, const eProcessState state, const uint32_t attempts, const std::string_view error, const bool bakeAo) override; + void SetUgcModelStats(const LWOOBJID id, const uint32_t bricks, const uint32_t triangles) override; std::optional GetUgcProcessInfo(const LWOOBJID id) override; uint64_t ResetUgcModelProcessing(const std::optional id, const bool failedOnly) override; std::vector GetUgcProcessList(const std::optional state, const std::string_view search, const uint32_t offset, const uint32_t limit) override; diff --git a/dDatabase/GameDatabase/MySQL/Tables/Ugc.cpp b/dDatabase/GameDatabase/MySQL/Tables/Ugc.cpp index 499a252ec..62edd6e57 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/Ugc.cpp +++ b/dDatabase/GameDatabase/MySQL/Tables/Ugc.cpp @@ -177,3 +177,7 @@ void MySQLDatabase::ExpediteUgcModel(const LWOOBJID id) { void MySQLDatabase::ExpediteUgcModels(const LWOOBJID characterId) { ExecuteUpdate("UPDATE ugc SET process_after = 0 WHERE character_id = ? AND is_optimized = 0 AND process_after > 0;", characterId); } + +void MySQLDatabase::SetUgcModelStats(const LWOOBJID id, const uint32_t bricks, const uint32_t triangles) { + ExecuteUpdate("UPDATE ugc SET brick_count = ?, triangle_count = ? WHERE id = ?;", bricks, triangles, id); +} diff --git a/dDatabase/GameDatabase/MySQL/Tables/UgcLookup.cpp b/dDatabase/GameDatabase/MySQL/Tables/UgcLookup.cpp index 77393bfe7..4dc2991fc 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/UgcLookup.cpp +++ b/dDatabase/GameDatabase/MySQL/Tables/UgcLookup.cpp @@ -14,6 +14,12 @@ namespace { entry.state = static_cast(result->getInt("is_optimized")); entry.error = std::string(result->getString("process_error").c_str()); entry.detail = std::string(result->getString("detail").c_str()); + entry.attempts = static_cast(result->getInt("process_attempts")); + entry.processedAt = result->getInt64("processed_at"); + entry.processAfter = result->getInt64("process_after"); + entry.bakeAo = result->getInt("bake_ao") != 0; + entry.bricks = static_cast(result->getInt64("brick_count")); + entry.triangles = static_cast(result->getInt64("triangle_count")); return entry; } } @@ -75,3 +81,26 @@ std::vector MySQLDatabase::GetUgcMail(const std::vector, uint64_t> MySQLDatabase::ListUgc(const eUgcKind kind, const UgcListQuery& query) { + const bool modular = kind == eUgcKind::MODULAR; + const std::string pattern = "%" + query.search.text + "%"; + const bool searching = UgcLookupSql::Searching(query.search); + const auto where = (searching ? UgcLookupSql::Where(query.search, modular) : std::string("WHERE 1=1 ")) + UgcLookupSql::ListFilter(query, modular); + std::vector entries; + uint64_t total = 0; + const auto page = UgcLookupSql::Select(modular) + where + UgcLookupSql::ListOrder(query, modular) + "LIMIT ? OFFSET ?;"; + const auto count = "SELECT COUNT(*) AS n " + UgcLookupSql::From(modular) + where + ";"; + if (searching) { + auto result = ExecuteSelect(page, pattern, pattern, pattern, pattern, pattern, pattern, query.limit, query.offset); + while (result->next()) entries.push_back(ReadEntry(result, kind)); + auto counted = ExecuteSelect(count, pattern, pattern, pattern, pattern, pattern, pattern); + if (counted->next()) total = static_cast(counted->getInt64("n")); + } else { + auto result = ExecuteSelect(page, query.limit, query.offset); + while (result->next()) entries.push_back(ReadEntry(result, kind)); + auto counted = ExecuteSelect(count); + if (counted->next()) total = static_cast(counted->getInt64("n")); + } + return { entries, total }; +} diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h index 3dd468ec6..6aa6bab30 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h @@ -206,6 +206,7 @@ public: // IUgcLookup std::vector SearchUgc(const UgcSearch& search, const uint32_t limit) override; + std::pair, uint64_t> ListUgc(const eUgcKind kind, const UgcListQuery& query) override; std::vector GetUgcEntries(const std::vector& ids) override; std::vector GetUgcPlacements(const std::vector& ugcIds) override; std::vector GetUgcMail(const std::vector& subkeys, const LOT modelItemLot) override; @@ -411,6 +412,7 @@ public: void SetUgcIconSettings(const std::string_view target, const std::string_view params) override; void DeleteUgcIconSettings(const std::string_view target) override; void SetUgcModelProcessed(const LWOOBJID id, const eProcessState state, const uint32_t attempts, const std::string_view error, const bool bakeAo) override; + void SetUgcModelStats(const LWOOBJID id, const uint32_t bricks, const uint32_t triangles) override; std::optional GetUgcProcessInfo(const LWOOBJID id) override; uint64_t ResetUgcModelProcessing(const std::optional id, const bool failedOnly) override; std::vector GetUgcProcessList(const std::optional state, const std::string_view search, const uint32_t offset, const uint32_t limit) override; diff --git a/dDatabase/GameDatabase/SQLite/Tables/Ugc.cpp b/dDatabase/GameDatabase/SQLite/Tables/Ugc.cpp index e037ca0c5..e11fe4266 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/Ugc.cpp +++ b/dDatabase/GameDatabase/SQLite/Tables/Ugc.cpp @@ -180,3 +180,7 @@ void SQLiteDatabase::ExpediteUgcModel(const LWOOBJID id) { void SQLiteDatabase::ExpediteUgcModels(const LWOOBJID characterId) { ExecuteUpdate("UPDATE ugc SET process_after = 0 WHERE character_id = ? AND is_optimized = 0 AND process_after > 0;", characterId); } + +void SQLiteDatabase::SetUgcModelStats(const LWOOBJID id, const uint32_t bricks, const uint32_t triangles) { + ExecuteUpdate("UPDATE ugc SET brick_count = ?, triangle_count = ? WHERE id = ?;", bricks, triangles, id); +} diff --git a/dDatabase/GameDatabase/SQLite/Tables/UgcLookup.cpp b/dDatabase/GameDatabase/SQLite/Tables/UgcLookup.cpp index 324ad60fb..a8682a16f 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/UgcLookup.cpp +++ b/dDatabase/GameDatabase/SQLite/Tables/UgcLookup.cpp @@ -14,6 +14,12 @@ namespace { entry.state = static_cast(result.getIntField("is_optimized")); entry.error = result.getStringField("process_error", ""); entry.detail = result.getStringField("detail", ""); + entry.attempts = static_cast(result.getIntField("process_attempts")); + entry.processedAt = result.getInt64Field("processed_at"); + entry.processAfter = result.getInt64Field("process_after"); + entry.bakeAo = result.getIntField("bake_ao") != 0; + entry.bricks = static_cast(result.getInt64Field("brick_count")); + entry.triangles = static_cast(result.getInt64Field("triangle_count")); return entry; } } @@ -75,3 +81,26 @@ std::vector SQLiteDatabase::GetUgcMail(const std::vector, uint64_t> SQLiteDatabase::ListUgc(const eUgcKind kind, const UgcListQuery& query) { + const bool modular = kind == eUgcKind::MODULAR; + const std::string pattern = "%" + query.search.text + "%"; + const bool searching = UgcLookupSql::Searching(query.search); + const auto where = (searching ? UgcLookupSql::Where(query.search, modular) : std::string("WHERE 1=1 ")) + UgcLookupSql::ListFilter(query, modular); + std::vector entries; + uint64_t total = 0; + const auto page = UgcLookupSql::Select(modular) + where + UgcLookupSql::ListOrder(query, modular) + "LIMIT ? OFFSET ?;"; + const auto count = "SELECT COUNT(*) AS n " + UgcLookupSql::From(modular) + where + ";"; + if (searching) { + auto [_, result] = ExecuteSelect(page, pattern, pattern, pattern, pattern, pattern, pattern, query.limit, query.offset); + for (; !result.eof(); result.nextRow()) entries.push_back(ReadEntry(result, kind)); + auto [__, counted] = ExecuteSelect(count, pattern, pattern, pattern, pattern, pattern, pattern); + if (!counted.eof()) total = static_cast(counted.getInt64Field("n")); + } else { + auto [_, result] = ExecuteSelect(page, query.limit, query.offset); + for (; !result.eof(); result.nextRow()) entries.push_back(ReadEntry(result, kind)); + auto [__, counted] = ExecuteSelect(count); + if (!counted.eof()) total = static_cast(counted.getInt64Field("n")); + } + return { entries, total }; +} diff --git a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h index 57d0757c9..2b7a79115 100644 --- a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h +++ b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h @@ -270,6 +270,7 @@ class TestSQLDatabase : public GameDatabase { // IUgcLookup std::vector SearchUgc(const UgcSearch& search, const uint32_t limit) override { return {}; } + std::pair, uint64_t> ListUgc(const eUgcKind kind, const UgcListQuery& query) override { return {}; } std::vector GetUgcEntries(const std::vector& ids) override { return {}; } std::vector GetUgcPlacements(const std::vector& ugcIds) override { return {}; } std::vector GetUgcMail(const std::vector& subkeys, const LOT modelItemLot) override { return {}; } @@ -409,6 +410,7 @@ class TestSQLDatabase : public GameDatabase { void SetUgcIconSettings(const std::string_view target, const std::string_view params) override {} void DeleteUgcIconSettings(const std::string_view target) override {} void SetUgcModelProcessed(const LWOOBJID id, const eProcessState state, const uint32_t attempts, const std::string_view error, const bool bakeAo) override {} + void SetUgcModelStats(const LWOOBJID id, const uint32_t bricks, const uint32_t triangles) override {} std::optional GetUgcProcessInfo(const LWOOBJID id) override { return {}; } uint64_t ResetUgcModelProcessing(const std::optional id, const bool failedOnly) override { return 0; } std::vector GetUgcProcessList(const std::optional state, const std::string_view search, const uint32_t offset, const uint32_t limit) override { return {}; } diff --git a/dUgcServer/UgcProcessor.cpp b/dUgcServer/UgcProcessor.cpp index 217215fca..60763f3a6 100644 --- a/dUgcServer/UgcProcessor.cpp +++ b/dUgcServer/UgcProcessor.cpp @@ -346,6 +346,13 @@ void UgcProcessor::Record(const Done& done) { : attempts >= m_Config.maxAttempts ? IUgc::eProcessState::FAILED : IUgc::eProcessState::PENDING; if (done.kind == Kind::MODEL) { Database::Get()->SetUgcModelProcessed(done.id, state, attempts, error, done.outcome.ok && done.outcome.aoBaked); + // What it counted (stats.json), for sorting on the dashboard + const auto stats = done.outcome.ok && !done.outcome.stats.empty() ? nlohmann::json::parse(done.outcome.stats, nullptr, false) : nlohmann::json(); + if (stats.is_object()) { + const auto& lods = stats.value("lods", nlohmann::json::array()); + const auto& first = lods.is_array() && !lods.empty() ? lods.front() : nlohmann::json::object(); + Database::Get()->SetUgcModelStats(done.id, stats.value("bricks", 0u), first.value("opaqueAfter", 0u) + first.value("transparent", 0u)); + } } else { Database::Get()->SetModularBuildProcessed(done.id, state, attempts, error); } diff --git a/migrations/dlu/mysql/88_ugc_model_stats.sql b/migrations/dlu/mysql/88_ugc_model_stats.sql new file mode 100644 index 000000000..f9fea3d54 --- /dev/null +++ b/migrations/dlu/mysql/88_ugc_model_stats.sql @@ -0,0 +1,13 @@ +/* UGC server: ugc.brick_count and ugc.triangle_count are what it counted when it made a model (the bricks, and the + triangles of the made mesh's most detailed level; 0 until it has), so the dashboard can sort models by size. */ +SET @dlu_column = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ugc ADD COLUMN brick_count INT UNSIGNED NOT NULL DEFAULT 0', 'DO 0') FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ugc' AND column_name = 'brick_count'); +PREPARE dlu_column_stmt FROM @dlu_column; +EXECUTE dlu_column_stmt; +DEALLOCATE PREPARE dlu_column_stmt; + +SET @dlu_column = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ugc ADD COLUMN triangle_count INT UNSIGNED NOT NULL DEFAULT 0', 'DO 0') FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ugc' AND column_name = 'triangle_count'); +PREPARE dlu_column_stmt FROM @dlu_column; +EXECUTE dlu_column_stmt; +DEALLOCATE PREPARE dlu_column_stmt; diff --git a/migrations/dlu/sqlite/71_ugc_model_stats.sql b/migrations/dlu/sqlite/71_ugc_model_stats.sql new file mode 100644 index 000000000..abaafc1d5 --- /dev/null +++ b/migrations/dlu/sqlite/71_ugc_model_stats.sql @@ -0,0 +1,3 @@ +/* UGC server: the bricks and triangles it counted when it made a model. See the MySQL migration. */ +ALTER TABLE ugc ADD COLUMN brick_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE ugc ADD COLUMN triangle_count INTEGER NOT NULL DEFAULT 0;