Files
factorio-expcluster/exp_scenario/module/features.lua
T
bbassieandClaude Opus 5.5 69e9f172a3 Cover guis, commands, and intervals in the features framework
Review feedback from Cooldude on the first version.

Features.register is renamed to Features.config, and guard also takes a
feature name so a feature without settings needs no config call.
Autofill uses this. Small configs now live at the top of the file that
uses them: death markers, mine depletion, station auto name, and
lawnmower. AFK kick and chat auto reply keep separate files because
they contain functions.

Periods no longer need a restart. A lib can list intervals, which map a
config key to a handler that runs every config[key] seconds. They are
checked once a second with a stateless tick check. AFK kick and death
markers use this and expose their periods on the page.

Guis: receive_update raises on_feature_changed, and control/features.lua
refreshes every player's gui when it fires. Autofill's toolbar button
checks Features.is_enabled, and hiding the button also hides its left
element. Quick actions refresh on the event too.

Commands: add_flags{ feature = ... } ties a command to a feature, and a
new authority denies it while the feature is disabled. The custom help
and quick actions already leave out denied commands. Lawnmower uses this
and is now added as a lib, so its destroy_decoratives handlers are
actually registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:43:31 +00:00

197 lines
6.4 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. Only boolean, number, and string defaults can be overridden.
Values used while the file loads can not change without a restart. Periods can
use intervals instead of on_nth_tick, everything else such as toolbar sprites
stays as a plain value and is 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,
check_seconds = 60,
})
--- 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 },
intervals = { check_seconds = check_players }, -- Runs every config.check_seconds seconds
})
--- 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(),
},
}
local overridable = {
boolean = true,
number = true,
string = true,
}
--- @class ExpScenario.FeatureConfig
--- @field enabled boolean
--- @alias ExpScenario.Feature string | ExpScenario.FeatureConfig
--- The live config tables by feature name
local features = {} --- @type table<string, ExpScenario.FeatureConfig>
--- Copies of the defaults by feature name, used to undo overrides
local defaults = {} --- @type table<string, table<string, any>>
--- Overrides received from the controller by feature name
local overrides = {} --- @type table<string, table<string, boolean | number | string>>
--- Reset a feature to its defaults and apply its overrides
--- @param name string
local function apply(name)
local config = features[name]
local feature_defaults = defaults[name]
for key, value in pairs(feature_defaults) do
config[key] = value
end
for key, value in pairs(overrides[name] or {}) do
local default = feature_defaults[key]
if default ~= nil and type(value) == type(default) and overridable[type(value)] then
config[key] = value
end
end
end
Storage.register(overrides, function(tbl)
overrides = tbl
for name in pairs(features) do
apply(name)
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_defaults = {}
for key, value in pairs(config) do
feature_defaults[key] = value
end
features[name] = config
defaults[name] = feature_defaults
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
--- Check if a feature is enabled
--- @param feature ExpScenario.Feature
--- @return boolean
function Features.is_enabled(feature)
return resolve(feature).enabled
end
--- Make the handlers of an event handler lib do nothing while the feature is disabled
-- A name which has not been declared is declared without config. The intervals field
-- maps config keys to handlers, each runs every config[key] seconds, checked once a second
--- @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)
local intervals = lib.intervals
if intervals then
lib.intervals = nil
lib.on_nth_tick = lib.on_nth_tick or {}
local every_second = lib.on_nth_tick[60]
lib.on_nth_tick[60] = function(event)
if every_second then
every_second(event)
end
-- Exactly one check each period lands within its first second
local tick = event.tick
for key, handler in pairs(intervals) do
if tick % (config[key] * 60) < 60 then
handler(event)
end
end
end
end
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>
--- 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
if features[name] then
local override = { enabled = update.enabled }
for key, value in pairs(update.values) do
override[key] = value
end
overrides[name] = override
apply(name)
script.raise_event(Features.events.on_feature_changed, { feature_name = name })
end
end
end
return Features