Files
bbassieandClaude Fable 5.1 bff6a1c612 Fix stale values, the unsynced page, and three smaller review finds
- The page waited for nothing, so before the subscription delivered
  the records every feature looked enabled with its defaults, and an
  Apply in that window would have written that state. It now shows a
  spinner until synced, like the role page.
- Values stored for a setting which was since renamed, removed, or
  changed type made every later save of that feature fail validation.
  The controller now reconciles the stored records with features.ts on
  start: stale values are dropped with a warning, records of removed
  features are deleted, and new features get their defaults.
- update_config no longer stores overrides for unknown paths, so a
  stale key is warned about once instead of on every load.
- The armor and gun inventory handlers of nuke protection are new to
  this branch and asserted on get_inventory, which is nil without a
  character. They return instead.
- The AFK check ran every 30 minutes while afk_minutes and kick_minutes
  can be set as low as 1. It runs every minute.
- sameValue lives in features.ts and isDefaultValue uses it, and the
  custom input branch of FieldInput is a single narrowed check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 21:58:25 +00:00

118 lines
4.4 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import t from "tap";
import { features, pruneFeatureValues, 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 Feature.register call, a path of "a.b" looks for b after the table a starts.
* Only covers booleans, numbers, strings, Feature.list, Feature.set, and Feature.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.]+|"[^"]*"|Feature\\.optional\\("\\w+"\\)|(?:Feature\\.(?:list|set))?\\{[^{}]*\\})`, "m")
.exec(source.slice(start));
if (!match) {
return undefined;
}
const literal = match[1];
if (literal.startsWith("Feature.optional")) {
return null;
}
if (literal.includes("{")) {
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(`Feature.register("${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", { afk_minutes: null }),
{ message: "Setting afk_minutes of afk_kick can not be 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();
});
t.test("pruneFeatureValues()", t2 => {
const afkKick = features.find(feature => feature.name === "afk_kick");
t2.same(
pruneFeatureValues(afkKick, { afk_minutes: 5, trust_time: 600, kick_minutes: "soon", active_role_id: null }),
{ kept: { afk_minutes: 5, active_role_id: null }, dropped: ["trust_time", "kick_minutes"] },
"removed settings and wrong types are dropped, valid values and optional nulls stay",
);
t2.end();
});