Let the framework own sets and lists instead of the features

Feature.to_set made every feature turn its config lists into sets on
each lookup, which is work the framework should do once. Sets are now a
field type of their own. Feature.set{...} declares one, the controller
sends a list, and _apply converts it when the override is applied, on
load and on update alike. Features index the config directly, and
to_set and its cache are gone.

Lists are explicit too. Feature.list{...} declares one, where before
any table of strings, and any empty table, counted as a list. Untagged
tables with string keys are nested sections, and other tables can not
be overridden. Feature.optional uses the same marker as list and set.

On the web side a string list can no longer name an input component,
so the cast that made one look like an object field is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-01 19:40:33 +00:00
1 parent 628e4df514
commit f8ca285ff6
9 files changed
+128 -90

No files matched your search

+11 -7
View File
@@ -17,16 +17,20 @@ export type FeatureField = {
name: string,
title: string,
description: string,
} & (
| ScalarField<{ type: "boolean", default: boolean }>
| ScalarField<{ type: "number", default: number | null, min?: number, unit?: string }>
| ScalarField<{ type: "string", default: string | null, enum?: string[] }>
/** Feature.list or Feature.set on the lua side, both are sent as a list */
| { type: "string_list", default: string[] }
);
type ScalarField<T> = T & {
/** Allows null, the lua side uses Feature.optional */
optional?: boolean,
/** Name of an input component registered with the web interface, such as "role" */
inputComponent?: string,
} & (
| { type: "boolean", default: boolean }
| { type: "number", default: number | null, min?: number, unit?: string }
| { type: "string", default: string | null, enum?: string[] }
| { type: "string_list", default: string[] }
);
};
export type Feature = {
name: string,
@@ -238,7 +242,7 @@ export function validateFeatureValues(name: string, values: Record<string, Featu
function checkFieldValue(field: FeatureField, value: FeatureValue, label: string) {
if (value === null) {
if (!field.optional) {
if (field.type === "string_list" || !field.optional) {
throw new Error(`${label} can not be null`);
}
return;
+1 -1
View File
@@ -243,7 +243,7 @@ return Feature.register("spawn_area", {
resource_refill_nearby = {
enabled = false,
range = 128,
resources_name = {
resources_name = Feature.list{
"iron-ore",
"copper-ore",
"stone",
@@ -16,7 +16,7 @@ local feature = Feature.register("discord_alerts", {
player_kicks = true, --- @setting player_kicks Alert when a player is kicked
player_promotes = false, --- @setting player_promotes Alert when a player is promoted or demoted
player_jail = true, --- @setting player_jail Alert when a player is jailed or unjailed
logged_commands = { --- @setting logged_commands Alert when a player uses one of these commands
logged_commands = Feature.set{ --- @setting logged_commands Alert when a player uses one of these commands
"config", "purge", "c", "command", "silent-command", "measured-command", "banlist", "permissions", "editor", "cheat",
},
})
@@ -259,7 +259,7 @@ end)
events[e.on_console_command] = function(event)
if event.player_index then
local player_name = get_player_name(event)
if Feature.to_set(config.logged_commands)[event.command] then
if config.logged_commands[event.command] then
emit_event{
title = event.command:gsub("^%l", string.upper),
description = "/" .. event.command .. " was used",
@@ -8,7 +8,7 @@ local Roles = require("modules/exp_roles")
local feature = Feature.register("nuke_protection", {
ignore_admins = true, --- @setting ignore_admins Admins can hold banned items
banned_items = { "atomic-bomb" }, --- @setting banned_items Items which are removed from the inventory of players without the bypass permission
banned_items = Feature.set{ "atomic-bomb" }, --- @setting banned_items Items which are removed from the inventory of players without the bypass permission
})
local config = feature.config
@@ -28,7 +28,7 @@ local function check_items(player, type)
if Roles.player_has_permission(player, "exp_scenario.bypass.nuke_protection") then return end
if config.ignore_admins and player.admin then return end
local banned_items = Feature.to_set(config.banned_items)
local banned_items = config.banned_items
local items = {} --- @type LuaItemStack[]
local inventory = assert(player.get_inventory(type))
-- Check what items the player has
+19 -8
View File
@@ -10,12 +10,12 @@ local feature = Feature.register("protection", {
ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter
repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_minutes in order to trigger repeated removal protection
repeat_minutes = 20, --- @setting repeat_minutes The length of time, in minutes, that protected removals will be remembered for
always_protected_names = {}, --- @setting always_protected_names Names of entities which are always protected
always_protected_types = { --- @setting always_protected_types Types of entities which are always protected
always_protected_names = Feature.set{}, --- @setting always_protected_names Names of entities which are always protected
always_protected_types = Feature.set{ --- @setting always_protected_types Types of entities which are always protected
"boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo",
},
always_trigger_repeat_names = {}, --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection
always_trigger_repeat_types = { --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection
always_trigger_repeat_names = Feature.set{}, --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection
always_trigger_repeat_types = Feature.set{ --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection
"reactor", "fusion-reactor", "rocket-silo",
},
})
@@ -45,16 +45,27 @@ local Protection = {
--- @field last uint Tick of the last protected removal
--- @field count number Protected removals since the last repeat violation
--- Get the keys of a set as a list, for the filters of find_entities_filtered
--- @param set table<string, true>
--- @return string[]
local function set_to_list(set)
local list = {}
for item in pairs(set) do
list[#list + 1] = item
end
return list
end
--- Names of entities which are always protected
--- @return string[]
function Protection.get_protected_entity_names()
return config.always_protected_names
return set_to_list(config.always_protected_names)
end
--- Types of entities which are always protected
--- @return string[]
function Protection.get_protected_entity_types()
return config.always_protected_types
return set_to_list(config.always_protected_types)
end
local protected_entities = {} --- @type table<uint, table<string, LuaEntity>> Keyed by surface index then entity key
@@ -115,7 +126,7 @@ end
--- @param entity LuaEntity
--- @return boolean
function Protection.is_entity_protected(entity)
if Feature.to_set(config.always_protected_names)[entity.name] or Feature.to_set(config.always_protected_types)[entity.type] then return true end
if config.always_protected_names[entity.name] or config.always_protected_types[entity.type] then return true end
local entities = protected_entities[entity.surface.index]
if not entities then return false end
return entities[Protection.get_entity_key(entity)] == entity
@@ -193,7 +204,7 @@ local function raise_violation(event, player)
script.raise_event(Protection.on_player_mined_protected, event)
local entity = event.entity
local always_repeat = Feature.to_set(config.always_trigger_repeat_names)[entity.name] or Feature.to_set(config.always_trigger_repeat_types)[entity.type]
local always_repeat = config.always_trigger_repeat_names[entity.name] or config.always_trigger_repeat_types[entity.type]
if always_repeat or player_repeats.count >= config.repeat_count then
player_repeats.count = 0
script.raise_event(Protection.on_repeat_violation, event)
+65 -48
View File
@@ -3,9 +3,10 @@ Features which the controller can enable, disable, and configure while the game
A feature's config table is changed in place when the controller sends overrides,
so read values when they are used rather than copying them into locals when the
file loads. Booleans, numbers, strings, and lists of strings can be overridden,
including those in nested tables which are addressed as "section.key". Use
Feature.optional for a value which has no default. Values used while the file
file loads. Booleans, numbers, and strings can be overridden, as can lists and
sets of strings made with Feature.list and Feature.set, and values with no default
made with Feature.optional. Tables with string keys are nested sections whose
values are addressed as "section.key", other tables can not be overridden. Values used while the file
loads, such as on_nth_tick periods, stay as plain values and are not listed in
features.ts.
@@ -14,6 +15,8 @@ local Feature = require("modules/exp_scenario/features")
local feature = Feature.register("my_feature", {
show_message = true,
role_id = Feature.optional("number"),
item_names = Feature.set{ "iron-plate" },
surface_names = Feature.list{ "nauvis" },
})
local config = feature.config
@@ -30,9 +33,11 @@ Gui.toolbar.create_button{
--- Commands are denied and left out of help while disabled:
Commands.new("my_command", { "..." }):add_flags{ feature = feature }
--- Changes are raised per value, values derived from a list can use Feature.to_set:
--- Sets are kept as sets, the controller sends a list which is converted when it is applied:
if config.item_names[item.name] then end
--- Changes are raised per value:
[Feature.events.on_config_updated] = function(event) end, -- { feature_name, path, old_value, new_value }
if Feature.to_set(config.names)[name] then end
]]
local Storage = require("modules/exp_util/storage")
@@ -41,7 +46,7 @@ local Storage = require("modules/exp_util/storage")
--- @field parent table The table holding the value within the config
--- @field key string The key of the value within its parent
--- @field default any
--- @field type string "boolean", "number", "string", "list" of strings, or a type which can not be overridden
--- @field type string "boolean", "number", "string", "list" or "set" of strings, or a type which can not be overridden
--- @class ExpScenario.Feature
--- @field name string
@@ -77,31 +82,48 @@ Storage.register(stored_overrides, function(tbl)
end
end)
--- Check if a table is a list of strings, empty tables count as lists of strings
--- @param tbl table
--- Check if a value is a list of strings, as sent by the controller for lists and sets
--- @param value any
--- @return boolean
local function is_string_list(tbl)
if next(tbl) ~= nil and tbl[1] == nil then return false end
for _, item in pairs(tbl) do
local function is_string_list(value)
if type(value) ~= "table" then return false end
if next(value) ~= nil and value[1] == nil then return false end
for _, item in pairs(value) do
if type(item) ~= "string" then return false end
end
return true
end
--- Check if two values are equal, lists are compared by their items
--- Check if two values are equal, lists and sets are compared by their contents
--- @param a any
--- @param b any
--- @return boolean
local function values_equal(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
if #a ~= #b then return false end
for index, item in ipairs(a) do
if b[index] ~= item then return false end
for key, value in pairs(a) do
if b[key] ~= value then return false end
end
for key in pairs(b) do
if a[key] == nil then return false end
end
return true
end
--- Collect the values of a config table, recursing into nested tables which are not lists
--- Marks a default made by Feature.list, Feature.set, or Feature.optional with the type of the field
local typed_mt = {}
--- Convert a list of strings into a set
--- @param list string[]
--- @return table<string, true>
local function list_to_set(list)
local set = {}
for _, item in ipairs(list) do
set[item] = true
end
return set
end
--- Collect the values of a config table, recursing into nested sections
--- @param fields table<string, ExpScenario.FeatureField>
--- @param tbl table
--- @param prefix string
@@ -110,15 +132,12 @@ local function collect_fields(fields, tbl, prefix)
if type(key) == "string" then
local field = { parent = tbl, key = key, default = value, type = type(value) }
if type(value) == "table" then
if value.__feature_optional then
field.default = nil
field.type = value.__feature_optional
tbl[key] = nil
elseif is_string_list(value) then
field.type = "list"
elseif value[1] ~= nil then
field.type = "table"
else
local typed = getmetatable(value) == typed_mt and value
if typed then
field.type = typed.type
field.default = typed.default
tbl[key] = typed.default
elseif type(next(value)) == "string" then
collect_fields(fields, value, prefix .. key .. ".")
field = nil
end
@@ -130,11 +149,25 @@ local function collect_fields(fields, tbl, prefix)
end
end
--- Mark a value as having no default, the argument is the type it can be overridden with
--- @param value_type "boolean" | "number" | "string" | "list"
--- Declare a value with no default, the argument is the type it can be overridden with
--- @param value_type "boolean" | "number" | "string"
--- @return any
function Feature.optional(value_type)
return { __feature_optional = value_type }
return setmetatable({ type = value_type }, typed_mt)
end
--- Declare a list of strings which can be overridden
--- @param items string[]
--- @return string[]
function Feature.list(items)
return setmetatable({ type = "list", default = items }, typed_mt)
end
--- Declare a set of strings which can be overridden, the controller sends a list which is converted when applied
--- @param items string[]
--- @return table<string, true>
function Feature.set(items)
return setmetatable({ type = "set", default = list_to_set(items) }, typed_mt)
end
--- Register a feature and its config, features are enabled unless the config says otherwise
@@ -174,8 +207,8 @@ end
--- @param value any
--- @return boolean
local function is_valid(field, value)
if field.type == "list" then
return type(value) == "table" and is_string_list(value)
if field.type == "list" or field.type == "set" then
return is_string_list(value)
end
return type(value) == field.type and (field.type == "boolean" or field.type == "number" or field.type == "string")
end
@@ -190,6 +223,8 @@ function Feature:_apply()
elseif not is_valid(field, value) then
log("[WARNING] Invalid override for " .. self.name .. "." .. path .. ", using the default")
value = field.default
elseif field.type == "set" then
value = list_to_set(value)
end
field.parent[field.key] = value
end
@@ -254,24 +289,6 @@ function Feature:update_config(enabled, values)
end
end
--- Sets made from lists in the config, keyed by the list so a replaced list gets a new set
local sets = setmetatable({}, { __mode = "k" }) --- @type table<string[], table<string, true>>
--- Get a lookup set of the items in a list, cached until the list is replaced
--- @param list string[]
--- @return table<string, true>
function Feature.to_set(list)
local set = sets[list]
if not set then
set = {}
for _, item in ipairs(list) do
set[item] = true
end
sets[list] = set
end
return set
end
--- @class ExpScenario.FeatureUpdate
--- @field name string
--- @field enabled boolean
+3 -3
View File
@@ -18,7 +18,7 @@ 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, lists of strings, and Feature.optional.
* Only covers booleans, numbers, strings, Feature.list, Feature.set, and Feature.optional.
*/
function luaDefault(source, path) {
const keys = path.split(".");
@@ -32,7 +32,7 @@ function luaDefault(source, path) {
}
const key = keys.at(-1);
const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Feature\\.optional\\("\\w+"\\)|\\{[^{}]*\\})`, "m")
const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Feature\\.optional\\("\\w+"\\)|(?:Feature\\.(?:list|set))?\\{[^{}]*\\})`, "m")
.exec(source.slice(start));
if (!match) {
return undefined;
@@ -41,7 +41,7 @@ function luaDefault(source, path) {
if (literal.startsWith("Feature.optional")) {
return null;
}
if (literal.startsWith("{")) {
if (literal.includes("{")) {
return [...literal.matchAll(/"([^"]*)"/g)].map(item => item[1]);
}
return JSON.parse(literal);
+18 -14
View File
@@ -34,7 +34,7 @@ Suite.test("update_config() changes the config in place", function(env)
end)
Suite.test("update_config() raises on_config_updated for each changed value", function(env)
local feature = env.Feature.register("test", { flag = true, names = { "a" }, section = { count = 1 } })
local feature = env.Feature.register("test", { flag = true, names = env.Feature.set{ "a" }, section = { count = 1 } })
feature:update_config(false, { names = { "a" }, ["section.count"] = 2 })
Suite.eq(#env.events, 2, "unchanged values raise nothing")
Suite.eq(env.Feature.events.on_config_updated, assert(env.events[1]).name, "the event is on_config_updated")
@@ -97,21 +97,33 @@ Suite.test("register() addresses nested values by path", function(env)
local feature = env.Feature.register("test", { section = { flag = true, count = 1 }, offset = offset })
feature:update_config(true, { ["section.flag"] = false, offset = { "x" } })
Suite.eq(feature.config.section, { flag = false, count = 1 }, "the nested value changes in place")
Suite.eq(feature.config.offset, offset, "lists of other types can not be overridden")
Suite.eq(feature.config.offset, offset, "tables which are not sections can not be overridden")
feature:update_config(true, {})
Suite.eq(feature.config.section.flag, true, "and goes back to its default")
end)
Suite.test("register() allows lists of strings to be overridden", function(env)
local feature = env.Feature.register("test", { names = { "a", "b" }, empty = {} })
feature:update_config(true, { names = { "c" }, empty = { "d" } })
Suite.test("list() declares a list of strings which can be overridden", function(env)
local feature = env.Feature.register("test", { names = env.Feature.list{ "a", "b" }, empty = env.Feature.list{}, plain = { "x" } })
Suite.eq(feature.config.names, { "a", "b" }, "the default is the list")
feature:update_config(true, { names = { "c" }, empty = { "d" }, plain = { "y" } })
Suite.eq(feature.config.names, { "c" }, "a list is replaced")
Suite.eq(feature.config.empty, { "d" }, "an empty default is a list")
Suite.eq(feature.config.empty, { "d" }, "an empty list can be overridden")
Suite.eq(feature.config.plain, { "x" }, "a plain table can not be overridden")
feature:update_config(true, { names = { 1 } })
Suite.eq(feature.config.names, { "a", "b" }, "a list with other types is invalid")
end)
Suite.test("set() declares a set of strings which is overridden with a list", function(env)
local feature = env.Feature.register("test", { names = env.Feature.set{ "a" }, empty = env.Feature.set{} })
Suite.eq(feature.config.names, { a = true }, "the default is a set")
Suite.eq(feature.config.empty, {}, "and can be empty")
feature:update_config(true, { names = { "b", "c" } })
Suite.eq(feature.config.names, { b = true, c = true }, "the list is converted to a set")
env.on_load{ test = { enabled = true, names = { "d" } } }
Suite.eq(feature.config.names, { d = true }, "also when the overrides are loaded")
end)
Suite.test("optional() declares a value without a default", function(env)
local feature = env.Feature.register("test", { role_id = env.Feature.optional("number") })
Suite.eq(feature.config.role_id, nil, "nil until overridden")
@@ -121,14 +133,6 @@ Suite.test("optional() declares a value without a default", function(env)
Suite.eq(feature.config.role_id, nil, "and back to nil")
end)
Suite.test("to_set() is cached until the list is replaced", function(env)
local feature = env.Feature.register("test", { names = { "a" } })
local set = env.Feature.to_set(feature.config.names)
Suite.eq(set, { a = true }, "a set of the items")
Suite.eq(env.Feature.to_set(feature.config.names) == set, true, "the same set while the list is unchanged")
env.on_load{ test = { enabled = true, names = { "b" } } }
Suite.eq(env.Feature.to_set(feature.config.names), { b = true }, "a new set after load replaces the list")
end)
Suite.test("config files register their feature", function(env)
local feature = env.load_config("popups")
+7 -5
View File
@@ -78,15 +78,17 @@ function searchFields(feature: Feature, query: string) {
function FieldInput(props: { field: FeatureField, value: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void }) {
const { field, value, disabled, onChange } = props;
const control = useContext(ControlContext);
const CustomInput = field.inputComponent ? control.inputComponents.get(field.inputComponent) : undefined;
if (CustomInput) {
const fieldDefinition = {
type: field.type === "string_list" ? "object" : field.type,
const CustomInput = field.type !== "string_list" && field.inputComponent
? control.inputComponents.get(field.inputComponent)
: undefined;
if (CustomInput && field.type !== "string_list") {
const fieldDefinition: lib.FieldDefinition = {
type: field.type,
title: field.title,
description: field.description,
optional: field.optional,
inputComponent: field.inputComponent,
} as lib.FieldDefinition;
};
return <CustomInput
fieldDefinition={fieldDefinition}
value={value as Exclude<FeatureValue, string[]>}