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

@@ -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) {