From 628e4df514f9e7ee40a9db653e06e820fed60265 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:03:11 +0000 Subject: [PATCH] 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 --- exp_scenario/features.ts | 37 +- exp_scenario/module/commands/_authorities.lua | 4 +- exp_scenario/module/commands/lawnmower.lua | 11 +- .../module/commands/protected_entities.lua | 10 +- .../module/config/chat_auto_reply.lua | 4 +- exp_scenario/module/config/popups.lua | 4 +- exp_scenario/module/config/spawn_area.lua | 4 +- exp_scenario/module/control/afk_kick.lua | 11 +- .../module/control/chat_auto_reply.lua | 9 +- exp_scenario/module/control/chat_popup.lua | 9 +- exp_scenario/module/control/damage_popups.lua | 9 +- exp_scenario/module/control/death_markers.lua | 9 +- .../module/control/discord_alerts.lua | 20 +- exp_scenario/module/control/features.lua | 8 +- .../module/control/mine_depletion.lua | 9 +- .../module/control/nuke_protection.lua | 19 +- exp_scenario/module/control/protection.lua | 44 +-- exp_scenario/module/control/spawn_area.lua | 9 +- .../module/control/station_auto_name.lua | 9 +- exp_scenario/module/features.lua | 347 +++++++++--------- exp_scenario/module/gui/autofill.lua | 10 +- exp_scenario/module/gui/quick_actions.lua | 8 +- exp_scenario/test/features.test.js | 14 +- exp_scenario/test/module/env.lua | 8 +- exp_scenario/test/module/features.lua | 173 ++++----- exp_scenario/web/components/FeaturesPage.tsx | 99 +++-- 26 files changed, 468 insertions(+), 430 deletions(-) diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts index fa97660b..48179a69 100644 --- a/exp_scenario/features.ts +++ b/exp_scenario/features.ts @@ -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 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}`); diff --git a/exp_scenario/module/commands/_authorities.lua b/exp_scenario/module/commands/_authorities.lua index 7b7a4f89..7379e4e4 100644 --- a/exp_scenario/module/commands/_authorities.lua +++ b/exp_scenario/module/commands/_authorities.lua @@ -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() diff --git a/exp_scenario/module/commands/lawnmower.lua b/exp_scenario/module/commands/lawnmower.lua index 043825b6..ab69d9d5 100644 --- a/exp_scenario/module/commands/lawnmower.lua +++ b/exp_scenario/module/commands/lawnmower.lua @@ -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, -}) +} diff --git a/exp_scenario/module/commands/protected_entities.lua b/exp_scenario/module/commands/protected_entities.lua index 34b66783..b2b14c9a 100644 --- a/exp_scenario/module/commands/protected_entities.lua +++ b/exp_scenario/module/commands/protected_entities.lua @@ -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 diff --git a/exp_scenario/module/config/chat_auto_reply.lua b/exp_scenario/module/config/chat_auto_reply.lua index 344d0fc2..718402d6 100644 --- a/exp_scenario/module/config/chat_auto_reply.lua +++ b/exp_scenario/module/config/chat_auto_reply.lua @@ -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 messages = { --- @setting messages will trigger when ever the word is said ["discord"] = { "info.discord", config_server_detail["discord"] }, diff --git a/exp_scenario/module/config/popups.lua b/exp_scenario/module/config/popups.lua index 60471cad..1ad77145 100644 --- a/exp_scenario/module/config/popups.lua +++ b/exp_scenario/module/config/popups.lua @@ -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 diff --git a/exp_scenario/module/config/spawn_area.lua b/exp_scenario/module/config/spawn_area.lua index aaca027c..3093062b 100644 --- a/exp_scenario/module/config/spawn_area.lua +++ b/exp_scenario/module/config/spawn_area.lua @@ -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 diff --git a/exp_scenario/module/control/afk_kick.lua b/exp_scenario/module/control/afk_kick.lua index c7f4b8e8..b2d82088 100644 --- a/exp_scenario/module/control/afk_kick.lua +++ b/exp_scenario/module/control/afk_kick.lua @@ -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, -}) +} diff --git a/exp_scenario/module/control/chat_auto_reply.lua b/exp_scenario/module/control/chat_auto_reply.lua index a8f00392..20f28571 100644 --- a/exp_scenario/module/control/chat_auto_reply.lua +++ b/exp_scenario/module/control/chat_auto_reply.lua @@ -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, }, -}) +} diff --git a/exp_scenario/module/control/chat_popup.lua b/exp_scenario/module/control/chat_popup.lua index b7da1600..ec01287b 100644 --- a/exp_scenario/module/control/chat_popup.lua +++ b/exp_scenario/module/control/chat_popup.lua @@ -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, }, -}) +} diff --git a/exp_scenario/module/control/damage_popups.lua b/exp_scenario/module/control/damage_popups.lua index e801ae42..deb40821 100644 --- a/exp_scenario/module/control/damage_popups.lua +++ b/exp_scenario/module/control/damage_popups.lua @@ -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, }, -}) +} diff --git a/exp_scenario/module/control/death_markers.lua b/exp_scenario/module/control/death_markers.lua index a6d1e4a1..41507326 100644 --- a/exp_scenario/module/control/death_markers.lua +++ b/exp_scenario/module/control/death_markers.lua @@ -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, }, -}) +} diff --git a/exp_scenario/module/control/discord_alerts.lua b/exp_scenario/module/control/discord_alerts.lua index 07cc7a39..87258e43 100644 --- a/exp_scenario/module/control/discord_alerts.lua +++ b/exp_scenario/module/control/discord_alerts.lua @@ -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 -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, -}) +} diff --git a/exp_scenario/module/control/features.lua b/exp_scenario/module/control/features.lua index 49ec13da..738ddc38 100644 --- a/exp_scenario/module/control/features.lua +++ b/exp_scenario/module/control/features.lua @@ -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, }, } diff --git a/exp_scenario/module/control/mine_depletion.lua b/exp_scenario/module/control/mine_depletion.lua index e1a1bfff..188e6b39 100644 --- a/exp_scenario/module/control/mine_depletion.lua +++ b/exp_scenario/module/control/mine_depletion.lua @@ -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, }, -}) +} diff --git a/exp_scenario/module/control/nuke_protection.lua b/exp_scenario/module/control/nuke_protection.lua index 2117fa70..13210cc3 100644 --- a/exp_scenario/module/control/nuke_protection.lua +++ b/exp_scenario/module/control/nuke_protection.lua @@ -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 -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, -}) +} diff --git a/exp_scenario/module/control/protection.lua b/exp_scenario/module/control/protection.lua index aa8d358e..d13c650c 100644 --- a/exp_scenario/module/control/protection.lua +++ b/exp_scenario/module/control/protection.lua @@ -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 -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 -local always_protected_types = {} --- @type table -local always_trigger_repeat_names = {} --- @type table -local always_trigger_repeat_types = {} --- @type table -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> Keyed by surface index then entity key local protected_areas = {} --- @type table> 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) diff --git a/exp_scenario/module/control/spawn_area.lua b/exp_scenario/module/control/spawn_area.lua index 3eba435c..0c18d2f8 100644 --- a/exp_scenario/module/control/spawn_area.lua +++ b/exp_scenario/module/control/spawn_area.lua @@ -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, }, -}) +} diff --git a/exp_scenario/module/control/station_auto_name.lua b/exp_scenario/module/control/station_auto_name.lua index 8d9273cc..142f4a31 100644 --- a/exp_scenario/module/control/station_auto_name.lua +++ b/exp_scenario/module/control/station_auto_name.lua @@ -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, }, -}) +} diff --git a/exp_scenario/module/features.lua b/exp_scenario/module/features.lua index 081d955a..c9ae2f5c 100644 --- a/exp_scenario/module/features.lua +++ b/exp_scenario/module/features.lua @@ -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 The values in the config by path +--- @field overrides table The overrides from the controller by path, restored from storage on load +local Feature = { + _registered = {}, --- @type table + 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 +--- @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> +local Feature_mt = { + __index = Feature, +} ---- Called after a feature is applied by feature name -local on_apply = {} --- @type table - ---- Overrides received from the controller by feature name then path -local overrides = {} --- @type table> - ---- 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> +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 +--- @param fields table --- @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 +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> + +--- Get a lookup set of the items in a list, cached until the list is replaced +--- @param list string[] +--- @return table +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 diff --git a/exp_scenario/module/gui/autofill.lua b/exp_scenario/module/gui/autofill.lua index f8b2a162..9a6b7093 100644 --- a/exp_scenario/module/gui/autofill.lua +++ b/exp_scenario/module/gui/autofill.lua @@ -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, }, -}) +} diff --git a/exp_scenario/module/gui/quick_actions.lua b/exp_scenario/module/gui/quick_actions.lua index 9a94bd9f..28c9ec37 100644 --- a/exp_scenario/module/gui/quick_actions.lua +++ b/exp_scenario/module/gui/quick_actions.lua @@ -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, } } diff --git a/exp_scenario/test/features.test.js b/exp_scenario/test/features.test.js index 2e3bf640..c5974ca3 100644 --- a/exp_scenario/test/features.test.js +++ b/exp_scenario/test/features.test.js @@ -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"] }), diff --git a/exp_scenario/test/module/env.lua b/exp_scenario/test/module/env.lua index 75d17960..0121e16f 100644 --- a/exp_scenario/test/module/env.lua +++ b/exp_scenario/test/module/env.lua @@ -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/.lua +--- @field load_config fun(name: string): ExpScenario.Feature Load module/config/.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 diff --git a/exp_scenario/test/module/features.lua b/exp_scenario/test/module/features.lua index 2c195d6d..afb806b2 100644 --- a/exp_scenario/test/module/features.lua +++ b/exp_scenario/test/module/features.lua @@ -1,137 +1,140 @@ local Suite = ... --- @type Suite -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() diff --git a/exp_scenario/web/components/FeaturesPage.tsx b/exp_scenario/web/components/FeaturesPage.tsx index 59f961a5..118384df 100644 --- a/exp_scenario/web/components/FeaturesPage.tsx +++ b/exp_scenario/web/components/FeaturesPage.tsx @@ -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 onChange(e.target.value)} style={{ maxWidth: 400 }} />; + if (field.enum) { + return onChange(e.target.value)} style={{ width: 300 }} />; case "string_list": return