From f8ca285ff6ffcf2cc3de64b1301ed715e5cd1c88 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:40:33 +0000 Subject: [PATCH] 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 --- exp_scenario/features.ts | 18 +-- exp_scenario/module/config/spawn_area.lua | 2 +- .../module/control/discord_alerts.lua | 4 +- .../module/control/nuke_protection.lua | 4 +- exp_scenario/module/control/protection.lua | 27 +++-- exp_scenario/module/features.lua | 113 ++++++++++-------- exp_scenario/test/features.test.js | 6 +- exp_scenario/test/module/features.lua | 32 ++--- exp_scenario/web/components/FeaturesPage.tsx | 12 +- 9 files changed, 128 insertions(+), 90 deletions(-) diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts index 48179a69..663e835d 100644 --- a/exp_scenario/features.ts +++ b/exp_scenario/features.ts @@ -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 & { /** 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 +--- @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> 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) diff --git a/exp_scenario/module/features.lua b/exp_scenario/module/features.lua index c9ae2f5c..db3f52c3 100644 --- a/exp_scenario/module/features.lua +++ b/exp_scenario/module/features.lua @@ -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 +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 --- @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 +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> - ---- Get a lookup set of the items in a list, cached until the list is replaced ---- @param list string[] ---- @return table -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 diff --git a/exp_scenario/test/features.test.js b/exp_scenario/test/features.test.js index c5974ca3..4446c8de 100644 --- a/exp_scenario/test/features.test.js +++ b/exp_scenario/test/features.test.js @@ -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); diff --git a/exp_scenario/test/module/features.lua b/exp_scenario/test/module/features.lua index afb806b2..d5a10c2e 100644 --- a/exp_scenario/test/module/features.lua +++ b/exp_scenario/test/module/features.lua @@ -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") diff --git a/exp_scenario/web/components/FeaturesPage.tsx b/exp_scenario/web/components/FeaturesPage.tsx index 118384df..d9dff19d 100644 --- a/exp_scenario/web/components/FeaturesPage.tsx +++ b/exp_scenario/web/components/FeaturesPage.tsx @@ -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 }