feat(dashboard): UGC search, and UGC icons on property and character pages

UGC Search (/ugc_search) finds creations by name, id or LOT with where each
one is. Property pages and character inventories show the icon the UGC server
made of each creation, its state and a link to /ugc?item=&kind=, for whoever
may view the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 10:23:35 -05:00
parent dbce5f1240
commit 6b6897dd64
13 changed files with 884 additions and 6 deletions

View File

@@ -57,6 +57,7 @@ set(DASHBOARDROUTES_SOURCES
"FeaturedProperties.cpp"
"ContrabandRoutes.cpp"
"UgcRoutes.cpp"
"UgcLinks.cpp"
"PropertyRentRoutes.cpp"
"PasswordRecovery.cpp"
"PrometheusMetrics.cpp"

View File

@@ -0,0 +1,332 @@
#include "UgcLinks.h"
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <curl/curl.h>
#include "Database.h"
#include "dConfig.h"
#include "eHTTPMethod.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "NifFile.h"
#include "RouteUtils.h"
#include "TtlCache.h"
#include "UgcLookup.h"
#include "Workers.h"
using namespace RouteUtils;
using namespace UgcLookup;
namespace {
constexpr uint32_t SEARCH_LIMIT = 50;
// Creators whose inventories a search looks in for creations that are not placed or mailed
constexpr size_t MAX_INVENTORIES = 25;
// ---- The UGC server's files, fetched by the dashboard (as the /ugc page's routes do) ----
struct Fetched {
long status{}; // HTTP status, 0 when the UGC server didn't answer
std::string body;
std::string error;
};
std::mutex g_CacheMutex;
TtlCache<std::string, std::shared_ptr<const Fetched>> g_Cache(std::chrono::seconds(15), 32 * 1024 * 1024);
constexpr size_t MAX_FETCH_BYTES = 128 * 1024 * 1024;
size_t Collect(char* data, size_t size, size_t count, void* userData) {
auto* out = static_cast<std::string*>(userData);
if (out->size() + size * count > MAX_FETCH_BYTES) return 0;
out->append(data, size * count);
return size * count;
}
// Web thread
std::string InternalUrl() {
auto url = Game::config->GetValue("ugc_internal_url");
if (url.empty()) url = "http://127.0.0.1:2008";
while (url.ends_with('/')) url.pop_back();
return url;
}
// Any thread
std::shared_ptr<const Fetched> Fetch(const std::string& url) {
{
std::lock_guard lock(g_CacheMutex);
if (auto hit = g_Cache.Get(url)) return *hit;
}
auto out = std::make_shared<Fetched>();
CURL* curl = curl_easy_init();
if (!curl) {
out->error = "could not start a request";
return out;
}
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "http,https");
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DarkflameServer-Dashboard");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, Collect);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out->body);
const auto code = curl_easy_perform(curl);
if (code == CURLE_OK) curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &out->status);
else out->error = curl_easy_strerror(code);
curl_easy_cleanup(curl);
if (out->status == 200 || out->status == 404) {
std::lock_guard lock(g_CacheMutex);
g_Cache.Put(url, out, out->body.size() + 64);
}
return out;
}
void FetchError(HTTPReply& reply, const Fetched& fetched) {
if (fetched.status == 408) return JsonError(reply, eHTTPStatusCode::REQUEST_TIMEOUT, "The UGC server is making it; try again in a moment");
if (fetched.status == 404) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "The UGC server has not made it");
if (fetched.status == 0) return JsonError(reply, eHTTPStatusCode::BAD_GATEWAY, "The UGC server doesn't answer (" + fetched.error + ")");
JsonError(reply, eHTTPStatusCode::BAD_GATEWAY, "The UGC server answered " + std::to_string(fetched.status));
}
// ---- Who may see a creation ----
// The property page's rule (as /api/properties/:id): properties_view, or the owner with own_properties
bool MayViewProperty(const HTTPContext& context, LWOOBJID propertyId) {
if (Can(context, "properties_view")) return true;
const auto info = Database::Get()->GetPropertyInfo(propertyId);
if (!info || !context.isAuthenticated) return false;
const auto owner = Database::Get()->GetCharacterInfo(info->ownerId);
return owner && owner->accountId == context.accountId && Can(context, "own_properties");
}
// The creations in a character's saved inventories
std::vector<ItemLink> CharacterCreations(LWOOBJID characterId) {
const auto refs = InventoryRefs(Database::Get()->GetCharacterXml(characterId));
if (refs.empty()) return {};
std::set<LWOOBJID> models, modular;
for (const auto& entry : Database::Get()->GetUgcEntries(Candidates(refs))) (entry.kind == eUgcKind::MODEL ? models : modular).insert(entry.id);
return LinkItems(refs, models, modular);
}
/**
* Whether the viewer may see what the UGC server made of a creation: with properties_view (as the /ugc page), when
* one of their own characters made it, or through a page they may see that shows it: ?property= (placed there) or
* ?character= (in that character's inventories).
*/
bool MaySee(const HTTPContext& context, eUgcKind kind, LWOOBJID id) {
if (Can(context, "properties_view")) return true;
if (!context.isAuthenticated) return false;
const auto entries = Database::Get()->GetUgcEntries({ id });
const auto entry = std::find_if(entries.begin(), entries.end(), [kind](const auto& e) { return e.kind == kind; });
if (entry == entries.end()) return false;
if (entry->accountId == context.accountId) return true;
if (const auto property = GeneralUtils::TryParse<LWOOBJID>(QueryValue(context.queryString, "property"))) {
if (!MayViewProperty(context, *property)) return false;
for (const auto& placement : Database::Get()->GetUgcPlacements({ id })) {
if (placement.propertyId == *property) return true;
}
}
if (const auto character = GeneralUtils::TryParse<LWOOBJID>(QueryValue(context.queryString, "character"))) {
const auto info = Database::Get()->GetCharacterInfo(*character);
if (!info || !CanViewCharacter(context, info->accountId)) return false;
for (const auto& link : CharacterCreations(*character)) {
if (link.kind == kind && link.ugcId == id) return true;
}
}
return false;
}
// The icon's address; `via` is the page's ?property= or ?character= that lets its viewer see it
std::string IconUrl(eUgcKind kind, LWOOBJID id, const std::string& via) {
return std::string("/api/ugc_links/icon/") + KindName(kind) + "/" + std::to_string(id) + (via.empty() ? "" : "?" + via);
}
nlohmann::json EntryJson(const IUgcLookup::UgcEntry& entry, const std::string& via, bool viewer) {
return {
{ "kind", KindName(entry.kind) }, { "ugcId", std::to_string(entry.id) }, { "state", StateName(entry.state) }, { "error", entry.error },
{ "icon", IconUrl(entry.kind, entry.id, via) }, { "link", viewer ? nlohmann::json(ViewerLink(entry.kind, entry.id)) : nlohmann::json() }
};
}
}
namespace UgcLinks {
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) {
RenderPage(reply, context, "ugc-search.jinja2", "ugc_search", { { "query", QueryValue(context.queryString, "q").substr(0, 100) } });
});
Route(eHTTPMethod::GET, "/api/ugc_links/search", Perm("properties_view"),
"Players' creations matching ?q=: a number matches the ugc / blueprint id, a placed model's object id, the property, creator "
"character or account id, or a LOT (a modular build's modules); text matches the creator's character or account name, a "
"property name, the name or description given to a placed model, or the upload's file name. \"field: text\" searches one of "
"id, owner, property, model, lot. {items: [{kind, ugcId, state, error, detail, characterId, characterName, accountId, accountName, "
"icon, link, where: [{type: property|mail|inventory, ...}]}]}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto search = ParseQuery(QueryValue(context.queryString, "q"));
if (search.text.empty() && !search.number) return JsonSuccess(reply, { { "items", nlohmann::json::array() } });
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 } });
}
}
nlohmann::json items = nlohmann::json::array();
for (const auto& entry : entries) {
auto item = EntryJson(entry, "", true);
item["detail"] = entry.detail;
item["characterId"] = std::to_string(entry.characterId);
item["characterName"] = entry.characterName;
item["accountId"] = entry.accountId;
item["accountName"] = entry.accountName;
const auto found = where.find({ entry.kind, entry.id });
item["where"] = found == where.end() ? nlohmann::json::array() : found->second;
items.push_back(std::move(item));
}
// The newest of both kinds first (ids are handed out in order)
std::stable_sort(items.begin(), items.end(), [](const auto& a, const auto& b) {
return GeneralUtils::TryParse<LWOOBJID>(a["ugcId"].template get<std::string>()).value_or(0) > GeneralUtils::TryParse<LWOOBJID>(b["ugcId"].template get<std::string>()).value_or(0);
});
JsonSuccess(reply, { { "items", items } });
});
Route(eHTTPMethod::GET, "/api/ugc_links/property/:id", 0,
"The player-built models placed on a property, with what the UGC server made of them: {items: [{modelId, kind, ugcId, state, error, "
"icon, mesh, link}]}. Whoever may view the property (properties_view, or its owner with own_properties)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto propertyId = PathId<LWOOBJID>(context.path, 3);
if (!propertyId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid property id");
if (!Database::Get()->GetPropertyInfo(*propertyId)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Property not found");
if (!MayViewProperty(context, *propertyId)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own properties");
std::map<LWOOBJID, std::vector<LWOOBJID>> placed; // ugc id -> placed models
for (const auto& model : Database::Get()->GetPropertyModels(*propertyId)) {
if (model.ugcId != 0) placed[model.ugcId].push_back(model.id);
}
std::vector<LWOOBJID> ids;
for (const auto& [id, _] : placed) ids.push_back(id);
const auto via = "property=" + std::to_string(*propertyId);
const bool viewer = Can(context, "properties_view");
nlohmann::json items = nlohmann::json::array();
for (const auto& entry : Database::Get()->GetUgcEntries(ids)) {
for (const auto modelId : placed[entry.id]) {
auto item = EntryJson(entry, via, viewer);
item["modelId"] = std::to_string(modelId);
if (entry.kind == eUgcKind::MODEL && entry.state == IUgc::eProcessState::DONE) {
item["mesh"] = "/api/ugc_links/mesh/" + std::to_string(entry.id) + "?" + via;
}
items.push_back(std::move(item));
}
}
JsonSuccess(reply, { { "items", items } });
});
Route(eHTTPMethod::GET, "/api/ugc_links/character/:id", 0,
"The creations (models, cars, rockets) in a character's saved inventories: {items: [{itemId, lot, inventory, kind, ugcId, state, error, icon, link}]}. "
"Whoever may view the character",
[](HTTPReply& reply, const HTTPContext& context) {
const auto characterId = PathId<LWOOBJID>(context.path, 3);
const auto info = characterId ? Database::Get()->GetCharacterInfo(*characterId) : std::nullopt;
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
if (!CanViewCharacter(context, info->accountId)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own characters");
const auto links = CharacterCreations(*characterId);
std::vector<LWOOBJID> ids;
for (const auto& link : links) ids.push_back(link.ugcId);
std::map<std::pair<eUgcKind, LWOOBJID>, IUgcLookup::UgcEntry> entries;
for (auto& entry : Database::Get()->GetUgcEntries(ids)) entries[{ entry.kind, entry.id }] = std::move(entry);
const auto via = "character=" + std::to_string(*characterId);
const bool viewer = Can(context, "properties_view");
nlohmann::json items = nlohmann::json::array();
for (const auto& link : links) {
const auto entry = entries.find({ link.kind, link.ugcId });
if (entry == entries.end()) continue;
auto item = EntryJson(entry->second, via, viewer);
item["itemId"] = std::to_string(link.item.itemId);
item["lot"] = link.item.lot;
item["inventory"] = link.item.inventory;
items.push_back(std::move(item));
}
JsonSuccess(reply, { { "items", items } });
});
Route(eHTTPMethod::GET, "/api/ugc_links/icon/:kind/:id", 0,
"The icon the UGC server made of a creation (kind model or modular), fetched from ugc_internal_url. With properties_view, for your own "
"characters' creations, or with ?property= / ?character= naming a page you may view that shows it. 404 until it is made",
[](HTTPReply& reply, const HTTPContext& context) {
const auto kind = ParseKind(PathSegment(context.path, 3));
const auto id = PathId<LWOOBJID>(context.path, 4);
if (!kind || !id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid kind or id");
if (!MaySee(context, *kind, *id)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may not view this creation");
const auto url = InternalUrl() + "/files/" + KindName(*kind) + "/" + std::to_string(*id) + "/icon.png";
Workers::Reply(reply, context, false, [url](HTTPReply& out) {
const auto fetched = Fetch(url);
if (fetched->status != 200) return FetchError(out, *fetched);
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::IMAGE_PNG;
out.message = fetched->body;
out.headers.push_back("Cache-Control: private, max-age=60");
}, WorkerPool::ePriority::URGENT);
});
Route(eHTTPMethod::GET, "/api/ugc_links/mesh/:id", 0,
"The .nif the UGC server made of a player-built model, converted for the 3D view (as /api/ugc/mesh/:id). ?lod=0 (most detailed) to 3. "
"Who may see it as /api/ugc_links/icon",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = PathId<LWOOBJID>(context.path, 3);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
if (!MaySee(context, eUgcKind::MODEL, *id)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may not view this creation");
const auto lod = std::min(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "lod")).value_or(0), 3u);
const auto url = InternalUrl() + "/files/model/" + std::to_string(*id) + "/model.nif";
Workers::Reply(reply, context, false, [url, lod](HTTPReply& out) {
const auto fetched = Fetch(url);
if (fetched->status != 200) return FetchError(out, *fetched);
std::string error;
const auto model = NifFile::Parse(fetched->body, lod, error);
if (!model) return JsonError(out, eHTTPStatusCode::UNPROCESSABLE_ENTITY, "The .nif can't be read: " + error);
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_OCTET_STREAM;
out.message = NifFile::Encode(*model, std::vector<std::string>(model->meshes.size()));
out.headers.push_back("Cache-Control: private, max-age=60");
});
});
}
}

View File

@@ -0,0 +1,7 @@
#pragma once
// 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();
}

View File

@@ -0,0 +1,181 @@
#pragma once
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <string_view>
#include <vector>
#include "tinyxml2.h"
#include "dCommonVars.h"
#include "GeneralUtils.h"
#include "IUgcLookup.h"
#include "LDFFormat.h"
/**
* Finding players' creations (brick built models and modular cars and rockets) for the dashboard: reading a search,
* finding which of a character's items are creations, and in which mail one is. Pure (no database), so it can be
* unit tested; routes/UgcLinks.cpp runs it.
*
* A creation shows up as:
* - a placed model (properties_contents.ugc_id): LOT 14 for a brick built model, its ugc_id the blueprint id
* - a brick built model item (LOT 6662): its blueprint id in the item's config (blueprintid, saved as x@bp)
* - a car or rocket item (any LOT): its subkey is the ugc_modular_build id
* - a mail attachment: the same, with the config as LDF lines (attachment_config) and the subkey in attachment_subkey
*/
namespace UgcLookup {
using eUgcKind = IUgcLookup::eUgcKind;
using UgcSearch = IUgcLookup::UgcSearch;
constexpr LOT MODEL_OBJECT_LOT = 14;
constexpr LOT MODEL_ITEM_LOT = 6662;
inline const char* KindName(eUgcKind kind) { return kind == eUgcKind::MODULAR ? "modular" : "model"; }
inline std::optional<eUgcKind> ParseKind(std::string_view text) {
if (text == "model") return eUgcKind::MODEL;
if (text == "modular") return eUgcKind::MODULAR;
return std::nullopt;
}
inline const char* StateName(IUgc::eProcessState state) {
switch (state) {
case IUgc::eProcessState::PENDING: return "pending";
case IUgc::eProcessState::DONE: return "done";
case IUgc::eProcessState::FAILED: return "failed";
}
return "unknown";
}
// Where the /ugc page shows a creation: /ugc?item=<id>&kind=model|modular (the page may read these to open it)
inline std::string ViewerLink(eUgcKind kind, LWOOBJID id) {
return "/ugc?item=" + std::to_string(id) + "&kind=" + KindName(kind);
}
inline std::string_view Trim(std::string_view text) {
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.front()))) text.remove_prefix(1);
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.back()))) text.remove_suffix(1);
return text;
}
/**
* A search box's text: a number is matched against ids (and LOTs), anything else against names. "field: text"
* searches one field: id, owner (or character, account), property, model (or name), lot.
*/
inline UgcSearch ParseQuery(std::string_view input) {
UgcSearch search;
auto text = std::string(Trim(input));
const auto colon = text.find(':');
if (colon != std::string::npos) {
auto key = text.substr(0, colon);
std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
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 },
};
const auto field = FIELDS.find(key);
if (field != FIELDS.end()) {
search.field = field->second;
text = std::string(Trim(std::string_view(text).substr(colon + 1)));
}
}
search.number = GeneralUtils::TryParse<int64_t>(text);
if (search.number && *search.number < 0) search.number.reset();
// Ids and LOTs are only numbers (anything else is searched as a name); a name may be all digits, so elsewhere a
// number is also matched as text
const bool numeric = search.field == UgcSearch::eField::ID || search.field == UgcSearch::eField::LOT;
if (numeric && !search.number) search.field = UgcSearch::eField::ANY;
if (!numeric || !search.number) search.text = text.substr(0, 64);
return search;
}
// An object id kept in an item's saved config: "9:<id>" (LDF type and value) or just the number
inline LWOOBJID ConfigId(const char* value) {
if (!value) return LWOOBJID_EMPTY;
std::string_view text(value);
const auto colon = text.rfind(':');
if (colon != std::string_view::npos) text = text.substr(colon + 1);
return GeneralUtils::TryParse<LWOOBJID>(text).value_or(LWOOBJID_EMPTY);
}
// An item in a character's saved inventories that may be a creation
struct ItemRef {
LWOOBJID itemId{};
LOT lot{};
uint32_t inventory{};
LWOOBJID subkey{}; // a modular build's id
LWOOBJID blueprint{}; // a brick built model's blueprint (ugc) id
};
// Every item of a character's saved XML with a subkey or a blueprint
inline std::vector<ItemRef> InventoryRefs(const std::string& xml) {
std::vector<ItemRef> refs;
tinyxml2::XMLDocument doc;
if (xml.empty() || doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) return refs;
const auto* obj = doc.FirstChildElement("obj");
const auto* inv = obj ? obj->FirstChildElement("inv") : nullptr;
const auto* items = inv ? inv->FirstChildElement("items") : nullptr;
for (const auto* in = items ? items->FirstChildElement("in") : nullptr; in; in = in->NextSiblingElement("in")) {
for (const auto* i = in->FirstChildElement("i"); i; i = i->NextSiblingElement("i")) {
ItemRef ref;
ref.itemId = i->Int64Attribute("id", 0);
ref.lot = i->IntAttribute("l", 0);
ref.inventory = static_cast<uint32_t>(in->IntAttribute("t", 0));
ref.subkey = i->Int64Attribute("sk", 0);
if (const auto* x = i->FirstChildElement("x")) {
ref.blueprint = ConfigId(x->Attribute("bp"));
if (ref.blueprint == LWOOBJID_EMPTY) ref.blueprint = ConfigId(x->Attribute("b"));
}
if (ref.subkey != LWOOBJID_EMPTY || ref.blueprint != LWOOBJID_EMPTY) refs.push_back(ref);
}
}
return refs;
}
// The ids that InventoryRefs' items may be creations by
inline std::vector<LWOOBJID> Candidates(const std::vector<ItemRef>& refs) {
std::set<LWOOBJID> ids;
for (const auto& ref : refs) {
if (ref.blueprint != LWOOBJID_EMPTY) ids.insert(ref.blueprint);
if (ref.subkey != LWOOBJID_EMPTY) ids.insert(ref.subkey);
}
return { ids.begin(), ids.end() };
}
struct ItemLink {
ItemRef item;
eUgcKind kind{};
LWOOBJID ugcId{};
};
// Which items are creations, given the ids that are models and those that are modular builds. A model item goes by
// its blueprint (its subkey is the model's own id); anything else by its subkey.
inline std::vector<ItemLink> LinkItems(const std::vector<ItemRef>& refs, const std::set<LWOOBJID>& models, const std::set<LWOOBJID>& modular) {
std::vector<ItemLink> links;
for (const auto& ref : refs) {
if (ref.blueprint != LWOOBJID_EMPTY && models.contains(ref.blueprint)) links.push_back({ ref, eUgcKind::MODEL, ref.blueprint });
else if (ref.subkey != LWOOBJID_EMPTY && modular.contains(ref.subkey)) links.push_back({ ref, eUgcKind::MODULAR, ref.subkey });
}
return links;
}
// The creation a mail attachment is: a model item by the blueprint in its config (LDF lines), else by its subkey
inline std::optional<std::pair<eUgcKind, LWOOBJID>> MailCreation(const IUgcLookup::UgcMail& mail, const std::set<LWOOBJID>& models, const std::set<LWOOBJID>& modular) {
if (mail.lot == MODEL_ITEM_LOT && !mail.config.empty()) {
LwoNameValue config;
config.InsertLines(mail.config);
const auto it = config.find(u"blueprintid");
if (it != config.end() && it->second) {
const auto blueprint = GeneralUtils::TryParse<LWOOBJID>(it->second->GetValueAsString()).value_or(LWOOBJID_EMPTY);
if (models.contains(blueprint)) return std::make_pair(eUgcKind::MODEL, blueprint);
}
}
if (mail.subkey != LWOOBJID_EMPTY && modular.contains(mail.subkey)) return std::make_pair(eUgcKind::MODULAR, mail.subkey);
return std::nullopt;
}
}