mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
With UGCUSE3DSERVICES=7:0 (the client's default) the client asks its world for a blueprint file's MD5 and size (REQUEST_UGC_MANIFEST_INFO, world 27) and then downloads BrickModels/UserMade/<id % 1000>/<id>.<ext>.sd0. Layouts checked in the 1.10.64 client: the request is a u64 blueprint id and a u8 resource type; the answer (UGC_MANIFEST_RESPONSE, client 60) repeats them and adds a u8 valid, the u32 size and the 16 byte MD5 of the inflated file, 37 bytes after the 0x53 exactly or the client drops it. - dNet: WorldPackets::RequestUgcManifestInfo, ClientPackets::UgcManifestResponse (eUgcResourceType), with byte tests against the client's layouts. - Database: ugc_file_checksums (per model or module combination and file) and ugc_modular_build.combination_id (migrations 89 / 72), GetUgcFileChecksum looks a blueprint up as a model, else as a build through its combination. - UGC server: every download is also written as .sd0 (Sd0::Compress); workers hand the checksums back and the main thread stores them; old items get their sd0 icon and checksum, and builds their combination id, once at start-up, a few per tick; serves <dir>/BrickModels/UserMade/<bucket>/<id>.<ext>.sd0 under client_path, /<folder>/UserBrickModels and the root (.hkx 404). - World: UgcManifest answers on the main thread with one indexed query per request; files not made yet are answered when they are (looked at again every 5 seconds), and a model in its quiet period is made right away. No worker threads, HTTP or file reads in the world. Off by default (ugc_manifest=0): checked in game, the client then downloads from http://127.0.0.1:80/lwoclient/UserBrickModels/ whatever its boot.cfg says and logs the player out when it can't connect, so icons need the UGC server on port 80 of each player's machine. docs/UgcServer.md has the details. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
653 lines
52 KiB
C++
653 lines
52 KiB
C++
#include "SettingsCatalog.h"
|
||
|
||
#include "UgcIconParams.h"
|
||
|
||
#include <charconv>
|
||
#include <cmath>
|
||
#include <map>
|
||
#include <sstream>
|
||
|
||
#include "GeneralUtils.h"
|
||
|
||
namespace {
|
||
using namespace SettingsCatalog;
|
||
|
||
const std::string SHARED = "sharedconfig.ini";
|
||
const std::string MASTER = "masterconfig.ini";
|
||
const std::string AUTH = "authconfig.ini";
|
||
const std::string CHAT = "chatconfig.ini";
|
||
const std::string WORLD = "worldconfig.ini";
|
||
const std::string DASHBOARD = "dashboardconfig.ini";
|
||
const std::string UGC = "ugcconfig.ini";
|
||
|
||
constexpr double PORT_MIN = 1;
|
||
constexpr double PORT_MAX = 65535;
|
||
constexpr double DAYS_MAX = 36500;
|
||
|
||
// ---- building blocks; the section is filled in by Catalog::Add ----
|
||
|
||
Setting Bool(const std::string& file, const std::string& key, const std::string& title, const std::string& description, bool on, bool restart = false) {
|
||
return { .key = key, .file = file, .title = title, .description = description, .type = eType::BOOL, .defaultValue = on ? "1" : "0", .restart = restart };
|
||
}
|
||
|
||
Setting Int(const std::string& file, const std::string& key, const std::string& title, const std::string& description,
|
||
std::string def, std::optional<double> min, std::optional<double> max, bool restart = false) {
|
||
return { .key = key, .file = file, .title = title, .description = description, .type = eType::INT, .defaultValue = std::move(def), .min = min, .max = max, .restart = restart };
|
||
}
|
||
|
||
Setting Port(const std::string& file, const std::string& key, const std::string& title, const std::string& description, std::string def) {
|
||
return Int(file, key, title, description, std::move(def), PORT_MIN, PORT_MAX, true);
|
||
}
|
||
|
||
Setting Days(const std::string& key, const std::string& title, const std::string& description, std::string def, double min = 0) {
|
||
auto setting = Int(DASHBOARD, key, title, description, std::move(def), min, DAYS_MAX);
|
||
setting.unit = "days";
|
||
return setting;
|
||
}
|
||
|
||
Setting Text(const std::string& file, const std::string& key, const std::string& title, const std::string& description,
|
||
std::string def = "", bool restart = false) {
|
||
return { .key = key, .file = file, .title = title, .description = description, .type = eType::TEXT, .defaultValue = std::move(def), .restart = restart };
|
||
}
|
||
|
||
Setting Secret(const std::string& file, const std::string& key, const std::string& title, const std::string& description, bool restart = false) {
|
||
return { .key = key, .file = file, .title = title, .description = description, .type = eType::SECRET, .restart = restart };
|
||
}
|
||
|
||
Setting List(const std::string& file, const std::string& key, const std::string& title, const std::string& description, eListOf of, std::string def = "") {
|
||
return { .key = key, .file = file, .title = title, .description = description, .type = eType::INT_LIST, .defaultValue = std::move(def), .listOf = of };
|
||
}
|
||
|
||
Setting Choice(const std::string& file, const std::string& key, const std::string& title, const std::string& description,
|
||
std::string def, std::vector<std::string> choices, bool restart = false) {
|
||
return { .key = key, .file = file, .title = title, .description = description, .type = eType::CHOICE, .defaultValue = std::move(def), .choices = std::move(choices), .restart = restart };
|
||
}
|
||
|
||
Setting Float(const std::string& file, const std::string& key, const std::string& title, const std::string& description,
|
||
std::string def, std::optional<double> min, std::optional<double> max) {
|
||
return { .key = key, .file = file, .title = title, .description = description, .type = eType::FLOAT, .defaultValue = std::move(def), .min = min, .max = max };
|
||
}
|
||
|
||
// ---- modifiers ----
|
||
|
||
Setting Unit(Setting setting, std::string unit) { setting.unit = std::move(unit); return setting; }
|
||
Setting Labels(Setting setting, std::vector<std::string> labels) { setting.choiceLabels = std::move(labels); return setting; }
|
||
Setting Format(Setting setting, eFormat format) { setting.format = format; return setting; }
|
||
Setting Unused(Setting setting) { setting.unused = true; return setting; }
|
||
Setting Multiline(Setting setting) { setting.multiline = true; return setting; }
|
||
Setting When(Setting setting, const std::string& file, const std::string& key, std::vector<std::string> values) {
|
||
setting.condition = Condition{ file, key, std::move(values) };
|
||
return setting;
|
||
}
|
||
|
||
struct Catalog {
|
||
std::vector<Category> categories;
|
||
std::vector<Section> sections;
|
||
std::vector<Setting> settings;
|
||
|
||
void AddCategory(std::string id, std::string name, std::string description) {
|
||
categories.push_back({ std::move(id), std::move(name), std::move(description) });
|
||
}
|
||
// Settings added after this go in this section (of the last category added)
|
||
void AddSection(std::string name, std::string description = "", eLayout layout = eLayout::ROWS, std::optional<Condition> condition = std::nullopt) {
|
||
sections.push_back({ categories.back().id, std::move(name), std::move(description), layout, std::move(condition) });
|
||
}
|
||
void Add(Setting setting) {
|
||
setting.section = sections.back().name;
|
||
settings.push_back(std::move(setting));
|
||
}
|
||
};
|
||
|
||
Catalog Build() {
|
||
Catalog c;
|
||
|
||
// ================= Server =================
|
||
c.AddCategory("server", "Server", "Where data lives, how the servers find each other and the players, and logging.");
|
||
|
||
c.AddSection("Database", "Only in the .ini files or the environment: the servers need it before they can read anything else.");
|
||
c.Add(Choice(SHARED, "database_type", "Database", "Where game data is stored.", "sqlite", { "sqlite", "mysql" }, true));
|
||
c.Add(When(Format(Text(SHARED, "sqlite_database_path", "SQLite file", "Relative to the server binaries.", "", true), eFormat::PATH), SHARED, "database_type", { "sqlite" }));
|
||
|
||
c.AddSection("MySQL", "The MySQL or MariaDB server.", eLayout::ROWS, Condition{ SHARED, "database_type", { "mysql" } });
|
||
c.Add(Format(Text(SHARED, "mysql_host", "Host", "Host name or address.", "", true), eFormat::HOST));
|
||
c.Add(Text(SHARED, "mysql_database", "Database name", "", "", true));
|
||
c.Add(Text(SHARED, "mysql_username", "User", "", "", true));
|
||
c.Add(Secret(SHARED, "mysql_password", "Password", "", true));
|
||
|
||
c.AddSection("Network", "Addresses and ports. Changing a port needs a restart of every server.");
|
||
c.Add(Format(Text(SHARED, "external_ip", "Public address", "The address players connect to. localhost for a server only you play on.", "localhost", true), eFormat::HOST));
|
||
c.Add(Format(Text(SHARED, "bind_ip", "Listen address", "The local IPv4 address the servers listen on. Empty for all interfaces; players are still sent the public address.", "", true), eFormat::HOST));
|
||
c.Add(Format(Text(MASTER, "master_ip", "Master address", "The address the other servers use to reach master.", "localhost", true), eFormat::HOST));
|
||
c.Add(Port(MASTER, "master_server_port", "Master port", "", "2000"));
|
||
c.Add(Port(AUTH, "auth_server_port", "Auth port", "The retail client always connects to 1001.", "1001"));
|
||
c.Add(Port(SHARED, "chat_server_port", "Chat port", "Used by the chat and world servers.", "2005"));
|
||
c.Add(Port(MASTER, "world_port_start", "First world port", "World servers take ports counting up from here.", "3000"));
|
||
c.Add(Secret(MASTER, "master_password", "Master password", "Shared by the servers to connect to master. Change it on public servers.", true));
|
||
c.Add(Int(SHARED, "max_clients", "Maximum connections", "How many clients each server accepts at once.", "999", 1, 10000, true));
|
||
c.Add(Unit(Int(SHARED, "maximum_mtu_size", "Packet size (MTU)", "Largest packet sent. Lower it if players get stuck at 55% loading.", "1228", 512, 1492, true), "bytes"));
|
||
c.Add(Unit(Int(SHARED, "maximum_outgoing_bandwidth", "Outgoing bandwidth limit", "0 means no limit. Raise it if enemies lag behind players.", "0", 0, std::nullopt, true), "bits/s"));
|
||
|
||
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(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");
|
||
c.Add(Format(Text(SHARED, "client_location", "Client folder", "The folder with res/, or the one with client/ and versions/.", "", true), eFormat::PATH));
|
||
c.Add(Int(SHARED, "client_net_version", "Network version", "Clients reporting another version are refused. 171022 for the retail client, 171023 for Darkflame Universe clients.", "171022", 0, std::nullopt, true));
|
||
c.Add(Int(SHARED, "version_major", "Version: major", "Sent to the client at login and used for level gating.", "1", 0, 65535));
|
||
c.Add(Int(SHARED, "version_current", "Version: current", "", "10", 0, 65535));
|
||
c.Add(Int(SHARED, "version_minor", "Version: minor", "", "64", 0, 65535));
|
||
|
||
c.AddSection("Logging and crashes");
|
||
c.Add(Bool(SHARED, "log_to_console", "Log to the console", "Also print log lines to the terminal.", true, true));
|
||
c.Add(Bool(SHARED, "log_debug_statements", "Debug logging", "Extra log lines for developers.", false, true));
|
||
c.Add(Format(Text(SHARED, "dump_folder", "Crash dump folder", "Where crash logs go. Empty turns them off.", "", true), eFormat::PATH));
|
||
c.Add(Bool(WORLD, "generate_dump", "Crash dumps from world servers", "Write a dump when a world server crashes (needs the crash dump folder).", false, true));
|
||
c.Add(Bool(WORLD, "save_lxfmls", "Save model files", "Save players' models (LXFML) to disk before they are split, for debugging.", false));
|
||
c.Add(Bool(WORLD, "bbb_consume_bricks", "Brick building uses bricks", "Saving a brick by brick model uses up the bricks in it, as live did. Off: the bricks go back to the backpack.", false));
|
||
c.Add(Bool(SHARED, "dont_generate_dcf", "Don't build the chat filter file", "Skip compiling the chat word list to a file.", false, true));
|
||
|
||
c.AddSection("Chat web API", "A small HTTP API on the chat server, on localhost only.");
|
||
c.Add(Bool(CHAT, "web_server_enabled", "Chat web API", "", false, true));
|
||
c.Add(When(Port(CHAT, "web_server_port", "Port", "", "2005"), CHAT, "web_server_enabled", { "1" }));
|
||
c.Add(Unused(Port(CHAT, "web_server_listen_port", "Port (old name)", "Not read: the chat server uses web_server_port.", "2005")));
|
||
|
||
c.AddSection("Physics", "How world servers split zones for collision checks.");
|
||
c.Add(Bool(WORLD, "phys_spatial_partitioning", "Spatial partitioning", "Faster collision checks. Leave on unless debugging physics.", true, true));
|
||
c.Add(When(Int(WORLD, "phys_sp_tilesize", "Tile size", "Keep tile size / tile count at 205/12 (1:1 with LU's terrain); 102/24 or 154/18 are faster.", "205", 1, 10000, true), WORLD, "phys_spatial_partitioning", { "1" }));
|
||
c.Add(When(Int(WORLD, "phys_sp_tilecount", "Tile count", "", "12", 1, 1000, true), WORLD, "phys_spatial_partitioning", { "1" }));
|
||
|
||
// ================= Gameplay =================
|
||
c.AddCategory("gameplay", "Gameplay", "What players can do in game.");
|
||
|
||
c.AddSection("Rules");
|
||
c.Add(Labels(Choice(SHARED, "default_team_loot", "Team loot", "How new teams share loot. Players can still change it in their team.", "1", { "0", "1" }),
|
||
{ "Shared loot (like live)", "Free for all" }));
|
||
c.Add(Bool(WORLD, "disable_chat", "Turn off chat", "Nobody can chat in game.", false, true));
|
||
c.Add(Bool(WORLD, "disable_extra_backpack", "No extra backpack space", "DLU gives 2 extra slots per level up; this turns that off.", false));
|
||
c.Add(Bool(WORLD, "disable_vanity", "No vanity NPCs", "Hide DLU's extra NPCs and plaques.", false, true));
|
||
c.Add(Bool(WORLD, "solo_racing", "Solo racing", "Allow racing alone.", false));
|
||
c.Add(Bool(WORLD, "classic_survival_scoring", "Classic survival scoring", "Rank Avant Gardens Survival by time, like live, instead of score.", false));
|
||
c.Add(Bool(WORLD, "pets_take_imagination", "Pets use imagination", "Like live.", false));
|
||
c.Add(Bool(WORLD, "allow_nameplate_off", "Players can hide their nameplate", "Staff always can; this lets players (GM 0) turn off the name above their head too.", false));
|
||
c.Add(Bool(WORLD, "allow_players_to_skip_cinematics", "Players can skip cinematics", "Most cutscenes get a skip option.", false));
|
||
c.Add(Bool(WORLD, "auto_reject_empty_properties", "Reject empty properties", "Properties made public without models are rejected automatically.", false));
|
||
c.Add(Unused(Bool(WORLD, "disable_drops", "Turn off loot drops", "Not read by this version.", false)));
|
||
|
||
c.AddSection("Hardcore mode", "Players lose items, coins and U-score when they die, and earn extra U-score from enemies.");
|
||
c.Add(Bool(WORLD, "hardcore_mode", "Hardcore mode", "Its settings appear below when it's on.", false));
|
||
c.Add(When(Bool(WORLD, "hardcore_dropinventory_on_death", "Drop everything on death", "Inventory and coins drop on the ground and can be picked up again.", false), WORLD, "hardcore_mode", { "1" }));
|
||
c.Add(When(Unit(Int(WORLD, "hardcore_lose_uscore_on_death_percent", "U-score lost on death", "", "10", 0, 100), "%"), WORLD, "hardcore_mode", { "1" }));
|
||
c.Add(When(Float(WORLD, "hardcore_coin_keep", "Coins kept on death", "Fraction of coins kept, 0 to 1.", "0", 0, 1), WORLD, "hardcore_mode", { "1" }));
|
||
c.Add(When(Unit(Int(WORLD, "hardcore_uscore_enemies_multiplier", "U-score per enemy", "Enemies give their max health times this. 0 turns it off.", "2", 0, 1000), "× health"), WORLD, "hardcore_mode", { "1" }));
|
||
c.Add(When(List(WORLD, "hardcore_excluded_item_drops", "Items never dropped", "Kept on death.", eListOf::LOT), WORLD, "hardcore_mode", { "1" }));
|
||
c.Add(When(List(WORLD, "hardcore_disabled_worlds", "Worlds without hardcore", "", eListOf::ZONE), WORLD, "hardcore_mode", { "1" }));
|
||
c.Add(When(Float(WORLD, "hardcore_uscore_reduction", "U-score reduction", "Multiplier for enemies in the worlds and of the kinds below (e.g. 0.5).", "1", 0, 100), WORLD, "hardcore_mode", { "1" }));
|
||
c.Add(When(List(WORLD, "hardcore_uscore_reduced_worlds", "Reduced U-score worlds", "Every enemy here gives reduced U-score.", eListOf::ZONE), WORLD, "hardcore_mode", { "1" }));
|
||
c.Add(When(List(WORLD, "hardcore_uscore_reduced_lots", "Reduced U-score enemies", "", eListOf::LOT), WORLD, "hardcore_mode", { "1" }));
|
||
c.Add(When(List(WORLD, "hardcore_uscore_excluded_enemies", "Enemies without U-score", "", eListOf::LOT), WORLD, "hardcore_mode", { "1" }));
|
||
|
||
c.AddSection("Property rent", "Owners pay rent for their properties, like live: each property world's PropertyTemplate price and period, or what the Property Rent page sets. Block Yard is free.");
|
||
c.Add(Bool(WORLD, "property_rent_enabled", "Charge rent", "Rent is taken from the owner's coins when they log in. Unpaid rent makes the property private until it is paid.", false));
|
||
c.Add(When(Unit(Int(WORLD, "property_rent_grace_days", "Grace period", "How long rent can be unpaid before the property is made private.", "3", 0, 365), "days"), WORLD, "property_rent_enabled", { "1" }));
|
||
|
||
c.AddSection("Property reputation", "Visitors earn properties reputation, which orders the property lists and the news screen's Today's Top Properties. "
|
||
"Only time other people spend moving around a property counts, with limits against farming it (see the dashboard documentation).");
|
||
c.Add(Bool(WORLD, "property_reputation_enabled", "Properties earn reputation", "", true));
|
||
c.Add(When(Unit(Int(WORLD, "property_reputation_min_visit", "Minimum visit", "A visit earns nothing before this (live's property reputation delay).", "120", 0, 3600), "seconds"), WORLD, "property_reputation_enabled", { "1" }));
|
||
c.Add(When(Unit(Float(WORLD, "property_reputation_multiplier", "Points per minute", "Times the property's reputationPerMinute (1). Raise it for small servers.", "1", 0, 1000), "×"), WORLD, "property_reputation_enabled", { "1" }));
|
||
c.Add(When(Unit(Int(WORLD, "property_reputation_max_minutes", "Minutes per visit", "At most this many minutes of one visit count.", "30", 1, 1440), "minutes"), WORLD, "property_reputation_enabled", { "1" }));
|
||
c.Add(When(Unit(Int(WORLD, "property_reputation_visitor_daily_cap", "Per visitor per day", "Most one account can give one property in a day.", "30", 0, 100000), "points"), WORLD, "property_reputation_enabled", { "1" }));
|
||
c.Add(When(Unit(Int(WORLD, "property_reputation_daily_cap", "Per property per day", "Most a property can get in a day from everyone.", "300", 0, 10000000), "points"), WORLD, "property_reputation_enabled", { "1" }));
|
||
c.Add(When(Float(WORLD, "property_reputation_repeat_falloff", "Repeat visitor falloff", "A visitor who gave the property reputation on d recent days earns 1 / (1 + this × d) as much.", "0.5", 0, 100), WORLD, "property_reputation_enabled", { "1" }));
|
||
c.Add(When(Unit(Int(WORLD, "property_reputation_repeat_days", "Recent days", "How far back repeat visits are counted.", "30", 1, 365), "days"), WORLD, "property_reputation_enabled", { "1" }));
|
||
c.Add(When(Bool(WORLD, "property_reputation_require_activity", "Only active visitors", "A minute only counts if the visitor moved; idle characters earn nothing.", true), WORLD, "property_reputation_enabled", { "1" }));
|
||
c.Add(When(Bool(WORLD, "property_reputation_ignore_staff", "Ignore staff", "Accounts with a GM level don't give reputation.", true), WORLD, "property_reputation_enabled", { "1" }));
|
||
c.Add(When(Bool(WORLD, "property_reputation_ignore_linked", "Ignore linked accounts", "Accounts sharing a play key, email or login address with the owner's don't give reputation.", true), WORLD, "property_reputation_enabled", { "1" }));
|
||
|
||
c.AddSection("Events", "Event flags sent at login; they switch on event content in levels (e.g. Talk_Like_A_Pirate).", eLayout::GRID);
|
||
for (int i = 1; i <= 8; i++) c.Add(Text(SHARED, "event_" + std::to_string(i), "Event " + std::to_string(i), "", ""));
|
||
|
||
c.AddSection("Help menu", "The five entries of the in-game Help menu. The text can use the client's simple HTML (<br/>, <a>, <font>).", eLayout::PAIRS);
|
||
for (int i = 0; i < 5; i++) {
|
||
c.Add(Text(WORLD, "help_" + std::to_string(i) + "_summary", "Entry " + std::to_string(i + 1) + " title", "", ""));
|
||
c.Add(Multiline(Text(WORLD, "help_" + std::to_string(i) + "_description", "Entry " + std::to_string(i + 1) + " text", "", "")));
|
||
}
|
||
|
||
c.AddSection("Client checks");
|
||
c.Add(Bool(WORLD, "check_fdb", "Check the client database", "Compare each client's CDClient (res/CDServer.fdb) with the server's.", false));
|
||
c.Add(When(Text(WORLD, "cdclient_mismatch_title", "Mismatch title", "Shown to players whose client database differs.", ""), WORLD, "check_fdb", { "1" }));
|
||
c.Add(When(Multiline(Text(WORLD, "cdclient_mismatch_message", "Mismatch message", "", "")), WORLD, "check_fdb", { "1" }));
|
||
|
||
c.AddSection("About");
|
||
c.Add(Format(Text(WORLD, "source", "Source code link", "Where your server's code is. If you changed it, link your version (AGPLv3).", "https://github.com/DarkflameUniverse/DarkflameServer"), eFormat::URL));
|
||
|
||
// ================= Players and access =================
|
||
c.AddCategory("players", "Players and access", "Who can log in, friends, and automatic moderation.");
|
||
|
||
c.AddSection("Logging in");
|
||
c.Add(Bool(AUTH, "dont_use_keys", "Don't require play keys", "Everyone with an account can log in, with or without a play key.", false));
|
||
c.Add(Bool(AUTH, "closed_to_non_devs", "Staff only", "Only accounts with a GM level can log in.", false));
|
||
c.Add(List(AUTH, "rewardcodes", "Reward codes", "Given to every account at login. 4 opens LEGO Club; 30 stops bricks being used up in build mode. Codes with an item mail it once.", eListOf::REWARD_CODE, "4,30"));
|
||
c.Add(Bool(AUTH, "log_login_addresses", "Remember login addresses", "Keep the network address each account logs in to the game from, so staff see accounts that share one. "
|
||
"Addresses are personal data: they're only shown as links between accounts, and deleted after log_login_address_days.", true));
|
||
|
||
c.AddSection("Moderation");
|
||
c.Add(Bool(SHARED, "mute_restrict_mail", "Muted players can't send mail", "", false));
|
||
c.Add(Bool(SHARED, "mute_restrict_trade", "Muted players can't trade", "", false));
|
||
c.Add(Bool(SHARED, "mute_auto_reject_names", "Reject names from muted players", "Character and pet names asked for while muted are rejected automatically.", false));
|
||
c.Add(Bool(WORLD, "disable_anti_speedhack", "Turn off speedhack detection", "Try this if players get kicked for lag.", false, true));
|
||
c.Add(Unused(Bool(WORLD, "log_ip_addresses_for_anti_cheat", "Log IP addresses", "Not read by this version.", true)));
|
||
|
||
c.AddSection("Chat log", "What players say is kept for moderators (the Chat Log page) and for chat bridges.");
|
||
c.Add(Bool(SHARED, "log_chat", "Keep a chat log", "Zone chat, and what the chat filter stopped.", true));
|
||
c.Add(When(Bool(SHARED, "log_private_chat", "Include whispers and team chat", "Only staff with the Read private chat permission see them.", true), SHARED, "log_chat", { "1" }));
|
||
c.Add(Bool(WORLD, "chat_bridge_filter", "Filter messages sent into the game", "Messages from the dashboard or a chat bridge go through the same chat filter as players'.", true));
|
||
|
||
c.AddSection("Friends");
|
||
c.Add(Int(CHAT, "max_number_of_friends", "Friends", "50 is live accurate; more needs a modded client in places.", "50", 0, 1000, true));
|
||
c.Add(Int(CHAT, "max_number_of_best_friends", "Best friends", "5 is live accurate.", "5", 0, 1000, true));
|
||
c.Add(Int(CHAT, "max_ignores", "Ignore list size", "", "32", 0, 1000));
|
||
|
||
c.AddSection("Dashboard sign-in");
|
||
c.Add(Int(DASHBOARD, "min_dashboard_gm_level", "Lowest GM level allowed in", "0 lets every player see their own account.", "0", 0, 9));
|
||
c.Add(Int(DASHBOARD, "require_2fa_gm_level", "Two-factor login required from GM level", "Staff at or above this level must use an authenticator app. 0 turns it off.", "0", 0, 9));
|
||
c.Add(Text(DASHBOARD, "totp_issuer", "Name in authenticator apps", "", "DarkflameServer"));
|
||
c.Add(Bool(DASHBOARD, "password_reset_recovery_codes", "Password reset with recovery codes",
|
||
"Players with two-factor login can choose a new password on /forgot_password with a code from their authenticator app and one of their recovery codes, no email needed.", true));
|
||
|
||
c.AddSection("Registration", "Creating accounts on the dashboard at /register.");
|
||
c.Add(Bool(DASHBOARD, "allow_registration", "Anyone can register", "", false));
|
||
c.Add(When(Bool(DASHBOARD, "registration_requires_play_key", "Needs a play key", "", true), DASHBOARD, "allow_registration", { "1" }));
|
||
c.Add(When(Bool(DASHBOARD, "registration_requires_email", "Needs an email address", "Only when email is set up.", false), DASHBOARD, "allow_registration", { "1" }));
|
||
|
||
// ================= Email =================
|
||
c.AddCategory("email", "Email", "For password resets, confirmations and report emails. Test it with Send Test under Email Settings on your account page.");
|
||
|
||
c.AddSection("Mail server");
|
||
c.Add(Format(Text(DASHBOARD, "smtp_host", "SMTP server", "Filled in for Microsoft or Google OAuth2.", "", true), eFormat::HOST));
|
||
c.Add(Int(DASHBOARD, "smtp_port", "Port", "587 with starttls, 465 with tls.", "587", PORT_MIN, PORT_MAX, true));
|
||
c.Add(Choice(DASHBOARD, "smtp_security", "Encryption", "none only for a relay on the same machine.", "starttls", { "starttls", "tls", "none" }, true));
|
||
c.Add(Choice(DASHBOARD, "smtp_auth", "Sign-in", "oauth2 is needed for Microsoft 365 and recommended for Gmail.", "password", { "password", "oauth2" }, true));
|
||
c.Add(When(Text(DASHBOARD, "smtp_username", "User", "", "", true), DASHBOARD, "smtp_auth", { "password" }));
|
||
c.Add(When(Secret(DASHBOARD, "smtp_password", "Password", "", true), DASHBOARD, "smtp_auth", { "password" }));
|
||
c.Add(Format(Text(DASHBOARD, "smtp_from_address", "From address", "", "", true), eFormat::EMAIL));
|
||
c.Add(Text(DASHBOARD, "smtp_from_name", "From name", "", "DarkflameServer", true));
|
||
c.Add(Format(Text(DASHBOARD, "smtp_ca_file", "Trusted certificates", "A PEM bundle for a private CA. Empty uses the system's.", "", true), eFormat::PATH));
|
||
c.Add(Bool(DASHBOARD, "smtp_verify_certificate", "Check the server's certificate", "Turn off only for local testing.", true, true));
|
||
c.Add(Unit(Int(DASHBOARD, "smtp_timeout", "Timeout", "", "20", 1, 300, true), "seconds"));
|
||
|
||
const Condition custom{ DASHBOARD, "smtp_oauth2_provider", { "custom" } };
|
||
c.AddSection("OAuth2 sign-in", "After filling these in, use Connect mail account under Email Settings on your account page.", eLayout::ROWS, Condition{ DASHBOARD, "smtp_auth", { "oauth2" } });
|
||
c.Add(Choice(DASHBOARD, "smtp_oauth2_provider", "Provider", "microsoft and google fill in the rest.", "custom", { "microsoft", "google", "custom" }, true));
|
||
c.Add(Text(DASHBOARD, "smtp_oauth2_client_id", "Client ID", "", "", true));
|
||
c.Add(Secret(DASHBOARD, "smtp_oauth2_client_secret", "Client secret", "", true));
|
||
c.Add(Choice(DASHBOARD, "smtp_oauth2_grant", "Grant", "client_credentials is Microsoft 365 app-only sending.", "authorization_code", { "authorization_code", "client_credentials" }, true));
|
||
c.Add(When(Text(DASHBOARD, "smtp_oauth2_tenant", "Microsoft tenant", "Tenant ID or domain; empty uses common.", "", true), DASHBOARD, "smtp_oauth2_provider", { "microsoft" }));
|
||
c.Add(When(Format(Text(DASHBOARD, "smtp_oauth2_authorize_url", "Authorize URL", "", "", true), eFormat::URL), custom.file, custom.key, custom.values));
|
||
c.Add(When(Format(Text(DASHBOARD, "smtp_oauth2_token_url", "Token URL", "", "", true), eFormat::URL), custom.file, custom.key, custom.values));
|
||
c.Add(When(Text(DASHBOARD, "smtp_oauth2_scope", "Scope", "", "", true), custom.file, custom.key, custom.values));
|
||
c.Add(Secret(DASHBOARD, "smtp_oauth2_refresh_token", "Refresh token", "Set by Connect mail account; only set it by hand for a token from elsewhere.", true));
|
||
|
||
// ================= Dashboard =================
|
||
c.AddCategory("dashboard", "Dashboard", "The web dashboard itself.");
|
||
|
||
c.AddSection("Web server");
|
||
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, "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));
|
||
c.Add(Unit(Int(DASHBOARD, "broadcast_interval", "Live update interval", "How often server status is pushed to open pages.", "2000", 250, 60000, true), "ms"));
|
||
c.Add(Unit(Int(DASHBOARD, "scenery_workers", "3D model conversion threads",
|
||
"Threads converting the client's models for the 3D views, so the dashboard keeps answering meanwhile. One of them only takes flairs and small models. 0 picks half the CPU cores (2 to 4).",
|
||
"0", 0, 16, true), "threads"));
|
||
|
||
c.AddSection("Keys", "Only in the .ini file or the environment.");
|
||
c.Add(Secret(DASHBOARD, "jwt_secret", "Session signing secret", "At least 32 characters; empty makes one. Changing it signs everyone out.", true));
|
||
c.Add(Secret(DASHBOARD, "totp_key", "Two-factor encryption key", "64 hex characters; empty uses dashboard_totp_key. Back it up.", true));
|
||
|
||
c.AddSection("Tools");
|
||
c.Add(Text(DASHBOARD, "challenge_milestones", "Challenge milestones", "Percentages of a community challenge announced in game as it gets there, e.g. 25,50,75 (completing it is always announced).", "25,50,75,100"));
|
||
c.Add(Bool(DASHBOARD, "enable_char_xml_upload", "Character XML editing", "Let staff with permission replace a character's XML.", false));
|
||
|
||
c.AddSection("Public pages", "Pages anyone can open without signing in. They never show account names, addresses or anything else private.");
|
||
c.Add(Bool(DASHBOARD, "public_status", "Public server status", "The page at /status, its JSON at /api/public/status (for server lists) and a widget for other sites.", false));
|
||
c.Add(When(Text(DASHBOARD, "public_server_name", "Server name", "Shown on the status page and widget.", "DarkflameServer"), DASHBOARD, "public_status", { "1" }));
|
||
c.Add(When(Bool(DASHBOARD, "public_status_players", "Players per world", "How many players are in each world.", true), DASHBOARD, "public_status", { "1" }));
|
||
c.Add(When(Bool(DASHBOARD, "public_status_names", "Names of players online", "Character names (never staff, never account names) by world.", false), DASHBOARD, "public_status", { "1" }));
|
||
c.Add(When(Bool(DASHBOARD, "public_status_uptime", "Uptime", "How long the server has been up, and how much of the last day and week.", true), DASHBOARD, "public_status", { "1" }));
|
||
c.Add(When(Bool(DASHBOARD, "public_status_health", "Server health", "Whether login and chat are up, and how many worlds are running.", true), DASHBOARD, "public_status", { "1" }));
|
||
c.Add(When(Unit(Int(DASHBOARD, "public_status_leaderboard_top", "Leaderboard places", "The top places of every leaderboard. 0 leaves leaderboards out.", "3", 0, 10), "places"), DASHBOARD, "public_status", { "1" }));
|
||
c.Add(When(Bool(DASHBOARD, "public_status_challenges", "Community challenges", "Public challenges that are running or finished in the last week, with their progress.", true), DASHBOARD, "public_status", { "1" }));
|
||
c.Add(When(Bool(DASHBOARD, "public_status_events", "Live events", "Live events running now (their title, where, and until when).", true), DASHBOARD, "public_status", { "1" }));
|
||
c.Add(When(Bool(DASHBOARD, "public_status_widget", "Widget for other sites", "A small page at /status/widget other sites may show in an iframe.", true), DASHBOARD, "public_status", { "1" }));
|
||
c.Add(When(Unit(Int(DASHBOARD, "public_status_cache_seconds", "Refresh every", "The status is worked out at most this often, however many people ask.", "60", 10, 3600), "seconds"), DASHBOARD, "public_status", { "1" }));
|
||
c.Add(Bool(DASHBOARD, "showcase_public", "Property showcase for everyone", "Let people who aren't signed in browse approved public properties at /showcase. Signed-in players need the showcase_view permission.", false));
|
||
|
||
c.AddSection("Metrics", "Prometheus metrics at /metrics: players, worlds, memory, chat, today's economy, moderation queues and scheduled tasks. Never names or addresses.");
|
||
c.Add(Unit(Int(DASHBOARD, "api_key_rate_limit", "API key rate limit", "Requests a minute an API key may make unless the key sets its own limit (up to 6000).", "120", 1, 6000), "/min"));
|
||
c.Add(Bool(DASHBOARD, "metrics_enabled", "Metrics endpoint", "Off: /metrics answers 404. On: scrapers send an API token of an account with metrics_view, or the token below.", false));
|
||
c.Add(When(Secret(DASHBOARD, "metrics_token", "Scraper token", "A shared secret scrapers may send as Authorization: Bearer instead of an API token. At least 16 characters; empty: API tokens only."), DASHBOARD, "metrics_enabled", { "1" }));
|
||
c.Add(When(Text(DASHBOARD, "metrics_allowed_ips", "Allowed addresses", "Comma separated addresses or IPv4 ranges (10.0.0.0/8) that may fetch metrics. Empty: any."), DASHBOARD, "metrics_enabled", { "1" }));
|
||
c.Add(When(Unit(Int(DASHBOARD, "metrics_cache_seconds", "Refresh every", "Metrics are worked out at most this often, however often they are fetched.", "10", 1, 300), "seconds"), DASHBOARD, "metrics_enabled", { "1" }));
|
||
|
||
c.AddSection("Strikes", "Given by staff when rejecting a name, pet name or property, removing a score or acting on a player report, if they choose to. The steps below happen on their own the first time an account reaches that many active strikes.");
|
||
c.Add(Days("strike_expiry_days", "Strikes count for", "Older strikes stay on the account's record but no longer count. 0: they always count.", "0"));
|
||
c.Add(Unit(Int(DASHBOARD, "strike_warn_at", "Warn at", "When an account reaches this many active strikes it gets a warning, shown to the player if they're online. 0: never.", "0", 0, 1000), "strikes"));
|
||
c.Add(Unit(Int(DASHBOARD, "strike_mute_at", "Mute at", "...is muted for the days below. 0: never.", "0", 0, 1000), "strikes"));
|
||
c.Add(Days("strike_mute_days", "Mute for", "", "3", 1));
|
||
c.Add(Unit(Int(DASHBOARD, "strike_ban_at", "Ban at", "...is banned for the days below. 0: never.", "0", 0, 1000), "strikes"));
|
||
c.Add(Days("strike_ban_days", "Ban for", "0: permanently.", "7"));
|
||
|
||
c.AddSection("AI moderator helper", "A Suggest button on player reports, chat, pending names and economy flags that asks Claude (Anthropic's API) to draft a suggestion for staff (ai_suggest permission). Only staff see it; nothing is applied or shown to players unless staff do it themselves. Each request sends the item, the player's recent chat around it, their strikes and moderation history and the rules below; never emails, addresses or passwords.");
|
||
c.Add(Bool(DASHBOARD, "ai_helper_enabled", "AI moderator helper", "Off: the Suggest buttons say the helper is off and nothing is sent.", false));
|
||
c.Add(Secret(DASHBOARD, "claude_api_key", "Claude API key", "From console.anthropic.com. Never shown back or logged."));
|
||
c.Add(Format(Text(DASHBOARD, "claude_api_base", "API address", "Only change it for a proxy or a local test server (http:// only for this machine).", "https://api.anthropic.com"), eFormat::URL));
|
||
{
|
||
auto model = Text(DASHBOARD, "claude_model", "Model", "Any model ID works. claude-sonnet-5 is a good balance; claude-haiku-4-5-20251001 is cheaper, claude-opus-5-5 more careful.", "claude-sonnet-5");
|
||
model.choices = { "claude-sonnet-5", "claude-opus-5-5", "claude-haiku-4-5-20251001" };
|
||
c.Add(model);
|
||
}
|
||
c.Add(Bool(DASHBOARD, "claude_structured_output", "Hold answers to a schema", "Ask the API for JSON of the expected shape (structured outputs). Turn off only if a model refuses it; answers are checked either way.", true));
|
||
c.Add(Multiline(Text(DASHBOARD, "ai_helper_rules", "Server rules", "Your code of conduct, as players know it. Sent with every request so suggestions follow your rules.")));
|
||
c.Add(Unit(Int(DASHBOARD, "ai_helper_timeout", "Time out after", "Per try; failed tries (busy API, network) are retried up to three times.", "60", 5, 600), "seconds"));
|
||
c.Add(Unit(Int(DASHBOARD, "ai_helper_max_tokens", "Longest answer", "max_tokens per request: caps what one suggestion can cost.", "2000", 256, 16000), "tokens"));
|
||
c.Add(Unit(Int(DASHBOARD, "ai_helper_per_minute", "Requests per minute", "For the whole dashboard.", "5", 1, 60), "requests"));
|
||
c.Add(Unit(Int(DASHBOARD, "ai_helper_per_day", "Requests per day", "For the whole dashboard, per UTC day. Asking about the same thing again reuses the stored suggestion and costs nothing.", "200", 1, 100000), "requests"));
|
||
c.Add(Unit(Int(DASHBOARD, "ai_helper_chat_minutes", "Chat around the item", "How far before and after a report or message the player's chat is included.", "10", 1, 1440), "minutes"));
|
||
|
||
c.AddSection("Economy checks", "Flags for the Economy page, checked every night.");
|
||
c.Add(Unit(Int(DASHBOARD, "anomaly_coin_multiplier", "Coin income: times the median", "Flag characters earning more than this many times the median player in a day...", "20", 1, 100000), "×"));
|
||
c.Add(Unit(Int(DASHBOARD, "anomaly_coin_minimum", "Coin income: at least", "...and more than this many.", "100000", 0, std::nullopt), "coins"));
|
||
c.Add(Unit(Int(DASHBOARD, "anomaly_item_multiplier", "Item spike: times usual", "Flag items created more than this many times their daily average...", "10", 1, 100000), "×"));
|
||
c.Add(Unit(Int(DASHBOARD, "anomaly_item_minimum", "Item spike: at least", "...and more than this many.", "200", 0, std::nullopt), "items"));
|
||
c.Add(Bool(DASHBOARD, "economy_duplicate_scan", "Nightly duplicate scan", "Reads every character's inventory.", true));
|
||
|
||
c.AddSection("Contraband", "Items on the Contraband page are flagged, or removed, when a character loads or receives one. The list itself is edited on that page.");
|
||
c.Add(Bool(WORLD, "contraband_ignore_staff", "Don't check staff", "Accounts with a GM level keep listed items and aren't flagged.", true));
|
||
c.Add(Bool(WORLD, "contraband_notify_players", "Tell players", "A mail (at login) or a chat message (when received) says which item was removed and why.", true));
|
||
|
||
c.AddSection("Backups");
|
||
c.Add(Format(Text(DASHBOARD, "backup_folder", "Backup folder", "Relative to the server binaries unless absolute.", "backups"), eFormat::PATH));
|
||
c.Add(Unit(Int(DASHBOARD, "backup_keep", "Backups to keep", "Older backups are deleted after each new one. 0 keeps them all.", "7", 0, 1000), "backups"));
|
||
c.Add(When(Format(Text(DASHBOARD, "backup_mysqldump", "mysqldump program", "The mysqldump (or mariadb-dump) to run.", "mysqldump"), eFormat::PATH), SHARED, "database_type", { "mysql" }));
|
||
|
||
// ================= Data retention =================
|
||
c.AddCategory("retention", "Data retention", "How long history is kept. The Log pruning task deletes older rows every night.");
|
||
|
||
c.AddSection("Logs", "0 keeps everything.");
|
||
c.Add(Days("log_activity_days", "Activity log", "", "365"));
|
||
c.Add(Days("log_command_days", "Command log", "", "365"));
|
||
c.Add(Days("log_audit_days", "Audit log", "", "730"));
|
||
c.Add(Days("log_cheat_detection_days", "Cheat detections", "", "365"));
|
||
c.Add(Days("log_task_days", "Scheduled task runs", "", "90"));
|
||
c.Add(Days("log_chat_days", "Chat log", "", "90"));
|
||
c.Add(Days("log_login_address_days", "Login addresses", "Addresses an account hasn't logged in from for this long are forgotten.", "90"));
|
||
c.Add(Days("health_days", "Server health history", "Minute-by-minute player counts and uptime.", "30"));
|
||
c.Add(Days("traffic_days", "Server traffic history", "Minute-by-minute packets, bytes and HTTP requests of every server (Diagnostics). The last hour at one second is only kept in memory.", "30"));
|
||
|
||
c.AddSection("Log bundles", "Server log files downloaded together from the System Log page (Download logs).");
|
||
c.Add(Unit(Int(DASHBOARD, "log_bundle_max_mb", "At most", "How much log text (before compression) one download may hold. Bundles are built in the system's temporary folder and deleted once sent.", "512", 1, 4000), "MB"));
|
||
|
||
c.AddSection("Player movement", "Where players went, for replays on the 3D world view (staff with players_history only).");
|
||
c.Add(Bool(DASHBOARD, "position_history", "Record player movement", "Keeps a player's position every few seconds while they move, and every 30 seconds while they stand still.", true));
|
||
c.Add(Unit(Int(DASHBOARD, "position_history_seconds", "Record every", "While a player moves. Less often keeps the table smaller; replays then move in straighter lines.", "5", 1, 600), "seconds"));
|
||
c.Add(Days("position_history_days", "Keep movement for", "About 17,000 rows per player online around the clock per day at 5 seconds.", "3", 1));
|
||
|
||
c.AddSection("Message inspector", "Saved game message captures (Message Inspector). The Message capture pruning task deletes older ones every night; running captures are never deleted.");
|
||
c.Add(Days("inspector_session_days", "Keep captures for", "0: no age limit.", "30"));
|
||
c.Add(Unit(Int(DASHBOARD, "inspector_max_mb", "At most", "When all saved captures together take more, the oldest are deleted. A busy 15 minute capture can take 50 MB. 0: no size limit.", "1024", 0, 1000000), "MB"));
|
||
|
||
c.AddSection("Economy history");
|
||
c.Add(Days("economy_detail_days", "Daily detail", "Older daily rows are merged into months.", "180", 31));
|
||
c.Add(Days("economy_map_days", "Map detail", "", "90", 31));
|
||
c.Add(Days("economy_transfer_days", "Trades and mail", "", "730", 1));
|
||
|
||
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"));
|
||
// 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(Bool(SHARED, "ugc_manifest", "Answer clients without 3D services", "Worlds tell game clients with UGCUSE3DSERVICES=7:0 (the default) the checksums of the files the UGC server made, so they download icons of cars, rockets and models. The client then downloads from http://127.0.0.1:80/lwoclient/UserBrickModels/ whatever its boot.cfg says, and one that can't connect there is logged out: only turn on when the UGC server answers there for every player.", false));
|
||
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), "%"));
|
||
c.Add(Int(UGC, "worker_nice", "Worker priority", "Linux nice value of the workers: 0 normal, 19 only when nothing else wants the CPU.", "0", 0, 19));
|
||
c.Add(Unit(Int(UGC, "max_memory_mb", "Most memory for jobs", "Jobs whose estimated memory would go past this together wait for running ones; a bigger one runs alone. 0: no limit.", "0", 0, 1000000), "MB"));
|
||
c.Add(Unit(Int(UGC, "max_model_bricks", "Biggest model", "Models with more bricks fail with that reason. 0: no limit.", "0", 0, 1000000), "bricks"));
|
||
c.Add(Text(UGC, "pause_hours", "Pause hours", "Local hours in which no new jobs start, e.g. 18-23 or 22-6. Empty: never.", ""));
|
||
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), "%"));
|
||
c.Add(Text(UGC, "lods", "Levels of detail", "brickprimitives levels made, 0 (most detailed) to 2, e.g. 0,2.", "0,2"));
|
||
c.Add(Float(UGC, "lod_distance_0", "LOD 0 distance", "", "0", 0, 100000));
|
||
c.Add(Float(UGC, "lod_distance_1", "LOD 1 distance", "", "50", 0, 100000));
|
||
c.Add(Float(UGC, "lod_distance_2", "LOD 2 distance", "", "100", 0, 100000));
|
||
c.Add(Float(UGC, "lod_distance_3", "LOD 3 distance", "", "280", 0, 100000));
|
||
c.Add(Float(UGC, "lod_cull", "Drawn up to", "", "10000", 0, 1000000));
|
||
c.Add(Text(UGC, "shader_opaque", "Opaque shader", "S<shader>_Opaque_Model.", "01"));
|
||
c.Add(Bool(UGC, "combine_transparent", "One shape for all transparent bricks", "Off: each transparent brick is its own shape, so the client can sort them.", false));
|
||
c.Add(Bool(UGC, "remove_hidden_faces", "Remove faces nobody can see", "", true));
|
||
c.Add(Bool(UGC, "hsr_ground_plane", "Nothing seen from below", "Also removes what can only be seen from under the model.", false));
|
||
c.Add(Unit(Int(UGC, "optimize_resolution", "Detail of the visibility renders", "", "1024", 64, 4096), "pixels"));
|
||
c.Add(Bool(UGC, "bake_ao", "Darken hidden corners", "Ambient occlusion baked into the vertex colors.", true));
|
||
c.Add(Int(UGC, "ao_samples", "Occlusion rays per vertex", "", "64", 1, 1024));
|
||
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
|
||
for (const auto& param : UgcIconParams::List()) {
|
||
char number[32];
|
||
const auto written = std::to_chars(number, number + sizeof(number), param.defaultValue);
|
||
auto setting = Float(UGC, param.setting, "Icon: " + param.label, param.description, std::string(number, written.ptr), param.min, param.max);
|
||
if (!param.unit.empty()) setting = Unit(setting, param.unit);
|
||
c.Add(setting);
|
||
}
|
||
|
||
return c;
|
||
}
|
||
|
||
const Catalog& Get() {
|
||
static const auto catalog = Build();
|
||
return catalog;
|
||
}
|
||
|
||
// Shortest form that reads back the same, e.g. 0.25 or 1000
|
||
std::string Number(double value) {
|
||
char buffer[64];
|
||
const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value);
|
||
return std::string(buffer, result.ptr);
|
||
}
|
||
|
||
std::string Trim(std::string text) {
|
||
text.erase(0, text.find_first_not_of(" \t\r\n"));
|
||
text.erase(text.find_last_not_of(" \t\r\n") + 1);
|
||
return text;
|
||
}
|
||
|
||
std::string Lower(std::string text) {
|
||
for (auto& c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||
return text;
|
||
}
|
||
|
||
std::string RangeText(const Setting& setting) {
|
||
if (setting.min && setting.max) return " between " + Number(*setting.min) + " and " + Number(*setting.max);
|
||
if (setting.min) return " of at least " + Number(*setting.min);
|
||
if (setting.max) return " of at most " + Number(*setting.max);
|
||
return "";
|
||
}
|
||
}
|
||
|
||
namespace SettingsCatalog {
|
||
const std::vector<Category>& Categories() { return Get().categories; }
|
||
const std::vector<Section>& Sections() { return Get().sections; }
|
||
const std::vector<Setting>& All() { return Get().settings; }
|
||
|
||
const Section* FindSection(const std::string& name) {
|
||
for (const auto& section : Sections()) if (section.name == name) return §ion;
|
||
return nullptr;
|
||
}
|
||
|
||
const Setting* Find(const std::string& file, const std::string& key) {
|
||
static const auto index = [] {
|
||
std::map<std::pair<std::string, std::string>, const Setting*> map;
|
||
for (const auto& setting : All()) map[{ setting.file, setting.key }] = &setting;
|
||
return map;
|
||
}();
|
||
const auto it = index.find({ file, key });
|
||
return it == index.end() ? nullptr : it->second;
|
||
}
|
||
|
||
const std::vector<std::pair<std::string, std::string>>& Files() {
|
||
static const std::vector<std::pair<std::string, std::string>> files{
|
||
{ SHARED, "Shared" }, { WORLD, "World" }, { AUTH, "Auth" }, { CHAT, "Chat" }, { MASTER, "Master" }, { DASHBOARD, "Dashboard" }, { UGC, "UGC" }
|
||
};
|
||
return files;
|
||
}
|
||
|
||
std::string FormatName(eFormat format) {
|
||
switch (format) {
|
||
case eFormat::PLAIN: return "plain";
|
||
case eFormat::PATH: return "path";
|
||
case eFormat::HOST: return "host";
|
||
case eFormat::URL: return "url";
|
||
case eFormat::EMAIL: return "email";
|
||
}
|
||
return "plain";
|
||
}
|
||
|
||
std::string ListOfName(eListOf listOf) {
|
||
switch (listOf) {
|
||
case eListOf::NUMBER: return "number";
|
||
case eListOf::ZONE: return "zone";
|
||
case eListOf::LOT: return "lot";
|
||
case eListOf::REWARD_CODE: return "reward_code";
|
||
}
|
||
return "number";
|
||
}
|
||
|
||
std::string LayoutName(eLayout layout) {
|
||
switch (layout) {
|
||
case eLayout::ROWS: return "rows";
|
||
case eLayout::GRID: return "grid";
|
||
case eLayout::PAIRS: return "pairs";
|
||
}
|
||
return "rows";
|
||
}
|
||
|
||
std::string TypeName(eType type) {
|
||
switch (type) {
|
||
case eType::BOOL: return "bool";
|
||
case eType::INT: return "int";
|
||
case eType::FLOAT: return "float";
|
||
case eType::TEXT: return "text";
|
||
case eType::SECRET: return "secret";
|
||
case eType::INT_LIST: return "int_list";
|
||
case eType::CHOICE: return "choice";
|
||
}
|
||
return "text";
|
||
}
|
||
|
||
std::optional<std::string> Normalize(const Setting& setting, const std::string& input, std::string& error) {
|
||
auto value = setting.multiline ? input : Trim(input);
|
||
if (value.empty()) return value;
|
||
if (!setting.multiline && value.find_first_of("\r\n") != std::string::npos) {
|
||
error = "Use one line";
|
||
return std::nullopt;
|
||
}
|
||
|
||
switch (setting.type) {
|
||
case eType::BOOL: {
|
||
const auto lower = Lower(value);
|
||
if (lower == "1" || lower == "true" || lower == "yes" || lower == "on") return "1";
|
||
if (lower == "0" || lower == "false" || lower == "no" || lower == "off") return "0";
|
||
error = "Use on or off (1 or 0)";
|
||
return std::nullopt;
|
||
}
|
||
case eType::INT: {
|
||
const auto parsed = GeneralUtils::TryParse<int64_t>(value);
|
||
if (!parsed || (setting.min && *parsed < *setting.min) || (setting.max && *parsed > *setting.max)) {
|
||
error = "Enter a whole number" + RangeText(setting);
|
||
return std::nullopt;
|
||
}
|
||
return std::to_string(*parsed);
|
||
}
|
||
case eType::FLOAT: {
|
||
const auto parsed = GeneralUtils::TryParse<double>(value);
|
||
if (!parsed || !std::isfinite(*parsed) || (setting.min && *parsed < *setting.min) || (setting.max && *parsed > *setting.max)) {
|
||
error = "Enter a number" + RangeText(setting);
|
||
return std::nullopt;
|
||
}
|
||
return Number(*parsed);
|
||
}
|
||
case eType::INT_LIST: {
|
||
std::string normalized;
|
||
for (auto part : GeneralUtils::SplitString(value, ',')) {
|
||
part = Trim(part);
|
||
if (part.empty()) continue;
|
||
const auto parsed = GeneralUtils::TryParse<int64_t>(part);
|
||
if (!parsed || *parsed < 0) {
|
||
error = "\"" + part + "\" is not a whole number; use numbers separated by commas";
|
||
return std::nullopt;
|
||
}
|
||
if (!normalized.empty()) normalized += ",";
|
||
normalized += std::to_string(*parsed);
|
||
}
|
||
return normalized;
|
||
}
|
||
case eType::CHOICE: {
|
||
for (const auto& choice : setting.choices) {
|
||
if (Lower(value) == Lower(choice)) return choice;
|
||
}
|
||
std::string list;
|
||
for (const auto& choice : setting.choices) list += (list.empty() ? "" : ", ") + choice;
|
||
error = "Pick one of: " + list;
|
||
return std::nullopt;
|
||
}
|
||
case eType::TEXT:
|
||
case eType::SECRET:
|
||
if (value.size() > 4000) {
|
||
error = "Too long (4000 characters at most)";
|
||
return std::nullopt;
|
||
}
|
||
if (setting.format == eFormat::URL && !value.starts_with("http://") && !value.starts_with("https://")) {
|
||
error = "Enter a web address starting with http:// or https://";
|
||
return std::nullopt;
|
||
}
|
||
if (setting.format == eFormat::EMAIL && (value.find('@') == std::string::npos || value.find_first_of(" \t,;") != std::string::npos)) {
|
||
error = "Enter one email address";
|
||
return std::nullopt;
|
||
}
|
||
if (setting.format == eFormat::HOST && value.find_first_of(" \t/") != std::string::npos) {
|
||
error = "Enter a host name or address, without spaces or slashes";
|
||
return std::nullopt;
|
||
}
|
||
return value;
|
||
}
|
||
return value;
|
||
}
|
||
}
|