mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-14 08:14:52 +09:00
Framework: - Intervals are gone. Update periods are hardcoded on_nth_tick values again, since nobody has needed to change them. - apply is a single loop over the declared fields and logs a warning when an override is invalid. receive_update also warns about unknown keys. - Nested tables are addressed as "section.key". Lists of strings can be overridden. Features.optional declares a value with no default. - Features.on_apply rebuilds values derived from the config, such as lookup sets. It also runs during on_load. exp_roles is assumed to be present: - AFK Kick picks a role (active_role_id) instead of a custom check function. - Chat Auto Reply, Protection, and Nuke Protection no longer take a permission as a setting. New features, each chosen for an edge case: - Discord Alerts: conditional handlers, and a command map that becomes a list. - Entity Protection: lists turned into sets at load, and bookkeeping that must keep running while disabled. - Nuke Protection: config keyed by defines, flattened to a banned items list. - Popups: one config shared by two control files. - Spawn Area: nested sections, and settings that only apply when the map is created. Page: now laid out like the role page. It has a search across every setting, one long list with a sticky sidebar to jump between sections, and the enabled switch separated at the front of each header with Reset to defaults on the right. Changed rows are highlighted, and a bottom bar offers Revert and Apply. Role settings use a role picker and lists use a tag select. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
4.0 KiB
JavaScript
108 lines
4.0 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import t from "tap";
|
|
import { features, validateFeatureValues } from "../dist/node/features.js";
|
|
|
|
/** Every lua file in the module, by path relative to it. */
|
|
function luaFiles(dir, prefix = "") {
|
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
const relative = path.posix.join(prefix, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return luaFiles(path.join(dir, entry.name), relative);
|
|
}
|
|
return entry.name.endsWith(".lua") ? [[relative, fs.readFileSync(path.join(dir, entry.name), "utf8")]] : [];
|
|
});
|
|
}
|
|
|
|
const moduleFiles = luaFiles(path.join(import.meta.dirname, "..", "module"));
|
|
|
|
/**
|
|
* Read a default from a Features.config call, a path of "a.b" looks for b after the table a starts.
|
|
* Only covers booleans, numbers, strings, lists of strings, and Features.optional.
|
|
*/
|
|
function luaDefault(source, path) {
|
|
const keys = path.split(".");
|
|
let start = 0;
|
|
for (const section of keys.slice(0, -1)) {
|
|
const match = new RegExp(`^\\s*${section} = \\{`, "m").exec(source.slice(start));
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
start += match.index + match[0].length;
|
|
}
|
|
|
|
const key = keys.at(-1);
|
|
const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Features\\.optional\\("\\w+"\\)|\\{[^{}]*\\})`, "m")
|
|
.exec(source.slice(start));
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
const literal = match[1];
|
|
if (literal.startsWith("Features.optional")) {
|
|
return null;
|
|
}
|
|
if (literal.startsWith("{")) {
|
|
return [...literal.matchAll(/"([^"]*)"/g)].map(item => item[1]);
|
|
}
|
|
return JSON.parse(literal);
|
|
}
|
|
|
|
t.test("features", t2 => {
|
|
for (const feature of features) {
|
|
t2.test(feature.name, t3 => {
|
|
const declarations = moduleFiles.filter(([, source]) => (
|
|
source.includes(`Features.config("${feature.name}"`) || source.includes(`Features.guard("${feature.name}"`)
|
|
));
|
|
t3.equal(declarations.length, 1, "one lua file declares the feature");
|
|
const source = declarations[0]?.[1] ?? "";
|
|
for (const field of feature.fields) {
|
|
t3.strictSame(luaDefault(source, field.name), field.default, `${field.name} default matches the lua side`);
|
|
}
|
|
t3.end();
|
|
});
|
|
}
|
|
t2.end();
|
|
});
|
|
|
|
t.test("validateFeatureValues()", t2 => {
|
|
t2.same(
|
|
validateFeatureValues("death_markers", { show_map_markers: false, collect_corpses: true }),
|
|
{ show_map_markers: false },
|
|
"values equal to their default are dropped",
|
|
);
|
|
t2.throws(() => validateFeatureValues("unknown", {}), { message: "Unknown feature unknown" });
|
|
t2.throws(
|
|
() => validateFeatureValues("death_markers", { map_icon: "x" }),
|
|
{ message: "Feature death_markers has no setting map_icon" },
|
|
);
|
|
t2.throws(
|
|
() => validateFeatureValues("death_markers", { show_map_markers: "no" }),
|
|
{ message: "Setting show_map_markers of death_markers must be a boolean" },
|
|
);
|
|
t2.throws(
|
|
() => validateFeatureValues("afk_kick", { afk_minutes: 0 }),
|
|
{ message: "Setting afk_minutes of afk_kick must be at least 1" },
|
|
);
|
|
t2.throws(
|
|
() => validateFeatureValues("afk_kick", { afk_minutes: Infinity }),
|
|
{ message: "Setting afk_minutes of afk_kick must be a finite number" },
|
|
);
|
|
t2.throws(
|
|
() => validateFeatureValues("protection", { always_protected_types: ["boiler", 1] }),
|
|
{ message: "Setting always_protected_types of protection must be a list of strings" },
|
|
);
|
|
t2.throws(
|
|
() => validateFeatureValues("afk_kick", { active_role_id: 1.5 }),
|
|
{ message: "Setting active_role_id of afk_kick must be a role id or null" },
|
|
);
|
|
t2.same(
|
|
validateFeatureValues("protection", { always_protected_types: ["boiler"], always_trigger_repeat_types: ["reactor", "fusion-reactor", "rocket-silo"] }),
|
|
{ always_protected_types: ["boiler"] },
|
|
"lists equal to their default are dropped",
|
|
);
|
|
t2.same(validateFeatureValues("afk_kick", { active_role_id: null }), {}, "no role is the default");
|
|
t2.same(validateFeatureValues("afk_kick", { active_role_id: 3 }), { active_role_id: 3 });
|
|
t2.same(validateFeatureValues("spawn_area", { "turrets.enabled": false }), { "turrets.enabled": false }, "nested settings");
|
|
t2.end();
|
|
});
|