mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-13 16:14:52 +09:00
Rework features into a Feature class with per field events
Review points from Cooldude: - Feature.register(name[, config]) is required again and returns a feature object. Each feature has its config, its fields, and its overrides, so one registry holds all the data. Only the overrides are stored. On load each feature points at its own stored table again. - The methods are feature:guard(lib), feature:update_config(enabled, values), and feature:is_enabled(). Feature.get(name) looks up a feature, and commands flag the feature object. - update_config raises on_config_updated once per changed value, with the feature name, path, old value, and new value. The gui refresh only reacts to the "enabled" path. - apply only sets values. on_apply is gone. Lookup sets built from lists use Feature.to_set, which is cached by the list itself. A replaced list gets a new set, so the sets are also correct after a load. Protection exposes getters for its protected names and types. Web: settings use small inputs, the same size as the config pages. Fields are shaped like clusterio config field definitions. A field with an inputComponent renders the component registered with the web interface, so the role setting now uses core's role input, and other plugin inputs can be used later. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4bc1773c82
commit
628e4df514
26 files changed
+468
-430
No files matched your search
+180
-167
@@ -1,141 +1,111 @@
|
||||
--[[-- 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,
|
||||
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. Values derived from the config can be rebuilt with Features.on_apply.
|
||||
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
|
||||
loads, such as on_nth_tick periods, stay as plain values and are not listed in
|
||||
features.ts.
|
||||
|
||||
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", {
|
||||
--- Registering a feature, small configs live at the top of the file which uses them:
|
||||
local Feature = require("modules/exp_scenario/features")
|
||||
local feature = Feature.register("my_feature", {
|
||||
show_message = true,
|
||||
role_id = Features.optional("number"),
|
||||
role_id = Feature.optional("number"),
|
||||
})
|
||||
local config = feature.config
|
||||
|
||||
--- Handlers do nothing while the feature is disabled, a feature without config can be guarded by name:
|
||||
return Features.guard(config, {
|
||||
--- Handlers do nothing while the feature is disabled:
|
||||
return feature:guard{
|
||||
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,
|
||||
visible = function(player, element) return feature:is_enabled() end,
|
||||
}
|
||||
|
||||
--- Commands are denied and left out of help while disabled:
|
||||
Commands.new("my_command", { "..." }):add_flags{ feature = "my_feature" }
|
||||
Commands.new("my_command", { "..." }):add_flags{ feature = feature }
|
||||
|
||||
--- Changes are raised per value, values derived from a list can use Feature.to_set:
|
||||
[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")
|
||||
|
||||
--- @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
|
||||
--- @class ExpScenario.Feature
|
||||
--- @field name string
|
||||
--- @field config table Changed in place when the config is updated
|
||||
--- @field fields table<string, ExpScenario.FeatureField> The values in the config by path
|
||||
--- @field overrides table<string, any> The overrides from the controller by path, restored from storage on load
|
||||
local Feature = {
|
||||
_registered = {}, --- @type table<string, ExpScenario.Feature>
|
||||
events = {
|
||||
--- Raised for each value which changes when the controller updates a feature
|
||||
--- @type EventData.ExpScenario.on_config_updated
|
||||
on_config_updated = script.generate_event_name(),
|
||||
},
|
||||
}
|
||||
|
||||
--- The live config tables by feature name
|
||||
local features = {} --- @type table<string, ExpScenario.FeatureConfig>
|
||||
--- @class EventData.ExpScenario.on_config_updated : EventData
|
||||
--- @field feature_name string
|
||||
--- @field path string The path of the value within the config, "enabled" when the feature is enabled or disabled
|
||||
--- @field old_value any
|
||||
--- @field new_value any
|
||||
|
||||
--- The values of each feature by feature name then path
|
||||
local fields = {} --- @type table<string, table<string, ExpScenario.FeatureField>>
|
||||
local Feature_mt = {
|
||||
__index = Feature,
|
||||
}
|
||||
|
||||
--- 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
|
||||
--- The overrides of each feature by name, only these are stored because the rest is rebuilt when the file loads
|
||||
local stored_overrides = {} --- @type table<string, table<string, any>>
|
||||
Storage.register(stored_overrides, function(tbl)
|
||||
stored_overrides = tbl
|
||||
for name, feature in pairs(Feature._registered) do
|
||||
feature.overrides = tbl[name] or {}
|
||||
feature:_apply()
|
||||
end
|
||||
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
|
||||
if next(tbl) ~= nil and tbl[1] == nil 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
|
||||
--- Check if two values are equal, lists are compared by their items
|
||||
--- @param a any
|
||||
--- @param b any
|
||||
--- @return boolean
|
||||
local function is_valid(field, value)
|
||||
if field.type == "list" then
|
||||
return type(value) == "table" and is_string_list(value)
|
||||
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
|
||||
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 }
|
||||
return true
|
||||
end
|
||||
|
||||
--- Collect the values of a config table, recursing into nested tables which are not lists
|
||||
--- @param feature_fields table<string, ExpScenario.FeatureField>
|
||||
--- @param fields table<string, ExpScenario.FeatureField>
|
||||
--- @param tbl table
|
||||
--- @param prefix string
|
||||
local function collect_fields(feature_fields, tbl, prefix)
|
||||
local function collect_fields(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) }
|
||||
@@ -146,95 +116,97 @@ local function collect_fields(feature_fields, tbl, prefix)
|
||||
tbl[key] = nil
|
||||
elseif is_string_list(value) then
|
||||
field.type = "list"
|
||||
elseif is_list(value) then
|
||||
elseif value[1] ~= nil then
|
||||
field.type = "table"
|
||||
else
|
||||
collect_fields(feature_fields, value, prefix .. key .. ".")
|
||||
collect_fields(fields, value, prefix .. key .. ".")
|
||||
field = nil
|
||||
end
|
||||
end
|
||||
if field then
|
||||
feature_fields[prefix .. key] = field
|
||||
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
|
||||
--- 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 Feature.optional(value_type)
|
||||
return { __feature_optional = value_type }
|
||||
end
|
||||
|
||||
--- Register a feature and its config, features are enabled unless the config says otherwise
|
||||
--- @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]]
|
||||
--- @param config table?
|
||||
--- @return ExpScenario.Feature
|
||||
function Feature.register(name, config)
|
||||
assert(Feature._registered[name] == nil, "Feature already registered: " .. name)
|
||||
config = config or {}
|
||||
if config.enabled == nil then
|
||||
config.enabled = true
|
||||
end
|
||||
|
||||
local feature_fields = {}
|
||||
collect_fields(feature_fields, config, "")
|
||||
local fields = {}
|
||||
collect_fields(fields, config, "")
|
||||
|
||||
features[name] = config
|
||||
fields[name] = feature_fields
|
||||
on_apply[name] = {}
|
||||
return config
|
||||
local feature = setmetatable({
|
||||
name = name,
|
||||
config = config,
|
||||
fields = fields,
|
||||
overrides = {},
|
||||
}, Feature_mt) --[[@as ExpScenario.Feature]]
|
||||
|
||||
Feature._registered[name] = feature
|
||||
return feature
|
||||
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)
|
||||
--- Get a registered feature by name
|
||||
--- @param name string
|
||||
--- @return ExpScenario.Feature
|
||||
function Feature.get(name)
|
||||
return Feature._registered[name] or error("Unknown feature: " .. tostring(name), 2)
|
||||
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
|
||||
--- Check if a field can take a value
|
||||
--- @param field ExpScenario.FeatureField
|
||||
--- @param value any
|
||||
--- @return boolean
|
||||
function Features.is_enabled(feature)
|
||||
return resolve(feature).enabled
|
||||
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
|
||||
|
||||
--- 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])
|
||||
--- Set every value to its override, or its default when there is no valid override
|
||||
--- @package
|
||||
function Feature:_apply()
|
||||
for path, field in pairs(self.fields) do
|
||||
local value = self.overrides[path]
|
||||
if value == nil then
|
||||
value = field.default
|
||||
elseif not is_valid(field, value) then
|
||||
log("[WARNING] Invalid override for " .. self.name .. "." .. path .. ", using the default")
|
||||
value = field.default
|
||||
end
|
||||
field.parent[field.key] = value
|
||||
end
|
||||
end
|
||||
|
||||
--- Check if the feature is enabled
|
||||
--- @return boolean
|
||||
function Feature:is_enabled()
|
||||
return self.config.enabled
|
||||
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)
|
||||
|
||||
function Feature:guard(lib)
|
||||
local config = self.config
|
||||
for _, handlers in pairs{ lib.events or {}, lib.on_nth_tick or {} } do
|
||||
for key, handler in pairs(handlers) do
|
||||
handlers[key] = function(event)
|
||||
@@ -248,6 +220,58 @@ function Features.guard(feature, lib)
|
||||
return lib
|
||||
end
|
||||
|
||||
--- Replace the overrides of the feature, then raise on_config_updated for each value which changed
|
||||
--- @param enabled boolean
|
||||
--- @param values table<string, boolean | number | string | string[]>
|
||||
function Feature:update_config(enabled, values)
|
||||
local overrides = { enabled = enabled }
|
||||
for path, value in pairs(values) do
|
||||
if self.fields[path] == nil then
|
||||
log("[WARNING] Unknown override for " .. self.name .. "." .. path)
|
||||
end
|
||||
overrides[path] = value
|
||||
end
|
||||
|
||||
local old_values = {}
|
||||
for path, field in pairs(self.fields) do
|
||||
old_values[path] = field.parent[field.key]
|
||||
end
|
||||
|
||||
self.overrides = overrides
|
||||
stored_overrides[self.name] = overrides
|
||||
self:_apply()
|
||||
|
||||
for path, field in pairs(self.fields) do
|
||||
local new_value = field.parent[field.key]
|
||||
if not values_equal(old_values[path], new_value) then
|
||||
script.raise_event(Feature.events.on_config_updated, {
|
||||
feature_name = self.name,
|
||||
path = path,
|
||||
old_value = old_values[path],
|
||||
new_value = new_value,
|
||||
})
|
||||
end
|
||||
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
|
||||
@@ -255,24 +279,13 @@ end
|
||||
|
||||
--- 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)
|
||||
function Feature.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 })
|
||||
local feature = Feature._registered[update.name]
|
||||
if feature then
|
||||
feature:update_config(update.enabled, update.values)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return Features
|
||||
return Feature
|
||||
Reference in new issue
Block a user