Files
factorio-expcluster/exp_scenario/module/features.lua
T
bbassieandClaude Opus 5.5 4bc1773c82 Address review: page layout, five more features, framework changes
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>
2026-09-30 15:28:23 +00:00

279 lines
9.5 KiB
Lua

--[[-- ExpScenario - Features
Features which the controller can enable, disable, and configure while the game is running
A feature's config is the table returned by Features.config. The controller sends
overrides over rcon, these are kept in storage and copied onto that same table,
so read values when they are used rather than copying them into locals when the
file loads. Values derived from the config can be rebuilt with Features.on_apply.
Booleans, numbers, strings, and lists of strings can be overridden, including
those in nested tables which are addressed as "section.key". Use Features.optional
for a value which has no default. Values used while the file loads, such as
on_nth_tick periods, stay as plain values and are not listed in features.ts.
--- Declaring a feature with config, small configs live at the top of the file which uses them:
local Features = require("modules/exp_scenario/features")
local config = Features.config("my_feature", {
show_message = true,
role_id = Features.optional("number"),
})
--- Handlers do nothing while the feature is disabled, a feature without config can be guarded by name:
return Features.guard(config, {
events = { [defines.events.on_player_joined_game] = on_player_joined_game },
})
--- Toolbar buttons are hidden while disabled, which also hides their left element:
Gui.toolbar.create_button{
visible = function(player, element) return Features.is_enabled("my_feature") end,
}
--- Commands are denied and left out of help while disabled:
Commands.new("my_command", { "..." }):add_flags{ feature = "my_feature" }
]]
local Storage = require("modules/exp_util/storage")
--- @class ExpScenario.Features
local Features = {
events = {
--- Raised when the controller changes a feature, gui and commands are refreshed by control/features.lua
on_feature_changed = script.generate_event_name(),
},
}
--- @class ExpScenario.FeatureConfig
--- @field enabled boolean
--- @alias ExpScenario.Feature string | ExpScenario.FeatureConfig
--- @class ExpScenario.FeatureField
--- @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
--- @class ExpScenario.Optional
--- @field __feature_optional string
--- The live config tables by feature name
local features = {} --- @type table<string, ExpScenario.FeatureConfig>
--- The values of each feature by feature name then path
local fields = {} --- @type table<string, table<string, ExpScenario.FeatureField>>
--- Called after a feature is applied by feature name
local on_apply = {} --- @type table<string, fun(config: table)[]>
--- Overrides received from the controller by feature name then path
local overrides = {} --- @type table<string, table<string, boolean | number | string | string[]>>
--- Check if a table is a list, empty tables count as lists
--- @param tbl table
--- @return boolean
local function is_list(tbl)
return next(tbl) == nil or tbl[1] ~= nil
end
--- Check if a table is a list of strings, empty tables count as lists of strings
--- @param tbl table
--- @return boolean
local function is_string_list(tbl)
if not is_list(tbl) then return false end
for _, item in pairs(tbl) do
if type(item) ~= "string" then return false end
end
return true
end
--- Check if an override can replace a field
--- @param field ExpScenario.FeatureField
--- @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)
end
return type(value) == field.type and (field.type == "boolean" or field.type == "number" or field.type == "string")
end
--- Reset a feature to its defaults and apply its overrides
--- @param name string
local function apply(name)
local feature_overrides = overrides[name] or {}
for path, field in pairs(fields[name]) do
local value = feature_overrides[path]
if value == nil then
value = field.default
elseif not is_valid(field, value) then
log("[WARNING] Invalid override for " .. name .. "." .. path .. ", using the default")
value = field.default
end
field.parent[field.key] = value
end
for _, callback in ipairs(on_apply[name]) do
callback(features[name])
end
end
Storage.register(overrides, function(tbl)
overrides = tbl
for name in pairs(features) do
apply(name)
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"
--- @return any
function Features.optional(value_type)
return { __feature_optional = value_type }
end
--- Collect the values of a config table, recursing into nested tables which are not lists
--- @param feature_fields table<string, ExpScenario.FeatureField>
--- @param tbl table
--- @param prefix string
local function collect_fields(feature_fields, tbl, prefix)
for key, value in pairs(tbl) do
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 is_list(value) then
field.type = "table"
else
collect_fields(feature_fields, value, prefix .. key .. ".")
field = nil
end
end
if field then
feature_fields[prefix .. key] = field
end
end
end
end
--- Declare a feature and its config, features are enabled unless the config says otherwise
--- @generic T : table
--- @param name string
--- @param config T?
--- @return T
function Features.config(name, config)
assert(features[name] == nil, "Feature already declared: " .. name)
config = config or {} --[[@as T]]
if config.enabled == nil then
config.enabled = true
end
local feature_fields = {}
collect_fields(feature_fields, config, "")
features[name] = config
fields[name] = feature_fields
on_apply[name] = {}
return config
end
--- Get the config of a feature from its name, or return the config given
--- @param feature ExpScenario.Feature
--- @return ExpScenario.FeatureConfig
local function resolve(feature)
if type(feature) == "table" then
return feature
end
return features[feature] or error("Unknown feature: " .. tostring(feature), 3)
end
--- Get the name of a feature from its config, or return the name given
--- @param feature ExpScenario.Feature
--- @return string
local function resolve_name(feature)
if type(feature) == "string" then
return feature
end
for name, config in pairs(features) do
if config == feature then
return name
end
end
error("Unknown feature config", 3)
end
--- Check if a feature is enabled
--- @param feature ExpScenario.Feature
--- @return boolean
function Features.is_enabled(feature)
return resolve(feature).enabled
end
--- Call a function now and whenever the config of a feature changes, used to rebuild values derived from it
-- Also called during on_load, so it must only change locals and not the game state
--- @param feature ExpScenario.Feature
--- @param callback fun(config: any)
function Features.on_apply(feature, callback)
local name = resolve_name(feature)
table.insert(on_apply[name], callback)
callback(features[name])
end
--- Make the event and on_nth_tick handlers of an event handler lib do nothing while the feature is disabled
-- A name which has not been declared is declared without config
--- @generic T : table
--- @param feature ExpScenario.Feature
--- @param lib T
--- @return T
function Features.guard(feature, lib)
if type(feature) == "string" and features[feature] == nil then
Features.config(feature)
end
local config = resolve(feature)
for _, handlers in pairs{ lib.events or {}, lib.on_nth_tick or {} } do
for key, handler in pairs(handlers) do
handlers[key] = function(event)
if config.enabled then
return handler(event)
end
end
end
end
return lib
end
--- @class ExpScenario.FeatureUpdate
--- @field name string
--- @field enabled boolean
--- @field values table<string, boolean | number | string | string[]>
--- Called over rcon by the instance plugin with the features which changed, unknown features are ignored
--- @param updates ExpScenario.FeatureUpdate[]
function Features.receive_update(updates)
for _, update in ipairs(updates) do
local name = update.name
local feature_fields = fields[name]
if feature_fields then
local override = { enabled = update.enabled }
for path, value in pairs(update.values) do
if feature_fields[path] == nil then
log("[WARNING] Unknown override for " .. name .. "." .. path)
end
override[path] = value
end
overrides[name] = override
apply(name)
script.raise_event(Features.events.on_feature_changed, { feature_name = name })
end
end
end
return Features