mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-12 07:54:52 +09:00
- Feature.register returns the config as a second value, so files write `local feature, config = Feature.register(...)`. - The lib is ExpScenario.Features and the instance class with its fields sits on Feature._prototype, which is the __index, the same shape as exp_commands. - update_config only looks at values whose override was added, removed, or replaced. _apply_field sets one value and returns it, so the comparison happens while applying and the second loop only raises. Overrides dropped from the previous set count as candidates because the value reverts to its default. - The `@setting name` tags are gone from the config comments. - Protection is guarded as a whole instead of checking config.enabled in one handler. The handlers also forget mined entities, so get_entities and is_entity_protected now drop invalid references, which also covers entities destroyed without an event. The list getters are gone, the selection overlay builds its lists from Protection.config. - Chat and damage popups are one feature in control/popups.lua. - ignore_admins and command_admin_only are removed, roles cover them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
332 lines
11 KiB
Lua
332 lines
11 KiB
Lua
--[[-- ExpScenario - Features
|
|
Features which the controller can enable, disable, and configure while the game is running
|
|
|
|
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, 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.
|
|
|
|
--- Registering a feature, small configs live at the top of the file which uses them:
|
|
local Feature = require("modules/exp_scenario/features")
|
|
local feature, config = Feature.register("my_feature", {
|
|
show_message = true,
|
|
role_id = Feature.optional("number"),
|
|
item_names = Feature.set{ "iron-plate" },
|
|
surface_names = Feature.list{ "nauvis" },
|
|
})
|
|
|
|
--- 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 feature:is_enabled() end,
|
|
}
|
|
|
|
--- Commands are denied and left out of help while disabled:
|
|
Commands.new("my_command", { "..." }):add_flags{ feature = feature }
|
|
|
|
--- 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 }
|
|
]]
|
|
|
|
local Storage = require("modules/exp_util/storage")
|
|
|
|
--- @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" or "set" of strings, or a type which can not be overridden
|
|
|
|
--- @class ExpScenario.Features
|
|
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 methods of every feature, kept apart from the lib so they are not called on it by mistake
|
|
--- @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
|
|
Feature._prototype = {}
|
|
|
|
--- @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
|
|
|
|
local Feature_mt = {
|
|
__index = Feature._prototype,
|
|
}
|
|
|
|
--- 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 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(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 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
|
|
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
|
|
|
|
--- 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
|
|
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) }
|
|
if type(value) == "table" then
|
|
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
|
|
end
|
|
if field then
|
|
fields[prefix .. key] = field
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
--- 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 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
|
|
--- @param name string
|
|
--- @param config table?
|
|
--- @return ExpScenario.Feature feature
|
|
--- @return table config The same table as feature.config
|
|
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 fields = {}
|
|
collect_fields(fields, config, "")
|
|
|
|
local feature = setmetatable({
|
|
name = name,
|
|
config = config,
|
|
fields = fields,
|
|
overrides = {},
|
|
}, Feature_mt) --[[@as ExpScenario.Feature]]
|
|
|
|
Feature._registered[name] = feature
|
|
return feature, config
|
|
end
|
|
|
|
--- 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
|
|
|
|
--- Check if a field can take a value
|
|
--- @param field ExpScenario.FeatureField
|
|
--- @param value any
|
|
--- @return boolean
|
|
local function is_valid(field, 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
|
|
|
|
--- Set a value to its override, or its default when there is no valid override
|
|
--- @package
|
|
--- @param path string
|
|
--- @param field ExpScenario.FeatureField
|
|
--- @return any # The value which was set
|
|
function Feature._prototype:_apply_field(path, field)
|
|
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
|
|
elseif field.type == "set" then
|
|
value = list_to_set(value)
|
|
end
|
|
field.parent[field.key] = value
|
|
return value
|
|
end
|
|
|
|
--- Set every value to its override, or its default when there is no valid override
|
|
--- @package
|
|
function Feature._prototype:_apply()
|
|
for path, field in pairs(self.fields) do
|
|
self:_apply_field(path, field)
|
|
end
|
|
end
|
|
|
|
--- Check if the feature is enabled
|
|
--- @return boolean
|
|
function Feature._prototype: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
|
|
--- @generic T : table
|
|
--- @param lib T
|
|
--- @return T
|
|
function Feature._prototype: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)
|
|
if config.enabled then
|
|
return handler(event)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
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._prototype:update_config(enabled, values)
|
|
local old_overrides = self.overrides
|
|
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)
|
|
else
|
|
overrides[path] = value
|
|
end
|
|
end
|
|
self.overrides = overrides
|
|
stored_overrides[self.name] = overrides
|
|
|
|
-- Only a value whose override was added, removed, or replaced can change
|
|
local paths = {}
|
|
for path in pairs(overrides) do
|
|
paths[path] = true
|
|
end
|
|
for path in pairs(old_overrides) do
|
|
-- A save can hold overrides for settings which no longer exist
|
|
if self.fields[path] then
|
|
paths[path] = true
|
|
end
|
|
end
|
|
|
|
local updates = {}
|
|
for path in pairs(paths) do
|
|
local field = self.fields[path]
|
|
local old_value = field.parent[field.key]
|
|
local new_value = self:_apply_field(path, field)
|
|
if not values_equal(old_value, new_value) then
|
|
updates[#updates + 1] = { feature_name = self.name, path = path, old_value = old_value, new_value = new_value }
|
|
end
|
|
end
|
|
|
|
for _, update in ipairs(updates) do
|
|
script.raise_event(Feature.events.on_config_updated, update)
|
|
end
|
|
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 Feature.receive_update(updates)
|
|
for _, update in ipairs(updates) do
|
|
local feature = Feature._registered[update.name]
|
|
if feature then
|
|
feature:update_config(update.enabled, update.values)
|
|
end
|
|
end
|
|
end
|
|
|
|
return Feature
|