mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-12 07:54:52 +09:00
- The instance sends every value of a feature, defaults included, so update_config is one loop which sets each field and compares. The stored table is the last set of values, and _apply is gone. - values_equal compares table sizes before the contents. - Protected entities are registered with register_on_object_destroyed and forgotten in on_object_destroyed, which covers every way an entity can go. The four removal handlers and the validity checks are gone. Entities destroyed while the feature is disabled are swept when it is enabled again. Saves from before the registrations table get it through the Storage on_init callback. - Spawn area applied the water offset twice and the pattern offset never. The pattern tiles now move with the entities above them. - pruneFeatureValues reads a validation result instead of catching an error, and FeaturesPage hands its pending state and unsaved bar to helpers. - Shorter comments throughout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
283 lines
9.0 KiB
Lua
283 lines
9.0 KiB
Lua
--[[-- ExpScenario - Features
|
|
Features which the controller can enable, disable, and configure while the game is running
|
|
|
|
The config table is changed in place when the controller sends values, so read
|
|
them where they are used rather than caching them when the file loads. Values
|
|
read while the file loads, such as on_nth_tick periods, 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, -- booleans, numbers, and strings
|
|
role_id = Feature.optional("number"), -- no default
|
|
item_names = Feature.set{ "iron-plate" }, -- indexed by name
|
|
surface_names = Feature.list{ "nauvis" },
|
|
section = { count = 1 }, -- addressed as "section.count"
|
|
})
|
|
|
|
--- Handlers do nothing while the feature is disabled:
|
|
return feature:guard{
|
|
events = { [defines.events.on_player_joined_game] = on_player_joined_game },
|
|
}
|
|
|
|
--- Toolbar buttons and commands:
|
|
Gui.toolbar.create_button{ visible = function(player) return feature:is_enabled() end }
|
|
Commands.new("my_command", { "..." }):add_flags{ feature = feature }
|
|
|
|
--- 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 path string
|
|
--- @field parent table
|
|
--- @field key string
|
|
--- @field default any
|
|
--- @field type string "boolean", "number", "string", "list", "set", or a type which can not be set
|
|
|
|
--- @class ExpScenario.Features
|
|
local Feature = {
|
|
_registered = {}, --- @type table<string, ExpScenario.Feature>
|
|
events = {
|
|
--- @type EventData.ExpScenario.on_config_updated
|
|
on_config_updated = script.generate_event_name(),
|
|
},
|
|
}
|
|
|
|
--- @class ExpScenario.Feature
|
|
--- @field name string
|
|
--- @field config table
|
|
--- @field fields table<string, ExpScenario.FeatureField> By path
|
|
--- @field values table<string, any> Last received from the controller, restored from storage on load
|
|
Feature._prototype = {}
|
|
|
|
--- @class EventData.ExpScenario.on_config_updated : EventData
|
|
--- @field feature_name string
|
|
--- @field path string "enabled" when the feature is enabled or disabled
|
|
--- @field old_value any
|
|
--- @field new_value any
|
|
|
|
local Feature_mt = {
|
|
__index = Feature._prototype,
|
|
}
|
|
|
|
--- Only the values are stored, the rest is rebuilt when the file loads
|
|
local stored_values = {} --- @type table<string, table<string, any>>
|
|
Storage.register(stored_values, function(tbl)
|
|
stored_values = tbl
|
|
for name, feature in pairs(Feature._registered) do
|
|
feature.values = tbl[name] or {}
|
|
for path, field in pairs(feature.fields) do
|
|
feature:_set_field(field, feature.values[path])
|
|
end
|
|
end
|
|
end)
|
|
|
|
--- @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
|
|
|
|
--- 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 table_size(a) ~= table_size(b) then return false end
|
|
for key, value in pairs(a) do
|
|
if b[key] ~= value then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
--- Marks a default made by Feature.list, Feature.set, or Feature.optional
|
|
local typed_mt = {}
|
|
|
|
--- @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
|
|
|
|
--- Nested tables with string keys are 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 path = prefix .. key
|
|
local field = { path = path, parent = tbl, key = key, default = value, type = type(value) }
|
|
if type(value) == "table" then
|
|
if getmetatable(value) == typed_mt then
|
|
field.type = value.type
|
|
field.default = value.default
|
|
tbl[key] = value.default
|
|
elseif type(next(value)) == "string" then
|
|
collect_fields(fields, value, path .. ".")
|
|
field = nil
|
|
end
|
|
end
|
|
if field then
|
|
fields[path] = field
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
--- A value with no default, the argument is the type it accepts
|
|
--- @param value_type "boolean" | "number" | "string"
|
|
--- @return any
|
|
function Feature.optional(value_type)
|
|
return setmetatable({ type = value_type }, typed_mt)
|
|
end
|
|
|
|
--- A list of strings
|
|
--- @param items string[]
|
|
--- @return string[]
|
|
function Feature.list(items)
|
|
return setmetatable({ type = "list", default = items }, typed_mt)
|
|
end
|
|
|
|
--- A set of strings, the controller sends a list
|
|
--- @param items string[]
|
|
--- @return table<string, true>
|
|
function Feature.set(items)
|
|
return setmetatable({ type = "set", default = list_to_set(items) }, typed_mt)
|
|
end
|
|
|
|
--- Features are enabled unless the config says otherwise
|
|
--- @param name string
|
|
--- @param config table?
|
|
--- @return ExpScenario.Feature feature
|
|
--- @return table 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,
|
|
values = {},
|
|
}, Feature_mt) --[[@as ExpScenario.Feature]]
|
|
|
|
Feature._registered[name] = feature
|
|
return feature, config
|
|
end
|
|
|
|
--- @param name string
|
|
--- @return ExpScenario.Feature
|
|
function Feature.get(name)
|
|
return Feature._registered[name] or error("Unknown feature: " .. tostring(name), 2)
|
|
end
|
|
|
|
--- @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, nil or an invalid value means the default
|
|
--- @package
|
|
--- @param field ExpScenario.FeatureField
|
|
--- @param value any
|
|
--- @return any # The value which was set
|
|
function Feature._prototype:_set_field(field, value)
|
|
if value ~= nil and not is_valid(field, value) then
|
|
log("[WARNING] Invalid value for " .. self.name .. "." .. field.path .. ", using the default")
|
|
value = nil
|
|
end
|
|
if value == nil then
|
|
value = field.default
|
|
elseif field.type == "set" then
|
|
value = list_to_set(value)
|
|
end
|
|
field.parent[field.key] = value
|
|
return value
|
|
end
|
|
|
|
--- @return boolean
|
|
function Feature._prototype:is_enabled()
|
|
return self.config.enabled
|
|
end
|
|
|
|
--- Make the event and on_nth_tick handlers of a 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 every value, then raise on_config_updated for each one which changed
|
|
--- @param values table<string, any> By path, "enabled" included
|
|
function Feature._prototype:update_config(values)
|
|
self.values = values
|
|
stored_values[self.name] = values
|
|
|
|
local updates = {}
|
|
for path, field in pairs(self.fields) do
|
|
local old_value = field.parent[field.key]
|
|
local new_value = self:_set_field(field, values[path])
|
|
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 values table<string, any>
|
|
|
|
--- Called over rcon by the instance plugin, 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.values)
|
|
end
|
|
end
|
|
end
|
|
|
|
return Feature
|