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 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Fable 5.1 committed 2026-10-03 07:35:58 +00:00
1 parent bff6a1c612
commit f90b8fcee1
21 files changed
+289 -286

No files matched your search

-3
View File
@@ -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", []),
+2 -3
View File
@@ -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 = {}
@@ -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<string, true>
--- @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)
@@ -27,7 +27,7 @@ local afk_time_units = {
return Feature.register("chat_auto_reply", {
--- @type table<string, LocalisedString | fun(player: LuaPlayer, is_command: boolean): LocalisedString>
messages = { --- @setting messages will trigger when ever the word is said
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<string, LocalisedString | fun(player: LuaPlayer, is_command: boolean): LocalisedString>
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" },
-12
View File
@@ -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
})
+30 -30
View File
@@ -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 },
+1 -2
View File
@@ -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"))
+7 -8
View File
@@ -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
@@ -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
@@ -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,
},
}
@@ -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,
},
}
@@ -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 }
+9 -10
View File
@@ -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
@@ -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
@@ -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[]
+92
View File
@@ -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,
},
}
+27 -40
View File
@@ -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<string, true>
--- @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<uint, table<string, LuaEntity>> Keyed by surface index then entity key
local protected_areas = {} --- @type table<uint, table<string, BoundingBox>> Keyed by surface index then area key
local repeats = {} --- @type table<string, ExpScenario_Protection.Repeat> 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<string, LuaEntity>
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)
@@ -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 = {
+62 -40
View File
@@ -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<string, ExpScenario.FeatureField> The values in the config by path
--- @field overrides table<string, any> The overrides from the controller by path, restored from storage on load
--- @class ExpScenario.Features
local Feature = {
_registered = {}, --- @type table<string, ExpScenario.Feature>
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<string, ExpScenario.FeatureField> The values in the config by path
--- @field overrides table<string, any> The overrides from the controller by path, restored from storage on load
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<string, boolean | number | string | string[]>
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
+1 -1
View File
@@ -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/<name>.lua
+25 -4
View File
@@ -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()