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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 19:27:37 -05:00
parent bdb4c39db3
commit 4679edd2b2
13 changed files with 121 additions and 8 deletions

View File

@@ -250,6 +250,13 @@ namespace {
return GeneralUtils::TryParse<LWOOBJID>(it->is_string() ? it->get<std::string>() : 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<std::pair<IUgc::eProcessState, uint64_t>>& counts) {
nlohmann::json out = nlohmann::json::object();
for (const auto state : magic_enum::enum_values<IUgc::eProcessState>()) 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<uint64_t>(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<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)) },
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") {

View File

@@ -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.');
});
}

View File

@@ -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') : '') + '</div></div></div>';
}
// 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 ? ' <span class="text-body-secondary">(' + esc(t.timed) + ' of ' + esc(t.made) + ' timed)</span>' : '')],
['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 '<div class="col-md-6"><div class="card"><div class="card-body py-2"><div class="small text-body-secondary mb-1">' + esc(title) + ': totals</div>' +
'<table class="table table-sm mb-0 small"><tbody>' + rows.map(function (r) { return '<tr><th class="fw-normal text-body-secondary">' + r[0] + '</th><td>' + r[1] + '</td></tr>'; }).join('') +
'</tbody></table></div></div></div>';
}
function owner(i) {
return i.characterName ? '<a href="/characters/' + esc(i.characterId) + '">' + esc(i.characterName) + '</a>' : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>';
}
@@ -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 '<button type="button" class="btn btn-sm btn-outline-primary text-start" data-preset="' + esc(k.kind) + '"' + (k.sample ? '' : ' disabled') + '>' +

View File

@@ -23,8 +23,9 @@
<details class="card mb-3" id="presetsCard">
<summary class="card-header py-2 small fw-semibold">Icon presets per type</summary>
<div class="card-body py-2 small">
<p class="text-body-secondary mb-2">Each type of icon (player models, and each car or rocket build type in the client's data) has a preset over the
default settings. Open one to set its camera, the model's turn, the light and the border on an example.</p>
<p class="text-body-secondary mb-2">Each car or rocket build type in the client's data has a preset over the default settings. Open one to set
its camera, the model's turn, the light and the border on an example. Player models have no preset: each one is a different size and
shape, so its icon is fitted to it from the defaults; open a model to change just its icon.</p>
<div class="d-flex flex-wrap gap-2" id="presetsList">Loading…</div>
<div data-ugc-settings="UGC icons" data-title="Icon defaults (under every preset)"></div>
</div>