diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index afd2dc98c..431554f7c 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -130,7 +130,6 @@ namespace { c.AddSection("Startup"); c.Add(Bool(MASTER, "prestart_servers", "Start auth, chat and char servers", "Master starts the other servers itself.", true, true)); c.Add(Bool(MASTER, "enable_dashboard", "Start the web dashboard", "", false, true)); - c.Add(Bool(MASTER, "enable_ugc_server", "Start the UGC server", "Makes and serves the meshes and icons of what players build (docs/UgcServer.md).", false, true)); c.Add(Bool(SHARED, "skip_account_creation", "Skip the first-account prompt", "For non-interactive setups: master doesn't ask for an account when there are none.", false, true)); c.AddSection("Game client"); @@ -298,8 +297,6 @@ namespace { c.Add(Port(DASHBOARD, "port", "Web port", "Where the dashboard listens for browsers.", "2006")); c.Add(Format(Text(DASHBOARD, "listen_ip", "Listen address", "127.0.0.1 for this machine only (put a reverse proxy in front); 0.0.0.0 for everyone.", "127.0.0.1", true), eFormat::HOST)); c.Add(Port(DASHBOARD, "net_port", "Server link port", "UDP port for the connection to master (the next one is used too). Keep clear of other servers' ports.", "2010")); - c.Add(Format(Text(DASHBOARD, "ugc_internal_url", "UGC server address (internal)", "Where the dashboard itself reaches the UGC server for its status and files. Empty: http://127.0.0.1:2008.", ""), eFormat::URL)); - c.Add(Format(Text(DASHBOARD, "ugc_public_url", "UGC server address (public)", "Only for the \"open on the UGC server\" links, e.g. https://ugc.example.com. The dashboard's own pages don't need the browser to reach it.", ""), eFormat::URL)); c.Add(Format(Text(DASHBOARD, "dashboard_url", "Public address", "For links in emails and alerts, e.g. https://dashboard.example.com.", ""), eFormat::URL)); c.Add(Bool(DASHBOARD, "secure_cookies", "HTTPS only cookies", "Turn on when the dashboard is served over HTTPS.", false)); c.Add(Bool(DASHBOARD, "behind_proxy", "Behind a reverse proxy", "Use the proxy's X-Forwarded-For for rate limits. Only when the dashboard can't be reached directly.", false)); @@ -412,14 +409,17 @@ namespace { c.AddSection("Character history"); c.Add(Days("snapshot_days", "Snapshots", "Older snapshots are deleted, but each character keeps its newest few (below).", "90", 1)); c.Add(Unit(Int(DASHBOARD, "snapshot_keep", "Always keep per character", "", "10", 1, 1000), "snapshots")); - c.AddSection("UGC server", "Makes the meshes and icons of what players build and serves them to the game client. See docs/UgcServer.md.", - eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } }); + // The UGC server's settings, also shown beside what they change on the UGC page (/ugc reads these sections) + c.AddCategory("ugc", "UGC server", "Makes the meshes and icons of what players build and serves them to the game client. See docs/UgcServer.md; the UGC page shows these beside what they change."); + c.AddSection("UGC serving", "Where the game client downloads from and where the dashboard reaches the UGC server."); + c.Add(Bool(MASTER, "enable_ugc_server", "Start the UGC server", "Makes and serves the meshes and icons of what players build (docs/UgcServer.md).", false, true)); c.Add(Port(UGC, "port", "Download port", "The game client's UGCSERVERPORT.", "2008")); c.Add(Format(Text(UGC, "listen_ip", "Listen address", "The game client has to reach it.", "0.0.0.0", true), eFormat::HOST)); c.Add(Port(UGC, "net_port", "Master connection port", "UDP; the next port is used too.", "2012")); 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(Format(Text(DASHBOARD, "ugc_internal_url", "UGC server address (internal)", "Where the dashboard itself reaches the UGC server for its status and files. Empty: http://127.0.0.1:2008.", ""), eFormat::URL)); + c.Add(Format(Text(DASHBOARD, "ugc_public_url", "UGC server address (public)", "Only for the \"open on the UGC server\" links, e.g. https://ugc.example.com. The dashboard's own pages don't need the browser to reach it.", ""), eFormat::URL)); + c.AddSection("UGC processing", "When models are made and how much of the machine the workers may use.", eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } }); 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), "%")); @@ -430,6 +430,7 @@ namespace { c.Add(Unit(Int(UGC, "poll_interval_ms", "Look for new models every", "", "2000", 100, 600000, true), "ms")); c.Add(Int(UGC, "poll_batch", "Models taken at once", "", "32", 1, 1000, true)); c.Add(Int(UGC, "max_attempts", "Attempts before giving up", "", "3", 1, 100, true)); + c.AddSection("UGC models", "How a model's mesh is made from its bricks (LU Toolbox's steps).", eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } }); c.Add(Labels(Choice(UGC, "color_palette", "Colors", "LU Toolbox's LU palette (unknown colors black), or the client's Materials.xml.", "lu_toolbox", { "lu_toolbox", "brickdb" }), { "LU Toolbox", "Brick database" })); c.Add(Unit(Float(UGC, "color_variation", "Color variation", "Each brick's brightness is shifted by up to this much (the same every time the model is made). 0: none.", "5", 0, 100), "%")); c.Add(Unit(Float(UGC, "transparent_opacity", "Transparent opacity", "", "58.82", 0, 100), "%")); @@ -449,6 +450,11 @@ namespace { c.Add(Float(UGC, "ao_distance", "Occlusion distance", "", "5", 0, 1000)); 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.AddSection("UGC storage", "The files made, kept on disk.", eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } }); + 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.AddSection("UGC icons", "The icons' size and the defaults of their framing and light. Presets per type and values for single items are set in the UGC page's icon editor.", + eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } }); c.Add(Unit(Int(UGC, "icon_size", "Icon size", "", "128", 16, 1024), "pixels")); // 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 diff --git a/dDashboardServer/static/js/settings.js b/dDashboardServer/static/js/settings.js index 96683b933..1d324ad55 100644 --- a/dDashboardServer/static/js/settings.js +++ b/dDashboardServer/static/js/settings.js @@ -535,10 +535,12 @@ d.sections.forEach(function (sec) { if (sec.condition) state.controllers[sec.condition.file + '/' + sec.condition.key] = true; }); // Drop edits to settings that no longer exist Object.keys(state.pending).forEach(function (key) { if (!state.byKey[key]) delete state.pending[key]; }); - var fromHash = location.hash.slice(1); - if (!state.category) state.category = state.categories.some(function (c) { return c.id === fromHash; }) ? fromHash : state.categories[0].id; + // #, or #/ for one setting (the UGC page's "Open in Settings" links) + var fromHash = decodeURIComponent(location.hash.slice(1)), linked = !state.category && state.byKey[fromHash]; + if (!state.category) state.category = state.categories.some(function (c) { return c.id === fromHash; }) ? fromHash : linked ? linked.category : state.categories[0].id; document.getElementById('addFile').innerHTML = d.files.map(function (f) { return ''; }).join(''); render(); + if (linked) goTo(fromHash); }); } diff --git a/dDashboardServer/static/js/ugc-settings.js b/dDashboardServer/static/js/ugc-settings.js new file mode 100644 index 000000000..f0c7f6f1f --- /dev/null +++ b/dDashboardServer/static/js/ugc-settings.js @@ -0,0 +1,78 @@ +/** + * The UGC server's settings on the UGC page, beside what they change: any element with data-ugc-settings="
, + *
" shows those sections of the settings catalog (the same entries, values and save path as the Settings + * page, /api/settings, so a change in either place is the other's too; the servers reload at once). Settings only read + * at start say so. Each has a link to it on the Settings page. Only for those with the settings permission. + */ +(function () { + 'use strict'; + + if (!window.DASH || !DASH.can('settings')) return; + var boxes = document.querySelectorAll('[data-ugc-settings]'); + if (!boxes.length) return; + var settings = null; + + function key(s) { return s.file + '/' + s.name; } + function sourceText(s) { + if (s.source === 'web') return 'set here'; + if (s.source === 'file') return 'from ' + s.file; + if (s.source === 'env') return 'from the environment'; + return 'default'; + } + function input(s) { + var id = 'ugcset-' + key(s).replace(/[^a-z0-9_]/gi, '-'), value = s.value === undefined || s.value === null ? '' : String(s.value); + var attrs = ' id="' + id + '" data-ugc-setting="' + esc(key(s)) + '"' + (s.fileOnly ? ' disabled' : ''); + if (s.type === 'bool') { + return '
'; + } + if (s.type === 'choice') { + return ''; + } + var number = s.type === 'int' || s.type === 'float'; + return ''; + } + function row(s) { + return '
Open in Settings
' + + '
' + input(s) + (s.unit ? '' + esc(s.unit) + '' : '') + '
' + + '
' + (s.description ? esc(s.description) + ' ' : '') + 'Default ' + esc(s.default === '' ? '(empty)' : s.default) + '; ' + + '' + esc(sourceText(s)) + '.' + (s.restart ? ' ' + fmt.badge('restart', 'warning') : '') + '
'; + } + function render(box) { + var sections = box.dataset.ugcSettings.split(',').map(function (x) { return x.trim(); }); + var shown = settings.filter(function (s) { return sections.indexOf(s.section) !== -1 && !s.secret; }); + var open = box.querySelector('details[open]') ? ' open' : ''; + box.innerHTML = '
' + esc(box.dataset.title || 'Settings') + ' (' + shown.length + ')' + + '
The same settings as on the Settings page; a change saves at once and the servers reload. ' + + 'Those marked restart are only read when the UGC server starts.
' + shown.map(row).join('') + '
'; + } + function load() { + return api.get('/api/settings').then(function (d) { + if (!d.success) return; + settings = d.settings; + boxes.forEach(render); + }); + } + + document.addEventListener('change', function (e) { + var el = e.target.closest && e.target.closest('[data-ugc-setting]'); + if (!el || !settings) return; + var s = settings.find(function (x) { return key(x) === el.dataset.ugcSetting; }); + if (!s) return; + var value = s.type === 'bool' ? el.checked : el.value; + // A value the .ini file (or environment) sets would win over one set here unless this one is told to win + api.post('/api/settings', { file: s.file, name: s.name, value: value, webWins: s.source === 'file' || s.source === 'env' || s.webWins }).then(function (d) { + toast(d.success ? d.message : (d.error || 'Not saved'), d.success ? 'success' : 'danger'); + if (!d.success) return; + s.value = d.value; + s.source = 'web'; + document.querySelectorAll('[data-ugc-source="' + CSS.escape(key(s)) + '"]').forEach(function (x) { x.textContent = sourceText(s); }); + }); + }); + if (window.Live) Live.on('settings', load); + load(); +})(); diff --git a/dDashboardServer/templates/ugc.jinja2 b/dDashboardServer/templates/ugc.jinja2 index b737769ba..202529380 100644 --- a/dDashboardServer/templates/ugc.jinja2 +++ b/dDashboardServer/templates/ugc.jinja2 @@ -14,7 +14,10 @@
-
Asking the UGC server…
+
Asking the UGC server…
+
+
+
@@ -23,6 +26,7 @@

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.

Loading…
+
@@ -41,6 +45,7 @@
+
@@ -205,6 +210,7 @@ } + {% endblock %} diff --git a/tests/dWebTests/UgcLookupTests.cpp b/tests/dWebTests/UgcLookupTests.cpp index 7e7119727..e0d519523 100644 --- a/tests/dWebTests/UgcLookupTests.cpp +++ b/tests/dWebTests/UgcLookupTests.cpp @@ -4,6 +4,7 @@ #include +#include "UgcAssemblies.h" #include "UgcLookup.h" #include "UgcLookupSql.h" @@ -103,14 +104,17 @@ namespace { Exec("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT);" "CREATE TABLE charinfo (id BIGINT PRIMARY KEY, account_id INTEGER, name TEXT);" "CREATE TABLE properties (id BIGINT PRIMARY KEY, owner_id BIGINT, name TEXT, zone_id INTEGER);" - "CREATE TABLE ugc (id INTEGER PRIMARY KEY, account_id INTEGER, character_id BIGINT, is_optimized INTEGER, filename TEXT, process_error TEXT DEFAULT '');" - "CREATE TABLE ugc_modular_build (ugc_id BIGINT PRIMARY KEY, character_id BIGINT, ldf_config TEXT, is_optimized INTEGER DEFAULT 0, process_error TEXT DEFAULT '');" + "CREATE TABLE ugc (id INTEGER PRIMARY KEY, account_id INTEGER, character_id BIGINT, is_optimized INTEGER, filename TEXT, process_error TEXT DEFAULT '', " + "process_attempts INTEGER DEFAULT 0, processed_at BIGINT DEFAULT 0, process_after BIGINT DEFAULT 0, bake_ao INTEGER DEFAULT 0, brick_count INTEGER DEFAULT 0, triangle_count INTEGER DEFAULT 0);" + "CREATE TABLE ugc_modular_build (ugc_id BIGINT PRIMARY KEY, character_id BIGINT, ldf_config TEXT, is_optimized INTEGER DEFAULT 0, process_error TEXT DEFAULT '', " + "process_attempts INTEGER DEFAULT 0, processed_at BIGINT DEFAULT 0);" "CREATE TABLE properties_contents (id BIGINT PRIMARY KEY, property_id BIGINT, ugc_id BIGINT, lot INTEGER, model_name TEXT DEFAULT '', model_description TEXT DEFAULT '');" "INSERT INTO accounts VALUES (1, 'builder'), (2, 'racer');" "INSERT INTO charinfo VALUES (10, 1, 'Bricky'), (20, 2, 'Speedy');" "INSERT INTO properties VALUES (500, 10, 'Castle Hill', 1150);" - "INSERT INTO ugc VALUES (1000, 1, 10, 1, 'tower.lxfml', ''), (1001, 1, 10, 0, 'boat.lxfml', '');" - "INSERT INTO ugc_modular_build VALUES (2000, 20, '1:8129+1:8130+1:9330', 1, '');" + "INSERT INTO ugc (id, account_id, character_id, is_optimized, filename, process_error, brick_count, triangle_count) VALUES " + "(1000, 1, 10, 1, 'tower.lxfml', '', 40, 900), (1001, 1, 10, 0, 'boat.lxfml', '', 0, 0);" + "INSERT INTO ugc_modular_build (ugc_id, character_id, ldf_config, is_optimized, process_error) VALUES (2000, 20, '1:8129+1:8130+1:9330', 1, '');" "INSERT INTO properties_contents VALUES (7000, 500, 1000, 14, 'Big tower', 'the tallest');"); } @@ -132,9 +136,125 @@ namespace { sqlite3_finalize(stmt); return ids; } + + // A list page's ids in order, and the count, as ListUgc runs them (all, or a search) + std::pair, int64_t> List(const IUgcLookup::UgcListQuery& query, bool modular) { + const bool searching = UgcLookupSql::Searching(query.search); + const auto where = (searching ? UgcLookupSql::Where(query.search, modular) : std::string("WHERE 1=1 ")) + UgcLookupSql::ListFilter(query, modular); + const auto pattern = "%" + query.search.text + "%"; + const auto run = [&](const std::string& sql, bool page) { + std::vector out; + sqlite3_stmt* stmt = nullptr; + EXPECT_EQ(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), SQLITE_OK) << sqlite3_errmsg(db) << "\n" << sql; + int i = 1; + if (searching) for (; i <= UgcLookupSql::TEXT_BINDS; i++) sqlite3_bind_text(stmt, i, pattern.c_str(), -1, SQLITE_TRANSIENT); + if (page) { + sqlite3_bind_int(stmt, i++, static_cast(query.limit)); + sqlite3_bind_int(stmt, i++, static_cast(query.offset)); + } + while (sqlite3_step(stmt) == SQLITE_ROW) out.push_back(sqlite3_column_int64(stmt, 0)); + sqlite3_finalize(stmt); + return out; + }; + const auto ids = run(UgcLookupSql::Select(modular) + where + UgcLookupSql::ListOrder(query, modular) + "LIMIT ? OFFSET ?;", true); + const auto count = run("SELECT COUNT(*) " + UgcLookupSql::From(modular) + where + ";", false); + return { ids, count.empty() ? -1 : count.front() }; + } }; } +TEST_F(UgcLookupSqlTests, ListsPagesWithCounts) { + IUgcLookup::UgcListQuery query; + auto [ids, total] = List(query, false); + EXPECT_EQ(ids, (std::vector{ 1001, 1000 })); // newest first + EXPECT_EQ(total, 2); + query.sort = IUgcLookup::eSort::BRICKS; + EXPECT_EQ(List(query, false).first, (std::vector{ 1000, 1001 })); + query.sort = IUgcLookup::eSort::NAME; // boat before tower + EXPECT_EQ(List(query, false).first, (std::vector{ 1001, 1000 })); + query.limit = 1; + query.offset = 1; + std::tie(ids, total) = List(query, false); + EXPECT_EQ(ids, (std::vector{ 1000 })); + EXPECT_EQ(total, 2); // the count is of every match, not the page + query = {}; + query.state = IUgc::eProcessState::DONE; + EXPECT_EQ(List(query, false), (std::pair, int64_t>{ { 1000 }, 1 })); + query.search = ParseQuery("boat"); // searched and in a state: none + EXPECT_EQ(List(query, false).second, 0); + query.state.reset(); + EXPECT_EQ(List(query, false), (std::pair, int64_t>{ { 1001 }, 1 })); + EXPECT_EQ(List({}, true), (std::pair, int64_t>{ { 2000 }, 1 })); +} + +TEST(UgcAssemblies, GroupsBuildsByTheirModules) { + using State = IUgc::eProcessState; + const auto build = [](LWOOBJID id, LWOOBJID owner, const std::string& ldf, State state) { + IUgcLookup::UgcEntry e; + e.kind = IUgcLookup::eUgcKind::MODULAR; + e.id = id; + e.characterId = owner; + e.detail = ldf; + e.state = state; + if (state == State::FAILED) e.error = "broken"; + return e; + }; + const std::map modules = { + { 4713, { 3, "Classic Rocket Nose Cone" } }, { 4714, { 3, "Classic Rocket Cockpit" } }, { 4715, { 3, "Classic Rocket Engine" } }, + { 8129, { 6, "Racing Car Chassis" } }, { 8130, { 6, "Racing Car Engine" } }, + }; + const std::vector builds = { + build(10, 1, "1:4713+1:4714+1:4715", State::DONE), + build(11, 2, "1:4715+1:4714+1:4713", State::PENDING), // the same modules written differently + build(12, 2, "1:8129+1:8130", State::FAILED), + build(13, 3, "", State::PENDING), // no modules: left out + build(14, 1, "1:4713+1:4714+1:4715", State::FAILED), + }; + auto list = UgcAssemblies::Group(builds, modules); + ASSERT_EQ(list.size(), 2u); + const auto rocket = std::find_if(list.begin(), list.end(), [](const auto& a) { return a.key == "4713-4714-4715"; }); + ASSERT_NE(rocket, list.end()); + EXPECT_EQ(rocket->builds, (std::vector{ 14, 11, 10 })); + EXPECT_EQ(rocket->owners.size(), 2u); + EXPECT_EQ(rocket->buildType, 3); + EXPECT_EQ(rocket->state, State::DONE); // one build's icon is made: the combination's is + EXPECT_EQ(rocket->iconBuild, 10); + EXPECT_TRUE(rocket->error.empty()); + const auto car = std::find_if(list.begin(), list.end(), [](const auto& a) { return a.key == "8129-8130"; }); + EXPECT_EQ(car->state, State::FAILED); + EXPECT_EQ(car->error, "broken"); + + UgcAssemblies::Filter filter; + filter.buildType = 6; + EXPECT_FALSE(UgcAssemblies::Matches(*rocket, filter, modules)); + EXPECT_TRUE(UgcAssemblies::Matches(*car, filter, modules)); + filter = {}; + filter.moduleText = "cockpit"; + EXPECT_TRUE(UgcAssemblies::Matches(*rocket, filter, modules)); + EXPECT_FALSE(UgcAssemblies::Matches(*car, filter, modules)); + filter = {}; + filter.moduleLot = 8130; + EXPECT_TRUE(UgcAssemblies::Matches(*car, filter, modules)); + filter = {}; + filter.builds = std::set{ 11 }; // an owner's build + EXPECT_TRUE(UgcAssemblies::Matches(*rocket, filter, modules)); + EXPECT_FALSE(UgcAssemblies::Matches(*car, filter, modules)); + filter = {}; + filter.state = State::FAILED; + EXPECT_FALSE(UgcAssemblies::Matches(*rocket, filter, modules)); + + UgcAssemblies::Sort(list, UgcAssemblies::eSort::REFERENCES, modules); + EXPECT_EQ(list.front().key, "4713-4714-4715"); + UgcAssemblies::Sort(list, UgcAssemblies::eSort::NEWEST, modules); + EXPECT_EQ(list.front().key, "4713-4714-4715"); // build 14 + UgcAssemblies::Sort(list, UgcAssemblies::eSort::NAME, modules); + EXPECT_EQ(list.front().key, "4713-4714-4715"); // Classic before Racing + UgcAssemblies::Sort(list, UgcAssemblies::eSort::OLDEST, modules); + EXPECT_EQ(list.front().key, "4713-4714-4715"); // build 10 + EXPECT_EQ(UgcAssemblies::ParseSort("uses"), UgcAssemblies::eSort::REFERENCES); + EXPECT_FALSE(UgcAssemblies::ParseSort("nonsense")); +} + TEST_F(UgcLookupSqlTests, FindsByIds) { EXPECT_EQ(Find("1000", false), (std::set{ 1000 })); // the blueprint id EXPECT_EQ(Find("7000", false), (std::set{ 1000 })); // the placed model's object id diff --git a/tests/smoke/__pycache__/dashboard_smoke_test.cpython-314.pyc b/tests/smoke/__pycache__/dashboard_smoke_test.cpython-314.pyc index ad11cc7a1..b248eeeaa 100644 Binary files a/tests/smoke/__pycache__/dashboard_smoke_test.cpython-314.pyc and b/tests/smoke/__pycache__/dashboard_smoke_test.cpython-314.pyc differ