diff --git a/config/_file_loader.lua b/config/_file_loader.lua index 563abc88..10bd035a 100644 --- a/config/_file_loader.lua +++ b/config/_file_loader.lua @@ -30,6 +30,7 @@ return { 'modules.commands.home', 'modules.commands.connect', 'modules.commands.last-location', + 'modules.commands.protection', 'modules.commands.spectate', 'modules.commands.search', diff --git a/config/discord_alerts.lua b/config/discord_alerts.lua index 1890aa4b..be3bceca 100644 --- a/config/discord_alerts.lua +++ b/config/discord_alerts.lua @@ -2,6 +2,7 @@ -- @config Discord-Alerts return { + entity_protection=true, player_reports=true, player_warnings=true, player_bans=true, diff --git a/config/expcore/roles.lua b/config/expcore/roles.lua index 6788525a..8200287d 100644 --- a/config/expcore/roles.lua +++ b/config/expcore/roles.lua @@ -99,6 +99,8 @@ Roles.new_role('Trainee','TrMod') 'command/give-warning', 'command/get-warnings', 'command/get-reports', + 'command/protect-entity', + 'command/protect-area', 'command/jail', 'command/unjail', 'command/kick', @@ -219,7 +221,8 @@ Roles.new_role('Regular','Reg') 'command/rainbow', 'command/go-to-spawn', 'command/me', - 'standard-decon' + 'standard-decon', + 'bypass-entity-protection' } :set_auto_assign_condition(function(player) if player.online_time >= hours3 then diff --git a/config/protection.lua b/config/protection.lua new file mode 100644 index 00000000..6f051c37 --- /dev/null +++ b/config/protection.lua @@ -0,0 +1,19 @@ +return { + ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter + ignore_permission = 'bypass-entity-protection', --- @setting ignore_permission Players with this permission will be ignored by the protection filter, leave nil if expcore.roles is not used + repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_lifetime in order to trigger repeated removal protection + repeat_lifetime = 3600*20, --- @setting repeat_lifetime The length of time, in ticks, that protected removals will be remembered for + refresh_rate = 3600*5, --- @setting refresh_rate How often the age of protected removals are checked against repeat_lifetime + always_protected_names = { --- @setting always_protected_names Names of entities which are always protected + + }, + always_protected_types = { --- @setting always_protected_types Types of entities which are always protected + 'boiler', 'generator', 'offshore-pump', 'power-switch', 'reactor', 'rocket-silo' + }, + always_trigger_repeat_names = { --- @setting always_trigger_repeat_names Names of entities which always trigger repeated removal protection + + }, + always_trigger_repeat_types = { --- @setting always_trigger_repeat_types Types of entities which always trigger repeated removal protection + 'reactor', 'rocket-silo' + } +} \ No newline at end of file diff --git a/locale/en/commands.cfg b/locale/en/commands.cfg index b4e21630..e1a4cc95 100644 --- a/locale/en/commands.cfg +++ b/locale/en/commands.cfg @@ -85,6 +85,16 @@ none-matching=No servers were found with that name, if you used an address pleas [expcom-lastlocation] response=Last location of __1__ was [gps=__2__,__3__] +[expcom-protection] +entered-entity-selection=Entered entity selection, select entites to protect, hold shift to remove protection. +entered-area-selection=Entered area selection, select areas to protect, hold shift to remove protection. +protected-entities=__1__ entities have been protected. +unprotected-entities=__1__ entities have been unprotected. +already-protected=This area is already protected. +protected-area=This area is now protected. +unprotected-area=This area is now unprotected. +repeat-offence=__1__ has removed __2__ at [gps=__3__,__4__] + [expcom-spectate] follow-self=You can not follow yourself diff --git a/modules/addons/discord-alerts.lua b/modules/addons/discord-alerts.lua index d1c4646c..7fb53a4f 100644 --- a/modules/addons/discord-alerts.lua +++ b/modules/addons/discord-alerts.lua @@ -76,6 +76,21 @@ local function emit_event(args) }) end +--- Repeated protected entity mining +if config.entity_protection then + local EntityProtection = require 'modules.control.protection' --- @dep modules.control.protection + Event.add(EntityProtection.events.on_repeat_violation, function(event) + local player_name = get_player_name(event) + emit_event{ + title='Entity Protection', + description='A player removed protected entities', + color=Colors.yellow, + ['Player']=''..player_name, + ['Entity']=''..event.entity.name + } + end) +end + --- Reports added and removed if config.player_reports then local Reports = require 'modules.control.reports' --- @dep modules.control.reports diff --git a/modules/commands/protection.lua b/modules/commands/protection.lua new file mode 100644 index 00000000..01066d35 --- /dev/null +++ b/modules/commands/protection.lua @@ -0,0 +1,216 @@ +--[[-- Commands Module - Protection + - Adds commands that can add and remove protection + @commands Protection +]] + +local Event = require 'utils.event' --- @dep utils.event +local Global = require 'utils.global' --- @dep utils.global +local Roles = require 'expcore.roles' --- @dep expcore.roles +local Commands = require 'expcore.commands' --- @dep expcore.commands +local format_chat_player_name = _C.format_chat_player_name --- @dep expcore.common +local EntityProtection = require 'modules.control.protection' --- @dep modules.control.protection +local Selection = require 'modules.control.selection' --- @dep modules.control.selection + +local SelectionProtectEntity = 'ProtectEntity' +local SelectionProtectArea = 'ProtectArea' + +local renders = {} -- Stores all renders for a player +Global.register({ + renders = renders +}, function(tbl) + renders = tbl.renders +end) + +--- Test if a point is inside an aabb +local function aabb_point_enclosed(point, aabb) + return point.x >= aabb.left_top.x and point.y >= aabb.left_top.y + and point.x <= aabb.right_bottom.x and point.y <= aabb.right_bottom.y +end + +--- Test if an aabb is inside another aabb +local function aabb_area_enclosed(aabbOne, aabbTwo) + return aabb_point_enclosed(aabbOne.left_top, aabbTwo) + and aabb_point_enclosed(aabbOne.right_bottom, aabbTwo) +end + +--- Align an aabb to the grid by expanding it +local function aabb_align_expand(aabb) + return { + left_top = { x = math.floor(aabb.left_top.x), y = math.floor(aabb.left_top.y) }, + right_bottom = { x = math.ceil(aabb.right_bottom.x), y = math.ceil(aabb.right_bottom.y) } + } +end + +--- Get the key used in protected_entities +local function get_entity_key(entity) + return string.format('%i,%i', math.floor(entity.position.x), math.floor(entity.position.y)) +end + +--- Get the key used in protected_areas +local function get_area_key(area) + return string.format('%i,%i', math.floor(area.left_top.x), math.floor(area.left_top.y)) +end + + +--- Show a protected entity to a player +local function show_protected_entity(player, entity) + local key = get_entity_key(entity) + if renders[player.index][key] then return end + local rb = entity.selection_box.right_bottom + local render_id = rendering.draw_sprite{ + sprite = 'utility/notification', + target = entity, + target_offset = { + (rb.x-entity.position.x)*0.75, + (rb.y-entity.position.y)*0.75 + }, + x_scale = 2, + y_scale = 2, + surface = entity.surface, + players = { player } + } + renders[player.index][key] = render_id +end + +--- Show a protected area to a player +local function show_protected_area(player, surface, area) + local key = get_area_key(area) + if renders[player.index][key] then return end + local render_id = rendering.draw_rectangle{ + color = {1, 1, 0, 0.5}, + filled = false, + width = 3, + left_top = area.left_top, + right_bottom = area.right_bottom, + surface = surface, + players = { player } + } + renders[player.index][key] = render_id +end + +--- Remove a render object for a player +local function remove_render(player, key) + local render = renders[player.index][key] + if render and rendering.is_valid(render) then rendering.destroy(render) end + renders[player.index][key] = nil +end + +--- Toggles entity protection selection +-- @command protect-entity +Commands.new_command('protect-entity', 'Toggles entity protection selection, hold shift to remove protection') +:add_alias('pe') +:register(function(player) + if Selection.is_selecting(player, SelectionProtectEntity) then + Selection.stop(player) + else + Selection.start(player, SelectionProtectEntity) + return Commands.success{'expcom-protection.entered-entity-selection'} + end +end) + +--- Toggles area protection selection +-- @command protect-area +Commands.new_command('protect-area', 'Toggles area protection selection, hold shift to remove protection') +:add_alias('pa') +:register(function(player) + if Selection.is_selecting(player, SelectionProtectArea) then + Selection.stop(player) + else + Selection.start(player, SelectionProtectArea) + return Commands.success{'expcom-protection.entered-area-selection'} + end +end) + +--- When an area is selected to add protection to entities +Selection.on_selection(SelectionProtectEntity, function(event) + local player = game.get_player(event.player_index) + for _, entity in ipairs(event.entities) do + EntityProtection.add_entity(entity) + show_protected_entity(player, entity) + end + player.print{'expcom-protection.protected-entities', #event.entities} +end) + +--- When an area is selected to remove protection from entities +Selection.on_alt_selection(SelectionProtectEntity, function(event) + local player = game.get_player(event.player_index) + for _, entity in ipairs(event.entities) do + EntityProtection.remove_entity(entity) + remove_render(player, get_entity_key(entity)) + end + player.print{'expcom-protection.unprotected-entities', #event.entities} +end) + +--- When an area is selected to add protection to the area +Selection.on_selection(SelectionProtectArea, function(event) + local area = aabb_align_expand(event.area) + local areas = EntityProtection.get_areas(event.surface) + local player = game.get_player(event.player_index) + for _, next_area in pairs(areas) do + if aabb_area_enclosed(area, next_area) then + return player.print{'expcom-protection.already-protected'} + end + end + EntityProtection.add_area(event.surface, area) + show_protected_area(player, event.surface, area) + player.print{'expcom-protection.protected-area'} +end) + +--- When an area is selected to remove protection from the area +Selection.on_alt_selection(SelectionProtectArea, function(event) + local area = aabb_align_expand(event.area) + local areas = EntityProtection.get_areas(event.surface) + local player = game.get_player(event.player_index) + for _, next_area in pairs(areas) do + if aabb_area_enclosed(next_area, area) then + EntityProtection.remove_area(event.surface, next_area) + player.print{'expcom-protection.unprotected-area'} + remove_render(player, get_area_key(next_area)) + end + end +end) + +--- When selection starts show all protected entities and protected areas +Event.add(Selection.events.on_player_selection_start, function(event) + if event.selection ~= SelectionProtectEntity and event.selection ~= SelectionProtectArea then return end + local player = game.get_player(event.player_index) + local surface = player.surface + renders[player.index] = {} + -- Show protected entities + local entities = EntityProtection.get_entities(surface) + for _, entity in pairs(entities) do + show_protected_entity(player, entity) + end + -- Show always protected entities by name + if #EntityProtection.protected_entity_names > 0 then + for _, entity in pairs(surface.find_entities_filtered{ name = EntityProtection.protected_entity_names, force = player.force }) do + show_protected_entity(player, entity) + end + end + -- Show always protected entities by type + if #EntityProtection.protected_entity_types > 0 then + for _, entity in pairs(surface.find_entities_filtered{ type = EntityProtection.protected_entity_types, force = player.force }) do + show_protected_entity(player, entity) + end + end + -- Show protected areas + local areas = EntityProtection.get_areas(surface) + for _, area in pairs(areas) do + show_protected_area(player, surface, area) + end +end) + +--- When selection ends hide protected entities and protected areas +Event.add(Selection.events.on_player_selection_end, function(event) + if event.selection ~= SelectionProtectEntity and event.selection ~= SelectionProtectArea then return end + for _, id in pairs(renders[event.player_index]) do + if rendering.is_valid(id) then rendering.destroy(id) end + end + renders[event.player_index] = nil +end) + +--- When there is a repeat offence print it in chat +Event.add(EntityProtection.events.on_repeat_violation, function(event) + local player_name = format_chat_player_name(event.player_index) + Roles.print_to_roles_higher('Regular', {'expcom-protection.repeat-offence', player_name, event.entity.localised_name, event.entity.position.x, event.entity.position.y}) +end) \ No newline at end of file diff --git a/modules/control/protection.lua b/modules/control/protection.lua new file mode 100644 index 00000000..61b5f5c6 --- /dev/null +++ b/modules/control/protection.lua @@ -0,0 +1,206 @@ +--[[-- Control Module - Protection + - Controls protected entities + @control Protection + @alias Protection +]] + +local Global = require 'utils.global' --- @dep utils.global +local Event = require 'utils.event' --- @dep utils.event +local config = require 'config.protection' --- @dep config.protection +local EntityProtection = { + protected_entity_names = table.deep_copy(config.always_protected_names), + protected_entity_types = table.deep_copy(config.always_protected_types), + events = { + --- When a player mines a protected entity + -- @event on_player_mined_protected + -- @tparam number player_index the player index of the player who got mined the entity + -- @tparam LuaEntity entity the entity which was mined + on_player_mined_protected = script.generate_event_name(), + --- When a player repeatedly mines protected entities + -- @event on_repeat_violation + -- @tparam number player_index the player index of the player who got mined the entities + -- @tparam LuaEntity entity the last entity which was mined + on_repeat_violation = script.generate_event_name(), + } +} + +-- Convert config tables into lookup tables +for _, config_key in ipairs{'always_protected_names', 'always_protected_types', 'always_trigger_repeat_names', 'always_trigger_repeat_types'} do + local tbl = config[config_key] + for key, value in ipairs(tbl) do + tbl[key] = nil + tbl[value] = true + end +end + +-- Require roles if a permission is assigned in the config +local Roles +if config.ignore_permission then + Roles = require 'expcore.roles' --- @dep expcore.roles +end + +----- Global Variables ----- +--- Variables stored in the global table + +local protected_entities = {} -- All entities which are protected +local protected_areas = {} -- All areas which are protected +local repeats = {} -- Stores repeat removals by players + +Global.register({ + protected_entities = protected_entities, + protected_areas = protected_areas, + repeats = repeats +}, function(tbl) + protected_entities = tbl.protected_entities + protected_areas = tbl.protected_areas + repeats = tbl.repeats +end) + +----- Local Functions ----- +--- Functions used internally to search and add to the protected array + +--- Get the key used in protected_entities +local function get_entity_key(entity) + return string.format('%i,%i', math.floor(entity.position.x), math.floor(entity.position.y)) +end + +--- Get the key used in protected_areas +local function get_area_key(area) + return string.format('%i,%i', math.floor(area.left_top.x), math.floor(area.left_top.y)) +end + +--- Check if an entity is always protected +local function check_always_protected(entity) + return config.always_protected_names[entity.name] or config.always_protected_types[entity.type] or false +end + +--- Check if an entity always triggers repeat protection +local function check_always_trigger_repeat(entity) + return config.always_trigger_repeat_names[entity.name] or config.always_trigger_repeat_types[entity.type] or false +end + +----- Public Functions ----- +--- Functions used to add and remove protected entities + +--- Add an entity to the protected list +function EntityProtection.add_entity(entity) + local entities = protected_entities[entity.surface.index] + if not entities then + entities = {} + protected_entities[entity.surface.index] = entities + end + entities[get_entity_key(entity)] = entity +end + +--- Remove an entity from the protected list +function EntityProtection.remove_entity(entity) + local entities = protected_entities[entity.surface.index] + if not entities then return end + entities[get_entity_key(entity)] = nil +end + +--- Get all protected entities on a surface +function EntityProtection.get_entities(surface) + return protected_entities[surface.index] or {} +end + +--- Check if an entity is protected +function EntityProtection.is_entity_protected(entity) + if check_always_protected(entity) then return true end + local entities = protected_entities[entity.surface.index] + if not entities then return false end + return entities[get_entity_key(entity)] == entity +end + +--- Add an area to the protected list +function EntityProtection.add_area(surface, area) + local areas = protected_areas[surface.index] + if not areas then + areas = {} + protected_areas[surface.index] = areas + end + areas[get_area_key(area)] = area +end + +--- Remove an area from the protected list +function EntityProtection.remove_area(surface, area) + local areas = protected_areas[surface.index] + if not areas then return end + areas[get_area_key(area)] = nil +end + +--- Get all protected areas on a surface +function EntityProtection.get_areas(surface) + return protected_areas[surface.index] or {} +end + +--- Check if an entity is protected +function EntityProtection.is_position_protected(surface, position) + local areas = protected_areas[surface.index] + if not areas then return false end + for _, area in pairs(areas) do + if area.left_top.x <= position.x and area.left_top.y <= position.y + and area.right_bottom.x >= position.x and area.right_bottom.y >= position.y + then + return true + end + end + return false +end + +----- Events ----- +--- All events registered by this module + +--- Raise events for protected entities +Event.add(defines.events.on_pre_player_mined_item, function(event) + local entity = event.entity + local player = game.get_player(event.player_index) + -- Check if the player should be ignored + if config.ignore_admins and player.admin then return end + if entity.last_user.index == player.index then return end + if config.ignore_permission and Roles.player_allowed(player, config.ignore_permission) then return end + + -- Check if the entity is protected + if EntityProtection.is_entity_protected(entity) + or EntityProtection.is_position_protected(entity.surface, entity.position) + then + -- Update repeats + local player_repeats = repeats[player.name] + if not player_repeats then + player_repeats = { last = game.tick, count = 0 } + repeats[player.name] = player_repeats + end + player_repeats.last = game.tick + player_repeats.count = player_repeats.count + 1 + -- Send events + event.name = EntityProtection.events.on_player_mined_protected + script.raise_event(EntityProtection.events.on_player_mined_protected, event) + if check_always_trigger_repeat(entity) or player_repeats.count >= config.repeat_count then + player_repeats.count = 0 -- Reset to avoid spamming of events + event.name = EntityProtection.events.on_repeat_violation + script.raise_event(EntityProtection.events.on_repeat_violation, event) + end + end +end) + +--- Remove old repeats +Event.on_nth_tick(config.refresh_rate, function() + local old = game.tick - config.repeat_lifetime + for player_name, player_repeats in pairs(repeats) do + if player_repeats.last <= old then + repeats[player_name] = nil + end + end +end) + +--- When an entity is removed remove it from the protection list +local function event_remove_entity(event) + EntityProtection.remove_entity(event.entity) +end + +Event.add(defines.events.on_pre_player_mined_item, event_remove_entity) +Event.add(defines.events.on_robot_pre_mined, event_remove_entity) +Event.add(defines.events.on_entity_died, event_remove_entity) +Event.add(defines.events.script_raised_destroy, event_remove_entity) + +return EntityProtection \ No newline at end of file diff --git a/modules/control/selection.lua b/modules/control/selection.lua new file mode 100644 index 00000000..4939253b --- /dev/null +++ b/modules/control/selection.lua @@ -0,0 +1,172 @@ +--[[-- Control Module - Selection + - Controls players who have a selection planner, mostly event handlers + @control Selection + @alias Selection +]] + +local Event = require 'utils.event' --- @dep utils.event +local Global = require 'utils.global' --- @dep utils.global +local Selection = { + events = { + --- When a player enters selection mode + -- @event on_player_selection_start + -- @tparam number player_index the player index of the player who entered selection mode + -- @tparam string selection the name of the selection being made + on_player_selection_start = script.generate_event_name(), + --- When a player leaves selection mode + -- @event on_player_selection_end + -- @tparam number player_index the player index of the player who left selection mode + -- @tparam string selection the name of the selection which ended + on_player_selection_end = script.generate_event_name(), + } +} + +local selection_tool = { name='selection-tool' } + +local selections = {} +Global.register({ + selections = selections +}, function(tbl) + selections = tbl.selections +end) + +--- Let a player select an area by providing a selection planner +-- @tparam LuaPlayer player The player to place into selection mode +-- @tparam string selection_name The name of the selection to start, used with on_selection +-- @tparam[opt=false] boolean single_use When true the selection will stop after first use +function Selection.start(player, selection_name, single_use, ...) + if not player or not player.valid then return end + if selections[player.index] then + -- Raise the end event if a selection was already in progress + script.raise_event(Selection.events.on_player_selection_end, { + name = Selection.events.on_player_selection_end, + tick = game.tick, + player_index = player.index, + selection = selections[player.index].name + }) + end + + -- Set the selection data + selections[player.index] = { + name = selection_name, + arguments = { ... }, + single_use = single_use == true, + character = player.character + } + + -- Raise the event + script.raise_event(Selection.events.on_player_selection_start, { + name = Selection.events.on_player_selection_start, + tick = game.tick, + player_index = player.index, + selection = selection_name + }) + + -- Give a selection tool if one is not in use + if player.cursor_stack.is_selection_tool then return end + player.clear_cursor() -- Clear the current item + player.cursor_stack.set_stack(selection_tool) + + -- Make a slot to place the selection tool even if inventory is full + if not player.character then return end + player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1 + player.hand_location = { inventory = defines.inventory.character_main, slot = #player.get_main_inventory() } +end + +--- Stop a player selection by removing the selection planner +-- @tparam LuaPlayer player The player to exit out of selection mode +function Selection.stop(player) + if not selections[player.index] then return end + local character = selections[player.index].character + local selection = selections[player.index].name + selections[player.index] = nil + + -- Raise the event + script.raise_event(Selection.events.on_player_selection_end, { + name = Selection.events.on_player_selection_end, + tick = game.tick, + player_index = player.index, + selection = selection + }) + + -- Remove the selection tool + if player.cursor_stack.is_selection_tool then + player.cursor_stack.clear() + else + player.remove_item(selection_tool) + end + + -- Remove the extra slot + if character and character == player.character then + player.character_inventory_slots_bonus = player.character_inventory_slots_bonus - 1 + player.hand_location = nil + end +end + +--- Get the selection arguments for a player +-- @tparam LuaPlayer player The player to get the selection arguments for +function Selection.get_arguments(player) + if not selections[player.index] then return end + return selections[player.index].arguments +end + +--- Test if a player is selecting something +-- @tparam LuaPlayer player The player to test +-- @tparam[opt] string selection_name If given will only return true if the selection is this selection +function Selection.is_selecting(player, selection_name) + if selection_name ~= nil then + if not selections[player.index] then return false end + return selections[player.index].name == selection_name + else + return player.cursor_stack.is_selection_tool + end +end + +--- Filter on_player_selected_area to this custom selection, appends the selection arguments +-- @tparam string selection_name The name of the selection to listen for +-- @tparam function handler The event handler +function Selection.on_selection(selection_name, handler) + Event.add(defines.events.on_player_selected_area, function(event) + local selection = selections[event.player_index] + if not selection or selection.name ~= selection_name then return end + handler(event, unpack(selection.arguments)) + end) +end + +--- Filter on_player_alt_selected_area to this custom selection, appends the selection arguments +-- @param string selection_name The name of the selection to listen for +-- @param function handler The event handler +function Selection.on_alt_selection(selection_name, handler) + Event.add(defines.events.on_player_alt_selected_area, function(event) + local selection = selections[event.player_index] + if not selection or selection.name ~= selection_name then return end + handler(event, unpack(selection.arguments)) + end) +end + +--- Stop selection if the selection tool is removed from the cursor +Event.add(defines.events.on_player_cursor_stack_changed, function(event) + local player = game.get_player(event.player_index) + if player.cursor_stack.is_selection_tool then return end + Selection.stop(player) +end) +--- Stop selection after an event such as death or leaving the game +local function stop_after_event(event) + local player = game.get_player(event.player_index) + Selection.stop(player) +end + +Event.add(defines.events.on_pre_player_left_game, stop_after_event) +Event.add(defines.events.on_pre_player_died, stop_after_event) + +--- Stop selection after a single use if single_use was true during Selection.start +local function stop_after_use(event) + if not selections[event.player_index] then return end + if not selections[event.player_index].single_use then return end + stop_after_event(event) +end + +Event.add(defines.events.on_player_selected_area, stop_after_use) +Event.add(defines.events.on_player_alt_selected_area, stop_after_use) + +return Selection \ No newline at end of file