From 4679edd2b201843b46271e15dcd12f1a908a547a Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Mon, 28 Sep 2026 19:27:37 -0500 Subject: [PATCH] feat(ugc): processing totals on the UGC page; no shared icon preset for player models - IUgc::GetUgcProcessTotals sums what the UGC server made, for models and for car and rocket builds: count, time spent (and how many are timed), CPU time, average and slowest, average and most memory (estimate), bricks, triangles and triangles saved (models whose count before hidden face removal is known). /api/ugc returns them as totals and the UGC page shows a card for each kind. Parity tested. - Player models no longer have a shared icon preset: every one is a different size and shape, so its icon is fitted to it from the settings. The UGC server ignores a kind:model preset, the dashboard refuses to save one, and the icon editor hides the type buttons for models. One model's own icon values still work. Car and rocket build types keep theirs. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/UgcRoutes.cpp | 14 +++++++++++- dDashboardServer/static/js/ugc-icon-editor.js | 6 ++++- dDashboardServer/static/js/ugc.js | 22 ++++++++++++++++++- dDashboardServer/templates/ugc.jinja2 | 5 +++-- dDatabase/GameDatabase/ITables/IUgc.h | 18 +++++++++++++++ dDatabase/GameDatabase/MySQL/MySQLDatabase.h | 1 + dDatabase/GameDatabase/MySQL/Tables/Ugc.cpp | 19 ++++++++++++++++ .../GameDatabase/SQLite/SQLiteDatabase.h | 1 + dDatabase/GameDatabase/SQLite/Tables/Ugc.cpp | 19 ++++++++++++++++ .../GameDatabase/TestSQL/TestSQLDatabase.h | 1 + dUgcServer/UgcProcessor.cpp | 6 ++++- docs/UgcServer.md | 6 +++-- tests/dDatabaseTests/DatabaseParityTests.cpp | 11 ++++++++++ 13 files changed, 121 insertions(+), 8 deletions(-) diff --git a/dDashboardServer/routes/UgcRoutes.cpp b/dDashboardServer/routes/UgcRoutes.cpp index 4ba502a87..c305631d3 100644 --- a/dDashboardServer/routes/UgcRoutes.cpp +++ b/dDashboardServer/routes/UgcRoutes.cpp @@ -250,6 +250,13 @@ namespace { return GeneralUtils::TryParse(it->is_string() ? it->get() : it->dump()); } + // Sums over what the UGC server has made (IUgc::GetUgcProcessTotals) + nlohmann::json Totals(const IUgc::ProcessTotals& t) { + return { { "made", t.made }, { "timed", t.timed }, { "ms", t.milliseconds }, { "cpuMs", t.cpuMilliseconds }, { "maxMs", t.maxMilliseconds }, + { "averageMs", t.timed ? t.milliseconds / t.timed : 0 }, { "memoryKbAverage", t.memoryKbAverage }, { "memoryKbMax", t.memoryKbMax }, + { "bricks", t.bricks }, { "triangles", t.triangles }, { "trianglesBefore", t.trianglesBefore }, { "trianglesAfter", t.trianglesAfter } }; + } + nlohmann::json Counts(const std::vector>& counts) { nlohmann::json out = nlohmann::json::object(); for (const auto state : magic_enum::enum_values()) out[IUgc::ProcessStateName(state)] = 0; @@ -351,6 +358,7 @@ namespace UgcRoutes { } } JsonSuccess(reply, { { "counts", { { "model", Counts(Database::Get()->GetUgcProcessCounts()) }, { "modular", Counts(Database::Get()->GetModularBuildProcessCounts()) } } }, + { "totals", { { "model", Totals(Database::Get()->GetUgcProcessTotals(false)) }, { "modular", Totals(Database::Get()->GetUgcProcessTotals(true)) } } }, { "items", items }, { "total", total }, { "page", page }, { "size", size }, { "more", static_cast(page + 1) * size < total }, { "kinds", kinds }, { "ugcPublicUrl", Game::config->GetValue("ugc_public_url") }, { "canManage", Can(context, "ugc_manage") } }); }); @@ -605,7 +613,8 @@ namespace UgcRoutes { const auto value = UgcSetting(param.setting); settings[param.key] = std::clamp(value ? GeneralUtils::TryParse(*value).value_or(param.defaultValue) : param.defaultValue, param.min, param.max); } - JsonSuccess(reply, { { "kind", kind }, { "target", target }, { "settings", settings }, { "preset", StoredValues(UgcIconParams::KindTarget(kind)) }, + JsonSuccess(reply, { { "kind", kind }, { "target", target }, { "settings", settings }, { "preset", kind == UgcIconParams::ModelKind() ? nlohmann::json() : StoredValues(UgcIconParams::KindTarget(kind)) }, + { "presets", kind != UgcIconParams::ModelKind() }, { "own", target.empty() ? nlohmann::json(nullptr) : StoredValues(target) } }); }); @@ -659,6 +668,9 @@ namespace UgcRoutes { bool known = false; for (const auto& entry : IconKinds()) known = known || entry["kind"] == kind; if (!known) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Unknown kind"); + if (kind == UgcIconParams::ModelKind()) { + return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Player models have no shared icon preset: each is fitted to its icon. Change one model's icon instead."); + } target = UgcIconParams::KindTarget(kind); what = "the " + kind + " icon preset"; } else if (body->value("kind", std::string()) == "modular") { diff --git a/dDashboardServer/static/js/ugc-icon-editor.js b/dDashboardServer/static/js/ugc-icon-editor.js index afb6612ce..45a9075d7 100644 --- a/dDashboardServer/static/js/ugc-icon-editor.js +++ b/dDashboardServer/static/js/ugc-icon-editor.js @@ -94,7 +94,11 @@ if (!d.success) throw new Error(d.error || 'No settings'); layers = { kind: d.kind, settings: d.settings, preset: d.preset, own: d.own }; $('framingKind').textContent = kindLabel(); - setAll(layered(3), d.own ? 'This item has its own values.' : d.preset ? 'The type’s preset.' : 'The default settings.'); + // Player models have no shared preset: each is fitted to its icon; only its own values change it + var shared = d.presets !== false; + $('framingSaveType').classList.toggle('d-none', !shared); + $('framingResetType').classList.toggle('d-none', !shared); + setAll(layered(3), d.own ? 'This item has its own values.' : d.preset ? 'The type’s preset.' : shared ? 'The default settings.' : 'Fitted to the icon from the default settings.'); }); } diff --git a/dDashboardServer/static/js/ugc.js b/dDashboardServer/static/js/ugc.js index 9c08271dc..974684ef1 100644 --- a/dDashboardServer/static/js/ugc.js +++ b/dDashboardServer/static/js/ugc.js @@ -78,6 +78,23 @@ fmt.badge(c.done + ' made', 'success') + ' ' + fmt.badge(c.pending + ' waiting', 'secondary') + ' ' + fmt.badge(c.failed + ' failed', c.failed ? 'danger' : 'secondary') + (c.empty ? ' ' + fmt.badge(c.empty + ' empty', 'light') : '') + ''; } + // What making them all took, and what it saved (the rows' recorded stats; ones made before a stat existed don't count) + function totalsCard(title, t) { + if (!t || !t.made) return ''; + var saved = t.trianglesBefore ? t.trianglesBefore - t.trianglesAfter : 0; + var rows = [ + ['Time spent making', duration(t.ms) + (t.timed < t.made ? ' (' + esc(t.timed) + ' of ' + esc(t.made) + ' timed)' : '')], + ['CPU time', duration(t.cpuMs)], + ['Average / slowest', duration(t.averageMs) + ' / ' + duration(t.maxMs)], + ['Memory (est.) average / most', mb(t.memoryKbAverage * 1024) + ' / ' + mb(t.memoryKbMax * 1024)] + ]; + if (t.bricks) rows.push(['Bricks', Number(t.bricks).toLocaleString()]); + if (t.triangles) rows.push(['Triangles (most detailed level)', Number(t.triangles).toLocaleString()]); + if (saved) rows.push(['Triangles saved', Number(saved).toLocaleString() + ' (' + (saved / t.trianglesBefore * 100).toFixed(1) + '% of ' + Number(t.trianglesBefore).toLocaleString() + ')']); + return '
' + esc(title) + ': totals
' + + '' + rows.map(function (r) { return ''; }).join('') + + '
' + r[0] + '' + r[1] + '
'; + } function owner(i) { return i.characterName ? '' + esc(i.characterName) + '' : '' + esc(i.characterId) + ''; } @@ -215,7 +232,8 @@ } $('manageButtons').classList.toggle('d-none', !canManage); $('cacheCard').classList.toggle('d-none', !canManage); - $('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets (builds)', d.counts.modular); + $('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets (builds)', d.counts.modular) + + (d.totals ? totalsCard('Models', d.totals.model) + totalsCard('Cars and rockets', d.totals.modular) : ''); } function filterQuery() { return 'kind=' + list.kind + '&q=' + encodeURIComponent(list.q) + '&state=' + list.state + '&type=' + encodeURIComponent(list.kind === 'modular' ? list.type : ''); @@ -646,6 +664,8 @@ function loadPresets() { UgcIconEditor.kinds().then(function (kinds) { + // Player models have no shared preset (each is fitted to its icon): only cars and rockets have presets + kinds = kinds.filter(function (k) { return k.kind !== 'model'; }); $('presetsList').innerHTML = kinds.map(function (k) { var sample = k.sample ? (k.kind === 'model' ? 'model ' + k.sample : 'modules ' + k.sample + (k.sampleBuilds ? ' (' + k.sampleBuilds + ' builds)' : '')) : 'nothing made yet'; return '