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:
bbassieandClaude Opus 5.5 committed 2026-10-01 13:03:11 +00:00
1 parent 4bc1773c82
commit 628e4df514
26 files changed
+468 -430

No files matched your search

+27 -10
View File
@@ -8,17 +8,24 @@
export type FeatureValue = boolean | number | string | string[] | null;
/**
* A setting of a feature, shaped like a clusterio config field definition so
* the web interface can render it with the same inputs, including the input
* components registered by plugins.
*/
export type FeatureField = {
name: string,
title: string,
description: string,
/** Allows null, the lua side uses Feature.optional */
optional?: boolean,
/** Name of an input component registered with the web interface, such as "role" */
inputComponent?: string,
} & (
| { type: "boolean", default: boolean }
| { type: "number", default: number, min?: number, unit?: string }
| { type: "string", default: string }
| { type: "number", default: number | null, min?: number, unit?: string }
| { type: "string", default: string | null, enum?: string[] }
| { type: "string_list", default: string[] }
/** Id of a clusterio role, null when no role is picked */
| { type: "role", default: null }
);
export type Feature = {
@@ -44,8 +51,9 @@ function list(name: string, title: string, description: string, value: string[])
return { name, title, description, type: "string_list", default: value };
}
/** Id of a clusterio role, null when no role is picked */
function role(name: string, title: string, description: string): FeatureField {
return { name, title, description, type: "role", default: null };
return { name, title, description, type: "number", default: null, optional: true, inputComponent: "role" };
}
export const features: Feature[] = [
@@ -229,17 +237,18 @@ export function validateFeatureValues(name: string, values: Record<string, Featu
}
function checkFieldValue(field: FeatureField, value: FeatureValue, label: string) {
if (value === null) {
if (!field.optional) {
throw new Error(`${label} can not be null`);
}
return;
}
switch (field.type) {
case "string_list":
if (!Array.isArray(value) || value.some(item => typeof item !== "string")) {
throw new Error(`${label} must be a list of strings`);
}
return;
case "role":
if (value !== null && !Number.isInteger(value)) {
throw new Error(`${label} must be a role id or null`);
}
return;
case "number":
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${label} must be a finite number`);
@@ -248,6 +257,14 @@ function checkFieldValue(field: FeatureField, value: FeatureValue, label: string
throw new Error(`${label} must be at least ${field.min}`);
}
return;
case "string":
if (typeof value !== "string") {
throw new Error(`${label} must be a string`);
}
if (field.enum && !field.enum.includes(value)) {
throw new Error(`${label} must be one of ${field.enum.join(", ")}`);
}
return;
default:
if (typeof value !== field.type) {
throw new Error(`${label} must be a ${field.type}`);
@@ -5,7 +5,7 @@ Adds a permission authority which checks the clusterio permission for a command
local Commands = require("modules/exp_commands")
local add, allow, deny = Commands.add_permission_authority, Commands.status.success, Commands.status.unauthorised
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Roles = require("modules/exp_roles")
local player_has_permission = Roles.player_has_permission
@@ -27,7 +27,7 @@ authorities.exp_permission =
authorities.feature_enabled =
add(function(player, command)
local feature = command.flags.feature
if feature and not Features.is_enabled(feature) then
if feature and not feature:is_enabled() then
return deny{ "exp-commands-authorities_feature.deny" }
else
return allow()
+6 -5
View File
@@ -4,13 +4,14 @@ Adds a command that clean up biter corpse and nuclear hole
local AABB = require("modules/exp_util/aabb")
local Commands = require("modules/exp_commands")
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Selection = require("modules/exp_util/selection")
local SelectArea = Selection.connect("ExpCommand_Lawnmower")
local config = Features.config("lawnmower", {
local feature = Feature.register("lawnmower", {
destroy_decoratives = false, --- @setting destroy_decoratives remove decoratives under entities when they are built
})
local config = feature.config
--- @class ExpCommand_Lawnmower.commands
local commands = {}
@@ -19,7 +20,7 @@ local commands = {}
--- @class ExpCommands_Lawnmower.commands.lawnmower: ExpCommand
--- @overload fun(player: LuaPlayer)
commands.lawnmower = Commands.new("lawnmower", { "exp-commands_lawnmower.description" })
:add_flags{ feature = config }
:add_flags{ feature = feature }
:register(function(player)
if SelectArea:stop(player) then
return Commands.status.success{ "exp_util.selection_exit", { "exp-commands_lawnmower.selection-name" } }
@@ -65,7 +66,7 @@ end
local e = defines.events
return Features.guard(config, {
return feature:guard{
events = {
[e.on_built_entity] = destroy_decoratives,
[e.on_robot_built_entity] = destroy_decoratives,
@@ -73,4 +74,4 @@ return Features.guard(config, {
[e.script_raised_revive] = destroy_decoratives,
},
commands = commands,
})
}
@@ -166,15 +166,17 @@ local function on_player_selection_start(event)
end
-- Show always protected entities by name
if #EntityProtection.protected_entity_names > 0 then
for _, entity in pairs(surface.find_entities_filtered{ name = EntityProtection.protected_entity_names, force = player.force }) do
local protected_entity_names = EntityProtection.get_protected_entity_names()
if #protected_entity_names > 0 then
for _, entity in pairs(surface.find_entities_filtered{ name = protected_entity_names, force = player.force }) do
show_protected_entity(player, entity)
end
end
-- Show always protected entities by type
if #EntityProtection.protected_entity_types > 0 then
for _, entity in pairs(surface.find_entities_filtered{ type = EntityProtection.protected_entity_types, force = player.force }) do
local protected_entity_types = EntityProtection.get_protected_entity_types()
if #protected_entity_types > 0 then
for _, entity in pairs(surface.find_entities_filtered{ type = protected_entity_types, force = player.force }) do
show_protected_entity(player, entity)
end
end
@@ -2,7 +2,7 @@
-- @config Chat-Auto-Reply
local ExpUtil = require("modules/exp_util")
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Async = require("modules/exp_util/async")
local config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail
@@ -25,7 +25,7 @@ local afk_time_units = {
seconds = true,
}
return Features.config("chat_auto_reply", {
return Feature.register("chat_auto_reply", {
--- @type table<string, LocalisedString | fun(player: LuaPlayer, is_command: boolean): LocalisedString>
messages = { --- @setting messages will trigger when ever the word is said
["discord"] = { "info.discord", config_server_detail["discord"] },
+2 -2
View File
@@ -1,9 +1,9 @@
--- Popups for chat and damage, shared by control/chat_popup.lua and control/damage_popups.lua
-- @config Popups
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
return Features.config("popups", {
return Feature.register("popups", {
show_player_messages = true, --- @setting show_player_messages weather a message in chat will make a popup above them
show_player_mentions = true, --- @setting show_player_mentions weather a mentioned player will have a popup when mentioned in chat
show_player_damage = true, --- @setting show_player_damage weather to show damage done by players
+2 -2
View File
@@ -1,9 +1,9 @@
--- Used to config the spawn generation settings yes there is alot here i know just ignore the long tables at the end (they were generated with a command)
-- @config Spawn-Area
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
return Features.config("spawn_area", {
return Feature.register("spawn_area", {
spawn_area = { --- @setting spawn_area Settings relating to the whole spawn area
-- Enable predefined patches: 128, else: 32
deconstruction_radius = 20, -- @setting deconstruction_radius All entities within this radius will be removed
+6 -5
View File
@@ -3,17 +3,18 @@ Kicks players when all players on the server are afk
]]
local Async = require("modules/exp_util/async")
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Roles = require("modules/exp_roles")
local Storage = require("modules/exp_util/storage")
local config = Features.config("afk_kick", {
local feature = Feature.register("afk_kick", {
admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time
trust_as_active = true, --- @setting trust_as_active When true trusted players (by playtime) will be treated as active regardless of afk time
active_role_id = Features.optional("number"), --- @setting active_role_id Players with this role or higher are treated as active regardless of afk time
active_role_id = Feature.optional("number"), --- @setting active_role_id Players with this role or higher are treated as active regardless of afk time
afk_minutes = 10, --- @setting afk_minutes The time in minutes that must pass for a player to be considered afk
kick_minutes = 30, --- @setting kick_minutes The time in minutes that must pass without any active players for all players to be kicked
trust_minutes = 600, --- @setting trust_minutes The time in minutes that a player must be online for to count as trusted
})
local config = feature.config
local ticks_per_minute = 3600
@@ -92,7 +93,7 @@ end
local e = defines.events
return Features.guard(config, {
return feature:guard{
events = {
[e.on_player_joined_game] = on_player_joined_game,
},
@@ -100,4 +101,4 @@ return Features.guard(config, {
[60 * 60 * 30] = check_afk_players,
},
has_active_player = has_active_player,
})
}
@@ -2,9 +2,10 @@
Adds auto replies to chat messages, as well as chat commands
]]
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Roles = require("modules/exp_roles")
local config = require("modules/exp_scenario/config/chat_auto_reply")
local feature = require("modules/exp_scenario/config/chat_auto_reply")
local config = feature.config
local find = string.find
local sub = string.sub
@@ -57,8 +58,8 @@ end
local e = defines.events
return Features.guard(config, {
return feature:guard{
events = {
[e.on_console_chat] = on_console_chat,
},
})
}
+5 -4
View File
@@ -3,8 +3,9 @@ Creates flying text entities when a player sends a message in chat
]]
local FlyingText = require("modules/exp_util/flying_text")
local Features = require("modules/exp_scenario/features")
local config = require("modules/exp_scenario/config/popups")
local Feature = require("modules/exp_scenario/features")
local feature = require("modules/exp_scenario/config/popups")
local config = feature.config
local lower = string.lower
local find = string.find
@@ -42,8 +43,8 @@ end
local e = defines.events
return Features.guard(config, {
return feature:guard{
events = {
[e.on_console_chat] = on_console_chat,
},
})
}
@@ -4,8 +4,9 @@ also shows player health when a player is attacked
]]
local FlyingText = require("modules/exp_util/flying_text")
local Features = require("modules/exp_scenario/features")
local config = require("modules/exp_scenario/config/popups")
local Feature = require("modules/exp_scenario/features")
local feature = require("modules/exp_scenario/config/popups")
local config = feature.config
local random = math.random
local floor = math.floor
@@ -45,8 +46,8 @@ end
local e = defines.events
return Features.guard(config, {
return feature:guard{
events = {
[e.on_entity_damaged] = on_entity_damaged,
},
})
}
@@ -3,10 +3,10 @@ Makes markers on the map where places have died and reclaims items if not recove
]]
local ExpUtil = require("modules/exp_util")
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Storage = require("modules/exp_util/storage")
local config = Features.config("death_markers", {
local feature = Feature.register("death_markers", {
collect_corpses = true, --- @setting collect_corpses enables items being returned to the spawn point in chests upon corpse expiring
show_map_markers = true, --- @setting show_map_markers shows markers on the map where bodies are
clean_map_markers = false, --- @setting clean_map_markers removes the map marker once the body is gone
@@ -15,6 +15,7 @@ local config = Features.config("death_markers", {
show_light_at_corpse = true, --- @setting show_light_at_corpse if a light should be rendered at the corpse
show_line_to_corpse = true, --- @setting show_line_to_corpse if a line should be rendered from you to your corpse
})
local config = feature.config
local map_tag_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true }
@@ -147,7 +148,7 @@ end
local e = defines.events
return Features.guard(config, {
return feature:guard{
on_nth_tick = {
[60 * 60 * 5] = check_map_tags,
},
@@ -157,4 +158,4 @@ return Features.guard(config, {
[e.on_player_respawned] = on_player_respawned,
[e.on_character_corpse_expired] = on_character_corpse_expired,
},
})
}
+6 -14
View File
@@ -4,11 +4,11 @@ Sends alert messages to our discord server when certain events are triggered
local ExpUtil = require("modules/exp_util")
local Colors = require("modules/exp_util/include/color")
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local EntityProtection = require("modules/exp_scenario/control/protection")
local Jail = require("modules/exp_scenario/control/jail")
local config = Features.config("discord_alerts", {
local feature = Feature.register("discord_alerts", {
show_playtime = true, --- @setting show_playtime Add the playtime of players to alerts
entity_protection = true, --- @setting entity_protection Alert when a player repeatedly removes protected entities
player_bans = true, --- @setting player_bans Alert when a player is banned or unbanned
@@ -20,15 +20,7 @@ local config = Features.config("discord_alerts", {
"config", "purge", "c", "command", "silent-command", "measured-command", "banlist", "permissions", "editor", "cheat",
},
})
--- Set of the logged commands, rebuilt when the config changes
local logged_commands = {} --- @type table<string, true>
Features.on_apply(config, function()
logged_commands = {}
for _, command in ipairs(config.logged_commands) do
logged_commands[command] = true
end
end)
local config = feature.config
--- Wrap an event handler so it only runs while the alert is enabled
--- @param setting string
@@ -267,7 +259,7 @@ end)
events[e.on_console_command] = function(event)
if event.player_index then
local player_name = get_player_name(event)
if logged_commands[event.command] then
if Feature.to_set(config.logged_commands)[event.command] then
emit_event{
title = event.command:gsub("^%l", string.upper),
description = "/" .. event.command .. " was used",
@@ -281,6 +273,6 @@ events[e.on_console_command] = function(event)
end
end
return Features.guard(config, {
return feature:guard{
events = events,
})
}
+5 -3
View File
@@ -2,11 +2,13 @@
Refreshes the gui of every player when a feature is enabled or disabled, see features.lua
]]
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Gui = require("modules/exp_gui")
--- Toolbar buttons of disabled features are hidden, which also hides their left elements
local function on_feature_changed()
--- @param event EventData.ExpScenario.on_config_updated
local function on_config_updated(event)
if event.path ~= "enabled" then return end
for _, player in pairs(game.connected_players) do
Gui._ensure_consistency{ player_index = player.index }
end
@@ -14,6 +16,6 @@ end
return {
events = {
[Features.events.on_feature_changed] = on_feature_changed,
[Feature.events.on_config_updated] = on_config_updated,
},
}
@@ -3,13 +3,14 @@ Marks mining drills for deconstruction when resources deplete
]]
local Async = require("modules/exp_util/async")
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local config = Features.config("mine_depletion", {
local feature = Feature.register("mine_depletion", {
fluid = true, --- @setting fluid When true, checks for for fluid pipes when removing miners
chest = true, --- @setting chest When true, checks for for chest when removing miners
beacon = true, --- @setting beacon When true, checks for for beacon when removing miners
})
local config = feature.config
local floor = math.floor
@@ -276,8 +277,8 @@ end
local e = defines.events
return Features.guard(config, {
return feature:guard{
events = {
[e.on_resource_depleted] = on_resource_depleted,
},
})
}
@@ -3,22 +3,14 @@ Disable new players from having certain items in their inventory, most commonly
]]
local ExpUtil = require("modules/exp_util")
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Roles = require("modules/exp_roles")
local config = Features.config("nuke_protection", {
local feature = Feature.register("nuke_protection", {
ignore_admins = true, --- @setting ignore_admins Admins can hold banned items
banned_items = { "atomic-bomb" }, --- @setting banned_items Items which are removed from the inventory of players without the bypass permission
})
--- Set of the banned items, rebuilt when the config changes
local banned_items = {} --- @type table<string, true>
Features.on_apply(config, function()
banned_items = {}
for _, item_name in ipairs(config.banned_items) do
banned_items[item_name] = true
end
end)
local config = feature.config
--- The inventories which are checked and the events which trigger the check
local inventories = {
@@ -36,6 +28,7 @@ local function check_items(player, type)
if Roles.player_has_permission(player, "exp_scenario.bypass.nuke_protection") then return end
if config.ignore_admins and player.admin then return end
local banned_items = Feature.to_set(config.banned_items)
local items = {} --- @type LuaItemStack[]
local inventory = assert(player.get_inventory(type))
-- Check what items the player has
@@ -68,6 +61,6 @@ for event_id, inventory in pairs(inventories) do
end
end
return Features.guard(config, {
return feature:guard{
events = events,
})
}
+14 -30
View File
@@ -3,10 +3,10 @@ Protects entities and areas from being mined by players other than the one who p
]]
local Storage = require("modules/exp_util/storage")
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Roles = require("modules/exp_roles")
local config = Features.config("protection", {
local feature = Feature.register("protection", {
ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter
repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_minutes in order to trigger repeated removal protection
repeat_minutes = 20, --- @setting repeat_minutes The length of time, in minutes, that protected removals will be remembered for
@@ -19,6 +19,7 @@ local config = Features.config("protection", {
"reactor", "fusion-reactor", "rocket-silo",
},
})
local config = feature.config
local format_string = string.format
local floor = math.floor
@@ -31,12 +32,6 @@ local Protection = {
--- Raised when a player mines protected entities repeatedly, or one which always counts as repeated
--- @type EventData.ExpScenario_Protection.on_repeat_violation
on_repeat_violation = script.generate_event_name(),
--- Names of entities which are always protected
--- @type string[]
protected_entity_names = {},
--- Types of entities which are always protected
--- @type string[]
protected_entity_types = {},
--- @package
events = {},
--- @package
@@ -50,28 +45,17 @@ local Protection = {
--- @field last uint Tick of the last protected removal
--- @field count number Protected removals since the last repeat violation
--- @param values string[]
--- @return table<string, true>
local function to_set(values)
local set = {}
for _, value in ipairs(values) do
set[value] = true
end
return set
--- Names of entities which are always protected
--- @return string[]
function Protection.get_protected_entity_names()
return config.always_protected_names
end
local always_protected_names = {} --- @type table<string, true>
local always_protected_types = {} --- @type table<string, true>
local always_trigger_repeat_names = {} --- @type table<string, true>
local always_trigger_repeat_types = {} --- @type table<string, true>
Features.on_apply(config, function()
Protection.protected_entity_names = config.always_protected_names
Protection.protected_entity_types = config.always_protected_types
always_protected_names = to_set(config.always_protected_names)
always_protected_types = to_set(config.always_protected_types)
always_trigger_repeat_names = to_set(config.always_trigger_repeat_names)
always_trigger_repeat_types = to_set(config.always_trigger_repeat_types)
end)
--- Types of entities which are always protected
--- @return string[]
function Protection.get_protected_entity_types()
return config.always_protected_types
end
local protected_entities = {} --- @type table<uint, table<string, LuaEntity>> Keyed by surface index then entity key
local protected_areas = {} --- @type table<uint, table<string, BoundingBox>> Keyed by surface index then area key
@@ -131,7 +115,7 @@ end
--- @param entity LuaEntity
--- @return boolean
function Protection.is_entity_protected(entity)
if always_protected_names[entity.name] or always_protected_types[entity.type] then return true end
if Feature.to_set(config.always_protected_names)[entity.name] or Feature.to_set(config.always_protected_types)[entity.type] then return true end
local entities = protected_entities[entity.surface.index]
if not entities then return false end
return entities[Protection.get_entity_key(entity)] == entity
@@ -209,7 +193,7 @@ local function raise_violation(event, player)
script.raise_event(Protection.on_player_mined_protected, event)
local entity = event.entity
local always_repeat = always_trigger_repeat_names[entity.name] or always_trigger_repeat_types[entity.type]
local always_repeat = Feature.to_set(config.always_trigger_repeat_names)[entity.name] or Feature.to_set(config.always_trigger_repeat_types)[entity.type]
if always_repeat or player_repeats.count >= config.repeat_count then
player_repeats.count = 0
script.raise_event(Protection.on_repeat_violation, event)
+5 -4
View File
@@ -2,8 +2,9 @@
Adds a custom spawn area with chests and afk turrets
]]
local Features = require("modules/exp_scenario/features")
local config = require("modules/exp_scenario/config/spawn_area")
local Feature = require("modules/exp_scenario/features")
local feature = require("modules/exp_scenario/config/spawn_area")
local config = feature.config
--- Apply an offset to a LuaPosition
--- @param position MapPosition.struct
@@ -299,9 +300,9 @@ end
local e = defines.events
return Features.guard(config, {
return feature:guard{
on_nth_tick = on_nth_tick,
events = {
[e.on_player_created] = on_player_created,
},
})
}
@@ -2,13 +2,14 @@
Automatically name stations when they are placed based on closest resource and direction from spawn
]]
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local config = Features.config("station_auto_name", {
local feature = Feature.register("station_auto_name", {
--- @setting station_name the name given to new stations, placeholders:
-- __icon__ __item_name__ __backer_name__ __direction__ __x__ __y__
station_name = "[L] __icon__",
})
local config = feature.config
local get_direction do
local directions = {
@@ -104,9 +105,9 @@ end
local e = defines.events
return Features.guard(config, {
return feature:guard{
events = {
[e.on_built_entity] = rename_station,
[e.on_robot_built_entity] = rename_station,
},
})
}
+180 -167
View File
@@ -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
+6 -4
View File
@@ -2,10 +2,12 @@
Adds a config menu for setting autofill of placed entities
]]
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Gui = require("modules/exp_gui")
local Roles = require("modules/exp_roles")
local config = require("modules.exp_legacy.config.gui.autofill")
local feature = Feature.register("autofill")
local FlyingText = require("modules/exp_util/flying_text")
local min = math.min
@@ -331,7 +333,7 @@ Gui.toolbar.create_button{
sprite = config.icon,
tooltip = { "exp-gui_autofill.tooltip-main" },
visible = function(player, element)
return Features.is_enabled("autofill") and Roles.player_has_permission(player, "exp_scenario.gui.autofill")
return feature:is_enabled() and Roles.player_has_permission(player, "exp_scenario.gui.autofill")
end
}
@@ -397,9 +399,9 @@ end
local e = defines.events
return Features.guard("autofill", {
return feature:guard{
elements = Elements,
events = {
[e.on_built_entity] = on_built_entity,
},
})
}
+5 -3
View File
@@ -4,7 +4,7 @@ Adds a few buttons for common actions
local Gui = require("modules/exp_gui")
local Commands = require("modules/exp_commands")
local Features = require("modules/exp_scenario/features")
local Feature = require("modules/exp_scenario/features")
local Roles = require("modules/exp_roles")
local addon_artillery = require("modules/exp_scenario/commands/artillery")
@@ -128,7 +128,9 @@ local function on_role_changed(event)
end
--- Actions for commands of a disabled feature are hidden
local function on_feature_changed()
--- @param event EventData.ExpScenario.on_config_updated
local function on_config_updated(event)
if event.path ~= "enabled" then return end
for _, player in pairs(game.connected_players) do
Elements.container.refresh_player(player)
end
@@ -138,6 +140,6 @@ return {
elements = Elements,
events = {
[Roles.events.on_player_roles_changed] = on_role_changed,
[Features.events.on_feature_changed] = on_feature_changed,
[Feature.events.on_config_updated] = on_config_updated,
}
}
+7 -7
View File
@@ -17,8 +17,8 @@ function luaFiles(dir, prefix = "") {
const moduleFiles = luaFiles(path.join(import.meta.dirname, "..", "module"));
/**
* Read a default from a Features.config call, a path of "a.b" looks for b after the table a starts.
* Only covers booleans, numbers, strings, lists of strings, and Features.optional.
* Read a default from a Feature.register call, a path of "a.b" looks for b after the table a starts.
* Only covers booleans, numbers, strings, lists of strings, and Feature.optional.
*/
function luaDefault(source, path) {
const keys = path.split(".");
@@ -32,13 +32,13 @@ function luaDefault(source, path) {
}
const key = keys.at(-1);
const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Features\\.optional\\("\\w+"\\)|\\{[^{}]*\\})`, "m")
const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Feature\\.optional\\("\\w+"\\)|\\{[^{}]*\\})`, "m")
.exec(source.slice(start));
if (!match) {
return undefined;
}
const literal = match[1];
if (literal.startsWith("Features.optional")) {
if (literal.startsWith("Feature.optional")) {
return null;
}
if (literal.startsWith("{")) {
@@ -51,7 +51,7 @@ t.test("features", t2 => {
for (const feature of features) {
t2.test(feature.name, t3 => {
const declarations = moduleFiles.filter(([, source]) => (
source.includes(`Features.config("${feature.name}"`) || source.includes(`Features.guard("${feature.name}"`)
source.includes(`Feature.register("${feature.name}"`)
));
t3.equal(declarations.length, 1, "one lua file declares the feature");
const source = declarations[0]?.[1] ?? "";
@@ -92,8 +92,8 @@ t.test("validateFeatureValues()", t2 => {
{ message: "Setting always_protected_types of protection must be a list of strings" },
);
t2.throws(
() => validateFeatureValues("afk_kick", { active_role_id: 1.5 }),
{ message: "Setting active_role_id of afk_kick must be a role id or null" },
() => validateFeatureValues("afk_kick", { afk_minutes: null }),
{ message: "Setting afk_minutes of afk_kick can not be null" },
);
t2.same(
validateFeatureValues("protection", { always_protected_types: ["boiler"], always_trigger_repeat_types: ["reactor", "fusion-reactor", "rocket-silo"] }),
+4 -4
View File
@@ -13,10 +13,10 @@ local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$"))
local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework
--- @class ExpScenario.TestEnv : Stubs
--- @field Features ExpScenario.Features A fresh copy of the features module
--- @field Feature ExpScenario.Feature A fresh copy of the features module
--- @field storage table The table registered with storage, as on_init would store it
--- @field on_load fun(tbl: table) Act out on_load with the given storage table
--- @field load_config fun(name: string): table Load module/config/<name>.lua
--- @field load_config fun(name: string): ExpScenario.Feature Load module/config/<name>.lua
--- @field logged string[] Messages passed to log
return Framework.suite(function(env)
@@ -33,8 +33,8 @@ return Framework.suite(function(env)
},
}
env.Features = assert(loadfile(plugin_root .. "/module/features.lua"))()
env.extend_requires{ ["modules/exp_scenario/features"] = env.Features }
env.Feature = assert(loadfile(plugin_root .. "/module/features.lua"))()
env.extend_requires{ ["modules/exp_scenario/features"] = env.Feature }
function env.on_load(tbl)
env.storage = tbl
+88 -85
View File
@@ -1,137 +1,140 @@
local Suite = ... --- @type Suite<ExpScenario.TestEnv>
Suite.test("config() enables features unless the defaults say otherwise", function(env)
Suite.eq(env.Features.config("on", { value = 1 }).enabled, true, "enabled by default")
Suite.eq(env.Features.config("off", { enabled = false }).enabled, false, "the defaults can disable it")
Suite.eq(env.Features.config("empty"), { enabled = true }, "config is optional")
Suite.throws(function() env.Features.config("on", {}) end, "Feature already declared: on", "names are unique")
--- The values of on_config_updated events, ignoring the event name and tick
local function updates(env)
local rtn = {}
for _, event in ipairs(env.events) do
rtn[#rtn + 1] = { event.feature_name, event.path, event.old_value, event.new_value }
end
return rtn
end
Suite.test("register() enables features unless the config says otherwise", function(env)
Suite.eq(env.Feature.register("on", { value = 1 }).config.enabled, true, "enabled by default")
Suite.eq(env.Feature.register("off", { enabled = false }):is_enabled(), false, "the config can disable it")
Suite.eq(env.Feature.register("empty").config, { enabled = true }, "config is optional")
Suite.throws(function() env.Feature.register("on", {}) end, "Feature already registered: on", "names are unique")
end)
Suite.test("is_enabled() takes a name or a config", function(env)
local config = env.Features.config("test", { enabled = false })
Suite.eq(env.Features.is_enabled("test"), false, "by name")
Suite.eq(env.Features.is_enabled(config), false, "by config")
Suite.throws(function() env.Features.is_enabled("unknown") end, "Unknown feature: unknown", "unknown names error")
Suite.test("get() finds registered features", function(env)
local feature = env.Feature.register("test")
Suite.eq(env.Feature.get("test") == feature, true, "by name")
Suite.throws(function() env.Feature.get("unknown") end, "Unknown feature: unknown", "unknown names error")
end)
Suite.test("receive_update() changes the config in place", function(env)
local config = env.Features.config("test", { flag = true, count = 5, text = "a" })
env.Features.receive_update{ { name = "test", enabled = false, values = { flag = false, count = 7 } } }
Suite.test("update_config() changes the config in place", function(env)
local feature = env.Feature.register("test", { flag = true, count = 5, text = "a" })
local config = feature.config
feature:update_config(false, { flag = false, count = 7 })
Suite.eq(config, { enabled = false, flag = false, count = 7, text = "a" }, "given values replace the defaults")
env.Features.receive_update{ { name = "test", enabled = true, values = { text = "b" } } }
feature:update_config(true, { text = "b" })
Suite.eq(config, { enabled = true, flag = true, count = 5, text = "b" }, "values left out go back to their default")
Suite.eq(env.storage, { test = { enabled = true, text = "b" } }, "the overrides are kept in storage")
end)
Suite.test("receive_update() raises on_feature_changed for each known feature", function(env)
env.Features.config("test")
env.Features.receive_update{
{ name = "test", enabled = false, values = {} },
{ name = "unknown", enabled = false, values = {} },
}
Suite.eq(env.events, {
{ name = env.Features.events.on_feature_changed, tick = game.tick, feature_name = "test" },
}, "raised once")
Suite.test("update_config() raises on_config_updated for each changed value", function(env)
local feature = env.Feature.register("test", { flag = true, names = { "a" }, section = { count = 1 } })
feature:update_config(false, { names = { "a" }, ["section.count"] = 2 })
Suite.eq(#env.events, 2, "unchanged values raise nothing")
Suite.eq(env.Feature.events.on_config_updated, assert(env.events[1]).name, "the event is on_config_updated")
local seen = updates(env)
table.sort(seen, function(a, b) return a[2] < b[2] end)
Suite.eq(seen, {
{ "test", "enabled", true, false },
{ "test", "section.count", 1, 2 },
}, "with the path, old value, and new value")
end)
Suite.test("receive_update() ignores values which can not be overridden", function(env)
Suite.test("update_config() ignores and logs values which can not be overridden", function(env)
local function check() return true end
local config = env.Features.config("test", { flag = true, check = check, icon = nil })
env.Features.receive_update{
local feature = env.Feature.register("test", { flag = true, check = check, icon = nil })
feature:update_config(true, { flag = "yes", check = false, icon = "x" })
Suite.eq(feature.config, { enabled = true, flag = true, check = check }, "wrong types, functions, and unknown keys are skipped")
Suite.eq(#env.logged, 3, "each one is logged")
end)
Suite.test("receive_update() updates known features", function(env)
local feature = env.Feature.register("test", { flag = true })
env.Feature.receive_update{
{ name = "test", enabled = true, values = { flag = false } },
{ name = "unknown", enabled = false, values = {} },
{ name = "test", enabled = true, values = { flag = "yes", check = false, icon = "x", extra = 1 } },
}
Suite.eq(config, { enabled = true, flag = true, check = check }, "wrong types, functions, and unknown keys are skipped")
Suite.eq(feature.config.flag, false, "the known feature changed")
Suite.eq(env.storage.unknown, nil, "unknown features are not stored")
end)
Suite.test("on_load applies the overrides from storage", function(env)
local config = env.Features.config("test", { flag = true })
Suite.test("on_load applies the overrides from storage without raising events", function(env)
local feature = env.Feature.register("test", { flag = true })
env.on_load{ test = { enabled = false, flag = false } }
Suite.eq(config, { enabled = false, flag = false }, "the saved overrides apply")
Suite.eq(feature.config, { enabled = false, flag = false }, "the saved overrides apply")
Suite.empty(env.events, "nothing is raised")
env.Features.receive_update{ { name = "test", enabled = true, values = {} } }
feature:update_config(true, {})
Suite.eq(env.storage, { test = { enabled = true } }, "later updates go to the loaded storage table")
end)
Suite.test("guard() skips handlers while the feature is disabled", function(env)
local config = env.Features.config("test")
local feature = env.Feature.register("test")
local calls = {}
local lib = env.Features.guard(config, {
local lib = feature:guard{
events = { on_event = function(event) calls[#calls + 1] = event end },
on_nth_tick = { [60] = function(event) calls[#calls + 1] = event end },
on_init = function() end,
})
}
lib.events.on_event("a")
lib.on_nth_tick[60]("b")
env.Features.receive_update{ { name = "test", enabled = false, values = {} } }
feature:update_config(false, {})
lib.events.on_event("c")
lib.on_nth_tick[60]("d")
Suite.eq(calls, { "a", "b" }, "only called while enabled")
end)
Suite.test("guard() declares features by name", function(env)
local calls = 0
local lib = env.Features.guard("test", { events = { on_event = function() calls = calls + 1 end } })
env.Features.receive_update{ { name = "test", enabled = false, values = {} } }
lib.events.on_event()
Suite.eq(calls, 0, "the name is declared without config")
Suite.eq(env.Features.is_enabled("test"), false, "and can be looked up")
end)
Suite.test("receive_update() logs invalid and unknown overrides", function(env)
local config = env.Features.config("test", { flag = true })
env.Features.receive_update{ { name = "test", enabled = true, values = { flag = 1, extra = 2 } } }
Suite.eq(config.flag, true, "the default is used")
Suite.eq(#env.logged, 2, "both are logged")
end)
Suite.test("config() addresses nested values by path", function(env)
Suite.test("register() addresses nested values by path", function(env)
local offset = { 1, 2 }
local config = env.Features.config("test", { section = { flag = true, count = 1 }, offset = offset })
env.Features.receive_update{ { name = "test", enabled = true, values = { ["section.flag"] = false, offset = { "x" } } } }
Suite.eq(config.section, { flag = false, count = 1 }, "the nested value changes in place")
Suite.eq(config.offset, offset, "lists of other types can not be overridden")
local feature = env.Feature.register("test", { section = { flag = true, count = 1 }, offset = offset })
feature:update_config(true, { ["section.flag"] = false, offset = { "x" } })
Suite.eq(feature.config.section, { flag = false, count = 1 }, "the nested value changes in place")
Suite.eq(feature.config.offset, offset, "lists of other types can not be overridden")
env.Features.receive_update{ { name = "test", enabled = true, values = {} } }
Suite.eq(config.section.flag, true, "and goes back to its default")
feature:update_config(true, {})
Suite.eq(feature.config.section.flag, true, "and goes back to its default")
end)
Suite.test("config() allows lists of strings to be overridden", function(env)
local config = env.Features.config("test", { names = { "a", "b" }, empty = {} })
env.Features.receive_update{ { name = "test", enabled = true, values = { names = { "c" }, empty = { "d" } } } }
Suite.eq(config.names, { "c" }, "a list is replaced")
Suite.eq(config.empty, { "d" }, "an empty default is a list")
env.Features.receive_update{ { name = "test", enabled = true, values = { names = { 1 } } } }
Suite.eq(config.names, { "a", "b" }, "a list with other types is invalid")
Suite.test("register() allows lists of strings to be overridden", function(env)
local feature = env.Feature.register("test", { names = { "a", "b" }, empty = {} })
feature:update_config(true, { names = { "c" }, empty = { "d" } })
Suite.eq(feature.config.names, { "c" }, "a list is replaced")
Suite.eq(feature.config.empty, { "d" }, "an empty default is a list")
feature:update_config(true, { names = { 1 } })
Suite.eq(feature.config.names, { "a", "b" }, "a list with other types is invalid")
end)
Suite.test("optional() declares a value without a default", function(env)
local config = env.Features.config("test", { role_id = env.Features.optional("number") })
Suite.eq(config.role_id, nil, "nil until overridden")
env.Features.receive_update{ { name = "test", enabled = true, values = { role_id = 3 } } }
Suite.eq(config.role_id, 3, "overridden with the given type")
env.Features.receive_update{ { name = "test", enabled = true, values = {} } }
Suite.eq(config.role_id, nil, "and back to nil")
local feature = env.Feature.register("test", { role_id = env.Feature.optional("number") })
Suite.eq(feature.config.role_id, nil, "nil until overridden")
feature:update_config(true, { role_id = 3 })
Suite.eq(feature.config.role_id, 3, "overridden with the given type")
feature:update_config(true, {})
Suite.eq(feature.config.role_id, nil, "and back to nil")
end)
Suite.test("on_apply() is called now and after every change", function(env)
local config = env.Features.config("test", { names = { "a" } })
local seen = {}
env.Features.on_apply("test", function(applied) seen[#seen + 1] = applied.names[1] end)
env.Features.receive_update{ { name = "test", enabled = true, values = { names = { "b" } } } }
env.on_load{ test = { enabled = true, names = { "c" } } }
Suite.eq(seen, { "a", "b", "c" }, "on register, update, and load")
Suite.eq(config.names, { "c" }, "after the config is applied")
Suite.test("to_set() is cached until the list is replaced", function(env)
local feature = env.Feature.register("test", { names = { "a" } })
local set = env.Feature.to_set(feature.config.names)
Suite.eq(set, { a = true }, "a set of the items")
Suite.eq(env.Feature.to_set(feature.config.names) == set, true, "the same set while the list is unchanged")
env.on_load{ test = { enabled = true, names = { "b" } } }
Suite.eq(env.Feature.to_set(feature.config.names), { b = true }, "a new set after load replaces the list")
end)
Suite.test("config files declare their feature", function(env)
local config = env.load_config("popups")
env.Features.receive_update{ { name = "popups", enabled = true, values = { show_player_damage = false } } }
Suite.eq(config.show_player_damage, false, "popups can be changed")
Suite.eq(config.show_player_health, true, "other values keep their default")
Suite.test("config files register their feature", function(env)
local feature = env.load_config("popups")
feature:update_config(true, { show_player_damage = false })
Suite.eq(feature.config.show_player_damage, false, "popups can be changed")
Suite.eq(feature.config.show_player_health, true, "other values keep their default")
end)
return Suite.run()
+59 -40
View File
@@ -1,7 +1,8 @@
import React, { useContext, useState } from "react";
import { Button, Checkbox, Col, Divider, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import { Button, Checkbox, Col, ConfigProvider, Divider, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount, useRoles } from "@clusterio/web_ui";
import * as lib from "@clusterio/lib";
import { ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount } from "@clusterio/web_ui";
import { Feature, FeatureField, FeatureValue, features, isDefaultValue } from "../../features.js";
import { FeatureRecord, FeatureUpdateRequest } from "../../messages.js";
@@ -70,24 +71,30 @@ function searchFields(feature: Feature, query: string) {
));
}
function RoleSelect(props: { value: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void }) {
const [roles, synced] = useRoles();
return <Select
showSearch
allowClear
placeholder="No role"
optionFilterProp="label"
style={{ minWidth: 200 }}
value={props.value ?? undefined}
onChange={value => props.onChange(value ?? null)}
options={[...roles.values()].map(role => ({ label: role.name, value: role.id }))}
disabled={props.disabled || !synced}
loading={!synced}
/>;
}
/**
* Render the input for a setting the same way the config pages do, input components registered
* with the web interface take priority so plugins can provide inputs for other types
*/
function FieldInput(props: { field: FeatureField, value: FeatureValue, disabled: boolean, onChange: (value: FeatureValue) => void }) {
const { field, value, disabled, onChange } = props;
const control = useContext(ControlContext);
const CustomInput = field.inputComponent ? control.inputComponents.get(field.inputComponent) : undefined;
if (CustomInput) {
const fieldDefinition = {
type: field.type === "string_list" ? "object" : field.type,
title: field.title,
description: field.description,
optional: field.optional,
inputComponent: field.inputComponent,
} as lib.FieldDefinition;
return <CustomInput
fieldDefinition={fieldDefinition}
value={value as Exclude<FeatureValue, string[]>}
onChange={onChange}
disabled={disabled}
/>;
}
switch (field.type) {
case "number":
return <InputNumber
@@ -95,10 +102,21 @@ function FieldInput(props: { field: FeatureField, value: FeatureValue, disabled:
min={field.min}
addonAfter={field.unit}
disabled={disabled}
onChange={next => onChange(next ?? field.default)}
onChange={next => onChange(next ?? (field.optional ? null : field.default))}
/>;
case "string":
return <Input value={value as string} disabled={disabled} onChange={e => onChange(e.target.value)} style={{ maxWidth: 400 }} />;
if (field.enum) {
return <Select
showSearch
style={{ minWidth: 175 }}
value={value as string}
options={field.enum.map(option => ({ label: option, value: option }))}
allowClear={field.optional}
disabled={disabled}
onChange={next => onChange(next ?? null)}
/>;
}
return <Input value={value as string} disabled={disabled} onChange={e => onChange(e.target.value)} style={{ width: 300 }} />;
case "string_list":
return <Select
mode="tags"
@@ -108,8 +126,6 @@ function FieldInput(props: { field: FeatureField, value: FeatureValue, disabled:
open={false}
style={{ width: "100%", maxWidth: 600 }}
/>;
case "role":
return <RoleSelect value={value} disabled={disabled} onChange={onChange} />;
default:
return null;
}
@@ -160,7 +176,7 @@ function FeatureSection(props: {
borderRadius: 4,
background: state.enabled !== stored.enabled ? modifiedColor : undefined,
}}>
<Switch checked={state.enabled} disabled={!canEdit} onChange={props.onEnabled} />
<Switch size="default" checked={state.enabled} disabled={!canEdit} onChange={props.onEnabled} />
</div>
<div style={{ flexGrow: 1 }}>
<strong>{feature.title}</strong>
@@ -301,23 +317,26 @@ export default function FeaturesPage() {
</div>
</Col>
<Col flex="auto" style={{ minWidth: 0 }}>
<Space direction="vertical" style={{ width: "100%" }}>
{shown.map(({ feature, fields }) => <FeatureSection
key={feature.name}
feature={feature}
fields={fields}
state={current(feature.name)}
stored={stored[feature.name]}
canEdit={canEdit}
onEnabled={enabled => change(feature.name, entry => { entry.enabled = enabled; })}
onValue={(key, value) => change(feature.name, entry => { entry.values[key] = value; })}
onReset={() => change(feature.name, entry => {
for (const field of feature.fields) {
entry.values[field.name] = field.default;
}
})}
/>)}
</Space>
{/* Small inputs match the line height of the text, same as the config pages */}
<ConfigProvider componentSize="small">
<Space direction="vertical" style={{ width: "100%" }}>
{shown.map(({ feature, fields }) => <FeatureSection
key={feature.name}
feature={feature}
fields={fields}
state={current(feature.name)}
stored={stored[feature.name]}
canEdit={canEdit}
onEnabled={enabled => change(feature.name, entry => { entry.enabled = enabled; })}
onValue={(key, value) => change(feature.name, entry => { entry.values[key] = value; })}
onReset={() => change(feature.name, entry => {
for (const field of feature.fields) {
entry.values[field.name] = field.default;
}
})}
/>)}
</Space>
</ConfigProvider>
</Col>
</Row>
</PageLayout>;