From f90b8fcee15b4db8059c72fe8f1d54f2d150be4a Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:35:58 +0000 Subject: [PATCH] Address the second inline review of the features framework - Feature.register returns the config as a second value, so files write `local feature, config = Feature.register(...)`. - The lib is ExpScenario.Features and the instance class with its fields sits on Feature._prototype, which is the __index, the same shape as exp_commands. - update_config only looks at values whose override was added, removed, or replaced. _apply_field sets one value and returns it, so the comparison happens while applying and the second loop only raises. Overrides dropped from the previous set count as candidates because the value reverts to its default. - The `@setting name` tags are gone from the config comments. - Protection is guarded as a whole instead of checking config.enabled in one handler. The handlers also forget mined entities, so get_entities and is_entity_protected now drop invalid references, which also covers entities destroyed without an event. The list getters are gone, the selection overlay builds its lists from Protection.config. - Chat and damage popups are one feature in control/popups.lua. - ignore_admins and command_admin_only are removed, roles cover them. Co-Authored-By: Claude Fable 5.1 --- exp_scenario/features.ts | 3 - exp_scenario/module/commands/lawnmower.lua | 5 +- .../module/commands/protected_entities.lua | 15 ++- .../module/config/chat_auto_reply.lua | 9 +- exp_scenario/module/config/popups.lua | 12 --- exp_scenario/module/config/spawn_area.lua | 60 +++++------ exp_scenario/module/control.lua | 3 +- exp_scenario/module/control/afk_kick.lua | 15 ++- .../module/control/chat_auto_reply.lua | 1 - exp_scenario/module/control/chat_popup.lua | 50 --------- exp_scenario/module/control/damage_popups.lua | 53 --------- exp_scenario/module/control/death_markers.lua | 17 ++- .../module/control/discord_alerts.lua | 19 ++-- .../module/control/mine_depletion.lua | 9 +- .../module/control/nuke_protection.lua | 7 +- exp_scenario/module/control/popups.lua | 92 ++++++++++++++++ exp_scenario/module/control/protection.lua | 67 +++++------- .../module/control/station_auto_name.lua | 5 +- exp_scenario/module/features.lua | 102 +++++++++++------- exp_scenario/test/module/env.lua | 2 +- exp_scenario/test/module/features.lua | 29 ++++- 21 files changed, 289 insertions(+), 286 deletions(-) delete mode 100644 exp_scenario/module/config/popups.lua delete mode 100644 exp_scenario/module/control/chat_popup.lua delete mode 100644 exp_scenario/module/control/damage_popups.lua create mode 100644 exp_scenario/module/control/popups.lua diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts index 3327bbb8..dc7c24de 100644 --- a/exp_scenario/features.ts +++ b/exp_scenario/features.ts @@ -86,7 +86,6 @@ export const features: Feature[] = [ description: "Reply to key words in chat and run chat commands", fields: [ str("command_prefix", "Command prefix", "Prefix for chat commands, and for key words to reply to everyone", "!"), - bool("command_admin_only", "Commands for admins only", "Only admins can use chat commands", false), ], }, { @@ -142,7 +141,6 @@ export const features: Feature[] = [ title: "Nuke Protection", description: "Remove banned items from players without the nuke protection bypass permission", fields: [ - bool("ignore_admins", "Ignore admins", "Admins can hold banned items", true), list("banned_items", "Banned items", "Items which are moved to a chest at spawn", ["atomic-bomb"]), ], }, @@ -163,7 +161,6 @@ export const features: Feature[] = [ title: "Entity Protection", description: "Stop players removing protected entities placed by others, players with the bypass permission are ignored", fields: [ - bool("ignore_admins", "Ignore admins", "Admins can remove protected entities", true), num("repeat_count", "Repeat count", "Protected entities removed within the repeat time which count as a repeat violation", 5, 1), num("repeat_minutes", "Repeat time", "How long removed protected entities are remembered", 20, 1, "minutes"), list("always_protected_names", "Protected entity names", "Entities which are always protected", []), diff --git a/exp_scenario/module/commands/lawnmower.lua b/exp_scenario/module/commands/lawnmower.lua index ab69d9d5..26cc33fa 100644 --- a/exp_scenario/module/commands/lawnmower.lua +++ b/exp_scenario/module/commands/lawnmower.lua @@ -8,10 +8,9 @@ local Feature = require("modules/exp_scenario/features") local Selection = require("modules/exp_util/selection") local SelectArea = Selection.connect("ExpCommand_Lawnmower") -local feature = Feature.register("lawnmower", { - destroy_decoratives = false, --- @setting destroy_decoratives remove decoratives under entities when they are built +local feature, config = Feature.register("lawnmower", { + destroy_decoratives = false, -- remove decoratives under entities when they are built }) -local config = feature.config --- @class ExpCommand_Lawnmower.commands local commands = {} diff --git a/exp_scenario/module/commands/protected_entities.lua b/exp_scenario/module/commands/protected_entities.lua index b2b14c9a..b35861e2 100644 --- a/exp_scenario/module/commands/protected_entities.lua +++ b/exp_scenario/module/commands/protected_entities.lua @@ -153,6 +153,17 @@ SelectArea:on_alt_selection(function(event) end end) +--- The keys of a set as a list, find_entities_filtered takes a list of names +--- @param set table +--- @return string[] +local function set_keys(set) + local keys = {} + for key in pairs(set) do + keys[#keys + 1] = key + end + return keys +end + --- When selection starts show all protected entities and protected areas local function on_player_selection_start(event) local player = game.players[event.player_index] @@ -166,7 +177,7 @@ local function on_player_selection_start(event) end -- Show always protected entities by name - local protected_entity_names = EntityProtection.get_protected_entity_names() + local protected_entity_names = set_keys(EntityProtection.config.always_protected_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) @@ -174,7 +185,7 @@ local function on_player_selection_start(event) end -- Show always protected entities by type - local protected_entity_types = EntityProtection.get_protected_entity_types() + local protected_entity_types = set_keys(EntityProtection.config.always_protected_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) diff --git a/exp_scenario/module/config/chat_auto_reply.lua b/exp_scenario/module/config/chat_auto_reply.lua index 718402d6..a9e627b9 100644 --- a/exp_scenario/module/config/chat_auto_reply.lua +++ b/exp_scenario/module/config/chat_auto_reply.lua @@ -27,7 +27,7 @@ local afk_time_units = { return Feature.register("chat_auto_reply", { --- @type table - messages = { --- @setting messages will trigger when ever the word is said + messages = { -- will trigger when ever the word is said ["discord"] = { "info.discord", config_server_detail["discord"] }, ["expgaming"] = { "info.website", config_server_detail["website"] }, ["website"] = { "info.website", config_server_detail["website"] }, @@ -65,11 +65,10 @@ return Feature.register("chat_auto_reply", { return { "exp_chat-auto-reply.reply-afk", max.name, ExpUtil.format_time_locale(max.afk_time, "long", afk_time_units) } end, }, - allow_command_prefix_for_messages = true, --- @setting allow_command_prefix_for_messages when true any message trigger will print to all player when prefixed - command_admin_only = false, --- @setting command_admin_only when true will only allow chat commands for admins - command_prefix = "!", --- @setting command_prefix prefix used for commands below and to print to all players (if enabled above) + allow_command_prefix_for_messages = true, -- when true any message trigger will print to all player when prefixed + command_prefix = "!", -- prefix used for commands below and to print to all players (if enabled above) --- @type table - commands = { --- @setting commands will trigger only when command prefix is given + commands = { -- will trigger only when command prefix is given ["dev"] = { "exp_chat-auto-reply.reply-dev" }, ["magic"] = { "exp_chat-auto-reply.reply-magic" }, ["aids"] = { "exp_chat-auto-reply.reply-aids" }, diff --git a/exp_scenario/module/config/popups.lua b/exp_scenario/module/config/popups.lua deleted file mode 100644 index 1ad77145..00000000 --- a/exp_scenario/module/config/popups.lua +++ /dev/null @@ -1,12 +0,0 @@ ---- Popups for chat and damage, shared by control/chat_popup.lua and control/damage_popups.lua --- @config Popups - -local Feature = require("modules/exp_scenario/features") - -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 - show_player_health = true, --- @setting show_player_health weather to show player health when attacked - damage_location_variance = 0.8, --- @setting damage_location_variance how close to the eade of an entity the popups will appear -}) diff --git a/exp_scenario/module/config/spawn_area.lua b/exp_scenario/module/config/spawn_area.lua index 689af943..605ac94f 100644 --- a/exp_scenario/module/config/spawn_area.lua +++ b/exp_scenario/module/config/spawn_area.lua @@ -4,39 +4,39 @@ local Feature = require("modules/exp_scenario/features") return Feature.register("spawn_area", { - spawn_area = { --- @setting spawn_area Settings relating to the whole spawn area + 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 + deconstruction_radius = 20, -- All entities within this radius will be removed tile_radius = 20, - deconstruction_tile = "concrete", --- @setting deconstruction_tile Tile to be placed in the deconstruction radius, use nil for map gen - landfill_radius = 50, --- @setting pattern_radius All water within this radius will be land filled + deconstruction_tile = "concrete", -- Tile to be placed in the deconstruction radius, use nil for map gen + landfill_radius = 50, -- All water within this radius will be land filled }, - turrets = { --- @setting turrets Settings relating to adding turrets to spawn - enabled = true, --- @setting enabled Whether turrets will be added to spawn - ammo_type = "uranium-rounds-magazine", --- @setting ammo_type The ammo type that will be used during refills - offset = { x = 0, y = 0 }, --- @setting offset The position offset to apply to turrets - locations = { --- @setting locations The locations of all turrets, this list can change during runtime + turrets = { -- Settings relating to adding turrets to spawn + enabled = true, -- Whether turrets will be added to spawn + ammo_type = "uranium-rounds-magazine", -- The ammo type that will be used during refills + offset = { x = 0, y = 0 }, -- The position offset to apply to turrets + locations = { -- The locations of all turrets, this list can change during runtime { -3, -3 }, { 3, -3 }, { -3, 3 }, { 3, 3 }, }, }, - afk_belts = { --- @setting afk_belts Settings relating to adding afk belts to spawn - enabled = true, --- @setting enabled Whether afk belts will be added to spawn - belt_type = "transport-belt", --- @setting belt_type The belt to be used as afk belts - protected = true, --- @setting protected Whether belts will be protected from player interaction - offset = { x = 0, y = 0 }, --- @setting offset The position offset to apply to afk belts - locations = { --- @setting locations The locations to spawn afk belts at, given as the top left position + afk_belts = { -- Settings relating to adding afk belts to spawn + enabled = true, -- Whether afk belts will be added to spawn + belt_type = "transport-belt", -- The belt to be used as afk belts + protected = true, -- Whether belts will be protected from player interaction + offset = { x = 0, y = 0 }, -- The position offset to apply to afk belts + locations = { -- The locations to spawn afk belts at, given as the top left position { -5, -5 }, { 5, -5 }, { -5, 5 }, { 5, 5 }, }, }, - water = { --- @setting water Settings relating to adding water to spawn - enabled = true, --- @setting enabled Whether water tiles will be added to spawn - water_tile = "water-mud", --- @setting water_tile The tile to be used as the water tile - offset = { x = 0, y = 0 }, --- @setting offset The position offset to apply to water tiles - locations = { --- @setting locations The location of the water tiles {x,y} + water = { -- Settings relating to adding water to spawn + enabled = true, -- Whether water tiles will be added to spawn + water_tile = "water-mud", -- The tile to be used as the water tile + offset = { x = 0, y = 0 }, -- The position offset to apply to water tiles + locations = { -- The location of the water tiles {x,y} -- Each is a 3x3 with the closest tile to 0,0 removed { 7, 8 }, { 7, 9 }, { 8, 7 }, { 8, 8 }, { 8, 9 }, { 9, 7 }, { 9, 8 }, { 9, 9 }, -- Bottom Right { 7, -9 }, { 7, -10 }, { 8, -8 }, { 8, -9 }, { 8, -10 }, { 9, -8 }, { 9, -9 }, { 9, -10 }, -- Top Right @@ -44,12 +44,12 @@ return Feature.register("spawn_area", { { -8, 8 }, { -8, 9 }, { -9, 7 }, { -9, 8 }, { -9, 9 }, { -10, 7 }, { -10, 8 }, { -10, 9 }, -- Bottom Left }, }, - entities = { --- @setting entities Settings relating to adding entities to spawn - enabled = true, --- @setting enabled Whether entities will be added to spawn - protected = true, --- @setting protected Whether entities will be protected from player interaction - operable = true, --- @setting operable Whether entities can be opened by players, must be true if chests are used - offset = { x = 0, y = -2 }, --- @setting offset The position offset to apply to entities - locations = { --- @setting locations The location and names of entities {name,x,y} + entities = { -- Settings relating to adding entities to spawn + enabled = true, -- Whether entities will be added to spawn + protected = true, -- Whether entities will be protected from player interaction + operable = true, -- Whether entities can be opened by players, must be true if chests are used + offset = { x = 0, y = -2 }, -- The position offset to apply to entities + locations = { -- The location and names of entities {name,x,y} { "stone-wall", -10, -5 }, { "stone-wall", -10, -4 }, { "stone-wall", -10, -3 }, { "stone-wall", -10, -2 }, { "stone-wall", -10, -1 }, { "stone-wall", -10, 0 }, { "stone-wall", -10, 3 }, { "stone-wall", -10, 4 }, { "stone-wall", -10, 5 }, { "stone-wall", -10, 6 }, { "stone-wall", -10, 7 }, { "stone-wall", -10, 8 }, { "small-lamp", -8, -4 }, { "small-lamp", -8, -1 }, { "iron-chest", -8, 0 }, { "iron-chest", -8, 3 }, { "small-lamp", -8, 4 }, { "small-lamp", -8, 7 }, { "stone-wall", -7, -8 }, { "small-electric-pole", -7, -2 }, { "iron-chest", -7, 0 }, { "iron-chest", -7, 3 }, { "small-electric-pole", -7, 5 }, { "stone-wall", -7, 11 }, { "stone-wall", -6, -8 }, { "small-lamp", -6, -6 }, @@ -67,10 +67,10 @@ return Feature.register("spawn_area", { }, }, pattern = { - enabled = true, --- @setting enabled Whether pattern tiles will be added to spawn - pattern_tile = "stone-path", --- @setting pattern_tile The tile to be used for the pattern - offset = { x = 0, y = -2 }, --- @setting offset The position offset to apply to pattern tiles - locations = { --- @setting locations The location of the pattern tiles {x,y} + enabled = true, -- Whether pattern tiles will be added to spawn + pattern_tile = "stone-path", -- The tile to be used for the pattern + offset = { x = 0, y = -2 }, -- The position offset to apply to pattern tiles + locations = { -- The location of the pattern tiles {x,y} { -49, -3 }, { -49, -2 }, { -49, 1 }, { -49, 2 }, { -49, 5 }, { -49, 6 }, { -48, -4 }, { -48, -3 }, { -48, -2 }, { -48, 1 }, { -48, 2 }, { -48, 5 }, { -48, 6 }, { -48, 7 }, { -47, -7 }, { -47, -6 }, { -47, -5 }, { -47, -4 }, { -47, -3 }, { -47, -2 }, { -47, 5 }, { -47, 6 }, { -47, 7 }, { -47, 8 }, { -47, 9 }, { -47, 10 }, { -46, -8 }, { -46, -7 }, { -46, -6 }, { -46, -5 }, { -46, -4 }, { -46, -3 }, { -46, -2 }, { -46, -1 }, { -46, 4 }, { -46, 5 }, { -46, 6 }, { -46, 7 }, { -46, 8 }, { -46, 9 }, { -46, 10 }, { -46, 11 }, { -45, -17 }, { -45, -16 }, { -45, -15 }, { -45, -14 }, { -45, -13 }, { -45, -12 }, { -45, -9 }, { -45, -8 }, { -45, -7 }, { -45, -2 }, { -45, -1 }, { -45, 0 }, { -45, 1 }, { -45, 2 }, { -45, 3 }, { -45, 4 }, { -45, 5 }, { -45, 10 }, { -45, 11 }, { -45, 12 }, { -45, 15 }, { -45, 16 }, { -45, 17 }, { -45, 18 }, { -45, 19 }, { -45, 20 }, { -44, -18 }, { -44, -17 }, { -44, -16 }, { -44, -15 }, { -44, -14 }, { -44, -13 }, { -44, -12 }, { -44, -9 }, { -44, -8 }, { -44, -1 }, { -44, 0 }, { -44, 1 }, { -44, 2 }, { -44, 3 }, { -44, 4 }, { -44, 11 }, { -44, 12 }, { -44, 15 }, { -44, 16 }, { -44, 17 }, { -44, 18 }, { -44, 19 }, diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index 24197c1a..a7702f6d 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -43,9 +43,7 @@ require("modules/exp_scenario/commands/waterfill") add(require("modules/exp_scenario/control/afk_kick")) add(require("modules/exp_scenario/control/bonus")) add(require("modules/exp_scenario/control/chat_auto_reply")) -add(require("modules/exp_scenario/control/chat_popup")) add(require("modules/exp_scenario/control/custom_start")) -add(require("modules/exp_scenario/control/damage_popups")) add(require("modules/exp_scenario/control/death_markers")) add(require("modules/exp_scenario/control/deconstruction_log")) add(require("modules/exp_scenario/control/degrading_tiles")) @@ -59,6 +57,7 @@ add(require("modules/exp_scenario/control/inventory_clear")) add(require("modules/exp_scenario/control/mine_depletion")) add(require("modules/exp_scenario/control/nuke_protection")) add(require("modules/exp_scenario/control/pollution_grading")) +add(require("modules/exp_scenario/control/popups")) add(require("modules/exp_scenario/control/protection")) add(require("modules/exp_scenario/control/protection_jail")) add(require("modules/exp_scenario/control/report_jail")) diff --git a/exp_scenario/module/control/afk_kick.lua b/exp_scenario/module/control/afk_kick.lua index c257c420..2f213e71 100644 --- a/exp_scenario/module/control/afk_kick.lua +++ b/exp_scenario/module/control/afk_kick.lua @@ -6,15 +6,14 @@ local Async = require("modules/exp_util/async") local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") local Storage = require("modules/exp_util/storage") -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 = 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 feature, config = Feature.register("afk_kick", { + admin_as_active = true, -- When true admins will be treated as active regardless of afk time + trust_as_active = true, -- When true trusted players (by playtime) will be treated as active regardless of afk time + active_role_id = Feature.optional("number"), -- Players with this role or higher are treated as active regardless of afk time + afk_minutes = 10, -- The time in minutes that must pass for a player to be considered afk + kick_minutes = 30, -- The time in minutes that must pass without any active players for all players to be kicked + trust_minutes = 600, -- The time in minutes that a player must be online for to count as trusted }) -local config = feature.config local ticks_per_minute = 3600 diff --git a/exp_scenario/module/control/chat_auto_reply.lua b/exp_scenario/module/control/chat_auto_reply.lua index 20f28571..1e1b6348 100644 --- a/exp_scenario/module/control/chat_auto_reply.lua +++ b/exp_scenario/module/control/chat_auto_reply.lua @@ -21,7 +21,6 @@ local function on_console_chat(event) -- Check if the player can chat commands local commands_allowed = true - if config.command_admin_only and not player.admin then commands_allowed = false end if not Roles.player_has_permission(player, "exp_scenario.chat.commands") then commands_allowed = false end -- Check if a key word appears in the message diff --git a/exp_scenario/module/control/chat_popup.lua b/exp_scenario/module/control/chat_popup.lua deleted file mode 100644 index ec01287b..00000000 --- a/exp_scenario/module/control/chat_popup.lua +++ /dev/null @@ -1,50 +0,0 @@ ---[[-- Control - Chat Popup -Creates flying text entities when a player sends a message in chat -]] - -local FlyingText = require("modules/exp_util/flying_text") -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 - ---- Create a chat bubble when a player types a message ---- @param event EventData.on_console_chat -local function on_console_chat(event) - if not event.player_index then return end - local player = assert(game.get_player(event.player_index)) - local name = player.name - - -- Sends the message as text above them - if config.show_player_messages then - FlyingText.create_as_player{ - target_player = player, - text = { "exp_chat-popup.flying-text-message", name, event.message }, - } - end - - if not config.show_player_mentions then return end - - -- Loops over online players to see if they name is included - local search_string = lower(event.message) - for _, mentioned_player in ipairs(game.connected_players) do - if mentioned_player.index ~= player.index then - if find(search_string, lower(mentioned_player.name), 1, true) then - FlyingText.create_as_player{ - target_player = mentioned_player, - text = { "exp_chat-popup.flying-text-ping", name }, - } - end - end - end -end - -local e = defines.events - -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 deleted file mode 100644 index deb40821..00000000 --- a/exp_scenario/module/control/damage_popups.lua +++ /dev/null @@ -1,53 +0,0 @@ ---[[-- Control - Damage PopUps -Displays the amount of dmg that is done by players to entities; -also shows player health when a player is attacked -]] - -local FlyingText = require("modules/exp_util/flying_text") -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 -local max = math.max - ---- Called when entity entity is damaged including the player character ---- @param event EventData.on_entity_damaged -local function on_entity_damaged(event) - local message - local cause = event.cause - local entity = event.entity - - -- Check which message to display - if config.show_player_health and entity.name == "character" then - message = { "exp_damage-popup.flying-text-health", floor(entity.health) } - elseif config.show_player_damage and entity.name ~= "character" and cause and cause.name == "character" then - message = { "exp_damage-popup.flying-text-damage", floor(event.original_damage_amount) } - end - - -- Outputs the message as floating text - if message then - local entity_position = entity.position - local entity_radius = max(1, entity.get_radius()) - local offset = (random() - 0.5) * entity_radius * config.damage_location_variance - local position = { x = entity_position.x + offset, y = entity_position.y - entity_radius } - - local health_percentage = assert(entity.get_health_ratio()) - local color = { r = 1 - health_percentage, g = health_percentage, b = 0 } - - FlyingText.create{ - text = message, - position = position, - color = color, - } - end -end - -local e = defines.events - -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 41507326..e293a080 100644 --- a/exp_scenario/module/control/death_markers.lua +++ b/exp_scenario/module/control/death_markers.lua @@ -6,16 +6,15 @@ local ExpUtil = require("modules/exp_util") local Feature = require("modules/exp_scenario/features") local Storage = require("modules/exp_util/storage") -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 - include_time_of_death = true, --- @setting include_time_of_death weather to include the time of death on the map marker - map_icon = nil, --- @setting map_icon the icon that the map marker shows; nil means no icon; format as a SingleID - 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 feature, config = Feature.register("death_markers", { + collect_corpses = true, -- enables items being returned to the spawn point in chests upon corpse expiring + show_map_markers = true, -- shows markers on the map where bodies are + clean_map_markers = false, -- removes the map marker once the body is gone + include_time_of_death = true, -- weather to include the time of death on the map marker + map_icon = nil, -- the icon that the map marker shows; nil means no icon; format as a SingleID + show_light_at_corpse = true, -- if a light should be rendered at the corpse + show_line_to_corpse = true, -- 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 } diff --git a/exp_scenario/module/control/discord_alerts.lua b/exp_scenario/module/control/discord_alerts.lua index 5690024e..064af60a 100644 --- a/exp_scenario/module/control/discord_alerts.lua +++ b/exp_scenario/module/control/discord_alerts.lua @@ -8,19 +8,18 @@ local Feature = require("modules/exp_scenario/features") local EntityProtection = require("modules/exp_scenario/control/protection") local Jail = require("modules/exp_scenario/control/jail") -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 - player_mutes = true, --- @setting player_mutes Alert when a player is muted or unmuted - player_kicks = true, --- @setting player_kicks Alert when a player is kicked - player_promotes = false, --- @setting player_promotes Alert when a player is promoted or demoted - player_jail = true, --- @setting player_jail Alert when a player is jailed or unjailed - logged_commands = Feature.set{ --- @setting logged_commands Alert when a player uses one of these commands +local feature, config = Feature.register("discord_alerts", { + show_playtime = true, -- Add the playtime of players to alerts + entity_protection = true, -- Alert when a player repeatedly removes protected entities + player_bans = true, -- Alert when a player is banned or unbanned + player_mutes = true, -- Alert when a player is muted or unmuted + player_kicks = true, -- Alert when a player is kicked + player_promotes = false, -- Alert when a player is promoted or demoted + player_jail = true, -- Alert when a player is jailed or unjailed + logged_commands = Feature.set{ -- Alert when a player uses one of these commands "config", "purge", "c", "command", "silent-command", "measured-command", "banlist", "permissions", "editor", "cheat", }, }) -local config = feature.config --- Wrap an event handler so it only runs while the alert is enabled --- @param setting string diff --git a/exp_scenario/module/control/mine_depletion.lua b/exp_scenario/module/control/mine_depletion.lua index 188e6b39..77efb618 100644 --- a/exp_scenario/module/control/mine_depletion.lua +++ b/exp_scenario/module/control/mine_depletion.lua @@ -5,12 +5,11 @@ Marks mining drills for deconstruction when resources deplete local Async = require("modules/exp_util/async") local Feature = require("modules/exp_scenario/features") -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 feature, config = Feature.register("mine_depletion", { + fluid = true, -- When true, checks for for fluid pipes when removing miners + chest = true, -- When true, checks for for chest when removing miners + beacon = true, -- When true, checks for for beacon when removing miners }) -local config = feature.config local floor = math.floor diff --git a/exp_scenario/module/control/nuke_protection.lua b/exp_scenario/module/control/nuke_protection.lua index d661b278..e8638ce4 100644 --- a/exp_scenario/module/control/nuke_protection.lua +++ b/exp_scenario/module/control/nuke_protection.lua @@ -6,11 +6,9 @@ local ExpUtil = require("modules/exp_util") local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -local feature = Feature.register("nuke_protection", { - ignore_admins = true, --- @setting ignore_admins Admins can hold banned items - banned_items = Feature.set{ "atomic-bomb" }, --- @setting banned_items Items which are removed from the inventory of players without the bypass permission +local feature, config = Feature.register("nuke_protection", { + banned_items = Feature.set{ "atomic-bomb" }, -- Items which are removed from the inventory of players without the bypass permission }) -local config = feature.config --- The inventories which are checked and the events which trigger the check local inventories = { @@ -26,7 +24,6 @@ local inventories = { local function check_items(player, type) -- If the player has perms to be ignored, then they should be 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 = config.banned_items local items = {} --- @type LuaItemStack[] diff --git a/exp_scenario/module/control/popups.lua b/exp_scenario/module/control/popups.lua new file mode 100644 index 00000000..f25b5fba --- /dev/null +++ b/exp_scenario/module/control/popups.lua @@ -0,0 +1,92 @@ +--[[-- Control - Popups +Creates flying text for chat messages and for damage dealt to and by players +]] + +local FlyingText = require("modules/exp_util/flying_text") +local Feature = require("modules/exp_scenario/features") + +local feature, config = Feature.register("popups", { + show_player_messages = true, -- weather a message in chat will make a popup above them + show_player_mentions = true, -- weather a mentioned player will have a popup when mentioned in chat + show_player_damage = true, -- weather to show damage done by players + show_player_health = true, -- weather to show player health when attacked + damage_location_variance = 0.8, -- how close to the eade of an entity the popups will appear +}) + +local lower = string.lower +local find = string.find +local random = math.random +local floor = math.floor +local max = math.max + +--- Create a chat bubble when a player types a message +--- @param event EventData.on_console_chat +local function on_console_chat(event) + if not event.player_index then return end + local player = assert(game.get_player(event.player_index)) + local name = player.name + + -- Sends the message as text above them + if config.show_player_messages then + FlyingText.create_as_player{ + target_player = player, + text = { "exp_chat-popup.flying-text-message", name, event.message }, + } + end + + if not config.show_player_mentions then return end + + -- Loops over online players to see if they name is included + local search_string = lower(event.message) + for _, mentioned_player in ipairs(game.connected_players) do + if mentioned_player.index ~= player.index then + if find(search_string, lower(mentioned_player.name), 1, true) then + FlyingText.create_as_player{ + target_player = mentioned_player, + text = { "exp_chat-popup.flying-text-ping", name }, + } + end + end + end +end + +--- Called when entity entity is damaged including the player character +--- @param event EventData.on_entity_damaged +local function on_entity_damaged(event) + local message + local cause = event.cause + local entity = event.entity + + -- Check which message to display + if config.show_player_health and entity.name == "character" then + message = { "exp_damage-popup.flying-text-health", floor(entity.health) } + elseif config.show_player_damage and entity.name ~= "character" and cause and cause.name == "character" then + message = { "exp_damage-popup.flying-text-damage", floor(event.original_damage_amount) } + end + + -- Outputs the message as floating text + if message then + local entity_position = entity.position + local entity_radius = max(1, entity.get_radius()) + local offset = (random() - 0.5) * entity_radius * config.damage_location_variance + local position = { x = entity_position.x + offset, y = entity_position.y - entity_radius } + + local health_percentage = assert(entity.get_health_ratio()) + local color = { r = 1 - health_percentage, g = health_percentage, b = 0 } + + FlyingText.create{ + text = message, + position = position, + color = color, + } + end +end + +local e = defines.events + +return feature:guard{ + events = { + [e.on_console_chat] = on_console_chat, + [e.on_entity_damaged] = on_entity_damaged, + }, +} diff --git a/exp_scenario/module/control/protection.lua b/exp_scenario/module/control/protection.lua index 42769268..27b58c84 100644 --- a/exp_scenario/module/control/protection.lua +++ b/exp_scenario/module/control/protection.lua @@ -6,20 +6,18 @@ local Storage = require("modules/exp_util/storage") local Feature = require("modules/exp_scenario/features") local Roles = require("modules/exp_roles") -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 - always_protected_names = Feature.set{}, --- @setting always_protected_names Names of entities which are always protected - always_protected_types = Feature.set{ --- @setting always_protected_types Types of entities which are always protected +local feature, config = Feature.register("protection", { + repeat_count = 5, -- Number of protected entities that must be removed within repeat_minutes in order to trigger repeated removal protection + repeat_minutes = 20, -- The length of time, in minutes, that protected removals will be remembered for + always_protected_names = Feature.set{}, -- Names of entities which are always protected + always_protected_types = Feature.set{ -- Types of entities which are always protected "boiler", "generator", "offshore-pump", "reactor", "heat-exchanger", "heat-pipe", "fusion-reactor", "fusion-generator", "power-switch", "rocket-silo", }, - always_trigger_repeat_names = Feature.set{}, --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection - always_trigger_repeat_types = Feature.set{ --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection + always_trigger_repeat_names = Feature.set{}, -- Names of entities which always trigger repeated removal protection + always_trigger_repeat_types = Feature.set{ -- Types of entities which always trigger repeated removal protection "reactor", "fusion-reactor", "rocket-silo", }, }) -local config = feature.config local format_string = string.format local floor = math.floor @@ -32,6 +30,8 @@ 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(), + --- The config of the feature, read only + config = config, --- @package events = {}, --- @package @@ -45,29 +45,6 @@ local Protection = { --- @field last uint Tick of the last protected removal --- @field count number Protected removals since the last repeat violation ---- Get the keys of a set as a list, for the filters of find_entities_filtered ---- @param set table ---- @return string[] -local function set_to_list(set) - local list = {} - for item in pairs(set) do - list[#list + 1] = item - end - return list -end - ---- Names of entities which are always protected ---- @return string[] -function Protection.get_protected_entity_names() - return set_to_list(config.always_protected_names) -end - ---- Types of entities which are always protected ---- @return string[] -function Protection.get_protected_entity_types() - return set_to_list(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 local repeats = {} --- @type table Keyed by player name @@ -116,10 +93,17 @@ function Protection.remove_entity(entity) end --- Get the protected entities on a surface, always protected entities are not included +-- Entities removed while the feature was disabled, or without an event, are forgotten here --- @param surface LuaSurface --- @return table function Protection.get_entities(surface) - return protected_entities[surface.index] or {} + local entities = protected_entities[surface.index] or {} + for key, entity in pairs(entities) do + if not entity.valid then + entities[key] = nil + end + end + return entities end --- Check if an entity is protected, either directly or by its name or type @@ -129,7 +113,13 @@ function Protection.is_entity_protected(entity) if config.always_protected_names[entity.name] or 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 + local key = Protection.get_entity_key(entity) + local protected = entities[key] + if protected and not protected.valid then + entities[key] = nil + return false + end + return protected == entity end --- Protect every position within an area @@ -178,12 +168,11 @@ function Protection.is_position_protected(surface, position) return false end ---- Players are never checked against their own entities, and can be excluded by permission or admin status +--- Players are never checked against their own entities, and can be excluded by permission --- @param player LuaPlayer --- @param entity LuaEntity --- @return boolean local function is_ignored(player, entity) - if config.ignore_admins and player.admin then return true end if entity.last_user == nil or entity.last_user.index == player.index then return true end if Roles.player_has_permission(player, "exp_scenario.bypass.entity_protection") then return true end return false @@ -216,10 +205,8 @@ end local function on_pre_player_mined_item(event) local entity = event.entity local player = game.players[event.player_index] - -- Protected entities are still forgotten while disabled, only the violations stop if - config.enabled - and not is_ignored(player, entity) + not is_ignored(player, entity) and (Protection.is_entity_protected(entity) or Protection.is_position_protected(entity.surface, entity.position)) then raise_violation(event, player) @@ -253,4 +240,4 @@ Protection.events[e.on_entity_died] = on_entity_removed Protection.events[e.script_raised_destroy] = on_entity_removed Protection.on_nth_tick[3600 * 5] = clear_old_repeats -return Protection +return feature:guard(Protection) diff --git a/exp_scenario/module/control/station_auto_name.lua b/exp_scenario/module/control/station_auto_name.lua index 142f4a31..061cc7ab 100644 --- a/exp_scenario/module/control/station_auto_name.lua +++ b/exp_scenario/module/control/station_auto_name.lua @@ -4,12 +4,11 @@ Automatically name stations when they are placed based on closest resource and d local Feature = require("modules/exp_scenario/features") -local feature = Feature.register("station_auto_name", { - --- @setting station_name the name given to new stations, placeholders: +local feature, config = Feature.register("station_auto_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 = { diff --git a/exp_scenario/module/features.lua b/exp_scenario/module/features.lua index dd5748a6..32387e88 100644 --- a/exp_scenario/module/features.lua +++ b/exp_scenario/module/features.lua @@ -12,13 +12,12 @@ features.ts. --- Registering a feature, small configs live at the top of the file which uses them: local Feature = require("modules/exp_scenario/features") -local feature = Feature.register("my_feature", { +local feature, config = Feature.register("my_feature", { show_message = true, role_id = Feature.optional("number"), item_names = Feature.set{ "iron-plate" }, surface_names = Feature.list{ "nauvis" }, }) -local config = feature.config --- Handlers do nothing while the feature is disabled: return feature:guard{ @@ -48,11 +47,7 @@ local Storage = require("modules/exp_util/storage") --- @field default any --- @field type string "boolean", "number", "string", "list" or "set" of strings, or a type which can not be overridden ---- @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 +--- @class ExpScenario.Features local Feature = { _registered = {}, --- @type table events = { @@ -62,6 +57,14 @@ local Feature = { }, } +--- The methods of every feature, kept apart from the lib so they are not called on it by mistake +--- @class ExpScenario.Feature +--- @field name string +--- @field config table Changed in place when the config is updated +--- @field fields table The values in the config by path +--- @field overrides table The overrides from the controller by path, restored from storage on load +Feature._prototype = {} + --- @class EventData.ExpScenario.on_config_updated : EventData --- @field feature_name string --- @field path string The path of the value within the config, "enabled" when the feature is enabled or disabled @@ -69,7 +72,7 @@ local Feature = { --- @field new_value any local Feature_mt = { - __index = Feature, + __index = Feature._prototype, } --- The overrides of each feature by name, only these are stored because the rest is rebuilt when the file loads @@ -173,7 +176,8 @@ end --- Register a feature and its config, features are enabled unless the config says otherwise --- @param name string --- @param config table? ---- @return ExpScenario.Feature +--- @return ExpScenario.Feature feature +--- @return table config The same table as feature.config function Feature.register(name, config) assert(Feature._registered[name] == nil, "Feature already registered: " .. name) config = config or {} @@ -192,7 +196,7 @@ function Feature.register(name, config) }, Feature_mt) --[[@as ExpScenario.Feature]] Feature._registered[name] = feature - return feature + return feature, config end --- Get a registered feature by name @@ -213,26 +217,36 @@ local function is_valid(field, value) return type(value) == field.type and (field.type == "boolean" or field.type == "number" or field.type == "string") end +--- Set a value to its override, or its default when there is no valid override +--- @package +--- @param path string +--- @param field ExpScenario.FeatureField +--- @return any # The value which was set +function Feature._prototype:_apply_field(path, field) + local value = self.overrides[path] + if value == nil then + value = field.default + elseif not is_valid(field, value) then + log("[WARNING] Invalid override for " .. self.name .. "." .. path .. ", using the default") + value = field.default + elseif field.type == "set" then + value = list_to_set(value) + end + field.parent[field.key] = value + return value +end + --- Set every value to its override, or its default when there is no valid override --- @package -function Feature:_apply() +function Feature._prototype:_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 - elseif field.type == "set" then - value = list_to_set(value) - end - field.parent[field.key] = value + self:_apply_field(path, field) end end --- Check if the feature is enabled --- @return boolean -function Feature:is_enabled() +function Feature._prototype:is_enabled() return self.config.enabled end @@ -240,7 +254,7 @@ end --- @generic T : table --- @param lib T --- @return T -function Feature:guard(lib) +function Feature._prototype:guard(lib) local config = self.config for _, handlers in pairs{ lib.events or {}, lib.on_nth_tick or {} } do for key, handler in pairs(handlers) do @@ -258,7 +272,8 @@ 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) +function Feature._prototype:update_config(enabled, values) + local old_overrides = self.overrides local overrides = { enabled = enabled } for path, value in pairs(values) do if self.fields[path] == nil then @@ -267,27 +282,34 @@ function Feature:update_config(enabled, values) overrides[path] = value end 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, - }) + -- Only a value whose override was added, removed, or replaced can change + local paths = {} + for path in pairs(overrides) do + paths[path] = true + end + for path in pairs(old_overrides) do + -- A save can hold overrides for settings which no longer exist + if self.fields[path] then + paths[path] = true end end + + local updates = {} + for path in pairs(paths) do + local field = self.fields[path] + local old_value = field.parent[field.key] + local new_value = self:_apply_field(path, field) + if not values_equal(old_value, new_value) then + updates[#updates + 1] = { feature_name = self.name, path = path, old_value = old_value, new_value = new_value } + end + end + + for _, update in ipairs(updates) do + script.raise_event(Feature.events.on_config_updated, update) + end end --- @class ExpScenario.FeatureUpdate diff --git a/exp_scenario/test/module/env.lua b/exp_scenario/test/module/env.lua index 0121e16f..813868f3 100644 --- a/exp_scenario/test/module/env.lua +++ b/exp_scenario/test/module/env.lua @@ -13,7 +13,7 @@ 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 Feature ExpScenario.Feature A fresh copy of the features module +--- @field Feature ExpScenario.Features 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): ExpScenario.Feature Load module/config/.lua diff --git a/exp_scenario/test/module/features.lua b/exp_scenario/test/module/features.lua index fb85bdaf..93751e1e 100644 --- a/exp_scenario/test/module/features.lua +++ b/exp_scenario/test/module/features.lua @@ -135,11 +135,32 @@ Suite.test("optional() declares a value without a default", function(env) end) +Suite.test("register() returns the config as well", function(env) + local feature, config = env.Feature.register("test", { flag = true }) + Suite.eq(config == feature.config, true, "the same table") +end) + +Suite.test("update_config() raises when an override is removed", function(env) + local feature = env.Feature.register("test", { flag = true, count = 1 }) + feature:update_config(true, { flag = false }) + env.events = {} + feature:update_config(true, {}) + Suite.eq(updates(env), { { "test", "flag", false, true } }, "the value goes back to its default") +end) + +Suite.test("update_config() skips stored overrides for settings which no longer exist", function(env) + local feature = env.Feature.register("test", { flag = true }) + env.on_load{ test = { enabled = true, gone = 1 } } + feature:update_config(true, { flag = false }) + Suite.eq(updates(env), { { "test", "flag", true, false } }, "only the known value is compared") + Suite.eq(env.storage.test, { enabled = true, flag = false }, "and the stale key is not kept") +end) + 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") + local feature = env.load_config("spawn_area") + feature:update_config(true, { ["turrets.enabled"] = false }) + Suite.eq(feature.config.turrets.enabled, false, "spawn_area can be changed") + Suite.eq(feature.config.turrets.ammo_type, "uranium-rounds-magazine", "other values keep their default") end) return Suite.run()