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