feat(dashboard): UGC lists paged with totals, prefix search and sorting; car and rocket assemblies

IUgcLookup::ListUgc pages either kind with the UGC search's matching, a state
and a sort, and counts the matches (MySQL and SQLite share the SQL). The UGC
server records each model's bricks and triangles (new ugc columns) so models
can be sorted by size. Cars and rockets are listed as assemblies, one per
combination of modules, with their build type, module names and how many
builds use them; filters for type, state, module and owner. Each assembly's
builds (with where they are) are paged, and a build's assembly can be looked
up for links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 15:43:47 -05:00
parent d0e6c96ead
commit 4f3983fb98
18 changed files with 557 additions and 67 deletions

View File

@@ -0,0 +1,155 @@
#pragma once
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <string_view>
#include <vector>
#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<uint32_t> lots;
int32_t buildType{ -1 }; // of the first module that is one
std::vector<LWOOBJID> builds; // newest first
std::set<LWOOBJID> 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<eSort> 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<char>(std::tolower(c)); });
return out;
}
// The builds grouped by their combination; builds without modules are left out
inline std::vector<Assembly> Group(const std::vector<IUgcLookup::UgcEntry>& builds, const std::map<uint32_t, ModuleInfo>& modules) {
std::map<std::string, Assembly> 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<LWOOBJID, const IUgcLookup::UgcEntry*> byId;
for (const auto& build : builds) byId[build.id] = &build;
std::vector<Assembly> 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<int32_t> buildType;
std::optional<IUgc::eProcessState> state;
std::optional<std::set<LWOOBJID>> 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<uint32_t> moduleLot; // or: this module is in it
};
inline bool Matches(const Assembly& assembly, const Filter& filter, const std::map<uint32_t, ModuleInfo>& 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<uint32_t, ModuleInfo>& 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<Assembly>& list, eSort sort, const std::map<uint32_t, ModuleInfo>& 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();
}
});
}
}

View File

@@ -87,6 +87,46 @@ namespace {
}
namespace UgcLinks {
std::map<LWOOBJID, nlohmann::json> Whereabouts(const std::vector<IUgcLookup::UgcEntry>& entries) {
std::vector<LWOOBJID> ids, modularIds;
std::set<LWOOBJID> 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<LWOOBJID, nlohmann::json> 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<LWOOBJID> 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<uint32_t>(QueryValue(context.queryString, "limit")).value_or(SEARCH_LIMIT), 1u, SEARCH_LIMIT);
const auto entries = Database::Get()->SearchUgc(search, limit);
std::vector<LWOOBJID> ids, modularIds;
std::set<LWOOBJID> 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<std::pair<eUgcKind, LWOOBJID>, 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<LWOOBJID> 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));
}

View File

@@ -1,7 +1,17 @@
#pragma once
#include <map>
#include <vector>
#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<LWOOBJID, nlohmann::json> Whereabouts(const std::vector<IUgcLookup::UgcEntry>& entries);
}

View File

@@ -76,7 +76,7 @@ namespace UgcLookup {
static const std::map<std::string, UgcSearch::eField> 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()) {

View File

@@ -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<uint32_t, UgcAssemblies::ModuleInfo>& Modules() {
static std::map<uint32_t, UgcAssemblies::ModuleInfo> 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<uint32_t>(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<IUgc::eProcessState> 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<uint32_t>& 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<IUgcLookup::UgcEntry> 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<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
const auto search = QueryValue(context.queryString, "search").substr(0, 64);
const auto size = std::clamp(GeneralUtils::TryParse<uint32_t>(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<std::string, uint64_t> 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<std::string, IUgcLookup::eSort> 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<std::string>()).find(parsed.kind) != std::string::npos)) {
filter.buildType = k["buildType"].get<int32_t>();
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<LWOOBJID> 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<uint32_t>(*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<size_t>(page) * size; i < assemblies.size() && i < static_cast<size_t>(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<uint64_t>(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<uint32_t>(QueryValue(context.queryString, "size")).value_or(25), 1u, 200u);
auto page = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
if (const auto wanted = GeneralUtils::TryParse<LWOOBJID>(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<uint32_t>((it - builds.begin()) / size);
}
std::vector<IUgcLookup::UgcEntry> shown;
for (size_t i = static_cast<size_t>(page) * size; i < builds.size() && i < static_cast<size_t>(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<LWOOBJID>(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",