feat(ugc): icon light matched to the game, debounce, shared car icons, purge, icon editor

Brighter icons: a world light, a fill from the camera, a highlight, exposure
and contrast; with the defaults the icons' mean luminance matches the game's
own model icons (118 against 120 on a scratch set). Every icon parameter is
listed once (UgcIconParams: key, setting, range, default); the settings,
the dashboard's settings entries and the icon editor come from that list.
Presets per kind (player models, each car or rocket build type from
ModularBuildComponent) and overrides per model or module combination are in
ugc_icon_settings.

Saved models wait ugc_debounce_seconds (sharedconfig) after the owner's last
save before they're made (ugc.process_after); a client asking for one, the
owner leaving the world or a reset ends the wait.

Cars and rockets: one icon per combination of modules (sorted LOTs), shared
by every build of it; builds of a combination made already are marked made
right away, the client's per-blueprint downloads serve the shared files.

The dashboard can delete one item's files, purge by filter or all, preview
icons with any values on the UGC server (/admin routes, master password),
save presets and overrides, and draw a kind's icons again (icons only).

Migrations dlu/mysql/86 and dlu/sqlite/69. Not done yet: the dashboard
editor's lighting controls in the page script (routes are there), docs for
it, the empty-model state, /ugc?item= links, the shared fetch helper; the
storage size and property loading bugs are next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 13:51:04 -05:00
parent 0c1253cd16
commit 9d2ad053fd
42 changed files with 1576 additions and 136 deletions

View File

@@ -98,4 +98,4 @@ target_include_directories(DashboardRoutes PRIVATE
)
target_compile_definitions(DashboardRoutes PRIVATE PROJECT_VERSION="\"${PROJECT_VERSION}\"")
target_link_libraries(DashboardRoutes PRIVATE ${COMMON_LIBRARIES} dWeb dServer sqlite3 OpenSSL::Crypto CURL::libcurl)
target_link_libraries(DashboardRoutes PRIVATE ${COMMON_LIBRARIES} dWeb dServer dUgc sqlite3 OpenSSL::Crypto CURL::libcurl)

View File

@@ -266,7 +266,7 @@ void RegisterMaintenanceRoutes() {
Sd0 sd0(header);
sd0.FromData(reinterpret_cast<const uint8_t*>(split.lxfml.data()), split.lxfml.size());
auto stream = sd0.GetAsStream();
Database::Get()->InsertNewUgcModel(stream, blueprintId, owner->accountId, owner->id);
Database::Get()->InsertNewUgcModel(stream, blueprintId, owner->accountId, owner->id, 0);
IPropertyContents::Model model;
model.id = modelId;

View File

@@ -1,5 +1,7 @@
#include "SettingsCatalog.h"
#include "UgcIconParams.h"
#include <charconv>
#include <cmath>
#include <map>
@@ -418,6 +420,7 @@ namespace {
c.Add(Text(UGC, "client_path", "Download path", "The game client's UGCSERVERDIR.", "/ugc", true));
c.Add(Format(Text(UGC, "ugc_output_dir", "Files folder", "Relative to the server binaries.", "ugc", true), eFormat::PATH));
c.Add(Unit(Int(UGC, "ugc_max_storage_mb", "Most space for files", "The files used longest ago are deleted past this and made again when asked for. 0: no limit.", "2048", 0, std::nullopt, true), "MB"));
c.Add(Unit(Int(SHARED, "ugc_debounce_seconds", "Wait after a save", "A saved model is made this long after the owner's last save (their other waiting models wait too), or as soon as a game client asks for it or the owner leaves. 0: right away.", "120", 0, 86400), "seconds"));
c.Add(Int(UGC, "worker_threads", "Worker threads", "0: half the CPU cores.", "0", 0, 64, true));
c.Add(Unit(Float(UGC, "max_cpu_percent", "Most CPU", "The workers together average at most this share of all CPU cores; they pause between bits of work to stay under it. 0: no limit.", "0", 0, 100), "%"));
c.Add(Int(UGC, "worker_nice", "Worker priority", "Linux nice value of the workers: 0 normal, 19 only when nothing else wants the CPU.", "0", 0, 19));
@@ -447,18 +450,15 @@ namespace {
c.Add(Float(UGC, "ao_strength", "Darkening strength", "0 to 1.", "1", 0, 1));
c.Add(Float(UGC, "glow_strength", "Glow strength", "What glowing colors add to the baked light.", "6", 0, 100));
c.Add(Unit(Int(UGC, "icon_size", "Icon size", "", "128", 16, 1024), "pixels"));
c.Add(Unit(Float(UGC, "icon_yaw", "Icon angle around", "", "53.36", -360, 360), "degrees"));
c.Add(Unit(Float(UGC, "icon_pitch", "Icon angle above", "", "19.54", -90, 90), "degrees"));
c.Add(Unit(Float(UGC, "icon_fov", "Icon field of view", "", "39.6", 1, 120), "degrees"));
c.Add(Float(UGC, "icon_margin", "Icon border", "1 fills the icon.", "1.03", 0.1, 10));
c.Add(Unit(Float(UGC, "icon_sun_yaw", "Icon sun angle around", "", "21", -360, 360), "degrees"));
c.Add(Unit(Float(UGC, "icon_sun_pitch", "Icon sun angle above", "", "50.3", -90, 90), "degrees"));
c.Add(Float(UGC, "icon_sun_strength", "Icon sun strength", "", "2.5", 0, 100));
c.Add(Float(UGC, "icon_ambient", "Icon world light", "", "0.192", 0, 10));
c.Add(Bool(UGC, "icon_shadows", "Icon shadows", "", true));
c.Add(Bool(UGC, "icon_ao", "Car and rocket icon ambient occlusion", "Player models' icons show the lighting baked into their mesh instead.", true));
c.Add(Unit(Float(UGC, "modular_icon_yaw", "Car and rocket icon angle around", "", "53.36", -360, 360), "degrees"));
c.Add(Unit(Float(UGC, "modular_icon_pitch", "Car and rocket icon angle above", "", "19.54", -90, 90), "degrees"));
// The icon's framing and light, from the one list of them (UgcIconParams); presets and overrides are set on the
// UGC page's icon editor
for (const auto& param : UgcIconParams::List()) {
char number[32];
const auto written = std::to_chars(number, number + sizeof(number), param.defaultValue);
auto setting = Float(UGC, param.setting, "Icon: " + param.label, param.description, std::string(number, written.ptr), param.min, param.max);
if (!param.unit.empty()) setting = Unit(setting, param.unit);
c.Add(setting);
}
return c;
}

View File

@@ -6,9 +6,14 @@
#include <curl/curl.h>
#include "CDClientDatabase.h"
#include "Database.h"
#include "UgcIconParams.h"
#include "NifFile.h"
#include "TtlCache.h"
#include "SettingsCatalog.h"
#include "SettingsHistory.h"
#include "UgcKeys.h"
#include "Workers.h"
#include "Game.h"
#include "GeneralUtils.h"
@@ -95,6 +100,111 @@ namespace {
return out;
}
// Any thread: a POST of JSON to one of the UGC server's /admin routes, with the key it checks (the master password)
std::shared_ptr<const Fetched> AdminPost(const std::string& url, const std::string& key, const std::string& body) {
auto out = std::make_shared<Fetched>();
CURL* curl = curl_easy_init();
if (!curl) {
out->error = "could not start a request";
return out;
}
curl_slist* headers = curl_slist_append(nullptr, "Content-Type: application/json");
headers = curl_slist_append(headers, ("X-Ugc-Admin-Key: " + key).c_str());
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, 120L);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(body.size()));
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_slist_free_all(headers);
curl_easy_cleanup(curl);
return out;
}
// Web thread: the key the UGC server's /admin routes want
std::string AdminKey() {
const auto master = Database::Get()->GetMasterInfo();
return master ? master->password : std::string();
}
// Sends an admin request from a worker and answers with the UGC server's JSON (or why it couldn't)
void ProxyAdmin(HTTPReply& reply, const HTTPContext& context, const std::string& route, const nlohmann::json& body) {
const auto url = InternalUrl() + route;
Workers::Reply(reply, context, false, [url, key = AdminKey(), text = body.dump()](HTTPReply& out) {
const auto fetched = AdminPost(url, key, text);
if (fetched->status == 0) return JsonError(out, eHTTPStatusCode::BAD_GATEWAY, "The UGC server doesn't answer at " + url + " (" + fetched->error + ")");
out.status = static_cast<eHTTPStatusCode>(fetched->status);
const bool png = fetched->body.starts_with("\x89PNG");
out.contentType = png ? eContentType::IMAGE_PNG : eContentType::APPLICATION_JSON;
out.message = fetched->body;
if (!png && fetched->status != 200 && !fetched->body.starts_with("{")) {
JsonError(out, static_cast<eHTTPStatusCode>(fetched->status), fetched->body.empty() ? "The UGC server answered " + std::to_string(fetched->status) : fetched->body);
}
out.headers.push_back("Cache-Control: no-store");
}, WorkerPool::ePriority::URGENT);
}
// Web thread: a ugcconfig.ini setting's value as the UGC server sees it (dashboard value, file value, default)
std::optional<std::string> UgcSetting(const std::string& name) {
if (const auto current = SettingsHistory::Current("ugcconfig.ini", name)) {
if (current->webValue && (current->webWins || !current->fileValue)) return current->webValue;
if (current->fileValue) return current->fileValue;
}
if (const auto* info = SettingsCatalog::Find("ugcconfig.ini", name)) return info->defaultValue;
return std::nullopt;
}
// Web thread: the icon kinds: player models, and each car or rocket build type in the client's data
// (ModularBuildComponent), named after the object its Assembly LOT is
nlohmann::json IconKinds() {
nlohmann::json kinds = nlohmann::json::array({ { { "kind", UgcIconParams::ModelKind() }, { "label", "Player models" } } });
auto result = CDClientDatabase::ExecuteQuery("SELECT buildType, xml FROM ModularBuildComponent GROUP BY buildType ORDER BY buildType;");
while (!result.eof()) {
const auto buildType = result.getIntField("buildType", 0);
std::string label = "Build type " + std::to_string(buildType);
const std::string xml = result.getStringField("xml", "");
if (const auto at = xml.find("LOT=\""); at != std::string::npos) {
const auto lot = GeneralUtils::TryParse<int32_t>(xml.substr(at + 5, xml.find('"', at + 5) - at - 5));
if (lot) {
auto name = CDClientDatabase::CreatePreppedStmt("SELECT displayName FROM Objects WHERE id = ? LIMIT 1;");
name.bind(1, *lot);
auto row = name.execQuery();
if (!row.eof() && row.getStringField("displayName", "")[0] != '\0') label = row.getStringField("displayName", "");
}
}
kinds.push_back({ { "kind", UgcIconParams::BuildKind(buildType) }, { "label", label }, { "buildType", buildType } });
result.nextRow();
}
return kinds;
}
// Web thread: a car or rocket's kind, from its first module's build type (ModuleComponent)
std::optional<std::string> ModularKind(const std::string& modules) {
const auto lots = UgcModularKey::Lots(modules);
if (lots.empty()) return std::nullopt;
auto query = CDClientDatabase::CreatePreppedStmt("SELECT m.buildType FROM ComponentsRegistry cr JOIN ModuleComponent m ON m.id = cr.component_id "
"WHERE cr.id = ? AND cr.component_type = 28 LIMIT 1;");
query.bind(1, static_cast<int32_t>(lots.front()));
auto row = query.execQuery();
if (row.eof()) return std::nullopt;
return UgcIconParams::BuildKind(row.getIntField("buildType", 0));
}
// Web thread: the values stored for a target, or null
nlohmann::json StoredValues(const std::string& target) {
const auto stored = Database::Get()->GetUgcIconSettings(target);
if (!stored) return nullptr;
return nlohmann::json::parse(UgcIconParams::ToJson(UgcIconParams::Parse(*stored)));
}
// Any thread: Get, through the cache (answers that aren't a file being made are kept)
std::shared_ptr<const Fetched> CachedGet(const std::string& url, bool status) {
{
@@ -116,7 +226,7 @@ namespace {
JsonError(reply, eHTTPStatusCode::BAD_GATEWAY, "The UGC server answered " + std::to_string(fetched.status));
}
const std::set<std::string> FILES = { "icon.png", "model.nif", "model.noao.nif", "stats.json",
const std::set<std::string> FILES = { "icon.png", "model.nif", "model.noao.nif", "stats.json", "combo.json",
"previous.icon.png", "previous.model.nif", "previous.model.noao.nif", "previous.stats.json" };
std::optional<std::string> KindOf(const std::string& text) {
@@ -147,12 +257,22 @@ namespace UgcRoutes {
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;
}
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 } });
{ "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];
}
}
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") } });
@@ -231,6 +351,157 @@ namespace UgcRoutes {
});
});
Route(eHTTPMethod::POST, "/api/ugc/cache/delete", Perm("ugc_manage"),
"Delete one item's generated files on the UGC server. Body: {kind: model|modular, id, after: on_demand|now|gone} (made again when asked "
"for, queued now, or left deleted). A car or rocket's files are shared by every build of the same modules",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto kind = body->value("kind", std::string("model")) == "modular" ? std::string("modular") : std::string("model");
const auto id = GeneralUtils::TryParse<LWOOBJID>((*body)["id"].is_string() ? (*body)["id"].get<std::string>() : (*body)["id"].dump());
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
const auto after = body->value("after", std::string("now"));
Audit(context, "ugc_delete_files", "Deleted the UGC files of " + kind + " " + std::to_string(*id) + " (" + after + ")");
ProxyAdmin(reply, context, "/admin/delete", { { "kind", kind }, { "ids", { std::to_string(*id) } }, { "after", after } });
});
Route(eHTTPMethod::POST, "/api/ugc/cache/purge", Perm("ugc_manage"),
"Delete generated files by filter. Body: {kind, state?, owner? (name), olderThanDays?, unusedDays?, all?, confirm (\"PURGE ALL\" with all and no "
"other filter), after: on_demand|now|gone}. State and owner pick the rows here; the ages are the files' own",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const bool modular = body->value("kind", std::string("model")) == "modular";
const auto state = ParseState(body->value("state", std::string()));
const auto owner = body->value("owner", std::string()).substr(0, 64);
const auto older = body->value("olderThanDays", int64_t{ 0 });
const auto unused = body->value("unusedDays", int64_t{ 0 });
nlohmann::json request = { { "kind", modular ? "modular" : "model" }, { "olderThanDays", older }, { "unusedDays", unused },
{ "after", body->value("after", std::string("on_demand")) } };
std::string what = std::string(modular ? "cars and rockets" : "models");
if (state || !owner.empty()) {
// The rows the filter picks, from the database
nlohmann::json ids = nlohmann::json::array();
for (uint32_t page = 0;; page++) {
const auto list = modular ? Database::Get()->GetModularBuildProcessList(state, owner, page * 1000, 1000) : Database::Get()->GetUgcProcessList(state, owner, page * 1000, 1000);
for (const auto& info : list) ids.push_back(std::to_string(info.id));
if (list.size() < 1000 || ids.size() >= 100000) break;
}
if (ids.empty()) return JsonSuccess(reply, { { "deleted", 0 }, { "bytes", 0 }, { "notes", { "Nothing matches the filter." } } });
request["ids"] = ids;
what += (state ? " " + body->value("state", std::string()) : "") + (owner.empty() ? "" : " of " + owner);
} else {
if (body->value("all", false) && older == 0 && unused == 0 && body->value("confirm", std::string()) != "PURGE ALL") {
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type PURGE ALL to delete every generated file");
}
if (!body->value("all", false) && older == 0 && unused == 0) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick a filter, or all");
request["all"] = true;
}
if (older > 0) what += ", made more than " + std::to_string(older) + " days ago";
if (unused > 0) what += ", not asked for in " + std::to_string(unused) + " days";
Audit(context, "ugc_purge", "Purged the UGC files of " + what + " (" + request["after"].get<std::string>() + ")");
ProxyAdmin(reply, context, "/admin/delete", request);
});
Route(eHTTPMethod::GET, "/api/ugc/icon/params", Perm("properties_view"),
"What an icon's framing and light can be set to: {params: [{key, setting, label, unit, min, max, step, default, description}], kinds: [{kind, label}]} "
"(player models, and each car or rocket build type in the client's data)",
[](HTTPReply& reply, const HTTPContext&) {
nlohmann::json params = nlohmann::json::array();
for (const auto& param : UgcIconParams::List()) {
params.push_back({ { "key", param.key }, { "setting", param.setting }, { "label", param.label }, { "unit", param.unit }, { "min", param.min },
{ "max", param.max }, { "step", param.step }, { "default", param.defaultValue }, { "description", param.description } });
}
JsonSuccess(reply, { { "params", params }, { "kinds", IconKinds() } });
});
Route(eHTTPMethod::GET, "/api/ugc/icon/settings", Perm("properties_view"),
"An item's icon values in their layers: {kind, target, settings (icon_* as the UGC server reads them), preset (the kind's, or null), own (the "
"item's or combination's, or null)}. Query: ?kind=model&id= or ?kind=modular&modules=, or ?preset=<kind> for a kind alone",
[](HTTPReply& reply, const HTTPContext& context) {
std::string kind, target;
if (const auto preset = QueryValue(context.queryString, "preset"); !preset.empty()) {
kind = preset;
} else if (QueryValue(context.queryString, "kind") == "modular") {
const auto modules = QueryValue(context.queryString, "modules");
const auto found = ModularKind(modules);
if (!found) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Those aren't modules");
kind = *found;
target = UgcIconParams::CombinationTarget(UgcModularKey::Normalize(modules));
} else {
const auto id = GeneralUtils::TryParse<LWOOBJID>(QueryValue(context.queryString, "id"));
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
kind = UgcIconParams::ModelKind();
target = UgcIconParams::ModelTarget(*id);
}
nlohmann::json settings = nlohmann::json::object();
for (const auto& param : UgcIconParams::List()) {
const auto value = UgcSetting(param.setting);
settings[param.key] = std::clamp(value ? GeneralUtils::TryParse<float>(*value).value_or(param.defaultValue) : param.defaultValue, param.min, param.max);
}
JsonSuccess(reply, { { "kind", kind }, { "target", target }, { "settings", settings }, { "preset", StoredValues(UgcIconParams::KindTarget(kind)) },
{ "own", target.empty() ? nlohmann::json(nullptr) : StoredValues(target) } });
});
Route(eHTTPMethod::POST, "/api/ugc/icon/preview", Perm("ugc_manage"),
"An icon drawn by the UGC server with the given values, not stored (PNG). Body: {kind: model, id, values} or {kind: modular, modules, values}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const bool model = body->value("kind", std::string()) == "model";
ProxyAdmin(reply, context, "/admin/preview", { { "kind", model ? "model" : "modular" }, { "id", (*body)["id"].is_string() ? (*body)["id"].get<std::string>() : std::string("0") },
{ "modules", body->value("modules", std::string()) }, { "values", nlohmann::json::parse(UgcIconParams::ToJson(UgcIconParams::Parse(body->value("values", nlohmann::json::object()).dump()))) } });
});
Route(eHTTPMethod::POST, "/api/ugc/icon/save", Perm("ugc_manage"),
"Save icon values as a kind's preset ({scope: kind, kind, values}) or as one item's own ({scope: item, kind: model, id, values} or {scope: item, "
"kind: modular, modules, values}); values null removes it. The icons already made keep theirs until they are drawn again",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
std::string target, what;
if (body->value("scope", std::string()) == "kind") {
const auto kind = body->value("kind", std::string());
bool known = false;
for (const auto& entry : IconKinds()) known = known || entry["kind"] == kind;
if (!known) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Unknown kind");
target = UgcIconParams::KindTarget(kind);
what = "the " + kind + " icon preset";
} else if (body->value("kind", std::string()) == "modular") {
const auto key = UgcModularKey::Normalize(body->value("modules", std::string()));
if (key.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "No modules");
target = UgcIconParams::CombinationTarget(key);
what = "the icon of the module combination " + key;
} else {
const auto id = GeneralUtils::TryParse<LWOOBJID>((*body)["id"].is_string() ? (*body)["id"].get<std::string>() : (*body)["id"].dump());
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
target = UgcIconParams::ModelTarget(*id);
what = "the icon of model " + std::to_string(*id);
}
const auto values = body->value("values", nlohmann::json());
if (values.is_null()) {
Database::Get()->DeleteUgcIconSettings(target);
Audit(context, "ugc_icon_settings", "Removed the settings of " + what);
return JsonSuccess(reply, { { "message", "Removed; draw the icons again to see it" } });
}
const auto clean = UgcIconParams::ToJson(UgcIconParams::Parse(values.dump()));
Database::Get()->SetUgcIconSettings(target, clean);
Audit(context, "ugc_icon_settings", "Set " + what + " to " + clean);
JsonSuccess(reply, { { "message", "Saved; draw the icons again to see it in game" } });
});
Route(eHTTPMethod::POST, "/api/ugc/icon/regenerate", Perm("ugc_manage"),
"Have the UGC server draw every stored icon of a kind again (only icons: player models' from their stored .nif). Body: {kind}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
const auto kind = body ? body->value("kind", std::string()) : std::string();
bool known = false;
for (const auto& entry : IconKinds()) known = known || entry["kind"] == kind;
if (!known) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Unknown kind");
Audit(context, "ugc_regenerate_icons", "Queued every " + kind + " icon to be drawn again");
ProxyAdmin(reply, context, "/admin/regenerate-icons", { { "kind", kind } });
});
Route(eHTTPMethod::POST, "/api/ugc/reprocess", Perm("ugc_manage"),
"Have the UGC server make items again. Body: {kind: model|modular, id} for one, {kind, failedOnly: true} for the failed ones, {kind} for all",
[](HTTPReply& reply, const HTTPContext& context) {

View File

@@ -41,12 +41,20 @@
var s = STATES[i.state] || [i.state, 'secondary'];
return '<div class="card ugc-tile p-2" tabindex="0" role="button" data-preview="' + esc(i.id) + '" title="' + esc(i.error || '') + '">' + iconImg(i, 128) +
'<div class="small text-truncate mt-1">' + (i.characterName ? esc(i.characterName) : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>') + '</div>' +
'<div class="small text-body-secondary text-truncate">' + esc(i.id) + '</div><div>' + fmt.badge(s[0], s[1]) + '</div></div>';
'<div class="small text-body-secondary text-truncate">' + esc(i.id) + '</div><div>' + fmt.badge(s[0], s[1]) + extraBadges(i) + '</div></div>';
}
// Cars and rockets: how many builds share this icon; waiting models: when their quiet period after a save ends
function extraBadges(i) {
var out = '';
if (i.sharedBy > 1) out += ' ' + fmt.badge('shared by ' + i.sharedBy, 'info');
if (i.state === 'pending' && i.processAfter > Date.now() / 1000) out += ' <span class="small text-body-secondary" title="Waits for the owner to stop saving">after ' + esc(fmt.unix(i.processAfter)) + '</span>';
return out;
}
function row(i) {
var s = STATES[i.state] || [i.state, 'secondary'];
var details = kind === 'modular' ? '<code class="small">' + esc(i.modules) + '</code>' : '';
var details = (kind === 'modular' ? '<code class="small">' + esc(i.modules) + '</code>' : '') + extraBadges(i);
if (i.error) details += '<div class="small text-danger">' + esc(i.error) + '</div>';
var buttons = '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>' +
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(i.id) + '">Make again</button>' : '');
@@ -64,6 +72,7 @@
canManage = d.canManage;
publicUrl = (d.ugcPublicUrl || '').replace(/\/+$/, '');
$('manageButtons').classList.toggle('d-none', !canManage);
$('cacheCard').classList.toggle('d-none', !canManage);
$('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets', d.counts.modular);
$('gallery').classList.toggle('d-none', view !== 'gallery');
$('listCard').classList.toggle('d-none', view !== 'list');
@@ -174,12 +183,20 @@
'<a href="/api/ugc/' + esc(id) + '/lxfml" download="ugc_' + esc(id) + '.lxfml">Download the LXFML</a><br>';
}
if (publicUrl) links += '<a href="' + esc(publicUrl + '/files/' + kind + '/' + id + '/icon.png') + '" target="_blank" rel="noopener">Open on the UGC server</a>';
if (canManage) links += '<div class="mt-2"><button type="button" class="btn btn-sm btn-outline-warning" data-remake-open="' + esc(id) + '">Make again</button></div>';
if (canManage) {
links += '<div class="mt-2 d-flex flex-wrap gap-1"><button type="button" class="btn btn-sm btn-outline-warning" data-remake-open="' + esc(id) + '">Make again</button>' +
'<button type="button" class="btn btn-sm btn-outline-danger" data-delete-open="now">Delete files, make again</button>' +
'<button type="button" class="btn btn-sm btn-outline-danger" data-delete-open="gone">Delete files, leave deleted</button></div>';
if (item.sharedBy > 1) links += '<div class="text-body-secondary mt-1">This icon is shared by ' + esc(item.sharedBy) + ' builds of the same modules.</div>';
}
if (item.error) links += '<div class="text-danger mt-2">' + esc(item.error) + '</div>';
$('previewLinks').innerHTML = links;
var model = kind === 'model';
['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', !model); });
$('statsBox').innerHTML = model ? '' : '<div class="small">Modules: <code>' + esc(item.modules || '') + '</code>. The game client puts cars and rockets together itself; only the icon is made.</div>';
$('statsBox').innerHTML = model ? '' : '<div class="small">Modules: <code>' + esc(item.modules || '') + '</code> (combination <code>' + esc(item.combination || '') +
'</code>). The game client puts cars and rockets together itself; only the icon is made, once per combination of modules.</div>';
$('framingCard').classList.toggle('d-none', model || !canManage);
if (!model && canManage) openFraming(item);
bootstrap.Modal.getOrCreateInstance($('previewModal')).show();
if (!model) return;
@@ -267,6 +284,135 @@
if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose();
lxfmlViewer = null;
});
// ---- deleting and purging stored files ----
function showDeleteResult(box, d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
var text = d.deleted + ' deleted (' + mb(d.bytes || 0) + ' freed). ' + (d.notes || []).join(' ');
if (box) box.textContent = text;
toast(text, 'success');
load();
}
$('previewLinks').addEventListener('click', function (e) {
var button = e.target.closest('[data-delete-open]');
if (!button || !current) return;
var after = button.dataset.deleteOpen;
if (!confirm('Delete the files made for ' + current.id + (after === 'gone' ? ' and leave them deleted?' : ' and make them again?'))) return;
api.post('/api/ugc/cache/delete', { kind: current.kind, id: current.id, after: after }).then(function (d) { showDeleteResult(null, d); });
});
function purge(all) {
var body = { kind: kind, state: $('purgeState').value, owner: $('purgeOwner').value.trim(), olderThanDays: +$('purgeOlder').value || 0,
unusedDays: +$('purgeUnused').value || 0, after: $('purgeAfter').value };
if (all) {
var typed = prompt('This deletes every stored ' + (kind === 'model' ? 'model' : 'car and rocket') + ' file. Type PURGE ALL to go ahead.');
if (typed !== 'PURGE ALL') return;
body = { kind: kind, all: true, confirm: typed, after: $('purgeAfter').value };
} else if (!body.state && !body.owner && !body.olderThanDays && !body.unusedDays) {
toast('Pick a filter first, or purge all', 'warning');
return;
} else if (!confirm('Delete the stored files of every ' + (kind === 'model' ? 'model' : 'car and rocket') + ' matching the filter?')) {
return;
}
$('purgeResult').textContent = 'Deleting…';
api.post('/api/ugc/cache/purge', body).then(function (d) { showDeleteResult($('purgeResult'), d); });
}
$('purgeButton').addEventListener('click', function () { purge(false); });
$('purgeAllButton').addEventListener('click', function () { purge(true); });
// ---- car and rocket icon framing ----
var FRAMING = [
['yaw', 'Angle around', -180, 180, 1], ['pitch', 'Angle above', -89, 89, 1], ['fov', 'Field of view', 5, 90, 0.5], ['margin', 'Border (zoom)', 0.5, 3, 0.01],
['offsetX', 'Shift right', -0.5, 0.5, 0.01], ['offsetY', 'Shift up', -0.5, 0.5, 0.01], ['sunYaw', 'Sun around', -180, 180, 1], ['sunPitch', 'Sun above', -10, 90, 1]
];
var framingItem = null, typeFraming = {}, previewTimer = null, previewUrl = null;
$('framingControls').innerHTML = FRAMING.map(function (f) {
return '<div class="col-sm-6"><label class="d-flex justify-content-between" for="framing_' + f[0] + '"><span>' + esc(f[1]) + '</span><span id="framing_' + f[0] + '_value"></span></label>' +
'<input type="range" class="form-range" id="framing_' + f[0] + '" min="' + f[2] + '" max="' + f[3] + '" step="' + f[4] + '"></div>';
}).join('');
function framingValues() {
var out = {};
FRAMING.forEach(function (f) { out[f[0]] = parseFloat($('framing_' + f[0]).value); });
return out;
}
function setFraming(values) {
FRAMING.forEach(function (f) {
if (values[f[0]] === undefined || values[f[0]] === null) return;
$('framing_' + f[0]).value = values[f[0]];
$('framing_' + f[0] + '_value').textContent = (+values[f[0]]).toFixed(f[4] < 1 ? 2 : 0);
});
}
function loadFramingType(useCombination) {
if (!framingItem) return;
var type = $('framingType').value;
return api.get('/api/ugc/framing?buildType=' + type + '&modules=' + encodeURIComponent(framingItem.modules || '')).then(function (d) {
if (!d.success) return;
typeFraming = d.type;
setFraming(d.type);
if (useCombination && d.combination) setFraming(d.combination);
$('framingState').textContent = d.combination && useCombination ? 'This combination has its own framing.' : 'The ' + d.name + ' framing.';
renderPreview();
});
}
function openFraming(item) {
framingItem = item;
// The build type from what the UGC server stored with the icon, when it has
fetchJson(fileUrl('modular', item.id, 'combo.json')).then(function (combo) {
if (combo && (combo.buildType === 3 || combo.buildType === 6)) $('framingType').value = String(combo.buildType);
loadFramingType(true);
});
}
function renderPreview() {
if (!framingItem) return;
var img = $('framingPreview');
img.style.opacity = 0.5;
fetch('/api/ugc/framing/preview', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ modules: framingItem.modules, framing: framingValues() }) }).then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'HTTP ' + r.status); });
return r.blob();
}).then(function (blob) {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(blob);
img.src = previewUrl;
img.style.opacity = 1;
}).catch(function (e) { img.style.opacity = 1; $('framingState').textContent = 'No preview: ' + e.message; });
}
$('framingControls').addEventListener('input', function (e) {
var f = FRAMING.find(function (x) { return 'framing_' + x[0] === e.target.id; });
if (f) $('framing_' + f[0] + '_value').textContent = (+e.target.value).toFixed(f[4] < 1 ? 2 : 0);
clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, 350);
});
$('framingType').addEventListener('change', function () { loadFramingType(false); });
$('framingPreviewButton').addEventListener('click', renderPreview);
$('framingReset').addEventListener('click', function () {
if (!framingItem) return;
if (!confirm('Go back to the type\'s framing? This combination\'s own framing, if it has one, is removed.')) return;
api.post('/api/ugc/framing/save', { scope: 'combination', modules: framingItem.modules, framing: null }).then(function () { loadFramingType(false); });
});
$('framingSaveType').addEventListener('click', function () {
var name = $('framingType').selectedOptions[0].textContent.toLowerCase();
if (!confirm('Use this framing for every ' + name + ' icon? Icons already made keep theirs until they are drawn again.')) return;
api.post('/api/ugc/framing/save', { scope: 'type', buildType: +$('framingType').value, framing: framingValues() }).then(function (d) {
toast(d.success ? d.message : (d.error || 'Failed'), d.success ? 'success' : 'danger');
});
});
$('framingSaveCombo').addEventListener('click', function () {
if (!framingItem) return;
api.post('/api/ugc/framing/save', { scope: 'combination', modules: framingItem.modules, framing: framingValues() }).then(function (d) {
toast(d.success ? d.message : (d.error || 'Failed'), d.success ? 'success' : 'danger');
if (d.success) $('framingState').textContent = 'This combination has its own framing.';
});
});
$('framingRegenerate').addEventListener('click', function () {
var name = $('framingType').selectedOptions[0].textContent.toLowerCase();
if (!confirm('Draw every stored ' + name + ' icon again with the saved framings?')) return;
api.post('/api/ugc/framing/regenerate', { buildType: +$('framingType').value }).then(function (d) {
toast(d.success ? d.queued + ' icon' + (d.queued === 1 ? '' : 's') + ' queued' : (d.error || 'Failed'), d.success ? 'success' : 'danger');
});
});
if (window.Live) Live.on('ugc', load);
load();

View File

@@ -17,6 +17,24 @@
<div class="card-body py-2 small" id="serverStatus">Asking the UGC server…</div>
</div>
<details class="card mb-3 d-none" id="cacheCard">
<summary class="card-header py-2 small fw-semibold">Stored files: delete and purge</summary>
<div class="card-body py-2 small">
<p class="text-body-secondary mb-2">Deletes files the UGC server made, of the kind picked below. Afterwards they are made again when a game client
asks for them, queued to be made now, or left deleted. A car or rocket's icon is shared by every build of the same modules.</p>
<div class="d-flex flex-wrap gap-2 align-items-end">
<label>State<select class="form-select form-select-sm" id="purgeState"><option value="">Any</option><option value="done">Made</option><option value="failed">Failed</option><option value="pending">Waiting</option></select></label>
<label>Owner<input class="form-control form-control-sm" id="purgeOwner" placeholder="Name (part of it)"></label>
<label>Made more than<div class="input-group input-group-sm"><input type="number" min="0" class="form-control" id="purgeOlder" value="0" style="width:5rem"><span class="input-group-text">days ago</span></div></label>
<label>Not asked for in<div class="input-group input-group-sm"><input type="number" min="0" class="form-control" id="purgeUnused" value="0" style="width:5rem"><span class="input-group-text">days</span></div></label>
<label>Then<select class="form-select form-select-sm" id="purgeAfter"><option value="on_demand">Make again when asked for</option><option value="now">Make again now</option><option value="gone">Leave deleted</option></select></label>
<button type="button" class="btn btn-sm btn-outline-warning" id="purgeButton">Purge matching</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="purgeAllButton">Purge all…</button>
</div>
<div class="mt-2" id="purgeResult"></div>
</div>
</details>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<div class="btn-group btn-group-sm" role="group" id="kindButtons">
<button type="button" class="btn btn-outline-primary active" data-kind="model">Models</button>
@@ -90,6 +108,24 @@
</div>
</div>
<div class="mt-3" id="statsBox"></div>
<div class="card mt-3 d-none" id="framingCard">
<div class="card-header py-2 small fw-semibold d-flex align-items-center gap-2">Icon framing
<select class="form-select form-select-sm w-auto ms-auto" id="framingType" aria-label="Build type"><option value="6">Car</option><option value="3">Rocket</option></select></div>
<div class="card-body py-2 small">
<div class="row g-3">
<div class="col-md-8"><div class="row g-2" id="framingControls"></div></div>
<div class="col-md-4 text-center"><img id="framingPreview" alt="Preview" class="border rounded ugc-checker" width="128" height="128">
<div class="text-body-secondary" id="framingState">Preview drawn by the UGC server, not stored.</div></div>
</div>
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="framingPreviewButton">Preview</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="framingSaveType">Save as default for this type</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="framingSaveCombo">Save for this combination</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="framingReset">Reset</button>
<button type="button" class="btn btn-sm btn-outline-warning ms-auto" id="framingRegenerate">Draw all icons of this type again</button>
</div>
</div>
</div>
</div>
</div></div>
</div>