';
}
// 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 '
' + r[0] + '
' + r[1] + '
'; }).join('') +
'
';
}
// The creator's character, and account for those who may see accounts
function owner(i) {
var character = i.characterName ? '' + esc(i.characterName) + '' : '' + esc(i.characterId) + '';
var account = i.accountId && i.accountName ? '
' : '';
return character + account;
}
function badge(state) { var s = STATES[state] || [state, 'secondary']; return fmt.badge(s[0], s[1]); }
// The id whose files show an item's icon: a model's own, an assembly's made build's (the UGC server finds its combination)
function iconId(i) { return list.kind === 'modular' ? i.iconBuild : i.id; }
function iconImg(i, size) {
if (i.state !== 'done') return '' + esc((STATES[i.state] || [i.state])[0]) + '';
return '';
}
function moduleNames(i) { return (i.moduleList || []).map(function (m) { return m.name || m.lot; }).join(', '); }
// A duration in the largest units that fit: "850 ms", "12.4 s", "2 min 5 s", "1 h 4 min", "2 d 3 h"
function duration(ms) {
ms = Math.round(ms);
if (ms < 1000) return ms + ' ms';
if (ms < 60000) return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + ' s';
var s = Math.round(ms / 1000), m = Math.floor(s / 60), h = Math.floor(m / 60), d = Math.floor(h / 24);
if (d) return d + ' d' + (h % 24 ? ' ' + (h % 24) + ' h' : '');
if (h) return h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '');
return m + ' min' + (s % 60 ? ' ' + (s % 60) + ' s' : '');
}
function megabytes(kb) { return kb >= 1048576 ? (kb / 1048576).toFixed(1) + ' GB' : kb >= 1024 ? Math.round(kb / 1024) + ' MB' : kb + ' KB'; }
// What the last make cost: its time, the worker's CPU time and the memory the UGC server estimated for it
function costText(i) {
if (!i.processMs) return '';
return 'took ' + duration(i.processMs) + (i.processCpuMs ? ', CPU ' + duration(i.processCpuMs) : '') + (i.processMemoryKb ? ', ~' + megabytes(i.processMemoryKb) + ' RAM (est.)' : '');
}
// When it was made and what that cost (the cost is only known for makes since it was recorded)
function madeText(i) {
if (!i.processedAt) return '';
return fmt.unix(i.processedAt) + (costText(i) ? ' \u00b7 ' + costText(i) : '');
}
// A model's file as the player named it: its name with the upload's extension (".lxfml"), else the upload's name
function fileName(i) {
var file = i.detail || '';
if (!i.modelName) return file;
var dot = file.lastIndexOf('.');
return i.modelName + (dot > 0 ? file.slice(dot) : '');
}
function waitBadge(i) {
return i.state === 'pending' && i.processAfter > Date.now() / 1000 ? ' after ' + esc(fmt.unix(i.processAfter)) + '' : '';
}
function tile(i) {
var title = list.kind === 'modular' ? esc(i.kindLabel || 'Build type ' + i.buildType) : (i.characterName ? esc(i.characterName) : '' + esc(i.characterId) + '');
var sub = list.kind === 'modular' ? esc(moduleNames(i)) : esc(i.id);
var extra = list.kind === 'modular' ? ' ' + fmt.badge(i.uses + ' build' + (i.uses === 1 ? '' : 's'), 'info') : waitBadge(i);
var tip = i.error || (list.kind === 'modular' ? moduleNames(i) : i.id) + (madeText(i) ? '\nMade ' + madeText(i) : '');
return '
' : ''; }
// The List view's columns. A column with a sort key sorts on the server by that key; desc marks the key's own
// direction (e.g. "bricks" is the most first), so clicking the other way asks for the reverse.
function column(title, render, sort, desc) {
return { data: null, title: title, orderable: !!sort, sortKey: sort, desc: !!desc, orderSequence: desc ? ['desc', 'asc'] : ['asc', 'desc'],
render: function (x, type, i) { return render(i); } };
}
// When it was made and what that cost: the same columns for models and assemblies
function madeColumns() {
return [
column('Made', function (i) { return '' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + ''; }, 'made', true),
column('Took', function (i) { return '' + (i.processMs ? esc(duration(i.processMs)) : '') + ''; }, 'slowest', true),
column('CPU', function (i) { return '' + (i.processCpuMs ? esc(duration(i.processCpuMs)) : '') + ''; }, 'cpu', true),
column('RAM (est.)', function (i) { return '' + (i.processMemoryKb ? '~' + esc(megabytes(i.processMemoryKb)) : '') + ''; }, 'memory', true)
];
}
var COLUMNS = {
model: [
column('Icon', function (i) { return iconImg(i, 48); }),
column('ID', function (i) { return '' + esc(i.id) + ''; }, 'newest', true),
column('Owner', owner, 'owner'),
column('State', function (i) {
return badge(i.state) + (i.attempts ? ' ' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '' : '') + waitBadge(i);
})
].concat(madeColumns(), [
column('Saved', function (i) {
if (!i.trianglesBefore) return '';
var removed = i.trianglesBefore - i.triangles, share = removed / i.trianglesBefore * 100;
return '' +
esc(share.toFixed(share < 10 ? 1 : 0)) + '% ' + esc(removed.toLocaleString()) + ' tris';
}, 'savings', true),
column('Size', function (i) { return '' + (i.bricks ? esc(i.bricks) + ' bricks ' + esc(i.triangles.toLocaleString()) + ' triangles' : '') + ''; }, 'bricks', true),
column('File', function (i) { return '' + esc(fileName(i)) + '' + errorText(i); }, 'name'),
column('Where', function (i) { return '' + whereText(i.where, i) + ''; }),
column('', function (i) { return '
' + actions(i, i.id) + '
'; })
]),
// The models' columns but Saved: an assembly's ID and owner are its newest build's, its size its modules, its
// file its type and modules, and it is where the builds that use it are (References)
modular: [
column('Icon', function (i) { return iconImg(i, 48); }),
column('ID', function (i) { return '' + esc(i.newestBuild) + ''; }, 'newest', true),
column('Owner', function (i) {
return owner(i) + (i.owners > 1 ? '
'; })
])
};
// The sort a DataTables order asks for, as /api/ugc's sort= and reverse=
function orderQuery(columns, order) {
var o = order && order[0], c = o && columns[o.column];
if (!c || !c.sortKey) return 'sort=newest';
return 'sort=' + c.sortKey + ((o.dir === 'desc') !== c.desc ? '&reverse=1' : '');
}
// Numbered pages with first and last, around the current one
function pager(el, page, pages, onPage) {
if (pages <= 1) { el.innerHTML = ''; return; }
var shown = [];
for (var p = Math.max(0, page - 2); p <= Math.min(pages - 1, page + 2); p++) shown.push(p);
var li = function (p, label, disabled, active) {
return '
' : '');
}).catch(function () { box.innerHTML = fmt.badge('Unknown', 'secondary') + ' Couldn\'t ask the dashboard for the UGC server\'s status.'; });
}
function remake(body, question) {
if (question && !confirm(question)) return;
body.kind = list.kind;
api.post('/api/ugc/reprocess', body).then(function (d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
toast(d.message, 'success');
load();
});
}
// ---- the open item ----
function statsTable(now, before) {
if (!now) return '
No stats for this model (made before the UGC server wrote them).
';
var lods = now.lods || [], old = before && before.lods || [];
var diff = function (a, b) {
if (b === undefined || b === null || a === b) return '';
var d = a - b;
return ' (' + (d > 0 ? '+' : '') + d + ')';
};
var rows = lods.map(function (l, i) {
var o = old[i] || {};
var removed = l.opaqueBefore ? (100 - l.opaqueAfter / l.opaqueBefore * 100).toFixed(0) + '%' : '';
return '
Modules (combination ' + esc(item.key) + ', used by ' + esc(item.uses) + ' build' + (item.uses === 1 ? '' : 's') +
'). The game client puts cars and rockets together itself; the UGC server only draws the icon, once per combination.
' + modulesHtml(item) : '';
$('referencesCard').classList.toggle('d-none', !modular);
if (modular) {
refs = { key: item.key, page: 0, q: '', highlight: build || '' };
$('refSearch').value = '';
loadRefs();
}
UgcIconEditor.open(modular ? { model: false, modules: item.modules || item.key.replace(/-/g, '+'), label: item.key } : { model: true, id: id });
bootstrap.Modal.getOrCreateInstance($('previewModal')).show();
if (modular) return;
Promise.all([fetchJson(fileUrl('model', id, 'stats.json')), fetchJson(fileUrl('model', id, 'previous.stats.json'))]).then(function (s) {
if (!current || current.id !== id) return;
current.stats = s[0];
current.statsLoaded = true;
$('statsBox').innerHTML = statsTable(s[0], s[1]);
var lods = (s[0] && s[0].lods) || [{ lod: 0 }];
$('lodSelect').innerHTML = lods.map(function (l, n) { return ''; }).join('');
$('versionSelect').querySelector('[value=previous]').disabled = !s[1];
$('versionSelect').value = 'current';
loadMesh(true);
});
var nifBox = $('nifViewer'), lxfmlBox = $('lxfmlViewer');
import('/js/ugc-viewer.js').then(function (module) {
if (!nifViewer) {
nifViewer = module.createNifViewer(nifBox);
nifViewer.setWireframe($('wireframeSwitch').checked);
nifViewer.setVertexColors($('colorsSwitch').checked);
}
if (current && current.id === id && current.statsLoaded) loadMesh(true);
}).catch(function (e) { nifBox.textContent = 'The 3D view could not load: ' + e.message; });
lxfmlBox.textContent = 'Loading…';
import('/js/lddviewer.js').then(function (module) {
if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose();
lxfmlBox.textContent = '';
lxfmlViewer = module.createViewer(lxfmlBox, {});
return lxfmlViewer.load([{ id: id, name: 'Model ' + id, position: [0, 0, 0], rotation: [0, 0, 0, 1], url: '/api/ugc/' + id + '/lxfml' }], 1);
}).catch(function (e) { lxfmlBox.textContent = 'The 3D view could not load: ' + e.message; });
}
function fetchJson(url) {
return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}
function readUrlItem() {
var p = new URLSearchParams(location.search), item = p.get('item');
return item ? { id: item, build: p.get('build') || '' } : null;
}
// ---- an assembly's references: the builds that use it, and where they are ----
var refs = null, refTimer = 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; }
// Where a creation is: placed on a property (with the name the player gave it there, and its 3D view), in a mail or
// in its creator's inventories (`item`: the creation, for its creator's name)
function whereText(w, item) {
return (w || []).map(function (x) {
if (x.type === 'property') {
// A model the player didn't name has the client's placeholder (Objects__name)
var named = x.modelName && !/^Objects_\d+_name$/.test(x.modelName) ? ' as ' + esc(x.modelName) + '' : '';
return '