mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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:
@@ -74,6 +74,7 @@
|
||||
#include "Showcase.h"
|
||||
#include "ContrabandRoutes.h"
|
||||
#include "UgcRoutes.h"
|
||||
#include "UgcLinks.h"
|
||||
#include "PropertyRentRoutes.h"
|
||||
#include "FeaturedProperties.h"
|
||||
#include "PasswordRecovery.h"
|
||||
@@ -546,6 +547,7 @@ int main(int argc, char** argv) {
|
||||
FeaturedProperties::RegisterRoutes();
|
||||
ContrabandRoutes::RegisterRoutes();
|
||||
UgcRoutes::RegisterRoutes();
|
||||
UgcLinks::RegisterRoutes();
|
||||
PropertyRentRoutes::RegisterRoutes();
|
||||
RegisterPasswordRecoveryRoutes();
|
||||
RegisterWSRoutes();
|
||||
|
||||
@@ -57,6 +57,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"FeaturedProperties.cpp"
|
||||
"ContrabandRoutes.cpp"
|
||||
"UgcRoutes.cpp"
|
||||
"UgcLinks.cpp"
|
||||
"PropertyRentRoutes.cpp"
|
||||
"PasswordRecovery.cpp"
|
||||
"PrometheusMetrics.cpp"
|
||||
|
||||
332
dDashboardServer/routes/UgcLinks.cpp
Normal file
332
dDashboardServer/routes/UgcLinks.cpp
Normal 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");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
7
dDashboardServer/routes/UgcLinks.h
Normal file
7
dDashboardServer/routes/UgcLinks.h
Normal 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();
|
||||
}
|
||||
181
dDashboardServer/routes/UgcLookup.h
Normal file
181
dDashboardServer/routes/UgcLookup.h
Normal 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;
|
||||
}
|
||||
}
|
||||
60
dDashboardServer/static/js/ugc-links.js
Normal file
60
dDashboardServer/static/js/ugc-links.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Players' creations on the property and character pages: the icon the UGC server made of a model, car or rocket in
|
||||
* place of the item's own icon, its state and a link to it on the /ugc page (for those who may open it). The routes
|
||||
* (/api/ugc_links/...) let whoever may view the page see the icons of the creations on it.
|
||||
*/
|
||||
(function () {
|
||||
var STATES = { done: ['Made', 'success'], pending: ['Waiting', 'secondary'], failed: ['Failed', 'danger'] };
|
||||
|
||||
window.UgcLinks = {
|
||||
// A badge for the UGC server's state; a failure's reason as its tooltip
|
||||
state: function (item) {
|
||||
var s = STATES[item.state] || [item.state, 'secondary'];
|
||||
return '<span class="badge text-bg-' + s[1] + '"' + (item.error ? ' title="' + esc(item.error) + '"' : '') + '>' + esc(s[0]) + '</span>';
|
||||
},
|
||||
// An <img> of the creation's icon that falls back to the item's own icon until the UGC server made one
|
||||
icon: function (item, lot, size) {
|
||||
size = size || 40;
|
||||
return '<img src="' + esc(item.icon) + '" width="' + size + '" height="' + size + '" class="rounded bg-body-tertiary" alt="" loading="lazy" ' +
|
||||
'onerror="this.onerror=null;this.src=\'/api/icon/' + esc(lot) + '\'">';
|
||||
},
|
||||
// {modelId: item} for the player-built models on a property
|
||||
forProperty: function (propertyId) {
|
||||
return api.get('/api/ugc_links/property/' + propertyId).then(function (d) {
|
||||
var map = {};
|
||||
((d && d.items) || []).forEach(function (item) { map[item.modelId] = item; });
|
||||
return map;
|
||||
}).catch(function () { return {}; });
|
||||
},
|
||||
// Shows the creations among a character page's items (.inv-item[data-id]) with their own icon and a link
|
||||
decorateInventory: function (characterId, root) {
|
||||
return api.get('/api/ugc_links/character/' + characterId).then(function (d) {
|
||||
((d && d.items) || []).forEach(function (item) {
|
||||
(root || document).querySelectorAll('.inv-item[data-id="' + item.itemId + '"]').forEach(function (el) {
|
||||
var img = el.querySelector('img');
|
||||
if (img) {
|
||||
var own = img.src;
|
||||
img.onerror = function () { img.onerror = null; img.src = own; };
|
||||
img.src = item.icon;
|
||||
}
|
||||
el.classList.add('inv-ugc');
|
||||
el.title = (el.title ? el.title + ' - ' : '') + 'Player-built ' + (item.kind === 'modular' ? 'car or rocket' : 'model') + ' (UGC ' + item.ugcId + ', ' + item.state + ')';
|
||||
if (item.link && !el.querySelector('.inv-ugc-link')) {
|
||||
// Items may sit inside a link already (the item trace), so this is a button, not a nested link
|
||||
var a = document.createElement('span');
|
||||
a.className = 'inv-ugc-link';
|
||||
a.setAttribute('role', 'link');
|
||||
a.tabIndex = 0;
|
||||
a.textContent = 'UGC';
|
||||
a.title = 'Open in the UGC viewer';
|
||||
var open = function (e) { e.preventDefault(); e.stopPropagation(); window.location.href = item.link; };
|
||||
a.addEventListener('click', open);
|
||||
a.addEventListener('keydown', function (e) { if (e.key === 'Enter') open(e); });
|
||||
el.appendChild(a);
|
||||
}
|
||||
});
|
||||
});
|
||||
}).catch(function () {});
|
||||
}
|
||||
};
|
||||
})();
|
||||
66
dDashboardServer/static/js/ugc-search.js
Normal file
66
dDashboardServer/static/js/ugc-search.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The UGC search (/ugc_search): players' creations by name, id or LOT and where each one is (/api/ugc_links/search).
|
||||
* ?q= fills the search in; the address keeps the last search so it can be shared.
|
||||
*/
|
||||
(function () {
|
||||
var input = document.getElementById('ugcSearchInput');
|
||||
var rows = document.getElementById('ugcSearchRows');
|
||||
var status = document.getElementById('ugcSearchStatus');
|
||||
var latest = 0, timer = null;
|
||||
var INVENTORIES = { 5: 'Models', 12: 'Vault models', 14: 'Brick-building models' };
|
||||
|
||||
function inventoryName(type) { return (window.Labels && Labels.name('inventories', type)) || INVENTORIES[type] || 'Inventory ' + type; }
|
||||
|
||||
function where(item) {
|
||||
if (!item.where.length) {
|
||||
return '<span class="text-body-secondary small" title="Not placed on a property, not in the mail and not in its creator\'s inventories: traded, sold or deleted">Not found</span>';
|
||||
}
|
||||
return item.where.map(function (w) {
|
||||
if (w.type === 'property') {
|
||||
// A model the player didn't name has the client's placeholder (Objects_<lot>_name)
|
||||
var given = w.modelName && !/^Objects_\d+_name$/.test(w.modelName);
|
||||
var name = given ? ' as <span class="text-break">' + esc(w.modelName) + '</span>' : '';
|
||||
return '<div>On ' + fmt.link('/properties/' + w.propertyId, w.propertyName || 'Property ' + w.propertyId) + name +
|
||||
' <span class="small text-body-secondary">(' + fmt.character(w.ownerId, w.ownerName) + ', ' + esc(w.zoneId) + ')</span>' +
|
||||
' <a class="small" href="/properties/' + esc(w.propertyId) + '/3d#model=' + esc(w.modelId) + '">3D</a></div>';
|
||||
}
|
||||
if (w.type === 'mail') return '<div>In the mail to ' + fmt.character(w.characterId, w.characterName) + '</div>';
|
||||
return '<div>In ' + fmt.character(w.characterId, item.characterName) + '\'s ' + esc(inventoryName(w.inventory)) + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
rows.innerHTML = items.length ? items.map(function (item) {
|
||||
var lot = item.kind === 'modular' ? 6416 : 6662;
|
||||
var label = item.kind === 'modular' ? 'Car or rocket' : 'Model';
|
||||
var title = item.link ? '<a href="' + esc(item.link) + '">' + label + ' ' + esc(item.ugcId) + '</a>' : label + ' ' + esc(item.ugcId);
|
||||
var detail = item.detail ? '<div class="small text-body-secondary text-break">' + (item.kind === 'modular' ? 'Modules <code>' + esc(item.detail) + '</code>' : esc(item.detail)) + '</div>' : '';
|
||||
var account = item.accountName ? '<div class="small text-body-secondary">' + (document.getElementById('ugcSearchPage').dataset.canAccounts ? fmt.link('/accounts/' + item.accountId, item.accountName) : esc(item.accountName)) + '</div>' : '';
|
||||
return '<tr><td data-label="">' + UgcLinks.icon(item, lot, 48) + '</td><td data-label="Creation">' + title + detail + '</td>' +
|
||||
'<td data-label="Made by">' + fmt.character(item.characterId, item.characterName) + account + '</td>' +
|
||||
'<td data-label="Where">' + where(item) + '</td><td data-label="UGC server">' + UgcLinks.state(item) + '</td></tr>';
|
||||
}).join('') : '<tr><td colspan="5" class="text-body-secondary">Nothing matches.</td></tr>';
|
||||
}
|
||||
|
||||
function search() {
|
||||
var q = input.value.trim();
|
||||
var request = ++latest;
|
||||
var url = new URL(window.location.href);
|
||||
if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');
|
||||
history.replaceState(null, '', url);
|
||||
if (!q) { rows.innerHTML = '<tr><td colspan="5" class="text-body-secondary">Type something to search.</td></tr>'; status.textContent = ''; return; }
|
||||
status.textContent = 'Searching…';
|
||||
api.get('/api/ugc_links/search?q=' + encodeURIComponent(q)).then(function (d) {
|
||||
if (request !== latest) return;
|
||||
var items = (d && d.items) || [];
|
||||
status.textContent = items.length + ' found' + (items.length >= 50 ? ' (the newest 50 of each kind; narrow the search for more)' : '');
|
||||
render(items);
|
||||
}).catch(function () { if (request === latest) status.textContent = 'The search failed.'; });
|
||||
}
|
||||
|
||||
input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(search, 300); });
|
||||
document.getElementById('ugcSearchForm').addEventListener('submit', function (e) { e.preventDefault(); clearTimeout(timer); search(); });
|
||||
input.value = document.getElementById('ugcSearchPage').dataset.query || '';
|
||||
if (input.value) search();
|
||||
input.focus();
|
||||
})();
|
||||
@@ -38,6 +38,20 @@
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* A player's creation (ugc-links.js): the UGC server's icon, and a link to it in the UGC viewer */
|
||||
.inv-item.inv-ugc { border-color: var(--bs-info-border-subtle); }
|
||||
.inv-ugc-link {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
color: var(--bs-info-text-emphasis);
|
||||
background: var(--bs-info-bg-subtle);
|
||||
}
|
||||
.inv-ugc-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -259,7 +273,7 @@
|
||||
<div class="d-flex flex-wrap">
|
||||
{% for item in inv.items %}
|
||||
{% if can.reports_view and item.id != "" %}<a href="/reports?object={{ item.id }}" class="text-reset text-decoration-none" aria-label="Trace {{ item.name }}">{% endif %}
|
||||
<div class="inv-item{% if item.equipped %}{{ " equipped" }}{% endif %}" data-lot="{{ item.lot }}" data-count="{{ item.count }}" data-equipped="{% if item.equipped %}1{% endif %}" data-bound="{% if item.bound %}1{% endif %}" title="{{ item.name }}">
|
||||
<div class="inv-item{% if item.equipped %}{{ " equipped" }}{% endif %}" data-id="{{ item.id }}" data-lot="{{ item.lot }}" data-count="{{ item.count }}" data-equipped="{% if item.equipped %}1{% endif %}" data-bound="{% if item.bound %}1{% endif %}" title="{{ item.name }}">
|
||||
<img src="/api/icon/{{ item.lot }}" alt="{{ item.name }}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2264%22 height=%2264%22><rect fill=%22%23333%22 width=%2264%22 height=%2264%22/><text x=%2232%22 y=%2236%22 fill=%22%23888%22 text-anchor=%22middle%22 font-size=%2210%22>{{ item.lot }}</text></svg>'">
|
||||
{% if item.count > 1 %}<span class="inv-count">{% if item.count > 999 %}999+{% else %}{{ item.count }}{% endif %}</span>{% endif %}
|
||||
{% if item.bound %}<span class="inv-lock">L</span>{% endif %}
|
||||
@@ -479,6 +493,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
var inventory = document.getElementById('inventoryModal');
|
||||
if (inventory) inventory.addEventListener('show.bs.modal', loadItemTooltips, { once: true });
|
||||
// Models, cars and rockets the player built show the UGC server's icon of them
|
||||
if (inventory) inventory.addEventListener('show.bs.modal', function () { UgcLinks.decorateInventory(characterId, inventory); }, { once: true });
|
||||
});
|
||||
|
||||
var characterId = document.getElementById('restrictions').getAttribute('data-character-id');
|
||||
@@ -633,6 +649,7 @@ function rescueCharacter() {
|
||||
}
|
||||
</script>
|
||||
<script src="/js/rescue.js"></script>
|
||||
<script src="/js/ugc-links.js"></script>
|
||||
<script src="/js/character-tools.js"></script>
|
||||
<script src="/js/character-missions.js"></script>
|
||||
<script src="/js/related.js"></script>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{% if can.accounts_view %}<a href="/accounts" class="list-group-item list-group-item-action{% if current_page == "accounts" %} active{% endif %}">Accounts</a>{% endif %}
|
||||
{% if can.characters_view %}<a href="/characters" class="list-group-item list-group-item-action{% if current_page == "characters" %} active{% endif %}">Characters</a>{% endif %}
|
||||
{% if can.properties_view %}<a href="/properties" class="list-group-item list-group-item-action{% if current_page == "properties" %} active{% endif %}">Properties</a>{% endif %}
|
||||
{% if can.properties_view %}<a href="/ugc_search" class="list-group-item list-group-item-action{% if current_page == "ugc_search" %} active{% endif %}">UGC Search</a>{% endif %}
|
||||
</div>
|
||||
|
||||
{% set inboxPages = ["moderation", "player_reports", "bug_reports"] %}
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/ugc-links.js"></script>
|
||||
<script type="module">
|
||||
const propertyId = document.getElementById('property').getAttribute('data-property-id');
|
||||
const UGC_LOT = 14;
|
||||
@@ -183,7 +184,9 @@ function renderDetails(p) {
|
||||
|
||||
let loaded = null; // the property as last loaded, for the reject dialog
|
||||
function load() {
|
||||
api.get('/api/properties/' + propertyId).then((p) => {
|
||||
// The UGC server's icon and state of each player-built model come with the property
|
||||
Promise.all([api.get('/api/properties/' + propertyId), UgcLinks.forProperty(propertyId)]).then(([p, ugc]) => {
|
||||
(p.models || []).forEach((m) => { m.ugc = ugc[m.id] || null; });
|
||||
if (p.success === false) { setText('propName', p.error); return; }
|
||||
loaded = p;
|
||||
setText('propName', p.name || 'Unnamed property');
|
||||
@@ -208,13 +211,21 @@ function load() {
|
||||
});
|
||||
}
|
||||
|
||||
// What the UGC server made of it, and a link to it in the UGC viewer (for those who may open it)
|
||||
function ugcState(m) {
|
||||
if (!m.ugc) return '';
|
||||
return '<div class="small mt-1">UGC server: ' + UgcLinks.state(m.ugc) + (m.ugc.link ? ' <a href="' + esc(m.ugc.link) + '">Open in UGC viewer</a>' : '') + '</div>';
|
||||
}
|
||||
|
||||
const labels = $('#modelsTable thead th').map(function () { return $(this).text().trim(); }).get();
|
||||
const columns = [
|
||||
{ data: null, render: (m, type) => {
|
||||
const given = playerName(m.model_name);
|
||||
if (type !== 'display') return (given || m.name) + ' ' + (m.model_description || '');
|
||||
return (given ? '<div class="fw-semibold text-break">' + esc(given) + '</div><div class="small text-body-secondary">' + esc(m.name) + '</div>' : '<div>' + esc(m.name) + '</div>') +
|
||||
(m.model_description ? '<div class="model-desc">' + esc(m.model_description) + '</div>' : '');
|
||||
// A player-built model's icon is the one the UGC server made of it (the item's icon until it has)
|
||||
const icon = m.ugc ? UgcLinks.icon(m.ugc, m.lot) : '<img src="/api/icon/' + esc(m.lot) + '" width="40" height="40" class="rounded" alt="" loading="lazy">';
|
||||
return '<div class="d-flex align-items-center gap-2"><div class="flex-shrink-0">' + icon + '</div><div class="min-w-0">' + (given ? '<div class="fw-semibold text-break">' + esc(given) + '</div><div class="small text-body-secondary">' + esc(m.name) + '</div>' : '<div>' + esc(m.name) + '</div>') +
|
||||
(m.model_description ? '<div class="model-desc">' + esc(m.model_description) + '</div>' : '') + '</div></div>';
|
||||
} },
|
||||
{ data: 'lot' },
|
||||
{ data: 'behaviors', render: (d, type) => type === 'display' ? (d.length ? d.length + ': ' + d.map((b) => esc(b.name || 'Behavior')).join(', ') : muted('-')) : d.length },
|
||||
@@ -225,13 +236,13 @@ const columns = [
|
||||
{ data: null, render: (m, type) => {
|
||||
if (m.lxfml_size === null || m.lxfml_size === undefined) {
|
||||
if (type !== 'display') return m.ldf_config || '';
|
||||
return m.ldf_config ? '<span class="small">Modular: <code>' + esc(m.ldf_config) + '</code></span>' : muted(m.lot === UGC_LOT ? 'Missing' : 'Prebuilt');
|
||||
return m.ldf_config ? '<span class="small">Modular: <code>' + esc(m.ldf_config) + '</code></span>' + ugcState(m) : muted(m.lot === UGC_LOT ? 'Missing' : 'Prebuilt');
|
||||
}
|
||||
if (type !== 'display') return m.lxfml_size;
|
||||
return '<span class="small">' + esc(bytes(m.lxfml_size)) + '</span> ' +
|
||||
(m.is_optimized ? fmt.badge('Optimized', 'info') + ' ' : '') + (m.bake_ao ? fmt.badge('Baked AO', 'secondary') + ' ' : '') +
|
||||
(m.ldf_config ? '<div class="small">Modular: <code>' + esc(m.ldf_config) + '</code></div>' : '') +
|
||||
'<div class="small text-body-secondary">UGC ' + esc(m.ugc_id) + (m.filename ? ' · ' + esc(m.filename) : '') + '</div>';
|
||||
'<div class="small text-body-secondary">UGC ' + esc(m.ugc_id) + (m.filename ? ' · ' + esc(m.filename) : '') + '</div>' + ugcState(m);
|
||||
} },
|
||||
{ data: 'position', orderable: false, render: (d) => '<span class="small text-nowrap">' + d.map((v) => v.toFixed(1)).join(', ') + '</span>' },
|
||||
{ data: 'id', orderable: false, className: 'actions', render: (d) =>
|
||||
|
||||
35
dDashboardServer/templates/ugc-search.jinja2
Normal file
35
dDashboardServer/templates/ugc-search.jinja2
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends "base.jinja2" %}
|
||||
|
||||
{% block title %}UGC Search - DarkflameServer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="ugcSearchPage" data-query="{{ query }}" data-can-accounts="{% if can.accounts_view %}1{% endif %}">
|
||||
<div class="mb-3">
|
||||
<h2 class="mb-1">UGC Search</h2>
|
||||
<p class="text-body-secondary mb-0">Find the models, cars and rockets players built, what the UGC server made of them and where they are.
|
||||
A number finds ids (the UGC / blueprint id, a placed model's object id, a property, character or account id) and LOTs;
|
||||
text finds creators' character and account names, property names and the names players gave their models.
|
||||
Search one field with <code>owner:</code>, <code>property:</code>, <code>model:</code>, <code>id:</code> or <code>lot:</code>.</p>
|
||||
</div>
|
||||
<form class="mb-3" id="ugcSearchForm" role="search">
|
||||
<div class="input-group">
|
||||
<input type="search" class="form-control" id="ugcSearchInput" placeholder="Name, id or LOT" aria-label="Search players' creations" autocomplete="off" maxlength="100">
|
||||
<button class="btn btn-primary" type="submit">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="ugcSearchStatus" class="text-body-secondary small mb-2"></div>
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0 table-stack">
|
||||
<thead><tr><th></th><th>Creation</th><th>Made by</th><th>Where</th><th>UGC server</th></tr></thead>
|
||||
<tbody id="ugcSearchRows"><tr><td colspan="5" class="text-body-secondary">Type something to search.</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/ugc-links.js"></script>
|
||||
<script src="/js/ugc-search.js"></script>
|
||||
{% endblock %}
|
||||
@@ -39,6 +39,7 @@ set(DWEBTESTS_SOURCES
|
||||
"DeferredReplyTests.cpp"
|
||||
"WorkerPoolTests.cpp"
|
||||
"OnceCacheTests.cpp"
|
||||
"UgcLookupTests.cpp"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/WorkerPool.cpp"
|
||||
"BackupFilesTests.cpp"
|
||||
"LogBundleTests.cpp"
|
||||
|
||||
164
tests/dWebTests/UgcLookupTests.cpp
Normal file
164
tests/dWebTests/UgcLookupTests.cpp
Normal file
@@ -0,0 +1,164 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <set>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include "UgcLookup.h"
|
||||
#include "UgcLookupSql.h"
|
||||
|
||||
using namespace UgcLookup;
|
||||
using eField = UgcSearch::eField;
|
||||
|
||||
TEST(UgcLookupTests, ParsesSearches) {
|
||||
auto s = ParseQuery(" 1152921504606954530 ");
|
||||
EXPECT_EQ(s.field, eField::ANY);
|
||||
EXPECT_EQ(s.number, 1152921504606954530);
|
||||
EXPECT_EQ(s.text, "1152921504606954530"); // a name may be all digits
|
||||
|
||||
s = ParseQuery("Bob");
|
||||
EXPECT_EQ(s.field, eField::ANY);
|
||||
EXPECT_FALSE(s.number);
|
||||
EXPECT_EQ(s.text, "Bob");
|
||||
|
||||
s = ParseQuery("Owner: Bob");
|
||||
EXPECT_EQ(s.field, eField::OWNER);
|
||||
EXPECT_EQ(s.text, "Bob");
|
||||
|
||||
s = ParseQuery("lot:8129");
|
||||
EXPECT_EQ(s.field, eField::LOT);
|
||||
EXPECT_EQ(s.number, 8129);
|
||||
EXPECT_TRUE(s.text.empty());
|
||||
|
||||
// An id field with no number is searched as text; an unknown field is part of the text
|
||||
s = ParseQuery("id: abc");
|
||||
EXPECT_EQ(s.field, eField::ANY);
|
||||
EXPECT_EQ(s.text, "abc");
|
||||
s = ParseQuery("my: house");
|
||||
EXPECT_EQ(s.field, eField::ANY);
|
||||
EXPECT_EQ(s.text, "my: house");
|
||||
|
||||
EXPECT_FALSE(ParseQuery("-5").number);
|
||||
EXPECT_TRUE(ParseQuery(" ").text.empty());
|
||||
}
|
||||
|
||||
TEST(UgcLookupTests, LinksToTheViewer) {
|
||||
EXPECT_EQ(ViewerLink(eUgcKind::MODEL, 42), "/ugc?item=42&kind=model");
|
||||
EXPECT_EQ(ViewerLink(eUgcKind::MODULAR, 7), "/ugc?item=7&kind=modular");
|
||||
EXPECT_EQ(ParseKind("modular"), eUgcKind::MODULAR);
|
||||
EXPECT_FALSE(ParseKind("other"));
|
||||
}
|
||||
|
||||
TEST(UgcLookupTests, FindsCreationsInAnInventory) {
|
||||
const std::string xml =
|
||||
"<obj><inv><items>"
|
||||
"<in t=\"5\">"
|
||||
"<i l=\"6662\" id=\"100\" s=\"0\" c=\"1\" sk=\"555\"><x bp=\"9:1000\" ui=\"9:555\"/></i>" // a model: its blueprint, its subkey is its UGID
|
||||
"<i l=\"8092\" id=\"101\" s=\"1\" c=\"1\" sk=\"2000\"><x ma=\"0:1:8129+1:8130\"/></i>" // a rocket: its subkey
|
||||
"<i l=\"6416\" id=\"102\" s=\"2\" c=\"1\" sk=\"0\"><x ma=\"0:1:8337\"/></i>" // an old car without a subkey
|
||||
"</in>"
|
||||
"<in t=\"0\"><i l=\"3254\" id=\"103\" s=\"0\" c=\"1\" sk=\"3000\"/><i l=\"1727\" id=\"104\" s=\"1\" c=\"5\"/></in>" // a pet; a plain item
|
||||
"</items></inv></obj>";
|
||||
const auto refs = InventoryRefs(xml);
|
||||
ASSERT_EQ(refs.size(), 3u);
|
||||
EXPECT_EQ(refs[0].blueprint, 1000);
|
||||
EXPECT_EQ(refs[0].inventory, 5u);
|
||||
EXPECT_EQ(refs[1].subkey, 2000);
|
||||
|
||||
const auto candidates = Candidates(refs);
|
||||
EXPECT_EQ(std::set<LWOOBJID>(candidates.begin(), candidates.end()), (std::set<LWOOBJID>{ 555, 1000, 2000, 3000 }));
|
||||
|
||||
// The pet's subkey (3000) is no creation; the model goes by its blueprint, not its UGID
|
||||
const auto links = LinkItems(refs, { 1000, 555 }, { 2000 });
|
||||
ASSERT_EQ(links.size(), 2u);
|
||||
EXPECT_EQ(links[0].item.itemId, 100);
|
||||
EXPECT_EQ(links[0].kind, eUgcKind::MODEL);
|
||||
EXPECT_EQ(links[0].ugcId, 1000);
|
||||
EXPECT_EQ(links[1].item.itemId, 101);
|
||||
EXPECT_EQ(links[1].kind, eUgcKind::MODULAR);
|
||||
EXPECT_EQ(links[1].ugcId, 2000);
|
||||
|
||||
EXPECT_TRUE(InventoryRefs("").empty());
|
||||
EXPECT_TRUE(InventoryRefs("<obj><inv>").empty());
|
||||
}
|
||||
|
||||
TEST(UgcLookupTests, FindsCreationsInTheMail) {
|
||||
IUgcLookup::UgcMail model{ .id = 1, .lot = MODEL_ITEM_LOT, .subkey = 555, .config = "blueprintid=9:1000\nuserModelID=9:555" };
|
||||
EXPECT_EQ(MailCreation(model, { 1000 }, {}), std::make_pair(eUgcKind::MODEL, LWOOBJID{ 1000 }));
|
||||
EXPECT_FALSE(MailCreation(model, { 999 }, {}));
|
||||
|
||||
IUgcLookup::UgcMail rocket{ .id = 2, .lot = 8092, .subkey = 2000, .config = "assemblyPartLOTs=0:1:8129" };
|
||||
EXPECT_EQ(MailCreation(rocket, {}, { 2000 }), std::make_pair(eUgcKind::MODULAR, LWOOBJID{ 2000 }));
|
||||
EXPECT_FALSE(MailCreation(rocket, { 2000 }, {}));
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The lookup's SQL against a small database with the game's tables (the columns it reads)
|
||||
class UgcLookupSqlTests : public ::testing::Test {
|
||||
protected:
|
||||
sqlite3* db = nullptr;
|
||||
|
||||
void SetUp() override {
|
||||
ASSERT_EQ(sqlite3_open(":memory:", &db), SQLITE_OK);
|
||||
Exec("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT);"
|
||||
"CREATE TABLE charinfo (id BIGINT PRIMARY KEY, account_id INTEGER, name TEXT);"
|
||||
"CREATE TABLE properties (id BIGINT PRIMARY KEY, owner_id BIGINT, name TEXT, zone_id INTEGER);"
|
||||
"CREATE TABLE ugc (id INTEGER PRIMARY KEY, account_id INTEGER, character_id BIGINT, is_optimized INTEGER, filename TEXT, process_error TEXT DEFAULT '');"
|
||||
"CREATE TABLE ugc_modular_build (ugc_id BIGINT PRIMARY KEY, character_id BIGINT, ldf_config TEXT, is_optimized INTEGER DEFAULT 0, process_error TEXT DEFAULT '');"
|
||||
"CREATE TABLE properties_contents (id BIGINT PRIMARY KEY, property_id BIGINT, ugc_id BIGINT, lot INTEGER, model_name TEXT DEFAULT '', model_description TEXT DEFAULT '');"
|
||||
"INSERT INTO accounts VALUES (1, 'builder'), (2, 'racer');"
|
||||
"INSERT INTO charinfo VALUES (10, 1, 'Bricky'), (20, 2, 'Speedy');"
|
||||
"INSERT INTO properties VALUES (500, 10, 'Castle Hill', 1150);"
|
||||
"INSERT INTO ugc VALUES (1000, 1, 10, 1, 'tower.lxfml', ''), (1001, 1, 10, 0, 'boat.lxfml', '');"
|
||||
"INSERT INTO ugc_modular_build VALUES (2000, 20, '1:8129+1:8130+1:9330', 1, '');"
|
||||
"INSERT INTO properties_contents VALUES (7000, 500, 1000, 14, 'Big tower', 'the tallest');");
|
||||
}
|
||||
|
||||
void TearDown() override { sqlite3_close(db); }
|
||||
|
||||
void Exec(const char* sql) { ASSERT_EQ(sqlite3_exec(db, sql, nullptr, nullptr, nullptr), SQLITE_OK) << sqlite3_errmsg(db); }
|
||||
|
||||
// The ids a search finds, of models and modular builds
|
||||
std::set<int64_t> Find(const std::string& query, bool modular) {
|
||||
const auto search = ParseQuery(query);
|
||||
const auto sql = UgcLookupSql::Select(modular) + UgcLookupSql::Where(search, modular) + "LIMIT 50;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
EXPECT_EQ(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), SQLITE_OK) << sqlite3_errmsg(db) << "\n" << sql;
|
||||
EXPECT_EQ(sqlite3_bind_parameter_count(stmt), UgcLookupSql::TEXT_BINDS);
|
||||
const auto pattern = "%" + search.text + "%";
|
||||
for (int i = 1; i <= UgcLookupSql::TEXT_BINDS; i++) sqlite3_bind_text(stmt, i, pattern.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::set<int64_t> ids;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) ids.insert(sqlite3_column_int64(stmt, 0));
|
||||
sqlite3_finalize(stmt);
|
||||
return ids;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
TEST_F(UgcLookupSqlTests, FindsByIds) {
|
||||
EXPECT_EQ(Find("1000", false), (std::set<int64_t>{ 1000 })); // the blueprint id
|
||||
EXPECT_EQ(Find("7000", false), (std::set<int64_t>{ 1000 })); // the placed model's object id
|
||||
EXPECT_EQ(Find("500", false), (std::set<int64_t>{ 1000 })); // the property
|
||||
EXPECT_EQ(Find("10", false), (std::set<int64_t>{ 1000, 1001 })); // the creator
|
||||
EXPECT_EQ(Find("id: 2", true), (std::set<int64_t>{ 2000 })); // the creator's account
|
||||
EXPECT_EQ(Find("2000", true), (std::set<int64_t>{ 2000 }));
|
||||
EXPECT_TRUE(Find("2000", false).empty());
|
||||
}
|
||||
|
||||
TEST_F(UgcLookupSqlTests, FindsByNames) {
|
||||
EXPECT_EQ(Find("brick", false), (std::set<int64_t>{ 1000, 1001 })); // the character's name
|
||||
EXPECT_EQ(Find("owner: racer", true), (std::set<int64_t>{ 2000 })); // the account's name
|
||||
EXPECT_TRUE(Find("owner: racer", false).empty());
|
||||
EXPECT_EQ(Find("castle", false), (std::set<int64_t>{ 1000 })); // a property it is placed on
|
||||
EXPECT_EQ(Find("property: 500", false), (std::set<int64_t>{ 1000 }));
|
||||
EXPECT_EQ(Find("tallest", false), (std::set<int64_t>{ 1000 })); // the description given to it
|
||||
EXPECT_EQ(Find("model: boat", false), (std::set<int64_t>{ 1001 })); // the upload's file name
|
||||
EXPECT_TRUE(Find("property: tower", false).empty()); // a model name is not a property name
|
||||
}
|
||||
|
||||
TEST_F(UgcLookupSqlTests, FindsByLot) {
|
||||
EXPECT_EQ(Find("lot: 8130", true), (std::set<int64_t>{ 2000 })); // a module
|
||||
EXPECT_EQ(Find("lot: 9330", true), (std::set<int64_t>{ 2000 })); // the last module
|
||||
EXPECT_TRUE(Find("lot: 813", true).empty()); // not part of a module's LOT
|
||||
EXPECT_EQ(Find("lot: 14", false), (std::set<int64_t>{ 1000 })); // placed as LOT 14
|
||||
}
|
||||
Reference in New Issue
Block a user