chore(settings): every catalog setting in the shipped .ini files; forget settings taken out of a file

- resources/*.ini list the 73 settings the catalog knew but the files left
  out (dashboard AI helper, backups, metrics, public status, strikes,
  property rent and reputation, chat log, contraband, logins...), with
  their title, description and default. The test
  ShippedFilesListEveryCatalogSetting keeps it that way; with
  DLU_WRITE_INI_TEMPLATES=1 it writes the missing ones.
- ConfigSync forgets a setting row when its key has left a file the
  server read: from the file, no value set on the dashboard, not a
  permission level. Before, rows of removed keys stayed forever.
- Docs: where setting rows come from and when they go, the templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 15:35:43 -05:00
parent 5a9dafe6f7
commit 7f0c0c23d0
7 changed files with 291 additions and 0 deletions

View File

@@ -58,6 +58,16 @@ namespace ConfigSync {
Database::Get()->ReportConfigFromFile(setting);
}
// A key taken out of a file this server read is forgotten: its row came from that file (not the environment,
// not only the dashboard) and nobody set it on the dashboard
std::set<std::string> filesRead;
for (const auto& entry : config.GetFileEntries()) filesRead.insert(entry.file);
for (const auto& row : Database::Get()->GetServerConfig({ filesRead.begin(), filesRead.end() })) {
if (row.fileSource != "file" || row.webValue || setLocally.contains(row.name) || Permissions::IsPermissionSetting(row.name)) continue;
LOG("Forgetting the setting %s of %s: it is no longer in the file", row.name.c_str(), row.file.c_str());
Database::Get()->DeleteServerConfig(row.file, row.name);
}
const auto rows = Database::Get()->GetServerConfig({ config.GetFileName(), "sharedconfig.ini" });
// Keys set only by environment variables count as set locally too
for (const auto& row : rows) {

View File

@@ -224,6 +224,18 @@ the Settings page by earlier versions are ignored. Programs such as `mysqldump`
Secrets (passwords, tokens, keys) from files are never copied into the database; secrets set on the web are stored in
the database and can't be read back from the page.
Where the rows come from and when they go: every server reports the keys of the files it read when it starts
(`ConfigSync::Sync`, table `server_config`). A key taken out of a file is forgotten the next time a server that reads
that file starts: its row is deleted when it came from the file (not an environment variable), has no value set on this
page, and isn't a permission level. Rows with a value set here stay until that value is removed.
The shipped `resources/*.ini` files list every setting in the catalog (`dDashboardServer/routes/SettingsCatalog.cpp`)
as `key=default` under a comment with its title and description, or name it in a comment (numbered families such as
`event_1`...`event_8` and the icon framing keys); the test `SettingsCatalogTests.ShippedFilesListEveryCatalogSetting`
fails when one is missing, and running it with `DLU_WRITE_INI_TEMPLATES=1` adds the missing ones to the files under
their section. CMake copies a file that isn't in the build folder yet and appends missing keys (without comments) to
one that is; values already in a server's files are never changed by it.
### Setting history
The **History** tab of the Settings page (same `settings` permission) lists every change made on the Settings page, and

View File

@@ -12,3 +12,11 @@ dont_use_keys=0
# 4 allows LEGOClub access
# 30 makes the client not consume bricks when in bbb mode
rewardcodes=4,30
# ---- Logging in ----
# Staff only: Only accounts with a GM level can log in.
closed_to_non_devs=0
# 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.
log_login_addresses=1

View File

@@ -143,3 +143,149 @@ log_task_days=90
# Change the lowest GM level (1-9) allowed to do something on the dashboard, e.g. permission_accounts_ban=3.
# Every permission and its name is listed on the dashboard's Permissions page, where GM 9 can also change them;
# a level set there beats this file.
# ---- Dashboard sign-in ----
# 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.
password_reset_recovery_codes=1
# ---- OAuth2 sign-in ----
# Refresh token: Set by Connect mail account; only set it by hand for a token from elsewhere. Read when the server
# starts.
smtp_oauth2_refresh_token=
# ---- Tools ----
# Challenge milestones: Percentages of a community challenge announced in game as it gets there, e.g. 25,50,75
# (completing it is always announced).
challenge_milestones=25,50,75,100
# ---- Public pages ----
# Public server status: The page at /status, its JSON at /api/public/status (for server lists) and a widget for other
# sites.
public_status=0
# Server name: Shown on the status page and widget.
public_server_name=DarkflameServer
# Players per world: How many players are in each world.
public_status_players=1
# Names of players online: Character names (never staff, never account names) by world.
public_status_names=0
# Uptime: How long the server has been up, and how much of the last day and week.
public_status_uptime=1
# Server health: Whether login and chat are up, and how many worlds are running.
public_status_health=1
# Leaderboard places: The top places of every leaderboard. 0 leaves leaderboards out. (places)
public_status_leaderboard_top=3
# Community challenges: Public challenges that are running or finished in the last week, with their progress.
public_status_challenges=1
# Live events: Live events running now (their title, where, and until when).
public_status_events=1
# Widget for other sites: A small page at /status/widget other sites may show in an iframe.
public_status_widget=1
# Refresh every: The status is worked out at most this often, however many people ask. (seconds)
public_status_cache_seconds=60
# 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.
showcase_public=0
# ---- Metrics ----
# API key rate limit: Requests a minute an API key may make unless the key sets its own limit (up to 6000). (/min)
api_key_rate_limit=120
# Metrics endpoint: Off: /metrics answers 404. On: scrapers send an API token of an account with metrics_view, or the
# token below.
metrics_enabled=0
# Scraper token: A shared secret scrapers may send as Authorization: Bearer instead of an API token. At least 16
# characters; empty: API tokens only.
metrics_token=
# Allowed addresses: Comma separated addresses or IPv4 ranges (10.0.0.0/8) that may fetch metrics. Empty: any.
metrics_allowed_ips=
# Refresh every: Metrics are worked out at most this often, however often they are fetched. (seconds)
metrics_cache_seconds=10
# ---- Strikes ----
# Strikes count for: Older strikes stay on the account's record but no longer count. 0: they always count. (days)
strike_expiry_days=0
# Warn at: When an account reaches this many active strikes it gets a warning, shown to the player if they're online. 0:
# never. (strikes)
strike_warn_at=0
# Mute at: ...is muted for the days below. 0: never. (strikes)
strike_mute_at=0
# Mute for (days)
strike_mute_days=3
# Ban at: ...is banned for the days below. 0: never. (strikes)
strike_ban_at=0
# Ban for: 0: permanently. (days)
strike_ban_days=7
# ---- AI moderator helper ----
# AI moderator helper: Off: the Suggest buttons say the helper is off and nothing is sent.
ai_helper_enabled=0
# Claude API key: From console.anthropic.com. Never shown back or logged.
claude_api_key=
# API address: Only change it for a proxy or a local test server (http:// only for this machine).
claude_api_base=https://api.anthropic.com
# 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_model=claude-sonnet-5
# 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.
claude_structured_output=1
# Server rules: Your code of conduct, as players know it. Sent with every request so suggestions follow your rules.
ai_helper_rules=
# Time out after: Per try; failed tries (busy API, network) are retried up to three times. (seconds)
ai_helper_timeout=60
# Longest answer: max_tokens per request: caps what one suggestion can cost. (tokens)
ai_helper_max_tokens=2000
# Requests per minute: For the whole dashboard. (requests)
ai_helper_per_minute=5
# Requests per day: For the whole dashboard, per UTC day. Asking about the same thing again reuses the stored suggestion
# and costs nothing. (requests)
ai_helper_per_day=200
# Chat around the item: How far before and after a report or message the player's chat is included. (minutes)
ai_helper_chat_minutes=10
# ---- Backups ----
# Backup folder: Relative to the server binaries unless absolute.
backup_folder=backups
# Backups to keep: Older backups are deleted after each new one. 0 keeps them all. (backups)
backup_keep=7
# mysqldump program: The mysqldump (or mariadb-dump) to run.
backup_mysqldump=mysqldump
# ---- Logs ----
# Chat log (days)
log_chat_days=90
# Login addresses: Addresses an account hasn't logged in from for this long are forgotten. (days)
log_login_address_days=90
# Server health history: Minute-by-minute player counts and uptime. (days)
health_days=30
# 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. (days)
traffic_days=30
# ---- Log bundles ----
# 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. (MB)
log_bundle_max_mb=512
# ---- Player movement ----
# Record player movement: Keeps a player's position every few seconds while they move, and every 30 seconds while they
# stand still.
position_history=1
# Record every: While a player moves. Less often keeps the table smaller; replays then move in straighter lines.
# (seconds)
position_history_seconds=5
# Keep movement for: About 17,000 rows per player online around the clock per day at 5 seconds. (days)
position_history_days=3
# ---- Message inspector ----
# Keep captures for: 0: no age limit. (days)
inspector_session_days=30
# 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. (MB)
inspector_max_mb=1024
# ---- Character history ----
# Snapshots: Older snapshots are deleted, but each character keeps its newest few (below). (days)
snapshot_days=90
# Always keep per character (snapshots)
snapshot_keep=10

View File

@@ -104,3 +104,9 @@ ugc_manifest=0
# gets every model's LXFML and builds it first (for its collision: the UGC server makes no physics), then is switched to
# the served mesh a few seconds after it has loaded. See docs/UgcServer.md.
ugc_manifest_models=0
# ---- Chat log ----
# Keep a chat log: Zone chat, and what the chat filter stopped.
log_chat=1
# Include whispers and team chat: Only staff with the Read private chat permission see them.
log_private_chat=1

View File

@@ -121,3 +121,50 @@ save_property_location=0
# by the zone's scene transitions, and the global scene) instead of the objects within the ghosting distances.
# Needs the zone's terrain scene map; zones without one keep distance ghosting.
ghosting_scenes=0
# ---- Logging and crashes ----
# Crash dumps from world servers: Write a dump when a world server crashes (needs the crash dump folder). Read when the
# server starts.
generate_dump=0
# ---- Property rent ----
# Charge rent: Rent is taken from the owner's coins when they log in. Unpaid rent makes the property private until it is
# paid.
property_rent_enabled=0
# Grace period: How long rent can be unpaid before the property is made private. (days)
property_rent_grace_days=3
# ---- Property reputation ----
# Properties earn reputation
property_reputation_enabled=1
# Minimum visit: A visit earns nothing before this (live's property reputation delay). (seconds)
property_reputation_min_visit=120
# Points per minute: Times the property's reputationPerMinute (1). Raise it for small servers. (×)
property_reputation_multiplier=1
# Minutes per visit: At most this many minutes of one visit count. (minutes)
property_reputation_max_minutes=30
# Per visitor per day: Most one account can give one property in a day. (points)
property_reputation_visitor_daily_cap=30
# Per property per day: Most a property can get in a day from everyone. (points)
property_reputation_daily_cap=300
# Repeat visitor falloff: A visitor who gave the property reputation on d recent days earns 1 / (1 + this × d) as much.
property_reputation_repeat_falloff=0.5
# Recent days: How far back repeat visits are counted. (days)
property_reputation_repeat_days=30
# Only active visitors: A minute only counts if the visitor moved; idle characters earn nothing.
property_reputation_require_activity=1
# Ignore staff: Accounts with a GM level don't give reputation.
property_reputation_ignore_staff=1
# Ignore linked accounts: Accounts sharing a play key, email or login address with the owner's don't give reputation.
property_reputation_ignore_linked=1
# ---- Chat log ----
# Filter messages sent into the game: Messages from the dashboard or a chat bridge go through the same chat filter as
# players'.
chat_bridge_filter=1
# ---- Contraband ----
# Don't check staff: Accounts with a GM level keep listed items and aren't flagged.
contraband_ignore_staff=1
# Tell players: A mail (at login) or a chat message (when received) says which item was removed and why.
contraband_notify_players=1

View File

@@ -3,7 +3,10 @@
#include <filesystem>
#include <fstream>
#include <regex>
#include <cstdlib>
#include <map>
#include <set>
#include <sstream>
#include "SettingsCatalog.h"
@@ -170,3 +173,62 @@ TEST(SettingsCatalogTests, CoversEverySettingTheCodeReads) {
EXPECT_GT(found, 100u);
for (const auto& key : missing) ADD_FAILURE() << key << " is read by the code but not in SettingsCatalog.cpp";
}
// Every setting in the catalog is in its shipped .ini (as `key=` or named in a comment), so a server's files list what
// it reads. With DLU_WRITE_INI_TEMPLATES=1 the missing ones are added to resources/*.ini instead, under their section,
// with their description and default.
TEST(SettingsCatalogTests, ShippedFilesListEveryCatalogSetting) {
const std::filesystem::path resources = std::filesystem::path(DLU_SOURCE_DIR) / "resources";
const bool write = std::getenv("DLU_WRITE_INI_TEMPLATES") && std::string(std::getenv("DLU_WRITE_INI_TEMPLATES")) == "1";
const auto mentions = [](const std::string& text, const std::string& key) {
const std::regex named("(^|[\\s#,:(])" + key + "(=|[\\s,:.)]|$)", std::regex::multiline);
return std::regex_search(text, named);
};
// file -> section -> the settings missing from it, in catalog order
std::map<std::string, std::vector<std::pair<std::string, std::vector<const SettingsCatalog::Setting*>>>> missing;
for (const auto& setting : SettingsCatalog::All()) {
if (setting.unused || setting.key.ends_with('_')) continue; // old names; numbered families (event_1, help_1...)
std::ifstream in(resources / setting.file);
const std::string text((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
if (mentions(text, setting.key)) continue;
auto& sections = missing[setting.file];
auto it = std::find_if(sections.begin(), sections.end(), [&](const auto& s) { return s.first == setting.section; });
if (it == sections.end()) it = sections.insert(sections.end(), { setting.section, {} });
it->second.push_back(&setting);
}
if (!write) {
for (const auto& [file, sections] : missing) {
for (const auto& [section, settings] : sections) {
for (const auto* setting : settings) ADD_FAILURE() << file << " " << setting->key << " is in SettingsCatalog.cpp but not in resources/" << file << " (run with DLU_WRITE_INI_TEMPLATES=1 to add it)";
}
}
return;
}
// A comment wrapped to 120 columns
const auto comment = [](const std::string& text) {
std::string out, line = "#";
std::istringstream words(text);
std::string word;
while (words >> word) {
if (line.size() + 1 + word.size() > 120) {
out += line + "\n";
line = "#";
}
line += " " + word;
}
return line == "#" ? out : out + line + "\n";
};
for (const auto& [file, sections] : missing) {
std::ofstream out(resources / file, std::ios::app);
for (const auto& [section, settings] : sections) {
out << "\n# ---- " << section << " ----\n";
for (const auto* setting : settings) {
std::string text = setting->title;
if (!setting->description.empty()) text += ": " + setting->description;
if (!setting->unit.empty()) text += " (" + setting->unit + ")";
if (setting->restart) text += " Read when the server starts.";
out << comment(text) << setting->key << "=" << (setting->type == SettingsCatalog::eType::SECRET ? "" : setting->defaultValue) << "\n";
}
}
}
}