diff --git a/.gitignore b/.gitignore index bf2406f1..9ddd01dc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ dist/ node_modules/ .vscode .luarc.json +.tap/ diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua index aaf9984a..88fdec5e 100644 --- a/exp_legacy/module/config/_file_loader.lua +++ b/exp_legacy/module/config/_file_loader.lua @@ -22,10 +22,8 @@ return { --- GUI "modules.gui.warp-list", "modules.gui.vlayer", - "modules.gui._role_updates", "modules.graftorio.require", -- graftorio --- Config Files "config.expcore.permission_groups", -- loads some predefined permission groups - "config.expcore.roles", -- loads some predefined roles } diff --git a/exp_legacy/module/config/afk_kick.lua b/exp_legacy/module/config/afk_kick.lua index cb982ade..3e52552a 100644 --- a/exp_legacy/module/config/afk_kick.lua +++ b/exp_legacy/module/config/afk_kick.lua @@ -1,4 +1,4 @@ -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") return { admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time @@ -8,6 +8,7 @@ return { trust_time = 3600 * 60 * 10, --- @setting trust_time The time in ticks that a player must be online for to count as trusted update_time = 3600 * 30, --- @setting update_time How often in ticks the script checks for active players custom_active_check = function(player) - return Roles.get_player_highest_role(player).index <= Roles.get_role_from_any("Veteran").index + local veteran = Roles.get_role_by_name("Veteran") + return veteran ~= nil and not Roles.get_player_highest_role(player):is_lower_than(veteran) end, } diff --git a/exp_legacy/module/config/chat_reply.lua b/exp_legacy/module/config/chat_reply.lua index 5eabb22d..97e035d7 100644 --- a/exp_legacy/module/config/chat_reply.lua +++ b/exp_legacy/module/config/chat_reply.lua @@ -65,7 +65,7 @@ return { }, 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_permission = "command/chat-commands", --- @setting command_permission the permission used to allow command prefixes + command_permission = "exp_scenario.chat.commands", --- @setting command_permission the permission used to allow command prefixes command_prefix = "!", --- @setting 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 diff --git a/exp_legacy/module/config/gui/player_list_actions.lua b/exp_legacy/module/config/gui/player_list_actions.lua index c6bb968c..146338aa 100644 --- a/exp_legacy/module/config/gui/player_list_actions.lua +++ b/exp_legacy/module/config/gui/player_list_actions.lua @@ -7,7 +7,7 @@ local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles +local Roles = require("modules/exp_roles") local Reports = require("modules.exp_legacy.modules.control.reports") --- @dep modules.control.reports local Warnings = require("modules.exp_legacy.modules.control.warnings") --- @dep modules.control.warnings local Jail = require("modules.exp_legacy.modules.control.jail") --- @dep modules.control.jail @@ -21,15 +21,6 @@ local function set_accessors(player_getter, action_setter) get_selected_player, set_selected_action = player_getter, action_setter end --- auth that will only allow when on player's of lower roles -local function auth_lower_role(player, selected_player_name) - local player_highest = Roles.get_player_highest_role(player) - local action_player_highest = Roles.get_player_highest_role(selected_player_name) - if player_highest.index < action_player_highest.index then - return true - end -end - -- gets the action player and a coloured name for the action to be used on local function get_action_player(player) local selected_player = get_selected_player(player) --[[@as LuaPlayer]] @@ -94,7 +85,7 @@ local report_player = new_button("utility/spawn_flag", { "exp-gui_player-list.re if Reports.is_reported(selected_player.name, player.name) then player.print({ "exp-commands_report.already-reported" }, Colors.orange_red) else - set_selected_action(player, "command/report") + set_selected_action(player, "exp_scenario.command.create_report") end end) @@ -102,7 +93,10 @@ local function report_player_callback(player, reason) local selected_player, selected_player_color = get_action_player(player) local by_player_name_color = format_player_name(player) game.print{ "exp-commands_reports.response", selected_player_color, reason } - Roles.print_to_roles_higher("Trainee", { "exp-commands_reports.response-admin", selected_player_color, by_player_name_color, reason }) + local trainee = Roles.get_role_by_name("Trainee") + for _, role in ipairs(trainee and Roles.get_higher_roles(trainee) or {}) do + role:print{ "exp-commands_reports.response-admin", selected_player_color, by_player_name_color, reason } + end Reports.report_player(selected_player.name, player.name, reason) end @@ -110,7 +104,7 @@ end -- @element warn_player local warn_player = new_button("utility/spawn_flag", { "exp-gui_player-list.warn-player" }) :on_click(function(def, player, element) - set_selected_action(player, "command/give-warning") + set_selected_action(player, "exp_scenario.command.create_warning") end) local function warn_player_callback(player, reason) @@ -128,7 +122,7 @@ local jail_player = new_button("utility/multiplayer_waiting_icon", { "exp-gui_pl if Jail.is_jailed(selected_player.name) then player.print({ "exp-commands_jail.already-jailed", selected_player_color }, Colors.orange_red) else - set_selected_action(player, "command/jail") + set_selected_action(player, "exp_scenario.command.jail") end end) @@ -143,7 +137,7 @@ end -- @element kick_player local kick_player = new_button("utility/warning_icon", { "exp-gui_player-list.kick-player" }) :on_click(function(def, player, element) - set_selected_action(player, "command/kick") + set_selected_action(player, "exp_scenario.gui.player_list.kick") end) local function kick_player_callback(player, reason) @@ -155,7 +149,7 @@ end -- @element ban_player local ban_player = new_button("utility/danger_icon", { "exp-gui_player-list.ban-player" }) :on_click(function(def, player, element) - set_selected_action(player, "command/ban") + set_selected_action(player, "exp_scenario.gui.player_list.ban") end) local function ban_player_callback(player, reason) @@ -166,40 +160,40 @@ end return { set_accessors = set_accessors, buttons = { - ["command/teleport"] = { + ["exp_scenario.command.teleport"] = { auth = function(player, selected_player) return player.name ~= selected_player.name end, -- cant teleport to your self goto_player, bring_player, }, - ["command/report"] = { + ["exp_scenario.command.create_report"] = { auth = function(player, selected_player) if player == selected_player then return false end - if not Roles.player_allowed(player, "command/give-warning") then - return not Roles.player_has_flag(selected_player, "report-immune") + if not Roles.player_has_permission(player, "exp_scenario.command.create_warning") then + return not Roles.player_has_permission(selected_player, "exp_scenario.bypass.reports") end end, -- can report any player that isn't immune and you aren't able to give warnings reason_callback = report_player_callback, report_player, }, - ["command/give-warning"] = { - auth = auth_lower_role, -- warn a lower user, replaces report + ["exp_scenario.command.create_warning"] = { + auth = Roles.player_outranks, -- warn a lower user, replaces report reason_callback = warn_player_callback, warn_player, }, - ["command/jail"] = { - auth = auth_lower_role, + ["exp_scenario.command.jail"] = { + auth = Roles.player_outranks, reason_callback = jail_player_callback, jail_player, }, - ["command/kick"] = { - auth = auth_lower_role, + ["exp_scenario.gui.player_list.kick"] = { + auth = Roles.player_outranks, reason_callback = kick_player_callback, kick_player, }, - ["command/ban"] = { - auth = auth_lower_role, + ["exp_scenario.gui.player_list.ban"] = { + auth = Roles.player_outranks, reason_callback = ban_player_callback, ban_player, }, diff --git a/exp_legacy/module/config/gui/rockets.lua b/exp_legacy/module/config/gui/rockets.lua index 2d90778d..c1c7e53f 100644 --- a/exp_legacy/module/config/gui/rockets.lua +++ b/exp_legacy/module/config/gui/rockets.lua @@ -39,9 +39,9 @@ return { allow_zoom_to_map = true, --- @setting allow_zoom_to_map false will disable the zoom to map feature allow_remote_launch = true, --- @setting allow_remote_launch false removes the remote launch button for all players remote_launch_admins_only = false, --- @setting remote_launch_admins_only true will remove the remote launch button for all non (game) admins - remote_launch_role_permission = "gui/rocket-info/remote_launch", --- @setting remote_launch_role_permission value used by custom permission system to allow or disallow the button + remote_launch_role_permission = "exp_scenario.gui.rocket_info.remote_launch", --- @setting remote_launch_role_permission value used by custom permission system to allow or disallow the button allow_toggle_active = true, --- @setting allow_toggle_active false removes the remote toggle auto launch button for all players toggle_active_admins_only = false, --- @setting toggle_active_admins_only true will remove the toggle auto launch button for all non (game) admins - toggle_active_role_permission = "gui/rocket-info/toggle-active", --- @setting toggle_active_role_permission value used by custom permission system to allow or disallow the button + toggle_active_role_permission = "exp_scenario.gui.rocket_info.toggle_active", --- @setting toggle_active_role_permission value used by custom permission system to allow or disallow the button }, } diff --git a/exp_legacy/module/config/gui/tasks.lua b/exp_legacy/module/config/gui/tasks.lua index 91f6bdcd..969b6ab3 100644 --- a/exp_legacy/module/config/gui/tasks.lua +++ b/exp_legacy/module/config/gui/tasks.lua @@ -3,11 +3,11 @@ return { -- Adding tasks - allow_add_task = "all", --- @setting allow_add_task dictates who is allowed to add new tasks; values: all, admin, expcore.roles, none - expcore_roles_allow_add_task = "gui/task-list/add", --- @setting expcore_roles_allow_add_task if expcore.roles is used then this is the required permission + allow_add_task = "all", --- @setting allow_add_task dictates who is allowed to add new tasks; values: all, admin, exp_roles, none + exp_roles_allow_add_task = "exp_scenario.gui.task_list.add", --- @setting exp_roles_allow_add_task if exp_roles is used then this is the required permission -- Editing tasks - allow_edit_task = "expcore.roles", --- @setting allow_edit_task dictates who is allowed to edit existing tasks; values: all, admin, expcore.roles, none - expcore_roles_allow_edit_task = "gui/task-list/edit", --- @setting expcore_roles_allow_edit_task if expcore.roles is used then this is the required permission + allow_edit_task = "exp_roles", --- @setting allow_edit_task dictates who is allowed to edit existing tasks; values: all, admin, exp_roles, none + exp_roles_allow_edit_task = "exp_scenario.gui.task_list.edit", --- @setting exp_roles_allow_edit_task if exp_roles is used then this is the required permission user_can_edit_own_tasks = true, --- @settings if true then the user who made the task can edit it regardless of the allow_edit_task setting } diff --git a/exp_legacy/module/config/gui/warps.lua b/exp_legacy/module/config/gui/warps.lua index af1a8bab..fa6c142b 100644 --- a/exp_legacy/module/config/gui/warps.lua +++ b/exp_legacy/module/config/gui/warps.lua @@ -8,23 +8,23 @@ return { default_icon = { type = "item", name = "discharge-defense-equipment" }, --- @setting default_icon the default icon that will be used for warps -- Warp cooldowns - bypass_warp_cooldown = "expcore.roles", --- @setting bypass_warp_cooldown dictates who the warp cooldown is applied to; values: all, admin, expcore.roles, none - expcore_roles_bypass_warp_cooldown = "gui/warp-list/bypass-cooldown", --- @setting expcore_roles_bypass_warp_cooldown if expcore.roles is used then this is the required permission + bypass_warp_cooldown = "exp_roles", --- @setting bypass_warp_cooldown dictates who the warp cooldown is applied to; values: all, admin, exp_roles, none + exp_roles_bypass_warp_cooldown = "exp_scenario.gui.warp_list.bypass_cooldown", --- @setting exp_roles_bypass_warp_cooldown if exp_roles is used then this is the required permission cooldown_duration = 60, --- @setting cooldown_duration the duration of the warp cooldown in seconds -- Warp proximity - bypass_warp_proximity = "expcore.roles", --- @setting bypass_warp_proximity dictates who the warp proximity is applied to; values: all, admin, expcore.roles, none - expcore_roles_bypass_warp_proximity = "gui/warp-list/bypass-proximity", --- @setting expcore_roles_bypass_warp_proximity if expcore.roles is used then this is the required permission + bypass_warp_proximity = "exp_roles", --- @setting bypass_warp_proximity dictates who the warp proximity is applied to; values: all, admin, exp_roles, none + exp_roles_bypass_warp_proximity = "exp_scenario.gui.warp_list.bypass_proximity", --- @setting exp_roles_bypass_warp_proximity if exp_roles is used then this is the required permission standard_proximity_radius = 4, --- @setting standard_proximity_radius the minimum distance a player is allowed to be to a warp in order to use it spawn_proximity_radius = 20, --- @setting spawn_proximity_radius the minimum distance a player is allowed to be from they spawn point to use warps -- Adding warps - allow_add_warp = "expcore.roles", --- @setting allow_add_warp dictates who is allowed to add warps; values: all, admin, expcore.roles, none - expcore_roles_allow_add_warp = "gui/warp-list/add", --- @setting expcore_roles_allow_add_warp if expcore.roles is used then this is the required permission + allow_add_warp = "exp_roles", --- @setting allow_add_warp dictates who is allowed to add warps; values: all, admin, exp_roles, none + exp_roles_allow_add_warp = "exp_scenario.gui.warp_list.add", --- @setting exp_roles_allow_add_warp if exp_roles is used then this is the required permission -- Editing warps - allow_edit_warp = "expcore.roles", --- @setting allow_edit_warp dictates who is allowed to edit warps; values: all, admin, expcore.roles, none - expcore_roles_allow_edit_warp = "gui/warp-list/edit", --- @setting expcore_roles_allow_edit_warp if expcore.roles is used then this is the required permission + allow_edit_warp = "exp_roles", --- @setting allow_edit_warp dictates who is allowed to edit warps; values: all, admin, exp_roles, none + exp_roles_allow_edit_warp = "exp_scenario.gui.warp_list.edit", --- @setting exp_roles_allow_edit_warp if exp_roles is used then this is the required permission user_can_edit_own_warps = false, --- @settings user_can_edit_own_warps if true then the user who made the warp can edit it regardless of the allow_edit_warp setting -- Warp area generation diff --git a/exp_legacy/module/config/nukeprotect.lua b/exp_legacy/module/config/nukeprotect.lua index a49c4e12..197ddb00 100644 --- a/exp_legacy/module/config/nukeprotect.lua +++ b/exp_legacy/module/config/nukeprotect.lua @@ -25,6 +25,6 @@ return { }, }, }, - ignore_permission = "bypass-nukeprotect", -- @setting ignore_permission The permission that nukeprotect will ignore + ignore_permission = "exp_scenario.bypass.nuke_protection", -- @setting ignore_permission The permission that nukeprotect will ignore ignore_admins = true, -- @setting ignore_admins Ignore admins, true by default. Allows usage outside of the roles module } diff --git a/exp_legacy/module/config/protection.lua b/exp_legacy/module/config/protection.lua index d598fb65..d645d04b 100644 --- a/exp_legacy/module/config/protection.lua +++ b/exp_legacy/module/config/protection.lua @@ -1,6 +1,6 @@ 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 + ignore_permission = "exp_scenario.bypass.entity_protection", --- @setting ignore_permission Players with this permission will be ignored by the protection filter, leave nil to protect against every player 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 diff --git a/exp_legacy/module/expcore/roles.lua b/exp_legacy/module/expcore/roles.lua deleted file mode 100644 index 641eeaea..00000000 --- a/exp_legacy/module/expcore/roles.lua +++ /dev/null @@ -1,1192 +0,0 @@ ---[[-- Core Module - Roles -- Factorio role system to manage custom permissions. -@core Roles -@alias Roles - -@usage--- Using Role System (assignment): ---When a map first starts you will want to define on mass all the players you expect to join and the roles to give them: -Roles.override_player_roles{ - Cooldude2606 = {'Owner', 'Admin', 'Member'}, - NotCooldude2606 = {'Member'} -} - ---Once the game is running you still want to be able to give role and remove them which is when you would use: -Roles.assign_player(player, 'Admin', by_player_name) -- this will give the "Admin" role to the player -Roles.unassign_player(player, {'Admin', 'Moderator'}, by_player_name) -- this will remove "Admin" and "Moderator" role in one go - -@usage--- Using Role System (role testing): ---To comparer two players you can comparer the index of they highest roles, can be used when you want to allow a "write" down type system: -Roles.get_player_highest_role(playerOne).index < Roles.get_player_highest_role(playerTwo).index -- remember that less means a higher role - ---Listing all of a players roles can also be useful which is when you would want to use: -Roles.get_player_roles(player) -- the return is an array that can be looped over however this is not in particular order - ---Finally you may want to test if a player has a certain role, flag or action allowed which is when you would use: -Roles.player_has_role(player, 'Admin') -- you can provide a role name if you only want a name based system -Roles.player_has_flag(player, 'is_donator') -- your roles can be grouped together with flags such as is_donator -Roles.player_allowed(player, 'game modifiers') -- or you can have an action based system where each action is something the player can do - -@usage--- Example Flag Define: ---Flags can be used to group multiple roles and actions under one catch all, for example if you want a piece of code to only ---be active for your donators then you would add a "is_donator" flag to all your donator roles and then in the code test if ---a player has that tag present: - --- give you donators a speed boost when they join; these functions aren't required but can be useful -Roles.define_flag_trigger('is_donator', function(player, state) - if state then - player.character_running_speed_modifier = 1.5 - else - player.character_running_speed_modifier = 1 - end -end) - --- then on all your donator roles you would add -Roles.new_role('Donator') -:set_flag('is_donator') - --- and in your code you would test for -if Roles.player_has_flag(player, 'is_donator') then - -- some donator only code -end - -@usage--- Example Role Define: ---You can't use a role system without any roles so first you must define your roles; each role has a minimum of a name with ---the option for a shorthand: -Roles.new_role('Administrator', 'Admin') - ---Next you will want to add any extras you want to have, such as a tag, colour, permission group or any custom flags: -Roles.new_role('Administrator', 'Admin') -:set_custom_tag('[Admin]') -:set_custom_color('red') -- this can be {r=0, g=0, b=0} or a predefined value -:set_permission_group('Staff') -- a second argument can be added if you have not used the custom permission group config -:set_flag('is_admin') - ---You will then want to decide if you want to allow all actions, this should of course be used sparely: -Roles.new_role('Administrator', 'Admin') -...extras... -:set_allow_all() - ---If you don't do this want this as i would advise you do then you will want to define what the role can do; this comes with ---an optional inheritance system if you like those sort of things in which case disallow may also be of some use to you: -Roles.new_role('Administrator', 'Admin') -...extras... -:set_parent('Moderator') -- the admin can do anything that a moderator can do -:allow{ -- these actions can be anything just try to keep them without conflicts - 'command/kill', - 'gui/game settings' -} - ---Here is what the finished admin role would look like: -Roles.new_role('Administrator', 'Admin') -:set_custom_tag('[Admin]') -:set_custom_color('red') -:set_permission_group('Staff') -:set_flag('is_admin') -:set_parent('Moderator') -:allow{ - 'command/kill', - 'gui/game settings' -} - -@usage--- Example System Define: ---Once all roles are defined these steps must be done to ensure the system is ready to use, this includes setting a default ---role, assigning a root (all permission) role that the server/system will use and the linear order that the roles fall into: - -Roles.set_default('Guest') -Roles.set_root('System') - -Roles.define_role_order{ - 'System', - 'Administrator', - 'Moderator', - 'Donator', - 'Guest' -} - ---Just remember that in this example all these roles have not been defined; so make sure all your roles that are used are defined ---before hand; a config file on load is useful for this to ensure that its loaded before the first player even joins. - -]] - -local ExpUtil = require("modules/exp_util") -local Async = require("modules/exp_util/async") -local Storage = require("modules/exp_util/storage") -local Event = require("modules/exp_legacy/utils/event") -local Groups = require("modules.exp_legacy.expcore.permission_groups") - -local Colours = ExpUtil.color -local write_json = ExpUtil.write_json - - ---- @class Roles.Role ---- @field name string ---- @field short_hand string ---- @field index number Position within the role order ---- @field allowed_actions table ---- @field allow_all_actions boolean ---- @field flags table ---- @field disallowed_actions table? ---- @field permission_group ([boolean, string] | string)? ---- @field custom_tag string? ---- @field custom_color Color? ---- @field parent string? ---- @field block_auto_assign boolean? ---- @field auto_assign_condition function? ---- @field set_allow_all fun(self: Roles.Role, state: boolean?): Roles.Role Defined on Roles._prototype - -local Roles = { - _prototype = {}, - config = { - order = {}, --- @type string[] Contains the order of the roles, lower index is better - roles = {}, --- @type table Contains the raw info for the roles, indexed by role name - flags = {}, -- Contains functions that run when a flag is added/removed from a player - internal = {}, -- Contains all internally accessed roles, such as root, default - players = {}, -- Contains the roles that players have - auto_assign = {}, -- Contains references to all roles which have auto assign conditions - deferred_roles = {}, -- Contains the roles that are to be assigned to players when they are unjailed - }, - events = { - on_role_assigned = script.generate_event_name(), - on_role_unassigned = script.generate_event_name(), - }, -} - ---- When global is loaded it will have the metatable re-assigned to the roles -Storage.register({ - Roles.config.players, - Roles.config.deferred_roles, -}, function(tbl) - Roles.config.players = tbl[1] - Roles.config.deferred_roles = tbl[2] -end) - ---- Getter. --- Functions which get roles --- @section get - ---- Internal function used to trigger a few different things when roles are changed --- this is the raw internal trigger as the other function is called at other times --- there is a second half called role_update which triggers after the event call, it also is called when a player joins -local function emit_player_roles_updated(player, type, roles, by_player_name, skip_game_print) - by_player_name = by_player_name or game.player and game.player.name or "" - local by_player = game.players[by_player_name] - local by_player_index = by_player and by_player.index or 0 - -- get the event id from the type of emit - local event = Roles.events.on_role_assigned - if type == "unassign" then - event = Roles.events.on_role_unassigned - end - -- Get the names of the roles - local role_names = {} - for index, role in ipairs(roles) do - role_names[index] = role.name - end - - -- output to all the different locations: game print, player sound, event trigger and role log - if not skip_game_print then - game.print({ "expcore-roles.game-message-" .. type, player.name, table.concat(role_names, ", "), by_player_name }, Colours.cyan) - end - if type == "assign" then - player.play_sound{ path = "utility/achievement_unlocked" } - else - player.play_sound{ path = "utility/game_lost" } - end - script.raise_event(event, { - name = event, - tick = game.tick, - player_index = player.index, - by_player_index = by_player_index, - roles = role_names, - }) - write_json("log/roles.log", { - player_name = player.name, - by_player_name = by_player_name, - type = type, - roles_changed = role_names, - }) -end - ---[[-- Returns a string which contains all roles in index order displaying all data for them -@treturn string the debug output string - -@usage-- Print the debug string -game.player.print(Roles.debug()) - -]] -function Roles.debug() - local output = "" - for index, role_name in ipairs(Roles.config.order) do - local role = Roles.config.roles[role_name] - local color = (role.custom_color or Colours.white) --[[@as Color.struct]] - local color_str = string.format("[color=%d, %d, %d]", color.r, color.g, color.b) - output = output .. string.format("\n%s %s) %s[/color]", color_str, index, serpent.line(role)) - end - - return output -end - ---[[-- Prints a message to all players in the given roles, may send duplicate message however factorio blocks spam -@tparam table roles table a of roles which to send the message to -@tparam string message the message to send to the players - -@usage-- Print a message to the given roles -Roles.print_to_roles({'Administrator', 'Moderator'}, 'Hello, World!') - -]] -function Roles.print_to_roles(roles, message) - for _, role in ipairs(roles) do - role = Roles.get_role_from_any(role) - if role then role:print(message) end - end -end - ---[[-- Prints a message to all players who have the given role or one which is higher (excluding default) -@tparam string role the name of the role to send the message to -@tparam string message the message to send to the players - -@usage-- Print a message to the roles above this role, includes the given role -Roles.print_to_roles_higher('Moderator', 'Hello, World!') - -]] -function Roles.print_to_roles_higher(role, message) - role = Roles.get_role_from_any(role) - if not role then return end - local roles = {} - for index, role_name in ipairs(Roles.config.order) do - if index <= role.index and role_name ~= Roles.config.internal.default then - roles[#roles + 1] = role_name - end - end - - Roles.print_to_roles(roles, message) -end - ---[[-- Prints a message to all players who have the given role or one which is lower (excluding default) -@tparam string role the name of the role to send the message to -@tparam string message the message to send to the players - -@usage-- Print a message to the roles below this role, includes the given role -Roles.print_to_roles_higher('Moderator', 'Hello, World!') - -]] -function Roles.print_to_roles_lower(role, message) - role = Roles.get_role_from_any(role) - if not role then return end - local roles = {} - for index, role_name in ipairs(Roles.config.order) do - if index >= role.index and role_name ~= Roles.config.internal.default then - roles[#roles + 1] = role_name - end - end - - Roles.print_to_roles(roles, message) -end - ---[[-- Get a role for the given name -@tparam string name the name of the role to get -@treturn Roles._prototype the role with that name or nil - -@usage-- Get a role by its name -local role = Roles.get_role_by_name('Moderator') - -]] -function Roles.get_role_by_name(name) - return Roles.config.roles[name] -end - ---[[-- Get a role with the given order index -@tparam number index the place in the order list of the role to get -@treturn Roles._prototype the role with that index in the order list or nil - -@usage-- Get a role by its index in the order list -local role = Roles.get_role_by_name(2) - -]] -function Roles.get_role_by_order(index) - local name = Roles.config.order[index] - if not name then return end - return Roles.config.roles[name] -end - ---[[-- Gets a role from a name, index or role object (where it is just returned) -nb: this function is used for the input for most outward facing functions -@tparam ?number|string|table any the value used to find the role -@treturn Roles._prototype the role that was found or nil see above - -@usage-- Get a role by its name or order -local role = Roles.get_role_from_any('Moderator') - -]] -function Roles.get_role_from_any(any) - local t_any = type(any) - if t_any == "number" or tonumber(any) then - any = tonumber(any) - return Roles.get_role_by_order(any) - elseif t_any == "string" then - return Roles.get_role_by_name(any) - elseif t_any == "table" then - return Roles.get_role_by_name(any.name) - end -end - ---- Returns all roles in order of index -function Roles.get_roles_ordered() - local rtn = {} - for index, role_name in ipairs(Roles.config.order) do - rtn[index] = Roles.config.roles[role_name] - end - return rtn -end - ---[[-- Gets all the roles of the given player, this will always contain the default role -@tparam LuaPlayer player the player to get the roles of -@treturn table a table where the values are the roles which the player has - -@usage-- Get the roles that a player has -local roles = Roles.get_player_roles(game.player) - -]] -function Roles.get_player_roles(player) - if not player or player.index == 0 then return { Roles.config.roles[Roles.config.internal.root] } end - local roles = Roles.config.players[player.name] or {} - local default = Roles.config.roles[Roles.config.internal.default] - local rtn = { default } - for index, role_name in ipairs(roles) do - rtn[index + 1] = Roles.config.roles[role_name] - end - - return rtn -end - ---[[-- Gets the highest role which the player has, can be used to compeer one player to another -@tparam LuaPlayer player the player to get the highest role of -@treturn the role with the highest order index which this player has - -@usage-- Get the highest role that a player has -local role = Roles.get_player_highest_role(game.player) - -]] ---- @return Roles.Role -function Roles.get_player_highest_role(player) - local roles = Roles.get_player_roles(player) - local highest --- @type Roles.Role? - for _, role in ipairs(roles) do - if not highest or role.index < highest.index then - highest = role - end - end - - return (assert(highest, "Player has no roles")) -end - ---- Assignment. --- Functions for changing player's roles --- @section assignment - ---[[-- Gives a player the given role(s) with an option to pass a by player name used in the log -@tparam LuaPlayer player the player that will be assigned the roles -@tparam table roles table a of roles that the player will be given, can be one role and can be role names -@tparam[opt=] string by_player_name the name of the player that will be shown in the log -@tparam[opt=false] boolean skip_checks when true there will be no checks are done for if the player is valid -@tparam[opt=false] boolean silent when true there will be no game message printed - -@usage-- Assign a player to the Moderator role -Roles.assign_player(game.player, 'Moderator') - -@usage-- Assign a player to the Moderator role, even if the player has never been on the map -Roles.assign_player('Cooldude2606', 'Moderator', nil, true) - -]] -function Roles.assign_player(player, roles, by_player_name, skip_checks, silent) - local valid_player = type(player) == "userdata" and player or game.get_player(player) - if not skip_checks and not valid_player then return end - - -- Convert the roles into a table (allows for optional array) - if type(roles) ~= "table" or roles.name then - roles = { roles } - end - - -- Convert to role objects - local role_objects = {} - for _, role in ipairs(roles) do - local role_object = Roles.get_role_from_any(role) - if role_object then table.insert(role_objects, role_object) end - end - - -- If the player has a role that needs to defer the role changes, save the roles that need to be assigned later into a table - if valid_player and Roles.player_has_flag(valid_player, "defer_role_changes") then - local assign_later = Roles.config.deferred_roles[valid_player.name] or {} - for _, role in ipairs(role_objects) do - local role_change = assign_later[role.name] - if role_change then - role_change.count = role_change.count + 1 - if role_change.count == 1 then - role_change.by_player_name = by_player_name or "" - role_change.silent = silent - end - else - assign_later[role.name] = { - count = 1, by_player_name = by_player_name or "", silent = silent, - } - end - end - - Roles.config.deferred_roles[valid_player.name] = assign_later - return - end - - for _, role in ipairs(role_objects) do - role:add_player(valid_player or player, valid_player == nil, true) - end - - if valid_player then - emit_player_roles_updated(valid_player, "assign", role_objects, by_player_name, silent) - end -end - ---[[-- Removes a player from the given role(s) with an option to pass a by player name used in the log -@tparam LuaPlayer player the player that will have the roles removed -@tparam table roles table a of roles to be removed from the player, can be one role and can be role names -@tparam[opt=] string by_player_name the name of the player that will be shown in the logs -@tparam[opt=false] boolean skip_checks when true there will be no checks are done for if the player is valid -@tparam[opt=false] boolean silent when true there will be no game message printed - -@usage-- Unassign a player from the Moderator role -Roles.unassign_player(game.player, 'Moderator') - -@usage-- Unassign a player from the Moderator role, even if the player has never been on the map -Roles.unassign_player('Cooldude2606', 'Moderator', nil, true) - -]] -function Roles.unassign_player(player, roles, by_player_name, skip_checks, silent) - local valid_player = type(player) == "userdata" and player or game.get_player(player) - if not skip_checks and not valid_player then return end - if not player then return end - - -- Convert the roles into a table (allows for optional array) - if type(roles) ~= "table" or roles.name then - roles = { roles } - end - - -- Convert to role objects - local role_objects = {} - for _, role in ipairs(roles) do - local role_object = Roles.get_role_from_any(role) - if role_object then table.insert(role_objects, role_object) end - end - - -- If the player has a role that needs to defer the role changes, save the roles that need to be unassigned later into a table - local defer_changes = Roles.player_has_flag(player, "defer_role_changes") - if defer_changes and valid_player then - local assign_later = Roles.config.deferred_roles[valid_player.name] or {} - for _, role in ipairs(role_objects) do - local role_change = assign_later[role.name] - if role_change then - role_change.count = role_change.count - 1 - if role_change.count == -1 then - role_change.by_player_name = by_player_name or "" - role_change.silent = silent - end - else - assign_later[role.name] = { - count = -1, by_player_name = by_player_name or "", silent = silent, - } - end - end - - Roles.config.deferred_roles[valid_player.name] = assign_later - end - - -- Remove the player from roles - local role_changes = {} - for _, role in ipairs(role_objects) do - if not defer_changes or role:has_flag("defer_role_changes") then - role:remove_player(valid_player or player, valid_player == nil, true) - table.insert(role_changes, role) - end - end - - -- If there are deferred role changes, apply them now - if defer_changes and not Roles.player_has_flag(valid_player, "defer_role_changes") then - local assign_later = Roles.config.deferred_roles[player.name] or {} - local assigns, unassigns = {}, {} - for role_name, details in pairs(assign_later) do - local role = Roles.get_role_from_any(role_name) - if role then - if details.count > 0 then - role:add_player(valid_player or player, valid_player == nil, true) - if not assigns[details.by_player_name] then assigns[details.by_player_name] = {} end - if not details.silent then table.insert(assigns[details.by_player_name], role) end - elseif details.count < 0 then - role:remove_player(valid_player or player, valid_player == nil, true) - if not unassigns[details.by_player_name] then unassigns[details.by_player_name] = {} end - if not details.silent then table.insert(unassigns[details.by_player_name], role) end - end - end - end - - for assign_by_player_name, assign_roles in pairs(assigns) do - if #assign_roles > 0 then emit_player_roles_updated(valid_player, "assign", assign_roles, assign_by_player_name) end - end - - for unassign_by_player_name, unassign_roles in pairs(unassigns) do - if #unassign_roles > 0 then emit_player_roles_updated(valid_player, "unassign", unassign_roles, unassign_by_player_name) end - end - - Roles.config.deferred_roles[player.name] = nil - end - - if valid_player and #role_changes > 0 then - emit_player_roles_updated(valid_player, "unassign", role_changes, by_player_name, silent) - end -end - ---[[-- Overrides all player roles with the given table of roles, useful to mass set roles on game start -@tparam[opt] string player_name the player to set the roles for, if not given all roles are overriden -@tparam table roles table a which is indexed by case sensitive player names and has the value of a table of role names - -@usage-- Override the roles of a single player, other users are not effected -Roles.override_player_roles('Cooldude2606', {'Moderator'}) - -@usage-- Override all existing roles, effects all users not just ones listed -Roles.override_player_roles{ - ['Cooldude2606'] = {'Administrator', 'Moderator'}, - ['arty714'] = {'Administrator', 'Moderator'}, -} - -]] -function Roles.override_player_roles(player_name, roles) - local player_roles = Roles.config.players - if not roles then - for k in pairs(player_roles) do player_roles[k] = nil end - - for k, new_roles in pairs(player_name) do player_roles[k] = new_roles end - else - Roles.config.players[player_name] = roles - end -end - ---- Checks. --- Functions for checking player's roles --- @section checks - ---[[-- A test for weather a player has the given role -@tparam LuaPlayer player the player to test the roles of -@tparam ?string|number|table search_role a pointer to the role that is being searched for -@treturn boolean true if the player has the role, false otherwise, nil for errors - -@usage-- Test if a player has a role -local has_role = Roles.player_has_role(game.player, 'Moderator') - -]] -function Roles.player_has_role(player, search_role) - local roles = Roles.get_player_roles(player) - if not roles then return end - search_role = Roles.get_role_from_any(search_role) - if not search_role then return end - for _, role in ipairs(roles) do - if role.name == search_role.name then return true end - end - - return false -end - ---[[-- A test for weather a player has the given flag true for at least one of they roles -@tparam LuaPlayer player the player to test the roles of -@tparam string flag_name the name of the flag that is being looked for -@treturn boolean true if the player has at least one role which has the flag set to true, false otherwise, nil for errors - -@usage-- Test if a player has a role -local has_flag = Roles.player_has_flag(game.player, 'is_donator') - -]] -function Roles.player_has_flag(player, flag_name) - local roles = Roles.get_player_roles(player) - if not roles then return end - for _, role in ipairs(roles) do - if role:has_flag(flag_name) then - return true - end - end - - return false -end - ---[[-- A test for weather a player has at least one role which is allowed the given action -@tparam LuaPlayer player the player to test the roles of -@tparam string action the name of the action that is being tested for -@treturn boolean true if the player has at least one role which is allowed this action, false otherwise, nil for errors - -@usage-- Test if a player has a role -local has_flag = Roles.player_has_flag(game.player, 'is_donator') - -]] -function Roles.player_allowed(player, action) - local roles = Roles.get_player_roles(player) - for _, role in ipairs(roles) do - if role:is_allowed(action) then - return true - end - end - - return false -end - ---- Definitions. --- Functions which are used to define roles --- @section checks - ---[[-- Used to set the role order, higher in the list is better, must be called at least once in config --- nb: function also re links parents due to expected position in the config file -@tparam table order table a which is keyed only by numbers (start 1) and values are roles in order with highest first - -@usage-- Define which roles are higher than others -Roles.define_role_order{ - 'System', - 'Administrator', - 'Moderator', - 'Donator', - 'Guest' -} - -]] -function Roles.define_role_order(order) - -- Clears and then rebuilds the order table - ExpUtil.assert_not_runtime() - Roles.config.order = {} - local done = {} - for index, role in ipairs(order) do - if type(role) == "table" and role.name then - done[role.name] = true - Roles.config.order[index] = role.name - else - done[role] = true - Roles.config.order[index] = role - end - end - - -- Check no roles were missed - for role_name in pairs(Roles.config.roles) do - if not done[role_name] then - error("Role missing " .. role_name .. " from role order, all defined roles must be included.", 2) - end - end - - -- Re-links roles to they parents as this is called at the end of the config - for index, role_name in pairs(Roles.config.order) do - local role = Roles.config.roles[role_name] - if not role then - error("Role with name " .. role_name .. " has not beed defined, either define it or remove it from the order list.", 2) - end - role.index = index - local parent = Roles.config.roles[role.parent] - if parent then - setmetatable(role.allowed_actions, { __index = parent.allowed_actions }) - end - end -end - ---[[-- Defines a new trigger for when a tag is added or removed from a player -@tparam string name the name of the flag which the roles will have -@tparam function callback the function that is called when roles are assigned - -@usage-- Defining a flag trigger -Roles.define_flag_trigger('is_donator', function(player, state) - player.character_running_speed_modifier = state and 1.5 or 1 -end) - -]] -function Roles.define_flag_trigger(name, callback) - Roles.config.flags[name] = Async.register(callback) -end - ---[[-- Sets the default role which every player will have, this needs to be called at least once -@tparam string name the name of the default role - -@usage-- Setting the default role -Roles.set_default('Guest') - -]] -function Roles.set_default(name) - local role = Roles.config.roles[name] - if not role then return end - Roles.config.internal.default = name -end - ---[[-- Sets the root role which will always have all permissions, any server actions act from this role -@tparam string name the name of the root role - -@usage-- Setting the root role -Roles.set_root('System') - -]] -function Roles.set_root(name) - local role = Roles.config.roles[name] - if not role then return end - role:set_allow_all(true) - Roles.config.internal.root = name -end - ---[[-- Defines a new role and returns the prototype to allow configuration -@tparam string name the name of the new role, must be unique -@tparam[opt=name] string short_hand the shortened version of the name -@treturn Roles._prototype the start of the config chain for this role - -@usage-- Defining a new role -local role = Roles.new_role('Moderator', 'Mod') - -]] -function Roles.new_role(name, short_hand) - ExpUtil.assert_not_runtime() - if Roles.config.roles[name] then error("Role name is non unique") end - local role = setmetatable({ - name = name, - short_hand = short_hand or name, - allowed_actions = {}, - allow_all_actions = false, - flags = {}, - }, { __index = Roles._prototype }) - Roles.config.roles[name] = role - return role -end - ---- Role Actions. --- Functions for using the role action system --- @section actions - ---[[-- Sets the default allow state of the role, true will allow all actions -@tparam[opt=true] boolean state true will allow all actions -@treturn Roles._prototype allows chaining - -@usage-- Allow all actions for this role, useful for root like roles -role:set_allow_all() - -]] -function Roles._prototype:set_allow_all(state) - if state == nil then state = true end - self.allow_all_actions = not not state -- not not forces a boolean value - return self -end - ---[[-- Sets the allow actions for this role, actions in this list will be allowed for this role -@tparam table actions indexed with numbers and is an array of action names, order has no effect -@treturn Roles._prototype allows chaining - -@usage-- Allow some actions for a role -role:allow{ - 'command/kill', - 'gui/game settings' -} - -]] -function Roles._prototype:allow(actions) - if type(actions) ~= "table" then - actions = { actions } - end - for _, action in ipairs(actions) do - self.allowed_actions[action] = true - end - - return self -end - ---[[-- Sets the disallow actions for this role, will prevent actions from being allowed regardless of inheritance -@tparam table actions indexed with numbers and is an array of action names, order has no effect -@treturn Roles._prototype allows chaining - -@usage-- Disallow an action for a role, useful if inherit an action from a parent -role:disallow{ - 'command/kill', - 'gui/game settings' -} - -]] -function Roles._prototype:disallow(actions) - if type(actions) ~= "table" then - actions = { actions } - end - for _, action in ipairs(actions) do - self.allowed_actions[action] = false - end - - return self -end - ---[[-- Test for if a role is allowed the given action, mostly internal see Roles.player_allowed -@tparam string action the name of the action to test if it is allowed -@treturn boolean true if action is allowed, false otherwise - -@usage-- Test if a role is allowed an action -local allowed = role:is_allowed('command/kill') - -]] -function Roles._prototype:is_allowed(action) - local is_root = Roles.config.internal.root == self.name - return self.allowed_actions[action] or self.allow_all_actions or is_root -end - ---- Role Flags. --- Functions for using the role flag system --- @section flags - ---[[-- Sets the state of a flag for a role, flags can be used to apply effects to players -@tparam string name the name of the flag to set the value of -@tparam[opt=true] boolean value the state to set the flag to -@treturn Roles._prototype allows chaining - -@usage-- Set a flag for a role -role:set_flag('is_admin') - -]] -function Roles._prototype:set_flag(name, value) - if value == nil then value = true end - self.flags[name] = not not value -- not not forces a boolean value - return self -end - ---[[-- Clears all flags from this role, individual flags can be removed with set_flag(name, false) -@treturn Roles._prototype allows chaining - -@usage-- Remove all flags from a role -role:clear_flags() - -]] -function Roles._prototype:clear_flags() - self.flags = {} - return self -end - ---[[-- A test for if the role has a flag set -@tparam string name the name of the flag to test for -@treturn boolean true if the flag is set, false otherwise - -@usage-- Test if a role has a flag -local has_flag = role:has_flag('is_admin') - -]] -function Roles._prototype:has_flag(name) - return self.flags[name] or false -end - ---- Role Properties. --- Functions for changing other properties --- @section properties - ---[[-- Sets a custom player tag for the role, can be accessed by other code -@tparam string tag the value that the tag will be -@treturn Roles._prototype allows chaining - -@usage-- Set a custom tag for this role, other code is required to set the tag -role:set_custom_tag('Mod') - -]] -function Roles._prototype:set_custom_tag(tag) - self.custom_tag = tag - return self -end - ---[[-- Sets a custom colour for the role, can be accessed by other code -@tparam table color ?string|table can either be and rgb colour or the name of a colour defined in the presets -@treturn Roles._prototype allows chaining - -@usage-- Set a custom colour for this role, other code is required to use this value -role:set_custom_color{ r=255, g=100, b=100} - -]] -function Roles._prototype:set_custom_color(color) - if type(color) ~= "table" then - color = Colours[color] - end - self.custom_color = color - return self -end - ---[[-- Sets the permission group for this role, players will be moved to the group of they highest role -@tparam string name the name of the permission group to have players moved to -@tparam[opt=false] boolean use_factorio_api when true the custom permission group module is ignored -@treturn Roles._prototype allows chaining - -@usage-- Set the permission group for this role, see permission_groups.lua -role:set_permission_group('Admin') - -]] -function Roles._prototype:set_permission_group(name, use_factorio_api) - ExpUtil.assert_not_runtime() - if use_factorio_api then - self.permission_group = { true, name } --[[@as [boolean, string] ]] - else - assert(Groups.get_group_by_name(name), "Permission group not found: " .. name) - self.permission_group = name - end - return self -end - ---[[-- Sets the parent for a role, any action not in allow or disallow will be looked for in its parents -nb: this is a recursive action, and changing the allows and disallows will effect all children roles -@tparam string role the name of the role that will be the parent; has imminent effect if role is already defined -@treturn Roles._prototype allows chaining - -@usage-- Set the parent for this role to inherit all actions allowed -role:set_parent('Guest') - -]] -function Roles._prototype:set_parent(role) - ExpUtil.assert_not_runtime() - self.parent = role - role = Roles.get_role_from_any(role) - if not role then return self end - setmetatable(self.allowed_actions, { __index = role.allowed_actions }) - return self -end - ---[[-- Sets an auto assign condition that is checked every 60 seconds, if true is returned then the player will receive the role -nb: this is one way, failing false after already gaining the role will not revoke the role -@tparam function callback receives only one param which is player to assign, return true to assign the player -@treturn Roles._prototype allows chaining - -@usage-- Give this role to a user if there are admin, ran every 60 seconds -role:set_auto_assign_condition(function(player) - return player.admin -end) - -]] -function Roles._prototype:set_auto_assign_condition(callback) - ExpUtil.assert_not_runtime() - self.auto_assign_condition = true - Roles.config.auto_assign[self.name] = callback - return self -end - ---[[-- Get the auto assign condition for this role, returns nil if no condition is set -@treturn function The callback which was assigned as the auto assign condition - -@usage-- Give this role to a user if there are admin, ran every 60 seconds -local condition = role:get_auto_assign_condition() - -]] -function Roles._prototype:get_auto_assign_condition() - return Roles.config.auto_assign[self.name] -end - ---[[-- Sets the role to not allow players to have auto assign effect them, useful to keep people locked to a role -@tparam[opt=true] boolean state when true the players with this role will not be auto assigned to other roles -@treturn Roles._prototype allows chaining - -@usage-- Make a role stop players from being auto assigned to other roles -role:set_block_auto_assign() - -]] -function Roles._prototype:set_block_auto_assign(state) - if state == nil then state = true end - self.block_auto_assign = not not state -- forces a boolean value - return self -end - ---- Role Players. --- Functions that control players in a role --- @section players - ---[[-- Adds a player to this role, players can have more than one role at a time, used internally see Roles.assign -@tparam LuaPlayer player the player that will be given this role -@tparam boolean skip_check when true player will be taken as the player name (use when player has not yet joined) -@tparam boolean skip_event when true the event emit will be skipped, this is used internally with Roles.assign -@treturn boolean true if the player was added successfully - -@usage-- Assign a player to this role -role:add_player(game.player) - -]] -function Roles._prototype:add_player(player, skip_check, skip_event) - local valid_player = type(player) == "userdata" and player or game.get_player(player) - -- Default role cant have players added or removed - if self.name == Roles.config.internal.default then return end - -- Check the player is valid, can be skipped but a name must be given - local player_name - if valid_player then - player_name = valid_player.name - elseif skip_check then - player_name = player - else - return false - end - -- Add the role name to the player's roles - local player_roles = Roles.config.players[player_name] - if player_roles then - for _, role_name in ipairs(player_roles) do - if role_name == self.name then return false end - end - - player_roles[#player_roles + 1] = self.name - else - Roles.config.players[player_name] = { self.name } - end - -- Emits event if required - if valid_player and not skip_event then - emit_player_roles_updated(valid_player, "assign", { self }) - end - return true -end - ---[[-- Removes a player from this role, players can have more than one role at a time, used internally see Roles.unassign -@tparam LuaPlayer player the player that will lose this role -@tparam boolean skip_check when true player will be taken as the player name (use when player has not yet joined) -@tparam boolean skip_event when true the event emit will be skipped, this is used internally with Roles.unassign -@treturn boolean true if the player was removed successfully - -@usage-- Unassign a player from this role -role:remove_player(game.player) - -]] -function Roles._prototype:remove_player(player, skip_check, skip_event) - local valid_player = type(player) == "userdata" and player or game.get_player(player) - -- Default role cant have players added or removed - if self.name == Roles.config.internal.default then return end - -- Check the player is valid, can be skipped but a name must be given - local player_name - if valid_player then - player_name = valid_player.name - elseif skip_check then - player_name = player - else - return false - end - -- Remove the role from the players roles - local player_roles = Roles.config.players[player_name] - local found = false - if player_roles then - for index, role_name in ipairs(player_roles) do - if role_name == self.name then - player_roles[index] = player_roles[#player_roles] - player_roles[#player_roles] = nil - found = true - break - end - end - - if #player_roles == 0 then - Roles.config.players[player_name] = nil - end - end - -- Emits event if required - if valid_player and not skip_event then - emit_player_roles_updated(valid_player, "unassign", { self }) - end - return found -end - ---[[-- Returns an array of all the players who have this role, can be filtered by online status -@tparam[opt=nil] boolean online when given will filter by this online state, nil will return all players -@treturn table all the players who have this role, indexed order is meaningless - -@usage-- Get all the players with this role -local players = role:get_players() - -@usage-- Get all online players with this role -local players = role:get_players(true) - -]] -function Roles._prototype:get_players(online) - local players = {} - -- Search all players to check if they have this role - for player_name, player_roles in pairs(Roles.config.players) do - for _, role_name in ipairs(player_roles) do - if role_name == self.name then - local player = game.players[player_name] - -- Filter by online state if required - if player and (online == nil or player.connected == online) then - players[#players + 1] = player - end - break - end - end - end - - return players -end - ---[[-- Will print a message to all players with this role -@tparam string message the message that will be printed to the players -@treturn number the number of players who received the message - -@usage-- Print a message to all players with this role -role:print('Hello, World!') - -]] -function Roles._prototype:print(message) - local players = self:get_players(true) - for _, player in ipairs(players) do - player.print(message) - end - - return #players -end - ---- Used internally to be the first trigger on an event change, would be messy to include this in 4 different places -local function role_update(event) - local player = game.players[event.player_index] - -- Updates flags given to the player - for flag, async_function in pairs(Roles.config.flags) do - local state = Roles.player_has_flag(player, flag) - async_function(player, state) - end - - -- Updates the players permission group - local highest = Roles.get_player_highest_role(player) - if highest.permission_group then - local permission_group = highest.permission_group --[[@as [boolean, string] ]] - if permission_group[1] then - local group = game.permissions.get_group(permission_group[2]) - if group then - Groups.add_to_permission_group_async(group, player) - end - else - Groups.set_player_group(player, highest.permission_group) - end - end -end - ---- Used internally to test if a player should be auto assigned a role -local function auto_assign(event) - local player = game.players[event.player_index] - local roles = Roles.config.players[player.name] or {} - - local lookup = {} - -- Create a lookup table for existing roles, and check for block auto assign - for _, role in ipairs(roles) do - if role.block_auto_assign then return end - lookup[role] = true - end - - local assigns, ctn = {}, 0 - -- Check roles with auto assign conditions and run the handler - for role, condition in pairs(Roles.config.auto_assign) do - if not lookup[role] then - local success, rtn = pcall(condition, player) - if not success then - log{ "expcore-roles.error-log-format-assign", role.name, rtn } - elseif rtn == true then - ctn = ctn + 1 - assigns[ctn] = role - end - end - end - - -- Assign new roles if any were passed - if ctn > 0 then Roles.assign_player(player, assigns) end -end - ---- When a player joined or has a role change then the update is triggered -Event.add(Roles.events.on_role_assigned, role_update) -Event.add(Roles.events.on_role_unassigned, role_update) -Event.add(defines.events.on_player_joined_game, role_update) - ---- Every 60 seconds and on join auto role assignment is checked -Event.add(defines.events.on_player_joined_game, auto_assign) -Event.on_nth_tick(3600, function() - for _, player in ipairs(game.connected_players) do - auto_assign{ player_index = player.index } - end -end) - --- Return Roles -return Roles diff --git a/exp_legacy/module/locale/en/expcore.cfg b/exp_legacy/module/locale/en/expcore.cfg index 0225b9f8..ed40d1ca 100644 --- a/exp_legacy/module/locale/en/expcore.cfg +++ b/exp_legacy/module/locale/en/expcore.cfg @@ -1,11 +1,3 @@ -[expcore-roles] -error-log-format-flag=[ERROR] roleFlag/__1__ :: __2__ -error-log-format-assign=[ERROR] rolePromote/__1__ :: __2__ -game-message-assign=__1__ has been assigned to __2__ by __3__ -game-message-unassign=__1__ has been unassigned from __2__ by __3__ -reject-role=Invalid Role Name. -reject-player-role=Player has a higher role. - [expcore-data] description-preference=Allows you to set/get your data saving preference. description-data=Writes all your player data to a file on your computer. diff --git a/exp_legacy/module/locale/zh-CN/expcore.cfg b/exp_legacy/module/locale/zh-CN/expcore.cfg index 5f4c5f12..01466073 100644 --- a/exp_legacy/module/locale/zh-CN/expcore.cfg +++ b/exp_legacy/module/locale/zh-CN/expcore.cfg @@ -1,11 +1,3 @@ -[expcore-roles] -error-log-format-flag=[ERROR] roleFlag/__1__ :: __2__ -error-log-format-assign=[ERROR] rolePromote/__1__ :: __2__ -game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ -game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ -reject-role=無效的身分組。 -reject-player-role=用戶已有更高的身分組。 - [expcore-data] description-preference=允許寫入資料。 description-data=把用戶資料寫入電腦 diff --git a/exp_legacy/module/locale/zh-TW/expcore.cfg b/exp_legacy/module/locale/zh-TW/expcore.cfg index 5f4c5f12..01466073 100644 --- a/exp_legacy/module/locale/zh-TW/expcore.cfg +++ b/exp_legacy/module/locale/zh-TW/expcore.cfg @@ -1,11 +1,3 @@ -[expcore-roles] -error-log-format-flag=[ERROR] roleFlag/__1__ :: __2__ -error-log-format-assign=[ERROR] rolePromote/__1__ :: __2__ -game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ -game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ -reject-role=無效的身分組。 -reject-player-role=用戶已有更高的身分組。 - [expcore-data] description-preference=允許寫入資料。 description-data=把用戶資料寫入電腦 diff --git a/exp_legacy/module/module.json b/exp_legacy/module/module.json index 2a2ec59e..bf21f097 100644 --- a/exp_legacy/module/module.json +++ b/exp_legacy/module/module.json @@ -8,6 +8,7 @@ "dependencies": { "clusterio": "*", "exp_util": "*", + "exp_roles": "*", "exp_gui": "*" } } diff --git a/exp_legacy/module/modules/control/jail.lua b/exp_legacy/module/modules/control/jail.lua index a60f7ab4..0d9b6cfe 100644 --- a/exp_legacy/module/modules/control/jail.lua +++ b/exp_legacy/module/modules/control/jail.lua @@ -7,25 +7,25 @@ -- import the module from the control modules local Jail = require("modules.exp_legacy.modules.control.jail") --- @dep modules.control.jail - -- This will move 'MrBiter' to the jail role and remove all other roles from them + -- This will give 'MrBiter' the jail role, which suppresses all of their other roles -- the player name and reason are only so they can be included in the event for user feedback Jail.jail_player('MrBiter', 'Cooldude2606', 'Likes biters too much') - -- This will give 'MrBiter' all his roles back and remove him from jail + -- This will remove the jail role from 'MrBiter', restoring their other roles -- again as above the player name is only used in the event for user feedback Jail.unjail_player('MrBiter', 'Cooldude2606') ]] -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local valid_player = function(p) return type(p) == "userdata" and p or game.get_player(p) end -local assign_roles = Roles.assign_player -local unassign_roles = Roles.unassign_player -local has_role = Roles.player_has_role -local get_roles = Roles.get_player_roles + +--- The role given to jailed players, it has a higher priority than every other role +local function jail_role() + return assert(Roles.get_role_by_name("Jail"), "The Jail role does not exist") +end local Jail = { - old_roles = {}, events = { --- When a player is assigned to jail -- @event on_player_jailed @@ -64,10 +64,11 @@ end -- @tparam LuaPlayer player the player to check if they are in jail -- @treturn boolean whether the player is currently in jail function Jail.is_jailed(player) - return has_role(valid_player(player), "Jail") + local valid = valid_player(player) + return valid ~= nil and jail_role():has_player(valid) end ---- Moves a player to jail and removes all other roles +--- Moves a player to jail, which suppresses all of their other roles -- @tparam LuaPlayer player the player who will be jailed -- @tparam string by_player_name the name of the player who is doing the jailing -- @tparam[opt='Non given.'] string reason the reason that the player is being jailed @@ -79,8 +80,8 @@ function Jail.jail_player(player, by_player_name, reason) reason = reason or "Non given." - if has_role(player, "Jail") then return end - local roles = get_roles(player) + local role = jail_role() + if role:has_player(player) then return end player.walking_state = { walking = false, direction = player.walking_state.direction } player.riding_state = { acceleration = defines.riding.acceleration.nothing, direction = player.riding_state.direction } @@ -89,16 +90,14 @@ function Jail.jail_player(player, by_player_name, reason) player.picking_state = false player.repair_state = { repairing = false, position = player.repair_state.position } - unassign_roles(player, roles, by_player_name, nil, true) - assign_roles(player, "Jail", by_player_name, nil, true) - assign_roles(player, roles, by_player_name, nil, true) + role:assign(player, { by_player_name = by_player_name, silent = true }) event_emit(Jail.events.on_player_jailed, player, by_player_name, reason) return true end ---- Moves a player out of jail and restores all roles previously removed +--- Moves a player out of jail, which restores all of their other roles -- @tparam LuaPlayer player the player that will be unjailed -- @tparam string by_player_name the name of the player that is doing the unjail -- @treturn boolean whether the player was unjailed successfully @@ -107,9 +106,10 @@ function Jail.unjail_player(player, by_player_name) if not player then return end if not by_player_name then return end - if not has_role(player, "Jail") then return end + local role = jail_role() + if not role:has_player(player) then return end - unassign_roles(player, "Jail", by_player_name, nil, true) + role:unassign(player, { by_player_name = by_player_name, silent = true }) event_emit(Jail.events.on_player_unjailed, player, by_player_name) diff --git a/exp_legacy/module/modules/control/protection.lua b/exp_legacy/module/modules/control/protection.lua index 506d889d..b0c35a0b 100644 --- a/exp_legacy/module/modules/control/protection.lua +++ b/exp_legacy/module/modules/control/protection.lua @@ -33,11 +33,7 @@ for _, config_key in ipairs{ "always_protected_names", "always_protected_types", end end --- Require roles if a permission is assigned in the config -local Roles --- @type table -if config.ignore_permission then - Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles -end +local Roles = require("modules/exp_roles") ----- Storage Variables ----- --- Variables stored in the global table @@ -159,7 +155,7 @@ Event.add(defines.events.on_pre_player_mined_item, function(event) -- Check if the player should be ignored if config.ignore_admins and player.admin then return end if entity.last_user == nil or entity.last_user.index == player.index then return end - if config.ignore_permission and Roles.player_allowed(player, config.ignore_permission) then return end + if config.ignore_permission and Roles.player_has_permission(player, config.ignore_permission) then return end -- Check if the entity is protected if EntityProtection.is_entity_protected(entity) diff --git a/exp_legacy/module/modules/data/greetings.lua b/exp_legacy/module/modules/data/greetings.lua index e8eade90..d4b159c2 100644 --- a/exp_legacy/module/modules/data/greetings.lua +++ b/exp_legacy/module/modules/data/greetings.lua @@ -8,7 +8,7 @@ local Commands = require("modules/exp_commands") local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data local CustomMessages = PlayerData.Settings:combine("JoinMessage") CustomMessages:set_metadata{ - permission = "command/join-message", + permission = "exp_scenario.command.set_join_message", } --- When a players data loads show their message diff --git a/exp_legacy/module/modules/data/quickbar.lua b/exp_legacy/module/modules/data/quickbar.lua index 52f8a4be..8550cae9 100644 --- a/exp_legacy/module/modules/data/quickbar.lua +++ b/exp_legacy/module/modules/data/quickbar.lua @@ -10,7 +10,7 @@ local config = require("modules.exp_legacy.config.preset_player_quickbar") --- @ local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data local PlayerFilters = PlayerData.Settings:combine("QuickbarFilters") PlayerFilters:set_metadata{ - permission = "command/save-quickbar", + permission = "exp_scenario.command.save_quickbar", stringify = function(value) if not value then return "No filters set" end local count = 0 diff --git a/exp_legacy/module/modules/data/tag.lua b/exp_legacy/module/modules/data/tag.lua index 89329ad0..e12e4229 100644 --- a/exp_legacy/module/modules/data/tag.lua +++ b/exp_legacy/module/modules/data/tag.lua @@ -4,17 +4,17 @@ ]] local Commands = require("modules/exp_commands") -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles +local Roles = require("modules/exp_roles") --- Stores the tag for a player local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data local PlayerTags = PlayerData.Settings:combine("Tag") local PlayerTagColors = PlayerData.Settings:combine("TagColor") PlayerTags:set_metadata{ - permission = "command/tag", + permission = "exp_scenario.command.tag", } PlayerTagColors:set_metadata{ - permission = "command/tag-color", + permission = "exp_scenario.command.tag_color", } local set_tag = function(player, tag, color) @@ -72,7 +72,7 @@ Commands.new("tag-clear", "Clears your tag. Or another player if you are admin." if other_player == player then -- No player given so removes your tag PlayerTags:remove(other_player) - elseif Roles.player_allowed(player, "command/clear-tag/always") then + elseif Roles.player_has_permission(player, "exp_scenario.command.tag_clear.always") then -- Player given and user is admin so clears that player's tag PlayerTags:remove(other_player) else diff --git a/exp_legacy/module/modules/gui/_role_updates.lua b/exp_legacy/module/modules/gui/_role_updates.lua deleted file mode 100644 index ac171b49..00000000 --- a/exp_legacy/module/modules/gui/_role_updates.lua +++ /dev/null @@ -1,7 +0,0 @@ -local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") -local Event = require("modules/exp_legacy/utils/event") - ---- @diagnostic disable: access-invisible -Event.add(Roles.events.on_role_assigned, Gui._ensure_consistency) -Event.add(Roles.events.on_role_unassigned, Gui._ensure_consistency) diff --git a/exp_legacy/module/modules/gui/vlayer.lua b/exp_legacy/module/modules/gui/vlayer.lua index 727f9b9d..5cd33356 100644 --- a/exp_legacy/module/modules/gui/vlayer.lua +++ b/exp_legacy/module/modules/gui/vlayer.lua @@ -5,7 +5,7 @@ ]] local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles +local Roles = require("modules/exp_roles") local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event local format_number = require("util").format_number --- @dep util local config = require("modules.exp_legacy.config.vlayer") --- @dep config.vlayer @@ -457,7 +457,7 @@ local vlayer_control_set = Gui.define("vlayer_control_set") vlayer_gui_control_see(disp) local b = vlayer_gui_control_build(disp) local r = vlayer_gui_control_remove(disp) - local v = Roles.player_allowed(player, "gui/vlayer-edit") + local v = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit") b.visible = v r.visible = v @@ -484,22 +484,21 @@ Gui.toolbar.create_button{ sprite = "entity/solar-panel", tooltip = { "vlayer.main-tooltip" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/vlayer") + return Roles.player_has_permission(player, "exp_scenario.gui.vlayer") end } --- Update the visibly of the buttons based on a players roles local function role_update_event(event) local player = game.players[event.player_index] - local visible = Roles.player_allowed(player, "gui/vlayer-edit") + local visible = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit") local container = Gui.get_left_element(vlayer_container, player) local disp = container.frame["vlayer_st_2"].disp.table disp[vlayer_gui_control_build.name].visible = visible disp[vlayer_gui_control_remove.name].visible = visible end -Event.add(Roles.events.on_role_assigned, role_update_event) -Event.add(Roles.events.on_role_unassigned, role_update_event) +Event.add(Roles.events.on_player_roles_changed, role_update_event) Event.on_nth_tick(config.update_tick_gui, function(_) local stats = vlayer.get_statistics() diff --git a/exp_legacy/module/modules/gui/warp-list.lua b/exp_legacy/module/modules/gui/warp-list.lua index 15359dbc..5c456558 100644 --- a/exp_legacy/module/modules/gui/warp-list.lua +++ b/exp_legacy/module/modules/gui/warp-list.lua @@ -9,7 +9,7 @@ local Gui = require("modules/exp_gui") local Datastore = require("modules.exp_legacy.expcore.datastore") --- @dep expcore.datastore local Storage = require("modules/exp_util/storage") local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles +local Roles = require("modules/exp_roles") local Colors = require("modules/exp_util/include/color") local config = require("modules.exp_legacy.config.gui.warps") --- @dep config.gui.warps local Warps = require("modules.exp_legacy.modules.control.warps") --- @dep modules.control.warps @@ -68,8 +68,8 @@ local function check_player_permissions(player, action, warp) return true elseif action_config == "admin" then return player.admin - elseif action_config == "expcore.roles" then - return Roles.player_allowed(player, config["expcore_roles_" .. action]) + elseif action_config == "exp_roles" then + return Roles.player_has_permission(player, config["exp_roles_" .. action]) end -- Return false as all other conditions have not been met @@ -708,7 +708,7 @@ Gui.toolbar.create_button{ sprite = config.default_icon.type .. "/" .. config.default_icon.name, tooltip = { "warp-list.main-tooltip" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/warp-list") + return Roles.player_has_permission(player, "exp_scenario.gui.warp_list") end }:on_click(function(def, player, element) -- Set gui keep open state for player that clicked the button: true if visible, false if invisible @@ -885,8 +885,7 @@ local function role_update_event(event) add_new_warp_element.visible = allow_add_warp end -Event.add(Roles.events.on_role_assigned, role_update_event) -Event.add(Roles.events.on_role_unassigned, role_update_event) +Event.add(Roles.events.on_player_roles_changed, role_update_event) --- When a chart tag is removed or edited make sure it is not one that belongs to a warp local function maintain_tag(event) diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index b9bf3a8b..45b79890 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -1,6 +1,7 @@ import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages"; +import { SeedRole, seedRoles, flattenSeedPermissions } from "./seed"; import * as path from "node:path"; export class ControllerPlugin extends BaseControllerPlugin { @@ -34,6 +35,7 @@ export class ControllerPlugin extends BaseControllerPlugin { this.controller.handle(messages.RoleListRequest, this.handleRoleListRequest.bind(this)); this.controller.handle(messages.RoleMetaUpdateRequest, this.handleRoleMetaUpdateRequest.bind(this)); + this.controller.handle(messages.SeedRolesRequest, this.handleSeedRolesRequest.bind(this)); this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this)); this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this)); @@ -43,6 +45,68 @@ export class ControllerPlugin extends BaseControllerPlugin { await this.roleMeta.save(); } + /* + Seeding + */ + + /** + * Create the roles the scenario shipped with. Roles which already exist + * by name are reused and only gain the seed permissions. + */ + async handleSeedRolesRequest() { + for (const [index, seedRole] of seedRoles.entries()) { + const role = this.seedRole(seedRole); + if (!role) { + continue; + } + + this.roleMeta.set(new messages.RoleMetaRecord( + role.id, + index + 1, + seedRole.priority ?? 0, + seedRole.shortHand, + "", + seedRole.color, + seedRole.autoAssignHours === undefined ? null : seedRole.autoAssignHours * 3600000, + seedRole.blockAutoAssign ?? false, + )); + } + + this.logger.info(`Seeded ${seedRoles.length} roles`); + } + + /** Find or create the clusterio role for a seed role, returns undefined if it has no role to use. */ + seedRole(seedRole: SeedRole) { + const roles = this.controller.roles; + if (seedRole.isAdmin) { + return roles.get(lib.Role.DefaultAdminRoleId); + } + if (seedRole.isDefault) { + const defaultRoleId = this.controller.config.get("controller.default_role_id"); + return defaultRoleId !== null ? roles.get(defaultRoleId) : undefined; + } + + const permissions = flattenSeedPermissions(seedRole); + for (const permission of permissions) { + if (!lib.permissions.has(permission)) { + this.logger.warn(`Seed role ${seedRole.name} grants unknown permission ${permission}`); + } + } + + let role = [...roles.valuesMutable()].find(other => other.name === seedRole.name); + if (role) { + for (const permission of permissions) { + role.permissions.add(permission); + } + } else { + const id = Math.max(5, ...[...roles.keys()].map(other => other + 1)); + role = new lib.Role(id, seedRole.name, "", permissions); + this.logger.info(`Created role ${seedRole.name}`); + } + roles.set(role); + return role; + } + /* Role properties */ diff --git a/exp_roles/index.ts b/exp_roles/index.ts index e3cb4093..d2c522fe 100644 --- a/exp_roles/index.ts +++ b/exp_roles/index.ts @@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = { messages.RoleListRequest, messages.RoleMetaUpdateRequest, + messages.SeedRolesRequest, messages.AssignmentListRequest, messages.AssignmentUpdateRequest, diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts index 615fe7c2..208a0982 100644 --- a/exp_roles/instance.ts +++ b/exp_roles/instance.ts @@ -48,6 +48,10 @@ export class InstancePlugin extends BaseInstancePlugin { this.instance.sendTo("controller", new messages.AssignmentListRequest()), ]); + if (!roles.some(role => role.isDefault)) { + this.logger.warn("No default role is set on the controller, players will hold no roles in game"); + } + await this.luaSend("initialise", { ...messages.encodeRolesForLua(roles), assignments: assignments.map(assignment => assignment.toJSON()), diff --git a/exp_roles/messages.ts b/exp_roles/messages.ts index 3886970b..7ba71459 100644 --- a/exp_roles/messages.ts +++ b/exp_roles/messages.ts @@ -365,6 +365,18 @@ export class RoleMetaUpdateRequest { } } +/** Create the roles the scenario shipped with, see seed.ts. */ +export class SeedRolesRequest { + declare ["constructor"]: typeof SeedRolesRequest; + static plugin = "exp_roles" as const; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.role.create" as const; + + constructor() {} +} + /* Assignment requests */ diff --git a/exp_roles/module/control.lua b/exp_roles/module/control.lua index f0622a0b..5db7b55a 100644 --- a/exp_roles/module/control.lua +++ b/exp_roles/module/control.lua @@ -1,14 +1,19 @@ --[[-- ExpRoles -Replaces the legacy role system with clusterio's roles and permissions. +Mirrors clusterio's roles and permissions into the game. -Roles and the players who hold them are owned by the controller. This module -mirrors that state into the game and presents the same interface the legacy -`expcore.roles` module did, so existing call sites keep working. +Roles, the permissions they grant, and the players who hold them are owned by +the controller. This module keeps a copy of that state and answers permission +checks from it, so a check such as `Roles.player_has_permission(player, +"exp_scenario.command.kill")` is answered from the same data the web ui shows. -Assignments made in game are applied locally first and then sent to the -controller, which avoids callers having to deal with the round trip. A local -assignment which is never sent, such as one earned from time on this map, can be -made with `assign_player_local`. +Roles are objects, looked up by their clusterio id, and everything done to or +with a role is a method on it. Assignments made in game are applied locally +first and then sent to the controller, which avoids callers having to deal with +the round trip. + +Only the roles with the highest priority a player holds apply. Jail sits above +every other role, so holding it suppresses the rest, including the default role +every player has. ]] local clusterio_api = require("modules/clusterio/api") @@ -17,41 +22,47 @@ local Async = require("modules/exp_util/async") --- @class ExpRoles local ExpRoles = { - config = { - --- Role names in order, a lower index is a more privileged role - order = {}, --- @type string[] - --- Roles indexed by name, this table is never replaced - roles = {}, --- @type table - --- Role names held by each player, includes local assignments - players = {}, --- @type table - --- Async handles run when a flag is gained or lost - flags = {}, --- @type table - }, events = { - on_role_assigned = script.generate_event_name(), - on_role_unassigned = script.generate_event_name(), + --- Raised when the roles a player holds change, or when a role they + --- hold is changed on the controller. In the second case `assigned` and + --- `unassigned` are both empty. + --- @type EventData.ExpRoles.on_player_roles_changed + on_player_roles_changed = script.generate_event_name(), }, } +--- @class EventData.ExpRoles.on_player_roles_changed : EventData +--- @field player_index uint +--- @field by_player_index uint 0 when the change did not come from a player +--- @field assigned number[] Ids of the roles which were assigned +--- @field unassigned number[] Ids of the roles which were unassigned + --- Methods shared by every role, kept apart from the fields so that defining --- them does not count as injecting fields into the role itself --- @class ExpRoles.RolePrototype local Role = {} ExpRoles._prototype = Role +--- Registered so roles keep their methods across save and load +local role_metatable = { __index = Role } +script.register_metatable("exp_roles.Role", role_metatable) + --- @class ExpRoles.Role : ExpRoles.RolePrototype --- @field id number Clusterio role id --- @field name string --- @field short_hand string ---- @field order number Order given by the controller, which can have gaps ---- @field index number Position within the role order, with no gaps +--- @field order number Position given by the controller, compare roles with the methods rather than this --- @field priority number Only the highest priority roles a player holds apply ---- @field custom_tag string ---- @field custom_color Color? ---- @field allowed_actions table Permission names granted by this role ---- @field flags table Legacy flag names granted by this role +--- @field tag string +--- @field color Color? +--- @field permissions table Permission names granted by this role --- @field block_auto_assign boolean +--- @class ExpRoles.AssignOptions +--- @field by_player_name string? Shown in the game message, defaults to the current player or the server +--- @field silent boolean? When true no game message is printed +--- @field local_only boolean? When true the controller is not told, for roles earned on this map only + --- @class ExpRoles.ScriptData --- @field roles table Roles indexed by clusterio id --- @field synced_players table Role ids from the controller @@ -61,379 +72,375 @@ ExpRoles._prototype = Role --- @field emit_updates boolean local script_data = {} ---[[ - Permission names -]] - ---- Legacy actions which map onto core clusterio permissions -local core_action_permissions = { - ["command/assign-role"] = "core.user.update_roles", - ["command/unassign-role"] = "core.user.update_roles", - ["command/get-roles"] = "core.role.list", -} - ---- Cache for the action to permission mapping, this is a hot path -local action_permissions = {} --- @type table -local flag_permissions = {} --- @type table - ---- Convert a legacy action such as `gui/warp-list/add` into a permission name ---- This must match permissionFromAction in exp_scenario/permissions.ts ---- @param action string ---- @return string -local function permission_from_action(action) - local permission = action_permissions[action] - if permission then return permission end - - permission = core_action_permissions[action] - if not permission then - local body = action:gsub("%-", "_"):gsub("/", ".") - if not body:find(".", 1, true) then - body = "action." .. body - end - permission = "exp_scenario." .. body - end - - action_permissions[action] = permission - return permission -end - ---- Convert a legacy flag such as `report-immune` into a permission name ---- This must match permissionFromFlag in exp_scenario/permissions.ts ---- @param flag string ---- @return string -local function permission_from_flag(flag) - local permission = flag_permissions[flag] - if permission then return permission end - - permission = "exp_scenario.flag." .. (flag:gsub("%-", "_")) - flag_permissions[flag] = permission - return permission -end +--- Async handlers run when the state of a permission may have changed +local permission_triggers = {} --- @type table --[[ Role state ]] ---- Rebuild config.order and config.roles from the synced roles, in place -local function rebuild_role_views() - local roles = {} - for _, role in pairs(script_data.roles) do - roles[#roles + 1] = role - end - - -- Sorted on the order given by the controller, which can have gaps, the - -- index each role is given is its position within that order - table.sort(roles, function(a, b) - if a.order == b.order then return a.id < b.id end - return a.order < b.order - end) - - local order, by_name = ExpRoles.config.order, ExpRoles.config.roles - for key in pairs(order) do order[key] = nil end - for key in pairs(by_name) do by_name[key] = nil end - - for index, role in ipairs(roles) do - role.index = index - order[index] = role.name - by_name[role.name] = role - end -end - --- Build a role from the record sent by the controller --- @param record table --- @param names string[]? When given, record.permissions holds indexes into it --- @return ExpRoles.Role local function decode_role(record, names) local meta = record.meta - local allowed_actions = {} + local permissions = {} if names then -- Indexes are zero based, having come from javascript for _, index in pairs(record.permissions) do - allowed_actions[names[index + 1]] = true + permissions[names[index + 1]] = true end else for _, permission in pairs(record.permissions) do - allowed_actions[permission] = true + permissions[permission] = true end end - local flags = {} - for permission in pairs(allowed_actions) do - local flag = permission:match("^exp_scenario%.flag%.(.+)$") - if flag then flags[flag] = true end - end - --- @type ExpRoles.Role return setmetatable({ id = record.id, name = record.name, short_hand = meta.short_hand or record.name, order = meta.order or record.id, - index = meta.order or record.id, priority = meta.priority or 0, - custom_tag = meta.tag or "", - custom_color = meta.color, - allowed_actions = allowed_actions, - flags = flags, + tag = meta.tag or "", + color = meta.color, + permissions = permissions, block_auto_assign = meta.block_auto_assign or false, - }, { __index = ExpRoles._prototype }) + }, role_metatable) end ---- Refresh the legacy view of the roles a player holds ---- @param player_name string -local function rebuild_player_view(player_name) - local role_ids = {} - for _, role_id in pairs(script_data.synced_players[player_name] or {}) do - role_ids[#role_ids + 1] = role_id - end - for _, role_id in pairs(script_data.local_players[player_name] or {}) do - role_ids[#role_ids + 1] = role_id - end +--- Sort roles in place, the most privileged first +--- @param roles ExpRoles.Role[] +--- @return ExpRoles.Role[] +function ExpRoles.sort_roles(roles) + table.sort(roles, function(a, b) + if a.order == b.order then return a.id < b.id end + return a.order < b.order + end) + return roles +end - if #role_ids == 0 then - ExpRoles.config.players[player_name] = nil - return - end - - local names, seen = {}, {} - for _, role_id in pairs(role_ids) do - local role = script_data.roles[role_id] - if role and not seen[role.name] then - seen[role.name] = true - names[#names + 1] = role.name +--- Get the most privileged role of a list +--- @param roles ExpRoles.Role[] +--- @return ExpRoles.Role? +local function highest_role_of(roles) + local highest + for _, role in pairs(roles) do + if highest == nil or role:is_higher_than(highest) then + highest = role end end - - ExpRoles.config.players[player_name] = names + return highest end ---- Rebuild the legacy view for every known player -local function rebuild_all_player_views() - local players = ExpRoles.config.players - for key in pairs(players) do players[key] = nil end +--- The server is represented by nil, or by a player object with index 0 +--- @param player LuaPlayer? +--- @return LuaPlayer? # The player when it is not the server +local function not_server(player) + if player == nil or player.index == 0 then return nil end + return player +end - local seen = {} - for player_name in pairs(script_data.synced_players) do seen[player_name] = true end - for player_name in pairs(script_data.local_players) do seen[player_name] = true end - for player_name in pairs(seen) do rebuild_player_view(player_name) end +--- Role ids a player has been given, without the default role or priority applied +--- @param player_name string +--- @return number[], table +local function get_held_role_ids(player_name) + local role_ids, seen = {}, {} + for _, list in pairs{ script_data.synced_players[player_name], script_data.local_players[player_name] } do + for _, role_id in pairs(list) do + if not seen[role_id] then + seen[role_id] = true + role_ids[#role_ids + 1] = role_id + end + end + end + return role_ids, seen +end + +--- Roles which apply to a player, with the default role and priority applied +--- @param player_name string +--- @return ExpRoles.Role[] +local function get_effective_roles(player_name) + local role_ids = get_held_role_ids(player_name) + if script_data.default_role_id then + role_ids[#role_ids + 1] = script_data.default_role_id + end + + -- Only the roles with the highest priority apply, so the list restarts + -- whenever a higher priority is found + local rtn, highest_priority = {}, nil + for _, role_id in pairs(role_ids) do + local role = script_data.roles[role_id] + if role then + if highest_priority == nil or role.priority > highest_priority then + highest_priority = role.priority + rtn = { role } + elseif role.priority == highest_priority then + rtn[#rtn + 1] = role + end + end + end + return rtn end --[[ Role lookup ]] ---- Get a role by its name +--- Get a role from its clusterio id +--- @param role_id number +--- @return ExpRoles.Role? +function ExpRoles.get_role(role_id) + return script_data.roles[role_id] +end + +--- Get a role from its name, roles should be referred to by id where possible --- @param name string --- @return ExpRoles.Role? function ExpRoles.get_role_by_name(name) - return ExpRoles.config.roles[name] -end - ---- Get a role by its position in the role order ---- @param index number ---- @return ExpRoles.Role? -function ExpRoles.get_role_by_order(index) - local name = ExpRoles.config.order[index] - return name and ExpRoles.config.roles[name] -end - ---- Get a role from a name, order index, or role ---- @param any string | number | ExpRoles.Role ---- @return ExpRoles.Role? -function ExpRoles.get_role_from_any(any) - local t_any = type(any) - local as_number = tonumber(any) - if as_number then - return ExpRoles.get_role_by_order(as_number) - elseif t_any == "string" then - return ExpRoles.get_role_by_name(any) - elseif t_any == "table" then - return ExpRoles.get_role_by_name(any.name) + for _, role in pairs(script_data.roles) do + if role.name == name then return role end end + return nil end ---- Get all roles in order, the most privileged first +--- Get every role, in no particular order --- @return ExpRoles.Role[] -function ExpRoles.get_roles_ordered() +function ExpRoles.get_roles() local rtn = {} - for index, role_name in ipairs(ExpRoles.config.order) do - rtn[index] = ExpRoles.config.roles[role_name] + for _, role in pairs(script_data.roles) do + rtn[#rtn + 1] = role + end + return rtn +end + +--- Get every role, the most privileged first +--- @return ExpRoles.Role[] +function ExpRoles.get_ordered_roles() + return ExpRoles.sort_roles(ExpRoles.get_roles()) +end + +--- Get the role every player holds +--- @return ExpRoles.Role? +function ExpRoles.get_default_role() + return script_data.default_role_id and script_data.roles[script_data.default_role_id] or nil +end + +--- Get a role and every role more privileged than it, the default role excluded +--- @param role ExpRoles.Role +--- @return ExpRoles.Role[] +function ExpRoles.get_higher_roles(role) + local rtn = {} + for _, other in pairs(script_data.roles) do + if not other:is_lower_than(role) and other.id ~= script_data.default_role_id then + rtn[#rtn + 1] = other + end + end + return rtn +end + +--- Get a role and every role less privileged than it, the default role excluded +--- @param role ExpRoles.Role +--- @return ExpRoles.Role[] +function ExpRoles.get_lower_roles(role) + local rtn = {} + for _, other in pairs(script_data.roles) do + if not other:is_higher_than(role) and other.id ~= script_data.default_role_id then + rtn[#rtn + 1] = other + end end return rtn end --- Role used when there is no player, such as for commands run by the server ---- It bypasses every permission check the same way the legacy root role did +--- It has core.admin so it passes every permission check local server_role = setmetatable({ id = -1, name = "", short_hand = "SRV", - order = 0, - index = 0, + order = -math.huge, priority = 0, - custom_tag = "", - custom_color = nil, - allowed_actions = { ["core.admin"] = true }, - flags = {}, + tag = "", + color = nil, + permissions = { ["core.admin"] = true }, block_auto_assign = true, -}, { __index = ExpRoles._prototype }) +}, role_metatable) ---- Get the roles a player holds, including the default role +--- Get the roles which apply to a player, including the default role --- Only the roles with the highest priority are returned, which lets a role --- such as Jail suppress every other role a player holds ---- @param player LuaPlayer | string | nil +--- @param player LuaPlayer? nil for the server --- @return ExpRoles.Role[] function ExpRoles.get_player_roles(player) - local player_name = type(player) == "table" and player.name or player --[[@as string?]] -- The server is not a player and is allowed to do anything - if player_name == nil then return { server_role } end - - local role_ids = {} - if script_data.default_role_id then - role_ids[#role_ids + 1] = script_data.default_role_id - end - for _, role_id in pairs(script_data.synced_players[player_name] or {}) do - role_ids[#role_ids + 1] = role_id - end - for _, role_id in pairs(script_data.local_players[player_name] or {}) do - role_ids[#role_ids + 1] = role_id - end - - local roles, highest_priority, seen = {}, nil, {} - for _, role_id in pairs(role_ids) do - local role = script_data.roles[role_id] - if role and not seen[role_id] then - seen[role_id] = true - if highest_priority == nil or role.priority > highest_priority then - highest_priority = role.priority - end - roles[#roles + 1] = role - end - end - - local rtn = {} - for _, role in pairs(roles) do - if role.priority == highest_priority then - rtn[#rtn + 1] = role - end - end - - table.sort(rtn, function(a, b) return a.index < b.index end) - return rtn + local valid = not_server(player) + if not valid then return { server_role } end + return get_effective_roles(valid.name) end ---- Get the most privileged role a player holds ---- @param player LuaPlayer | string | nil ---- @return ExpRoles.Role? +--- Get the most privileged role which applies to a player +--- @param player LuaPlayer? nil for the server +--- @return ExpRoles.Role function ExpRoles.get_player_highest_role(player) - return ExpRoles.get_player_roles(player)[1] + local role = highest_role_of(ExpRoles.get_player_roles(player)) + return (assert(role, "Player has no roles, is the default role set and exp_roles syncing?")) end --[[ Permission checks ]] ---- Check if a player is allowed to perform an action ---- @param player LuaPlayer | string | nil ---- @param action string A legacy action such as `command/kill` +--- Check if any of the roles grants a permission +--- @param roles ExpRoles.Role[] +--- @param permission string --- @return boolean -function ExpRoles.player_allowed(player, action) - local permission = permission_from_action(action) - for _, role in pairs(ExpRoles.get_player_roles(player)) do - local allowed = role.allowed_actions - if allowed["core.admin"] or allowed[permission] then +local function roles_grant(roles, permission) + for _, role in pairs(roles) do + local permissions = role.permissions + if permissions["core.admin"] or permissions[permission] then return true end end - return false end ---- Check if a player has a flag set by at least one of their roles ---- @param player LuaPlayer | string | nil ---- @param flag_name string +--- Check if a player has a permission through any of their roles +--- @param player LuaPlayer? nil for the server +--- @param permission string A clusterio permission such as `exp_scenario.command.kill` --- @return boolean -function ExpRoles.player_has_flag(player, flag_name) - local permission = permission_from_flag(flag_name) - for _, role in pairs(ExpRoles.get_player_roles(player)) do - if role.allowed_actions[permission] then +function ExpRoles.player_has_permission(player, permission) + return roles_grant(ExpRoles.get_player_roles(player), permission) +end + +--- Check if a player has at least one of the permissions +--- @param player LuaPlayer? nil for the server +--- @param ... string Clusterio permission names +--- @return boolean +function ExpRoles.player_has_any_permission(player, ...) + local roles = ExpRoles.get_player_roles(player) + for index = 1, select("#", ...) do + if roles_grant(roles, (select(index, ...))) then return true end end - return false end ---- Check if a player holds a role ---- @param player LuaPlayer | string | nil ---- @param search_role string | number | ExpRoles.Role +--- Check if a player has every one of the permissions +--- @param player LuaPlayer? nil for the server +--- @param ... string Clusterio permission names --- @return boolean -function ExpRoles.player_has_role(player, search_role) - local role = ExpRoles.get_role_from_any(search_role) - if not role then return false end - - for _, player_role in pairs(ExpRoles.get_player_roles(player)) do - if player_role.name == role.name then return true end +function ExpRoles.player_has_all_permission(player, ...) + local roles = ExpRoles.get_player_roles(player) + for index = 1, select("#", ...) do + if not roles_grant(roles, (select(index, ...))) then + return false + end end - - return false + return true end ---- Check if a player bypasses all permission checks ---- This replaces the legacy root role ---- @param player LuaPlayer | string | nil +--- Check if a player is more privileged than another player +--- A player with core.admin, which includes the server, outranks every player +--- @param player LuaPlayer? nil for the server +--- @param other LuaPlayer? nil for the server --- @return boolean -function ExpRoles.is_root(player) - for _, role in pairs(ExpRoles.get_player_roles(player)) do - if role.allowed_actions["core.admin"] then return true end - end - - return false +function ExpRoles.player_outranks(player, other) + if ExpRoles.player_has_permission(player, "core.admin") then return true end + local highest = highest_role_of(ExpRoles.get_player_roles(player)) + local other_highest = highest_role_of(ExpRoles.get_player_roles(other)) + if highest == nil then return false end + return other_highest == nil or highest:is_higher_than(other_highest) end --[[ - Role prototype + Role methods ]] ---- Check if this role allows an action +--- Check if this role grants a permission --- @param self ExpRoles.Role ---- @param action string +--- @param permission string --- @return boolean -function Role.is_allowed(self, action) - local allowed = self.allowed_actions - return allowed["core.admin"] or allowed[permission_from_action(action)] or false +function Role.has_permission(self, permission) + local permissions = self.permissions + return permissions["core.admin"] or permissions[permission] or false end ---- Check if this role sets a flag +--- Check if this role is more privileged than another --- @param self ExpRoles.Role ---- @param name string +--- @param other ExpRoles.Role --- @return boolean -function Role.has_flag(self, name) - return self.allowed_actions[permission_from_flag(name)] or false +function Role.is_higher_than(self, other) + if self.order == other.order then return self.id < other.id end + return self.order < other.order end ---- Get the players who hold this role +--- Check if this role is less privileged than another +--- @param self ExpRoles.Role +--- @param other ExpRoles.Role +--- @return boolean +function Role.is_lower_than(self, other) + return other:is_higher_than(self) +end + +--- Check if a player has been given this role, or it is the default role +--- A role a player holds does not always apply, see ExpRoles.get_player_roles +--- @param self ExpRoles.Role +--- @param player LuaPlayer? nil for the server +--- @return boolean +function Role.has_player(self, player) + local valid = not_server(player) + if not valid then return self == server_role end + if self.id == script_data.default_role_id then return true end + local _, held = get_held_role_ids(valid.name) + return held[self.id] == true +end + +--- Get the names of every player who has been given this role +--- This includes players who have never joined this map, and is not affected +--- by priority, so a jailed moderator is still listed under moderator +--- @param self ExpRoles.Role +--- @return string[] +function Role.get_player_names(self) + -- A player can be in both lists, so collect into a set first + local seen = {} + for _, players in pairs{ script_data.synced_players, script_data.local_players } do + for player_name, role_ids in pairs(players) do + for _, role_id in pairs(role_ids) do + if role_id == self.id then + seen[player_name] = true + break + end + end + end + end + + local names = {} + for player_name in pairs(seen) do + names[#names + 1] = player_name + end + return names +end + +--- Get the players on this map who have been given this role --- @param self ExpRoles.Role --- @param online boolean? When given, filter by connected state --- @return LuaPlayer[] function Role.get_players(self, online) local players = {} - for player_name, role_names in pairs(ExpRoles.config.players) do - for _, role_name in pairs(role_names) do - if role_name == self.name then - local player = game.players[player_name] - if player and (online == nil or player.connected == online) then - players[#players + 1] = player - end - break - end + for _, player_name in pairs(self:get_player_names()) do + local player = game.get_player(player_name) + if player and (online == nil or player.connected == online) then + players[#players + 1] = player end end return players end ---- Print a message to every online player who holds this role +--- Print a message to every online player who has been given this role --- @param self ExpRoles.Role --- @param message LocalisedString --- @return number # Number of players the message was sent to @@ -447,76 +454,22 @@ function Role.print(self, message) end --[[ - Printing to roles + Permission triggers ]] ---- Print a message to every player holding one of the given roles ---- @param roles (string | number | ExpRoles.Role)[] ---- @param message LocalisedString -function ExpRoles.print_to_roles(roles, message) - for _, role in pairs(roles) do - local resolved = ExpRoles.get_role_from_any(role) - if resolved then resolved:print(message) end - end -end - ---- Print a message to every player holding the given role or a more privileged one ---- @param role string | number | ExpRoles.Role ---- @param message LocalisedString -function ExpRoles.print_to_roles_higher(role, message) - local resolved = ExpRoles.get_role_from_any(role) - if not resolved then return end - - local roles = {} - for index, role_name in ipairs(ExpRoles.config.order) do - if index <= resolved.index and role_name ~= ExpRoles.get_default_role_name() then - roles[#roles + 1] = role_name - end - end - - ExpRoles.print_to_roles(roles, message) -end - ---- Print a message to every player holding the given role or a less privileged one ---- @param role string | number | ExpRoles.Role ---- @param message LocalisedString -function ExpRoles.print_to_roles_lower(role, message) - local resolved = ExpRoles.get_role_from_any(role) - if not resolved then return end - - local roles = {} - for index, role_name in ipairs(ExpRoles.config.order) do - if index >= resolved.index and role_name ~= ExpRoles.get_default_role_name() then - roles[#roles + 1] = role_name - end - end - - ExpRoles.print_to_roles(roles, message) -end - ---- Get the name of the role every player holds ---- @return string? -function ExpRoles.get_default_role_name() - local role = script_data.default_role_id and script_data.roles[script_data.default_role_id] - return role and role.name or nil -end - ---[[ - Flags -]] - ---- Register a callback run when a player gains or loses a flag ---- @param name string +--- Register a callback run with the state of a permission whenever the roles +--- of a player may have changed, and when they join the game +--- @param permission string --- @param callback fun(player: LuaPlayer, state: boolean) -function ExpRoles.define_flag_trigger(name, callback) - ExpRoles.config.flags[name] = Async.register(callback) +function ExpRoles.define_permission_trigger(permission, callback) + permission_triggers[permission] = Async.register(callback) end ---- Run every flag trigger for a player +--- Run every permission trigger for a player --- @param player LuaPlayer -local function apply_flag_triggers(player) - for flag, async_function in pairs(ExpRoles.config.flags) do - async_function(player, ExpRoles.player_has_flag(player, flag)) +local function apply_permission_triggers(player) + for permission, async_function in pairs(permission_triggers) do + async_function(player, ExpRoles.player_has_permission(player, permission)) end end @@ -524,44 +477,74 @@ end Assignment ]] ---- Raise the assigned or unassigned event and tell the player what changed +--- Raise the roles changed event and tell the player what changed --- @param player LuaPlayer ---- @param change_type "assign" | "unassign" ---- @param roles ExpRoles.Role[] +--- @param assigned ExpRoles.Role[] +--- @param unassigned ExpRoles.Role[] --- @param by_player_name string? --- @param silent boolean? -local function emit_player_roles_updated(player, change_type, roles, by_player_name, silent) +local function emit_player_roles_changed(player, assigned, unassigned, by_player_name, silent) by_player_name = by_player_name or (game.player and game.player.name) or "" - local by_player = game.players[by_player_name] + local by_player = game.get_player(by_player_name) - local role_names = {} - for index, role in ipairs(roles) do - role_names[index] = role.name + local assigned_ids, unassigned_ids = {}, {} + local assigned_names, unassigned_names = {}, {} + for index, role in ipairs(assigned) do + assigned_ids[index], assigned_names[index] = role.id, role.name + end + for index, role in ipairs(unassigned) do + unassigned_ids[index], unassigned_names[index] = role.id, role.name end if not silent then - local joined = table.concat(role_names, ", ") - game.print(change_type == "assign" - and { "exp-roles.game-message-assign", player.name, joined, by_player_name } - or { "exp-roles.game-message-unassign", player.name, joined, by_player_name }) + if #assigned > 0 then + game.print{ "exp-roles.game-message-assign", player.name, table.concat(assigned_names, ", "), by_player_name } + end + if #unassigned > 0 then + game.print{ "exp-roles.game-message-unassign", player.name, table.concat(unassigned_names, ", "), by_player_name } + end end - if change_type == "assign" then + if #assigned > 0 then player.play_sound{ path = "utility/achievement_unlocked" } - else + elseif #unassigned > 0 then player.play_sound{ path = "utility/game_lost" } end - local event = change_type == "assign" and ExpRoles.events.on_role_assigned or ExpRoles.events.on_role_unassigned - script.raise_event(event, { - name = event, + script.raise_event(ExpRoles.events.on_player_roles_changed, { + name = ExpRoles.events.on_player_roles_changed, tick = game.tick, player_index = player.index, by_player_index = by_player and by_player.index or 0, - roles = role_names, + assigned = assigned_ids, + unassigned = unassigned_ids, }) - apply_flag_triggers(player) + apply_permission_triggers(player) +end + +--- Compare the roles a player had been given before and after a change and +--- raise the event for what differs +--- @param player_name string +--- @param before table +--- @param by_player_name string? +--- @param silent boolean? +local function emit_held_diff(player_name, before, by_player_name, silent) + local player = game.get_player(player_name) + if not player then return end + + local _, after = get_held_role_ids(player_name) + local assigned, unassigned = {}, {} + for role_id in pairs(after) do + if not before[role_id] then assigned[#assigned + 1] = script_data.roles[role_id] end + end + for role_id in pairs(before) do + if not after[role_id] then unassigned[#unassigned + 1] = script_data.roles[role_id] end + end + + if #assigned > 0 or #unassigned > 0 then + emit_player_roles_changed(player, assigned, unassigned, by_player_name, silent) + end end --- Remove a role id from a list, returns true when it was present @@ -591,149 +574,90 @@ local function add_role_id(list, role_id) return true end ---- Convert a role, role name, or array of either into an array of roles ---- @param roles any ---- @return ExpRoles.Role[] -local function resolve_roles(roles) - if type(roles) ~= "table" or roles.name then - roles = { roles } - end - - local rtn = {} - for _, role in pairs(roles) do - local resolved = ExpRoles.get_role_from_any(role) - if resolved then rtn[#rtn + 1] = resolved end - end - - return rtn -end - ---- Apply a role change locally and tell the controller about it +--- Apply a role change to the local state, returns true when something changed --- @param player_name string ---- @param roles ExpRoles.Role[] ---- @param change_type "assign" | "unassign" +--- @param role ExpRoles.Role +--- @param assign boolean --- @param sync boolean ---- @return ExpRoles.Role[] # The roles which actually changed -local function apply_local_change(player_name, roles, change_type, sync) +--- @return boolean +local function apply_local_change(player_name, role, assign, sync) local local_roles = script_data.local_players[player_name] or {} local pending = script_data.pending[player_name] or {} - local changed = {} + local changed - for _, role in pairs(roles) do - if change_type == "assign" then - local synced = script_data.synced_players[player_name] or {} - local already_synced = false - for _, role_id in pairs(synced) do - if role_id == role.id then already_synced = true break end - end - if not already_synced and add_role_id(local_roles, role.id) then - changed[#changed + 1] = role - if sync then add_role_id(pending, role.id) end - end - else - local removed = remove_role_id(local_roles, role.id) - remove_role_id(pending, role.id) - -- A synced role is only taken away here when the controller is - -- being told as well, otherwise the next update would restore it - if sync then - removed = remove_role_id(script_data.synced_players[player_name], role.id) or removed - end - if removed then - changed[#changed + 1] = role - end + if assign then + local synced = script_data.synced_players[player_name] or {} + local already_synced = false + for _, role_id in pairs(synced) do + if role_id == role.id then already_synced = true break end + end + changed = not already_synced and add_role_id(local_roles, role.id) + if changed and sync then add_role_id(pending, role.id) end + else + changed = remove_role_id(local_roles, role.id) + remove_role_id(pending, role.id) + -- A synced role is only taken away here when the controller is + -- being told as well, otherwise the next update would restore it + if sync then + changed = remove_role_id(script_data.synced_players[player_name], role.id) or changed end end script_data.local_players[player_name] = next(local_roles) and local_roles or nil script_data.pending[player_name] = next(pending) and pending or nil - rebuild_player_view(player_name) return changed end --- Send a role change to the controller --- @param player_name string ---- @param roles ExpRoles.Role[] ---- @param change_type "assign" | "unassign" -local function emit_assignment_update(player_name, roles, change_type) +--- @param role ExpRoles.Role +--- @param assign boolean +local function emit_assignment_update(player_name, role, assign) if not script_data.emit_updates then return end - local role_ids = {} - for index, role in ipairs(roles) do - role_ids[index] = role.id - end - clusterio_api.send_json("exp_roles:assignment_update", { name = player_name, - assign = change_type == "assign" and role_ids or nil, - unassign = change_type == "unassign" and role_ids or nil, + assign = assign and { role.id } or nil, + unassign = not assign and { role.id } or nil, }) end ---- Change the roles a player holds ---- @param player LuaPlayer | string ---- @param roles any A role, role name, or array of either ---- @param change_type "assign" | "unassign" ---- @param by_player_name string? ---- @param silent boolean? ---- @param sync boolean -local function change_player_roles(player, roles, change_type, by_player_name, silent, sync) - local player_name = type(player) == "table" and player.name or player --[[@as string?]] - if not player_name then return end +--- Change whether a player holds a role +--- @param role ExpRoles.Role +--- @param player LuaPlayer +--- @param assign boolean +--- @param options ExpRoles.AssignOptions? +local function change_player_role(role, player, assign, options) + local valid = not_server(player) + if not valid then return end + options = options or {} - local role_objects = resolve_roles(roles) - if #role_objects == 0 then return end + local player_name = valid.name + local _, before = get_held_role_ids(player_name) + if not apply_local_change(player_name, role, assign, not options.local_only) then return end - local changed = apply_local_change(player_name, role_objects, change_type, sync) - if #changed == 0 then return end - - if sync then - emit_assignment_update(player_name, changed, change_type) + if not options.local_only then + emit_assignment_update(player_name, role, assign) end - local valid_player = game.get_player(player_name) - if valid_player then - emit_player_roles_updated(valid_player, change_type, changed, by_player_name, silent) - end + emit_held_diff(player_name, before, options.by_player_name, options.silent) end ---- Give a player one or more roles, the change is sent to the controller ---- @param player LuaPlayer | string ---- @param roles any A role, role name, or array of either ---- @param by_player_name string? ---- @param skip_checks boolean? Unused, kept for compatibility ---- @param silent boolean? -function ExpRoles.assign_player(player, roles, by_player_name, skip_checks, silent) - change_player_roles(player, roles, "assign", by_player_name, silent, true) +--- Give a player this role, the change is sent to the controller unless local_only is set +--- @param self ExpRoles.Role +--- @param player LuaPlayer +--- @param options ExpRoles.AssignOptions? +function Role.assign(self, player, options) + change_player_role(self, player, true, options) end ---- Take one or more roles from a player, the change is sent to the controller ---- @param player LuaPlayer | string ---- @param roles any A role, role name, or array of either ---- @param by_player_name string? ---- @param skip_checks boolean? Unused, kept for compatibility ---- @param silent boolean? -function ExpRoles.unassign_player(player, roles, by_player_name, skip_checks, silent) - change_player_roles(player, roles, "unassign", by_player_name, silent, true) -end - ---- Give a player one or more roles on this map only, the controller is not told ---- Use this for roles earned from progress which does not leave this map ---- @param player LuaPlayer | string ---- @param roles any A role, role name, or array of either ---- @param by_player_name string? ---- @param silent boolean? -function ExpRoles.assign_player_local(player, roles, by_player_name, silent) - change_player_roles(player, roles, "assign", by_player_name, silent, false) -end - ---- Take one or more local roles from a player, the controller is not told ---- @param player LuaPlayer | string ---- @param roles any A role, role name, or array of either ---- @param by_player_name string? ---- @param silent boolean? -function ExpRoles.unassign_player_local(player, roles, by_player_name, silent) - change_player_roles(player, roles, "unassign", by_player_name, silent, false) +--- Take this role from a player, the change is sent to the controller unless local_only is set +--- @param self ExpRoles.Role +--- @param player LuaPlayer +--- @param options ExpRoles.AssignOptions? +function Role.unassign(self, player, options) + change_player_role(self, player, false, options) end --[[ @@ -741,7 +665,7 @@ end ]] --- Stop holding a pending role locally once the controller has confirmed it ---- Roles assigned with assign_player_local are never pending so are untouched +--- Roles assigned with local_only are never pending so are untouched --- @param player_name string local function drop_confirmed_local(player_name) local pending = script_data.pending[player_name] @@ -763,10 +687,6 @@ end --- Restore local references to persistent script data after load function ExpRoles.on_load() script_data = compat.script_data["exp_roles"] - if script_data then - rebuild_role_views() - rebuild_all_player_views() - end end --- Enable or disable sending role changes back to the controller @@ -775,6 +695,14 @@ function ExpRoles.set_emit_events(enabled) script_data.emit_updates = enabled ~= false end +--- Apply the triggers of every connected player, and raise the event so guis +--- can refresh, after the roles themselves have changed +local function roles_changed() + for _, player in pairs(game.connected_players) do + emit_player_roles_changed(player, {}, {}, "", true) + end +end + --- Replace all local state with the state held by the controller --- Permission names are sent once and referenced by index, see encodeRolesForLua --- @param payload { permission_names: string[], roles: table[], assignments: table[] } @@ -816,13 +744,7 @@ function ExpRoles.initialise(payload) end script_data.pending = {} - rebuild_role_views() - rebuild_all_player_views() - - for _, player in pairs(game.connected_players) do - apply_flag_triggers(player) - end - + roles_changed() script_data.emit_updates = emit_updates end @@ -845,12 +767,7 @@ function ExpRoles.receive_role_updates(records) end end - rebuild_role_views() - rebuild_all_player_views() - - for _, player in pairs(game.connected_players) do - apply_flag_triggers(player) - end + roles_changed() end --- Receive changes to the roles held by players from the controller @@ -858,6 +775,8 @@ end function ExpRoles.receive_assignment_updates(records) for _, record in pairs(records) do local player_name = record.name + local _, before = get_held_role_ids(player_name) + if record.is_deleted then script_data.synced_players[player_name] = nil else @@ -865,27 +784,22 @@ function ExpRoles.receive_assignment_updates(records) end drop_confirmed_local(player_name) - rebuild_player_view(player_name) - - local player = game.get_player(player_name) - if player then apply_flag_triggers(player) end + emit_held_diff(player_name, before, "") end end --- Roll back a local assignment which the controller rejected --- @param payload { name: string, role_ids: number[] } function ExpRoles.reject_assignment(payload) - local player_name = payload.name - local roles = {} + local player = game.get_player(payload.name) for _, role_id in pairs(payload.role_ids) do local role = script_data.roles[role_id] - if role then roles[#roles + 1] = role end + if role and player then + role:unassign(player, { by_player_name = "", silent = true, local_only = true }) + elseif role then + apply_local_change(payload.name, role, false, false) + end end - - if #roles == 0 then return end - - -- Sync is false, so this is never sent back to the controller - change_player_roles(player_name, roles, "unassign", "", true, false) end --- Get the current script data for debugging purposes @@ -915,11 +829,11 @@ function ExpRoles.on_server_startup() ExpRoles.on_load() end ---- Apply the flag triggers for a player who just joined +--- Apply the permission triggers for a player who just joined --- @param event EventData.on_player_joined_game function ExpRoles.on_player_joined_game(event) local player = game.get_player(event.player_index) - if player then apply_flag_triggers(player) end + if player then apply_permission_triggers(player) end end return ExpRoles diff --git a/exp_roles/module/events.lua b/exp_roles/module/events.lua index 12d8456d..c3029c49 100644 --- a/exp_roles/module/events.lua +++ b/exp_roles/module/events.lua @@ -2,8 +2,8 @@ Wires the role sync into the factorio event handler. This is kept apart from control.lua because `ExpRoles.events` holds the ids of -the events this module raises, which is the interface the legacy role system -had, and event_handler expects `events` to be the handlers to register. +the events this module raises, while event_handler expects `events` to be the +handlers to register. ]] local clusterio_api = require("modules/clusterio/api") diff --git a/exp_roles/module/locale/zh-CN.cfg b/exp_roles/module/locale/zh-CN.cfg new file mode 100644 index 00000000..23b5caea --- /dev/null +++ b/exp_roles/module/locale/zh-CN.cfg @@ -0,0 +1,3 @@ +[exp-roles] +game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ +game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ diff --git a/exp_roles/module/locale/zh-TW.cfg b/exp_roles/module/locale/zh-TW.cfg new file mode 100644 index 00000000..23b5caea --- /dev/null +++ b/exp_roles/module/locale/zh-TW.cfg @@ -0,0 +1,3 @@ +[exp-roles] +game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ +game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ diff --git a/exp_roles/package.json b/exp_roles/package.json index d6530a11..05f53e71 100644 --- a/exp_roles/package.json +++ b/exp_roles/package.json @@ -7,7 +7,9 @@ "repository": "explosivegaming/ExpCluster", "main": "dist/node/index.js", "scripts": { - "prepare": "tsc --build && webpack-cli --env production" + "prepare": "tsc --build && webpack-cli --env production", + "test": "tap --disable-coverage --allow-empty-coverage test/*.test.js", + "coverage": "tap --coverage-report=text test/*.test.js" }, "engines": { "node": ">=18" @@ -24,6 +26,7 @@ "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^", "@clusterio/web_ui": "workspace:^", + "@expcluster/scenario": "workspace:^", "@types/node": "catalog:", "@types/react": "catalog:", "antd": "catalog:", @@ -32,6 +35,8 @@ "react-router-dom": "catalog:", "typescript": "catalog:", "webpack": "catalog:", + "fengari": "^0.1.5", + "tap": "^21.1.0", "webpack-cli": "catalog:", "webpack-merge": "catalog:" }, diff --git a/exp_roles/seed.ts b/exp_roles/seed.ts new file mode 100644 index 00000000..97325245 --- /dev/null +++ b/exp_roles/seed.ts @@ -0,0 +1,268 @@ +import { RoleColor } from "./messages"; + +/** + * A role created by the seed, as the scenario defined it before roles moved to + * the controller. `parent` stands in for the inheritance + * the old config had and is flattened by the seed. The default and admin roles + * already exist, so those entries only provide the in game properties. + */ +export interface SeedRole { + name: string; + shortHand: string; + color: RoleColor | null; + priority?: number; + autoAssignHours?: number; + blockAutoAssign?: boolean; + /** Uses the controller's default role rather than creating one. */ + isDefault?: boolean; + /** Uses the controller's admin role rather than creating one. */ + isAdmin?: boolean; + /** Name of the role whose permissions are also granted, applied recursively. */ + parent?: string; + permissions: string[]; +} + +export const seedRoles: SeedRole[] = [ + { + name: "System", + shortHand: "SYS", + color: null, + isAdmin: true, + permissions: [], + }, + { + name: "Senior Administrator", + shortHand: "SAdmin", + color: new RoleColor(233, 63, 233), + parent: "Administrator", + permissions: [ + "exp_scenario.command._rcon", + "exp_scenario.command.debug", + "exp_scenario.command.set_cheat_mode", + "exp_scenario.command.research_all", + ], + }, + { + name: "Administrator", + shortHand: "Admin", + color: new RoleColor(233, 63, 233), + parent: "Moderator", + permissions: [ + "exp_scenario.gui.warp_list.bypass_proximity", + "exp_scenario.gui.warp_list.bypass_cooldown", + "exp_scenario.command.connect_all", + ], + }, + { + name: "Moderator", + shortHand: "Mod", + color: new RoleColor(0, 170, 0), + parent: "Trainee", + permissions: [ + "exp_scenario.player.instant_respawn", + "exp_scenario.command.assign_role", + "exp_scenario.command.unassign_role", + "exp_scenario.command.repair", + "exp_scenario.command.kill.always", + "exp_scenario.command.tag_clear.always", + "exp_scenario.command.spawn.always", + "exp_scenario.command.clear_reports", + "exp_scenario.command.clear_warnings", + "exp_scenario.command.clear_script_warnings", + "exp_scenario.command.clear_last_warnings", + "exp_scenario.command.clear_inventory", + "exp_scenario.command.kill_enemies", + "exp_scenario.command.remove_enemies", + "exp_scenario.command.home", + "exp_scenario.command.set_home", + "exp_scenario.command.get_home", + "exp_scenario.command.return", + "exp_scenario.command.connect_player", + "exp_scenario.command.set_bot_queue", + "exp_scenario.command.set_game_speed", + "exp_scenario.command.set_friendly_fire", + "exp_scenario.command.set_always_day", + "exp_scenario.command.set_pollution_enabled", + "exp_scenario.command.clear_pollution", + "exp_scenario.gui.rocket_info.toggle_active", + "exp_scenario.gui.rocket_info.remote_launch", + "exp_scenario.gui.bonus", + "exp_scenario.decon.fast_trees", + ], + }, + { + name: "Trainee", + shortHand: "TrMod", + color: new RoleColor(0, 170, 0), + parent: "Veteran", + permissions: [ + "exp_scenario.player.admin", + "exp_scenario.player.spectator", + "exp_scenario.bypass.reports", + "exp_scenario.command.admin_chat", + "exp_scenario.command.goto", + "exp_scenario.command.teleport", + "exp_scenario.command.bring", + "exp_scenario.command.create_warning", + "exp_scenario.command.get_warnings", + "exp_scenario.command.get_reports", + "exp_scenario.command.protect_entity", + "exp_scenario.command.protect_area", + "exp_scenario.command.protect_tag", + "exp_scenario.command.jail", + "exp_scenario.command.unjail", + "exp_scenario.gui.player_list.kick", + "exp_scenario.gui.player_list.ban", + "exp_scenario.command.spectate", + "exp_scenario.command.follow", + "exp_scenario.command.search", + "exp_scenario.command.search_online", + "exp_scenario.command.search_amount", + "exp_scenario.command.search_recent", + "exp_scenario.command.clear_blueprints_surface", + "exp_scenario.gui.playerdata", + ], + }, + { + name: "Board Member", + shortHand: "Board", + color: new RoleColor(247, 246, 54), + parent: "Sponsor", + permissions: [ + "exp_scenario.command.goto", + "exp_scenario.command.repair", + "exp_scenario.command.spectate", + "exp_scenario.command.follow", + "exp_scenario.gui.playerdata", + ], + }, + { + name: "Senior Backer", + shortHand: "Backer", + color: new RoleColor(238, 172, 44), + parent: "Sponsor", + permissions: [], + }, + { + name: "Sponsor", + shortHand: "Spon", + color: new RoleColor(238, 172, 44), + parent: "Supporter", + permissions: [ + "exp_scenario.bypass.reports", + "exp_scenario.player.instant_respawn", + "exp_scenario.gui.rocket_info.toggle_active", + "exp_scenario.gui.rocket_info.remote_launch", + "exp_scenario.gui.bonus", + "exp_scenario.command.home", + "exp_scenario.command.set_home", + "exp_scenario.command.get_home", + "exp_scenario.command.return", + "exp_scenario.decon.fast_trees", + ], + }, + { + name: "Supporter", + shortHand: "Sup", + color: new RoleColor(230, 99, 34), + parent: "Veteran", + permissions: [ + "exp_scenario.player.spectator", + "exp_scenario.command.tag_color", + "exp_scenario.command.jail", + "exp_scenario.command.unjail", + "exp_scenario.command.set_join_message", + "exp_scenario.command.remove_join_message", + ], + }, + { + name: "Partner", + shortHand: "Part", + color: new RoleColor(140, 120, 200), + parent: "Veteran", + permissions: [ + "exp_scenario.player.spectator", + "exp_scenario.command.jail", + "exp_scenario.command.unjail", + ], + }, + { + name: "Veteran", + shortHand: "Vet", + color: new RoleColor(140, 120, 200), + parent: "Member", + autoAssignHours: 10, + permissions: [ + "exp_scenario.chat.commands", + "exp_scenario.command.clear_ground_items", + "exp_scenario.command.clear_blueprints", + "exp_scenario.command.set_trains_to_automatic", + ], + }, + { + name: "Member", + shortHand: "Mem", + color: new RoleColor(24, 172, 188), + parent: "Regular", + permissions: [ + "exp_scenario.bypass.deconstruction_log", + "exp_scenario.gui.task_list.add", + "exp_scenario.gui.task_list.edit", + "exp_scenario.gui.warp_list.add", + "exp_scenario.gui.warp_list.edit", + "exp_scenario.gui.surveillance", + "exp_scenario.gui.vlayer_edit", + "exp_scenario.gui.tool", + "exp_scenario.command.save_quickbar", + "exp_scenario.command.vlayer_info", + "exp_scenario.command.lawnmower", + "exp_scenario.command.waterfill", + "exp_scenario.command.artillery", + ], + }, + { + name: "Regular", + shortHand: "Reg", + color: new RoleColor(79, 155, 163), + autoAssignHours: 3, + permissions: [ + "exp_scenario.command.kill", + "exp_scenario.command.rainbow", + "exp_scenario.command.spawn", + "exp_scenario.command.me", + "exp_scenario.decon.standard", + "exp_scenario.bypass.entity_protection", + "exp_scenario.bypass.nuke_protection", + ], + }, + { + name: "Jail", + shortHand: "Jail", + color: new RoleColor(50, 50, 50), + priority: 1, + blockAutoAssign: true, + permissions: [], + }, + { + name: "Guest", + shortHand: "", + color: new RoleColor(185, 187, 160), + isDefault: true, + permissions: [], + }, +]; + +/** The permissions a seed role grants, including those of its parents. */ +export function flattenSeedPermissions(role: SeedRole, roles = seedRoles) { + const permissions = new Set(); + const seen = new Set(); + let current: SeedRole | undefined = role; + while (current && !seen.has(current.name)) { + seen.add(current.name); + for (const permission of current.permissions) { + permissions.add(permission); + } + current = roles.find(other => other.name === current!.parent); + } + return permissions; +} diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js new file mode 100644 index 00000000..bf2f843c --- /dev/null +++ b/exp_roles/test/controller.test.js @@ -0,0 +1,250 @@ +"use strict"; +const t = require("tap"); +const lib = require("@clusterio/lib"); +const { Controller } = require("@clusterio/controller"); +const { ControllerPlugin } = require("../dist/node/controller"); +const messages = require("../dist/node/messages"); +const { seedRoles } = require("../dist/node/seed"); + +// Importing this defines the exp_scenario permissions the seed grants +require("@expcluster/scenario/dist/node/permissions"); + +// The controller validates message classes against the link registry +lib.Link.register(messages.RoleUpdatedEvent); +lib.Link.register(messages.AssignmentUpdatedEvent); +lib.Link.register(messages.RoleListRequest); +lib.Link.register(messages.RoleMetaUpdateRequest); +lib.Link.register(messages.SeedRolesRequest); +lib.Link.register(messages.AssignmentListRequest); +lib.Link.register(messages.AssignmentUpdateRequest); + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +// Silence the singleton logger to avoid polluting the test output +lib.logger.silent = true; +t.after(() => { + lib.logger.silent = false; +}); + +/** Build a plugin around a real controller, which is side effect free while not started. */ +async function startPlugin(t2, { roles = [] } = {}) { + const controllerConfig = new lib.ControllerConfig("controller", { + "controller.database_directory": t2.testdir(), + "controller.default_role_id": lib.Role.DefaultPlayerRoleId, + }); + const controller = new Controller(logger, [], controllerConfig); + for (const role of roles) { + controller.roles.set(role); + } + + // Spies which record and then defer to the real behaviour + const state = { broadcasts: [], permissionUpdates: [] }; + const broadcast = controller.subscriptions.broadcast.bind(controller.subscriptions); + controller.subscriptions.broadcast = event => { + state.broadcasts.push(event); + broadcast(event); + }; + const permissionsUpdated = controller.userPermissionsUpdated.bind(controller); + controller.userPermissionsUpdated = user => { + state.permissionUpdates.push(user.name); + permissionsUpdated(user); + }; + + const plugin = new ControllerPlugin({ name: "exp_roles" }, controller, undefined, logger); + await plugin.init(); + return { plugin, controller, state }; +} + +const role = (id, name, permissions = []) => new lib.Role(id, name, "", new Set(permissions)); + +t.test("class ControllerPlugin", t2 => { + t2.test(".init() and .sweepRoleMeta() manage the role properties", async t3 => { + const { plugin } = await startPlugin(t3, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); + + t3.strictSame(plugin.roleMeta.get(0).order, 1, "the first role is ordered first"); + t3.strictSame(plugin.roleMeta.get(5).order, 2, "the next role is ordered after it"); + + plugin.roleMeta.set(new messages.RoleMetaRecord(99, 3)); + plugin.sweepRoleMeta(); + t3.notOk(plugin.roleMeta.get(99), "properties without a role are removed"); + t3.ok(plugin.roleMeta.get(5), "properties with a role are kept"); + }); + + t2.test(".buildRoleRecord() combines the role with its properties", async t3 => { + const { plugin, controller } = await startPlugin(t3, { + roles: [role(1, "Player"), role(5, "Moderator", ["exp_scenario.command.kill"])], + }); + plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 1, "Mod")); + + const record = plugin.buildRoleRecord(controller.roles.get(5)); + t3.strictSame(record.name, "Moderator", "the name comes from the role"); + t3.strictSame(record.permissions, ["exp_scenario.command.kill"], "the permissions come from the role"); + t3.strictSame(record.meta.shortHand, "Mod", "the properties come from the datastore"); + t3.notOk(record.isDefault, "not the default role"); + t3.ok(plugin.buildRoleRecord(controller.roles.get(1)).isDefault, "the default role is marked"); + }); + + t2.test(".rolesUpdated() and .roleMetaUpdated() broadcast to subscribers", async t3 => { + const { plugin, controller, state } = await startPlugin(t3, { roles: [role(1, "Player")] }); + + state.broadcasts.length = 0; + controller.roles.set(role(5, "Moderator")); + const updated = state.broadcasts + .filter(event => event instanceof messages.RoleUpdatedEvent) + .flatMap(event => event.updates.map(update => update.name)); + t3.ok(updated.includes("Moderator"), "a new role is broadcast"); + + state.broadcasts.length = 0; + plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 0, "Mod")); + t3.ok(state.broadcasts.some( + event => event instanceof messages.RoleUpdatedEvent && event.updates[0].meta.shortHand === "Mod" + ), "a property change is broadcast as its role"); + + state.broadcasts.length = 0; + await plugin.onControllerConfigFieldChanged("controller.default_role_id"); + t3.ok( + state.broadcasts.some(event => event instanceof messages.RoleUpdatedEvent), + "a default role change rebroadcasts", + ); + }); + + t2.test(".handleRoleSubscription() replays only newer records", async t3 => { + const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player")] }); + controller.roles.set(role(5, "Moderator")); + const updatedAtMs = plugin.buildRoleRecord(controller.roles.get(5)).updatedAtMs; + + const all = await plugin.handleRoleSubscription({ lastRequestTimeMs: 0 }); + t3.strictSame(all.updates.length, 2, "everything is replayed from the start"); + const none = await plugin.handleRoleSubscription({ lastRequestTimeMs: updatedAtMs }); + t3.strictSame(none, null, "nothing is replayed when up to date"); + }); + + t2.test(".listAssignmentRecords() mirrors users without the default role", async t3 => { + const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] }); + controller.users.createUser("alice").set("roleIds", new Set([lib.Role.DefaultPlayerRoleId, 5])); + controller.users.createUser("bob"); + + const records = plugin.listAssignmentRecords(); + t3.strictSame(records.length, 1, "users with only the default role are left out"); + t3.strictSame(records[0].name, "alice"); + t3.strictSame([...records[0].roleIds], [5], "the default role is not listed"); + }); + + t2.test(".handleAssignmentUpdateRequest() validates the user and roles", async t3 => { + const { plugin, controller, state } = await startPlugin(t3, { + roles: [role(1, "Player"), role(5, "Moderator"), role(6, "Regular")], + }); + controller.users.createUser("alice").set("roleIds", new Set([5])); + + await t3.rejects( + plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("nobody", [], [])), + { message: /does not exist/ }, "an unknown user is refused", + ); + await t3.rejects( + plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("alice", [99], [])), + { message: /does not exist/ }, "an unknown role is refused", + ); + + await plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("alice", [6], [5])); + t3.strictSame([...controller.users.getByName("alice").roleIds], [6], "roles are added and removed"); + t3.ok(state.permissionUpdates.includes("alice"), "permission changes are pushed to the user"); + }); + + t2.test(".applyAutoAssign() and .onPlayerEvent() grant roles from online time", async t3 => { + const { plugin, controller } = await startPlugin(t3, { + roles: [role(1, "Player"), role(6, "Regular"), role(7, "Jail")], + }); + const onlineTime = ms => new Map([[1, lib.PlayerStats.fromJSON({ online_time_ms: ms })]]); + controller.users.createUser("newcomer").set("instanceStats", onlineTime(3600000)); + controller.users.createUser("veteran").set("instanceStats", onlineTime(7200000)); + const jailed = controller.users.createUser("jailed"); + jailed.set("roleIds", new Set([7])); + jailed.set("instanceStats", onlineTime(7200000)); + const roleIds = name => controller.users.getByName(name).roleIds; + + // The blocking role is marked first, since setting properties applies auto assignment + plugin.roleMeta.set(new messages.RoleMetaRecord(7, 3, 1, "", "", null, null, true)); + plugin.roleMeta.set(new messages.RoleMetaRecord(6, 2, 0, "", "", null, 7200000)); + t3.ok(roleIds("veteran").has(6), "enough online time grants the role"); + t3.notOk(roleIds("newcomer").has(6), "not enough online time does not"); + t3.notOk(roleIds("jailed").has(6), "a blocking role prevents the grant"); + + // Any user update also applies auto assignment, so the leave path is + // observed directly rather than through a role change + const applied = []; + plugin.applyAutoAssign = names => applied.push(names); + await plugin.onPlayerEvent(undefined, { type: "leave", name: "veteran" }); + t3.strictSame(applied, [["veteran"]], "a leave applies auto assignment for that user"); + await plugin.onPlayerEvent(undefined, { type: "join", name: "veteran" }); + t3.strictSame(applied.length, 1, "other player events do not"); + }); + + t2.test(".handleRoleListRequest() returns the current roles", async t3 => { + const { plugin } = await startPlugin(t3, { + roles: [role(1, "Player"), role(5, "Moderator")], + }); + plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 0, "Mod")); + + const response = await plugin.handleRoleListRequest(new messages.RoleListRequest()); + t3.strictSame(response.length, 2, "the current roles are returned"); + t3.ok(response.some(record => record.name === "Moderator")); + t3.ok(response.some(record => record.name === "Player")); + }); + + t2.test(".handleRoleMetaUpdateRequest() validates the role", async t3 => { + const { plugin } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] }); + + await t3.rejects( + plugin.handleRoleMetaUpdateRequest(new messages.RoleMetaUpdateRequest(new messages.RoleMetaRecord(99, 1))), + { message: /does not exist/ }, "properties for an unknown role are refused", + ); + + await plugin.handleRoleMetaUpdateRequest(new messages.RoleMetaUpdateRequest( + new messages.RoleMetaRecord(5, 2, 0, "Mod"), + )); + t3.strictSame(plugin.roleMeta.get(5).shortHand, "Mod", "the properties are stored"); + }); + + t2.test(".handleAssignmentListRequest() returns the current assignments", async t3 => { + const { plugin, controller } = await startPlugin(t3, { + roles: [role(1, "Player"), role(5, "Moderator")], + }); + controller.users.createUser("alice").set("roleIds", new Set([5])); + controller.users.createUser("bob"); + + const response = await plugin.handleAssignmentListRequest(new messages.AssignmentListRequest()); + t3.strictSame(response.length, 1, "the current assignments are returned"); + t3.strictSame(response[0].name, "alice"); + t3.strictSame(response[0].roleIds, new Set([5]), "the default role is not listed"); + }); + + t2.test(".handleAssignmentSubscription() replays only newer records", async t3 => { + const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] }); + controller.users.createUser("alice").set("roleIds", new Set([5])); + const updatedAtMs = plugin.listAssignmentRecords()[0].updatedAtMs; + + const all = await plugin.handleAssignmentSubscription({ lastRequestTimeMs: 0 }); + t3.strictSame(all.updates.length, 1, "everything is replayed from the start"); + const none = await plugin.handleAssignmentSubscription({ lastRequestTimeMs: updatedAtMs }); + t3.strictSame(none, null, "nothing is replayed when up to date"); + }); + + t2.test(".handleSeedRolesRequest() creates the roles and reuses them by name", async t3 => { + const { plugin, controller } = await startPlugin(t3, { + roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")], + }); + + await plugin.handleSeedRolesRequest(); + t3.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); + + const moderator = [...controller.roles.values()].find(other => other.name === "Moderator"); + t3.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in"); + t3.ok(plugin.roleMeta.get(moderator.id), "the role properties are created"); + t3.strictSame(plugin.roleMeta.get(moderator.id).shortHand, "Mod", "the properties match the seed"); + + await plugin.handleSeedRolesRequest(); + t3.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles"); + }); + + t2.end(); +}); diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js new file mode 100644 index 00000000..cb3db308 --- /dev/null +++ b/exp_roles/test/instance.test.js @@ -0,0 +1,185 @@ +"use strict"; +const t = require("tap"); +const lib = require("@clusterio/lib"); +const { Instance } = require("@clusterio/host"); +const { InstancePlugin } = require("../dist/node/instance"); +const { plugin: pluginDeclaration } = require("../dist/node/index"); +const messages = require("../dist/node/messages"); + +// The instance validates message classes against the link registry, and the +// plugin's config fields must be defined before an InstanceConfig can set them +lib.Link.register(messages.RoleUpdatedEvent); +lib.Link.register(messages.AssignmentUpdatedEvent); +lib.Link.register(messages.RoleListRequest); +lib.Link.register(messages.AssignmentListRequest); +lib.Link.register(messages.AssignmentUpdateRequest); +lib.addPluginConfigFields([pluginDeclaration]); + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +class TestConnector extends lib.BaseConnector { + constructor() { + super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 })); + this.valid = true; + this.connected = true; + this.hasSession = true; + } + + send() {} +} + +const sampleRoles = () => [ + new messages.RoleRecord(1, "Player", ["exp_scenario.gui.readme"], new messages.RoleMetaRecord(1, 2), true), + new messages.RoleRecord(5, "Moderator", ["exp_scenario.gui.readme", "core.admin"], new messages.RoleMetaRecord(5, 1)), +]; +const sampleAssignments = () => [new messages.AssignmentRecord("alice", new Set([5]))]; + +/** Build a plugin around a real instance with spies on what leaves it, started on first use by each test. */ +async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles() } = {}) { + const instanceConfig = new lib.InstanceConfig("host"); + instanceConfig.set("instance.id", 1); + instanceConfig.set("instance.name", "test"); + instanceConfig.set("exp_roles.sync_mode", syncMode); + + const instance = new Instance( + { assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), "factorioDir", instanceConfig + ); + + // Spies which record the messages and commands leaving the instance + const state = { sent: [], rcons: [], warnings: [], failNext: null }; + instance.server = { + handle: () => {}, + sendRcon: async command => { + state.rcons.push(command); + return ""; + }, + }; + instance.sendTo = async (dst, request) => { + state.sent.push(request); + if (state.failNext) { + const error = state.failNext; + state.failNext = null; + throw error; + } + if (request instanceof messages.RoleListRequest) { + return roles; + } + if (request instanceof messages.AssignmentListRequest) { + return sampleAssignments(); + } + return undefined; + }; + + const plugin = new InstancePlugin({ name: "exp_roles" }, instance, {}); + plugin.logger = { ...logger, warn: message => state.warnings.push(message) }; + await plugin.init(); + return { plugin, state }; +} + +/** The lua receiver and payload of a recorded rcon command. */ +function decodeRcon(command) { + const match = command.match(/^\/sc exp_roles\.(\w+)\(helpers\.json_to_table\[=\[(.*)\]=\]\)$/s); + return { receiver: match[1], payload: JSON.parse(match[2]) }; +} + +t.test("class InstancePlugin", t2 => { + t2.test(".onStart() subscribes and initialises the lua module", async t3 => { + const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" }); + await plugin.onStart(); + + const subscribed = state.sent.filter(request => request instanceof lib.SubscriptionRequest); + t3.strictSame(subscribed.length, 2, "roles and assignments are subscribed to"); + t3.ok(state.sent.some(request => request instanceof messages.RoleListRequest), "the roles are requested"); + t3.ok( + state.sent.some(request => request instanceof messages.AssignmentListRequest), + "the assignments are requested", + ); + + const initialise = decodeRcon(state.rcons[0]); + t3.strictSame(initialise.receiver, "initialise", "the lua module is initialised"); + t3.strictSame(initialise.payload.permission_names, ["exp_scenario.gui.readme", "core.admin"], + "each permission name is sent once"); + t3.strictSame(initialise.payload.roles[1].permissions, [0, 1], "roles reference permissions by index"); + t3.strictSame(initialise.payload.assignments, [{ name: "alice", role_ids: [5] }], "the assignments are sent"); + + const emit = decodeRcon(state.rcons[1]); + t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "enabled does not emit changes back"); + t3.strictSame(state.warnings.length, 0, "no warnings with a default role"); + }); + + t2.test(".onStart() with bidirectional emits changes back", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.onStart(); + const emit = decodeRcon(state.rcons[state.rcons.length - 1]); + t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]); + }); + + t2.test(".onStart() with disabled does nothing", async t3 => { + const { plugin, state } = await startPlugin(t3, { syncMode: "disabled" }); + await plugin.onStart(); + t3.strictSame(state.sent.length, 0, "nothing is sent"); + t3.strictSame(state.rcons.length, 0, "no commands are run"); + }); + + t2.test(".onStart() warns when no default role is set", async t3 => { + const roles = sampleRoles().map(record => { record.isDefault = false; return record; }); + const { plugin, state } = await startPlugin(t3, { roles }); + await plugin.onStart(); + t3.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role"); + }); + + t2.test(".handleRoleUpdatedEvent() and .handleAssignmentUpdatedEvent() forward to lua", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); + const roles = decodeRcon(state.rcons[0]); + t3.strictSame(roles.receiver, "receive_role_updates", "role updates are forwarded"); + t3.strictSame(roles.payload[0].name, "Player", "the records are sent in plain form"); + + await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); + const assignments = decodeRcon(state.rcons[1]); + t3.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); + }); + + t2.test(".handleRoleUpdatedEvent() and .handleAssignmentUpdatedEvent() are gated while disabled", async t3 => { + const { plugin, state } = await startPlugin(t3, { syncMode: "disabled" }); + await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); + await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); + t3.strictSame(state.rcons.length, 0, "no commands are run"); + }); + + t2.test(".handleAssignmentUpdateIPC() sends the change and rolls back a refusal", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + const request = state.sent[0]; + t3.ok(request instanceof messages.AssignmentUpdateRequest, "the change is sent as a request"); + t3.strictSame([request.name, request.assign, request.unassign], ["alice", [5], []], "the request names the change"); + + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: undefined, unassign: [5] }); + t3.strictSame(state.sent[1].unassign, [5], "removals are sent the same way"); + + state.failNext = new Error("User 'alice' does not exist"); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + const rollback = decodeRcon(state.rcons[0]); + t3.strictSame(rollback.receiver, "reject_assignment", "the refusal is rolled back in lua"); + t3.strictSame(rollback.payload, { name: "alice", role_ids: [5] }, "the rollback names the refused roles"); + + state.failNext = new Error("User 'alice' does not exist"); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: undefined, unassign: [5] }); + t3.strictSame(state.rcons.length, 1, "a refused removal has nothing to roll back"); + }); + + t2.test(".handleAssignmentUpdateIPC() is gated unless bidirectional", async t3 => { + const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" }); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + t3.strictSame(state.sent.length, 0, "nothing is sent"); + }); + + t2.test(".onInstanceConfigFieldChanged() toggles emit with the sync mode", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.onInstanceConfigFieldChanged("exp_roles.sync_mode", "enabled", "bidirectional"); + const emit = decodeRcon(state.rcons[0]); + t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting"); + }); + + t2.end(); +}); diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js new file mode 100644 index 00000000..8ec26550 --- /dev/null +++ b/exp_roles/test/messages.test.js @@ -0,0 +1,116 @@ +"use strict"; +const t = require("tap"); +const messages = require("../dist/node/messages"); +const { testMatrix, testRoundTripJsonSerialisable } = require("../../test/common"); + +const fullMeta = new messages.RoleMetaRecord( + 7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false, +); + +t.test("class RoleColor", t2 => { + testRoundTripJsonSerialisable(t2, messages.RoleColor, testMatrix( + [0, 255], // r + [0, 128], // g + [0, 1], // b + )); + + t2.end(); +}); + +t.test("class RoleMetaRecord", t2 => { + testRoundTripJsonSerialisable(t2, messages.RoleMetaRecord, testMatrix( + [7], // id + [3], // order + [0, 1], // priority + ["", "Mod"], // shortHand + ["", "[Mod]"], // tag + [null, new messages.RoleColor(1, 2, 3)], // color + [null, 3600000], // autoAssignOnlineTimeMs + [false, true], // blockAutoAssign + [0, 12345], // updatedAtMs + [false, true], // isDeleted + )); + + t2.end(); +}); + +t.test("class RoleRecord", t2 => { + testRoundTripJsonSerialisable(t2, messages.RoleRecord, testMatrix( + [7], // id + ["Moderator"], // name + [[], ["a.b", "c.d"]], // permissions + [new messages.RoleMetaRecord(7, 3), fullMeta], // meta + [false, true], // isDefault + [0, 12345], // updatedAtMs + [false, true], // isDeleted + )); + + t2.end(); +}); + +t.test("class AssignmentRecord", t2 => { + testRoundTripJsonSerialisable(t2, messages.AssignmentRecord, testMatrix( + ["alice"], // name + [new Set(), new Set([5, 6])], // roleIds + [0, 12345], // updatedAtMs + [false, true], // isDeleted + )); + + t2.test("get id()", t3 => { + const record = new messages.AssignmentRecord("alice", new Set([5]), 12345); + t3.equal(record.id, "alice"); + t3.end(); + }); + + t2.end(); +}); + +const sampleRecord = new messages.RoleRecord(7, "Moderator", ["a.b"], fullMeta, true, 12345); +const sampleAssignment = new messages.AssignmentRecord("alice", new Set([5]), 12345); + +t.test("class RoleUpdatedEvent", t2 => { + testRoundTripJsonSerialisable(t2, messages.RoleUpdatedEvent, testMatrix( + [[], [sampleRecord]], // updates + )); + + t2.end(); +}); + +t.test("class AssignmentUpdatedEvent", t2 => { + testRoundTripJsonSerialisable(t2, messages.AssignmentUpdatedEvent, testMatrix( + [[], [sampleAssignment]], // updates + )); + + t2.end(); +}); + +t.test("class RoleMetaUpdateRequest", t2 => { + testRoundTripJsonSerialisable(t2, messages.RoleMetaUpdateRequest, testMatrix( + [new messages.RoleMetaRecord(7, 3), fullMeta], // meta + )); + + t2.end(); +}); + +t.test("class AssignmentUpdateRequest", t2 => { + testRoundTripJsonSerialisable(t2, messages.AssignmentUpdateRequest, testMatrix( + ["alice"], // name + [[], [5]], // assign + [[], [6]], // unassign + )); + + t2.end(); +}); + +t.test("encodeRolesForLua() sends each permission name once", t2 => { + const meta = id => new messages.RoleMetaRecord(id, id); + const encoded = messages.encodeRolesForLua([ + new messages.RoleRecord(1, "A", ["p.one", "p.two"], meta(1)), + new messages.RoleRecord(2, "B", ["p.two", "p.three"], meta(2)), + ]); + + t2.strictSame(encoded.permission_names, ["p.one", "p.two", "p.three"], "names are deduplicated"); + t2.strictSame(encoded.roles[0].permissions, [0, 1], "indexes are zero based"); + t2.strictSame(encoded.roles[1].permissions, [1, 2], "shared names reuse their index"); + t2.end(); +}); diff --git a/exp_roles/test/module.test.js b/exp_roles/test/module.test.js new file mode 100644 index 00000000..2e5d4aa5 --- /dev/null +++ b/exp_roles/test/module.test.js @@ -0,0 +1,14 @@ +"use strict"; +const path = require("node:path"); +const t = require("tap"); +const { reportLuaTests } = require("../../test/lua/runner"); + +const envFile = path.join(__dirname, "module", "env.lua"); + +// Each file runs in its own lua state, and each test in a fresh environment +t.test("control.lua", t2 => { + for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) { + t2.test(file, t3 => reportLuaTests(t3, envFile, path.join(__dirname, "module", file))); + } + t2.end(); +}); diff --git a/exp_roles/test/module/assignment.lua b/exp_roles/test/module/assignment.lua new file mode 100644 index 00000000..69cfb7b9 --- /dev/null +++ b/exp_roles/test/module/assignment.lua @@ -0,0 +1,192 @@ +--- Tests for the assignment methods of module/control.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see env.lua +local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty + +--- alice is a moderator and bob a regular, with changes sent to the controller +local function setup(env) + local players = { + alice = env.add_player("alice"), + bob = env.add_player("bob"), + } + env.initialise{ + env.assignment("alice", { "Moderator" }), + env.assignment("bob", { "Regular" }), + } + env.Roles.set_emit_events(true) + env.reset_log() + return players +end + +test(".assign() applies locally and is sent to the controller", function(env) + local players = setup(env) + local moderator = env.R("Moderator") + moderator:assign(players.bob, { by_player_name = "alice" }) + eq(env.sent, { + { channel = "exp_roles:assignment_update", data = { name = "bob", assign = { moderator.id } } }, + }, "the assignment is sent to the controller") + check(moderator:has_player(players.bob), "the role applies before confirmation") + eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = players.bob.index, + by_player_index = players.alice.index, + assigned = { moderator.id }, + unassigned = {}, + }, + }, "the event carries the change") + eq(env.printed, { + { "exp-roles.game-message-assign", "bob", "Moderator", "alice" }, + }, "the change is announced") + eq(env.sounds, { "bob:utility/achievement_unlocked" }, "the assign sound plays") + + local sd = env.script_data() + eq(sd.local_players.bob, { moderator.id }, "the role is held locally") + eq(sd.pending.bob, { moderator.id }, "the role is pending") + + env.reset_log() + moderator:assign(players.bob) + empty(env.sent, "assigning a held role sends nothing") + empty(env.events, "assigning a held role raises nothing") +end) + +test(".assign() defaults the by player to game.player", function(env) + local players = setup(env) + game.player = players.alice + env.R("Jail"):assign(players.bob, { silent = true }) + eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = players.bob.index, + by_player_index = players.alice.index, + assigned = { env.R("Jail").id }, + unassigned = {}, + }, + }, "the acting player is game.player") +end) + +test("receive_assignment_updates() releases the local hold once confirmed", function(env) + local players = setup(env) + env.R("Moderator"):assign(players.bob) + env.reset_log() + env.Roles.receive_assignment_updates{ env.assignment("bob", { "Regular", "Moderator" }) } + empty(env.events, "a confirmation raises no events") + empty(env.printed, "a confirmation announces nothing") + + local sd = env.script_data() + check(sd.local_players.bob == nil and sd.pending.bob == nil, "the role is no longer held locally") + check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation") +end) + +test(".unassign() removes a synced role", function(env) + local players = setup(env) + local regular = env.R("Regular") + regular:unassign(players.bob, { by_player_name = "alice", silent = true }) + eq(env.sent, { + { channel = "exp_roles:assignment_update", data = { name = "bob", unassign = { regular.id } } }, + }, "the unassignment is sent to the controller") + check(not regular:has_player(players.bob), "the role is removed locally") + eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = players.bob.index, + by_player_index = players.alice.index, + assigned = {}, + unassigned = { regular.id }, + }, + }, "the event carries the change") + empty(env.printed, "silent suppresses the announcement") + eq(env.sounds, { "bob:utility/game_lost" }, "the unassign sound plays") +end) + +test("reject_assignment() rolls the assignment back", function(env) + local players = setup(env) + local jail = env.R("Jail") + jail:assign(players.alice) + check(jail:has_player(players.alice), "the role applies before rejection") + + env.reset_log() + env.Roles.reject_assignment{ name = "alice", role_ids = { jail.id } } + check(not jail:has_player(players.alice), "the rejected role is rolled back") + empty(env.sent, "the rollback is not sent to the controller") + eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = players.alice.index, + by_player_index = 0, + assigned = {}, + unassigned = { jail.id }, + }, + }, "the rollback raises the event") + + local sd = env.script_data() + check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") +end) + +test(".assign() with local_only never reaches the controller", function(env) + local players = setup(env) + local regular = env.R("Regular") + regular:assign(players.alice, { silent = true, local_only = true }) + empty(env.sent, "the assignment is not sent") + check(regular:has_player(players.alice), "the role applies") + + local sd = env.script_data() + check(sd.pending.alice == nil, "the role is not pending") + eq(sd.local_players.alice, { regular.id }, "the role is held locally") + + env.Roles.receive_assignment_updates{ env.assignment("alice", { "Moderator" }) } + check(regular:has_player(players.alice), "the role survives controller updates") + + env.reset_log() + regular:unassign(players.alice, { local_only = true }) + check(not regular:has_player(players.alice), "the role is removed") + empty(env.sent, "the removal is not sent") +end) + +test(".assign() of a higher priority role suppresses the rest", function(env) + local players = setup(env) + local jail = env.R("Jail") + jail:assign(players.alice, { by_player_name = "", silent = true }) + eq(Suite.names(env.Roles.get_player_roles(players.alice)), { "Jail" }, "only the jail role applies") + check(env.R("Moderator"):has_player(players.alice), "a suppressed role is still held") + check(not env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "permissions are lost") + check(not env.Roles.player_has_permission(players.alice, "exp_scenario.gui.readme"), "the default role is lost") + check(not env.Roles.player_outranks(players.alice, players.bob), "a jailed player no longer outranks") + eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = players.alice.index, + by_player_index = 0, + assigned = { jail.id }, + unassigned = {}, + }, + }, "the event lists only the jail role") + + env.reset_log() + jail:unassign(players.alice, { by_player_name = "", silent = true }) + eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, + "unjail restores the roles") + eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = players.alice.index, + by_player_index = 0, + assigned = {}, + unassigned = { jail.id }, + }, + }, "the unjail event lists only the jail role") +end) + +test(".assign() ignores the server", function(env) + setup(env) + env.R("Moderator"):assign(env.server) + empty(env.sent, "assigning to the server sends nothing") + empty(env.events, "assigning to the server raises nothing") +end) + +return Suite.run() diff --git a/exp_roles/test/module/env.lua b/exp_roles/test/module/env.lua new file mode 100644 index 00000000..d4e3953a --- /dev/null +++ b/exp_roles/test/module/env.lua @@ -0,0 +1,99 @@ +--[[-- Test environment for module/control.lua +The module specific extension between the shared stubs and the test files: it +extends each environment with a fresh copy of the roles module and the +fixtures below, and hands each test file a suite built around that. + +Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite +returned here becomes `...` in each test file. +]] + +local shared_root = ... --- @type string +local source = assert(debug.getinfo(1, "S")).source:gsub("\\", "/") +local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$")) + +local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework + +--- Standard fixture: permission names sent once and referenced by zero based index +local permission_names = { + "core.admin", -- 0 + "exp_scenario.command.kill", -- 1 + "exp_scenario.command.jail", -- 2 + "exp_scenario.player.admin", -- 3 + "exp_scenario.gui.readme", -- 4 + "exp_scenario.command.assign_role", -- 5 +} + +local function meta(order, extra) + local m = { id = 0, order = order } + for k, v in pairs(extra or {}) do m[k] = v end + return m +end + +--- Standard fixture: the roles as the controller would send them on initialise +local function role_records() + return { + { id = 0, name = "Cluster Admin", permissions = { 0 }, meta = meta(1, { short_hand = "SYS" }) }, + { id = 5, name = "Moderator", permissions = { 1, 2, 3, 5 }, meta = meta(2, { color = { r = 0, g = 170, b = 0 } }) }, + { id = 6, name = "Regular", permissions = { 1 }, meta = meta(3) }, + { id = 7, name = "Jail", permissions = {}, meta = meta(4, { priority = 1, block_auto_assign = true }) }, + { id = 1, name = "Player", permissions = { 4 }, meta = meta(5), is_default = true }, + { id = 9, name = "Gone", permissions = {}, meta = meta(6), is_deleted = true }, + } +end + +--- Fixture role ids by name, avoiding a search of the roles on every lookup +local role_ids = {} +for _, record in ipairs(role_records()) do + role_ids[record.name] = record.id +end + +--- The environment given to each test: the stubs extended with a fresh copy +--- of the roles module and the fixture helpers +--- @class ExpRoles.TestEnv : Stubs +--- @field Roles ExpRoles A fresh copy of the roles module +--- @field R fun(name: string): ExpRoles.Role Get a fixture role by name +--- @field assignment fun(player_name: string, role_names: string[]): { name: string, role_ids: number[], is_deleted: boolean? } +--- @field script_data fun(): ExpRoles.ScriptData The module's script data, for asserting on internal state +--- @field initialise fun(assignments: table[]?) Load the standard fixture and the given assignments + +return Framework.suite(function(env) + --- @cast env ExpRoles.TestEnv + env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpRoles + env.Roles.on_server_startup() + + --- Get a fixture role by name, failing the test when it does not exist + --- Roles added by a test are found by a search instead + function env.R(name) + local role_id = role_ids[name] + if role_id then + return (assert(env.Roles.get_role(role_id), "No role with id " .. role_id)) + end + return (assert(env.Roles.get_role_by_name(name), "No role named " .. name)) + end + + --- An assignment record as the controller would send it, roles by name + function env.assignment(player_name, role_names) + local ids = {} + for index, role_name in ipairs(role_names) do + ids[index] = assert(role_ids[role_name], "No fixture role named " .. role_name) + end + return { name = player_name, role_ids = ids } + end + + --- The module's script data, for asserting on internal state + function env.script_data() + --- @diagnostic disable-next-line: access-invisible + return env.Roles._script_data() + end + + --- Load the standard fixture and the given assignments + function env.initialise(assignments) + env.Roles.initialise{ + permission_names = permission_names, + roles = role_records(), + assignments = assignments or {}, + } + end + + return env +end) diff --git a/exp_roles/test/module/holders.lua b/exp_roles/test/module/holders.lua new file mode 100644 index 00000000..a57c8cac --- /dev/null +++ b/exp_roles/test/module/holders.lua @@ -0,0 +1,68 @@ +--- Tests for listing and printing to the players who hold a role +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua +local test, check, eq = Suite.test, Suite.check, Suite.eq + +--- alice and dave are moderators with dave offline, zed has never joined +local function setup(env) + local players = { + alice = env.add_player("alice"), + bob = env.add_player("bob"), + dave = env.add_player("dave", false), + } + env.initialise{ + env.assignment("alice", { "Moderator" }), + env.assignment("bob", { "Regular" }), + env.assignment("dave", { "Moderator" }), + env.assignment("zed", { "Moderator" }), + } + env.Roles.set_emit_events(true) + return players +end + +test(".get_player_names() includes players not on this map", function(env) + setup(env) + eq(Suite.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" }, + "every player given the role is listed") +end) + +test(".get_player_names() counts a player in both lists once", function(env) + local players = setup(env) + env.R("Regular"):assign(players.alice, { silent = true, local_only = true }) + env.Roles.receive_assignment_updates{ env.assignment("alice", { "Moderator", "Regular" }) } + + local sd = env.script_data() + check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists") + eq(Suite.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }, "the player is counted once") +end) + +test(".get_players() is limited to this map and can be filtered", function(env) + setup(env) + local moderator = env.R("Moderator") + eq(Suite.sorted(Suite.names(moderator:get_players())), { "alice", "dave" }, "players are limited to this map") + eq(Suite.names(moderator:get_players(true)), { "alice" }, "filtered to connected players") + eq(Suite.names(moderator:get_players(false)), { "dave" }, "filtered to offline players") +end) + +test(".print() reaches online holders", function(env) + setup(env) + env.reset_log() + eq(env.R("Moderator"):print("hello"), 1, "print returns the number of players reached") + eq(env.printed, { { "hello", to = "alice" } }, "only online holders receive the message") + eq(env.R("Jail"):print("hello"), 0, "a role with no online holders reaches nobody") +end) + +test(".print() over get_higher_roles() can repeat", function(env) + local players = setup(env) + env.R("Regular"):assign(players.alice, { silent = true, local_only = true }) + env.reset_log() + for _, role in ipairs(env.Roles.get_higher_roles(env.R("Regular"))) do + role:print("hello") + end + + local got = {} + for _, entry in ipairs(env.printed) do got[#got + 1] = entry.to end + -- alice holds two of the roles, so like the legacy system the message can repeat + eq(Suite.sorted(got), { "alice", "alice", "bob" }, "the online holders of each role are reached") +end) + +return Suite.run() diff --git a/exp_roles/test/module/lookup.lua b/exp_roles/test/module/lookup.lua new file mode 100644 index 00000000..0efcf1b8 --- /dev/null +++ b/exp_roles/test/module/lookup.lua @@ -0,0 +1,71 @@ +--- Tests for the role lookup functions of module/control.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua +local test, check, eq = Suite.test, Suite.check, Suite.eq + +test("get_role() returns the role with the given clusterio id", function(env) + env.initialise{} + check(env.Roles.get_role(env.R("Regular").id) == env.R("Regular"), "the role is found by id") + eq(env.Roles.get_role(9), nil, "deleted roles are not known") +end) + +test("get_role_by_name() searches the roles", function(env) + env.initialise{} + check(env.Roles.get_role_by_name("Moderator") == env.R("Moderator"), "the role is found by name") + eq(env.Roles.get_role_by_name("Gone"), nil, "deleted roles are not known") +end) + +test("get_roles() returns every role", function(env) + env.initialise{} + eq(Suite.sorted(Suite.names(env.Roles.get_roles())), + { "Cluster Admin", "Jail", "Moderator", "Player", "Regular" }, + "every role is returned with deleted roles skipped") +end) + +test("get_ordered_roles() returns the most privileged first", function(env) + env.initialise{} + eq(Suite.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }, + "roles follow their order") +end) + +test("sort_roles() orders most privileged first and breaks ties on the id", function(env) + env.initialise{} + local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") } + eq(Suite.names(sorted), { "Cluster Admin", "Regular", "Player" }, "the list is sorted in place") + + -- A role with the same order as Regular but a higher id sorts after it + env.Roles.receive_role_updates{ + { id = 10, name = "Twin", permissions = {}, meta = { id = 0, order = env.R("Regular").order } }, + } + local tied = env.Roles.sort_roles{ env.R("Twin"), env.R("Regular") } + eq(Suite.names(tied), { "Regular", "Twin" }, "order ties break on the id") +end) + +test("get_default_role() follows is_default", function(env) + env.initialise{} + check(env.Roles.get_default_role() == env.R("Player"), "the default role is known") +end) + +test("get_higher_roles() and get_lower_roles() include the role and exclude the default", function(env) + env.initialise{} + eq(Suite.sorted(Suite.names(env.Roles.get_higher_roles(env.R("Regular")))), + { "Cluster Admin", "Moderator", "Regular" }, "higher roles") + eq(Suite.sorted(Suite.names(env.Roles.get_lower_roles(env.R("Regular")))), + { "Jail", "Regular" }, "lower roles") +end) + +test(".is_higher_than() and .is_lower_than() compare on order", function(env) + env.initialise{} + check(env.R("Moderator"):is_higher_than(env.R("Player")), "a lower order is more privileged") + check(env.R("Player"):is_lower_than(env.R("Moderator")), "is_lower_than is the reverse") + check(not env.R("Moderator"):is_higher_than(env.R("Moderator")), "a role is not higher than itself") +end) + +test("initialise() decodes the role records", function(env) + env.initialise{} + eq(env.R("Cluster Admin").short_hand, "SYS", "short hand decoded") + eq(env.R("Moderator").short_hand, "Moderator", "short hand falls back to the name") + eq(env.R("Moderator").color, { r = 0, g = 170, b = 0 }, "color decoded") + check(env.R("Jail").priority == 1 and env.R("Jail").block_auto_assign, "priority and block auto assign decoded") +end) + +return Suite.run() diff --git a/exp_roles/test/module/players.lua b/exp_roles/test/module/players.lua new file mode 100644 index 00000000..c7e00257 --- /dev/null +++ b/exp_roles/test/module/players.lua @@ -0,0 +1,95 @@ +--- Tests for the player checks of module/control.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua +local test, check, eq = Suite.test, Suite.check, Suite.eq + +--- alice is a moderator, bob is a regular, and carol has only the default role +local function setup(env) + local players = { + alice = env.add_player("alice"), + bob = env.add_player("bob"), + carol = env.add_player("carol"), + } + env.initialise{ + env.assignment("alice", { "Moderator" }), + env.assignment("bob", { "Regular" }), + } + return players +end + +test("get_player_roles() includes the default role", function(env) + local players = setup(env) + eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, + "held roles and the default role") + eq(Suite.names(env.Roles.get_player_roles(players.carol)), { "Player" }, + "a player with no roles has the default role") +end) + +test("get_player_roles() treats nil and index 0 as the server", function(env) + setup(env) + eq(Suite.names(env.Roles.get_player_roles(nil)), { "" }, "nil is the server") + eq(Suite.names(env.Roles.get_player_roles(env.server)), { "" }, "index 0 is the server") + check(env.Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission") + check(not env.R("Player"):has_player(nil), "the server does not have the default role") +end) + +test("get_player_highest_role() returns the most privileged role", function(env) + local players = setup(env) + check(env.Roles.get_player_highest_role(players.alice) == env.R("Moderator"), "the highest held role") + check(env.Roles.get_player_highest_role(players.carol) == env.R("Player"), "the default role when none are held") +end) + +test("get_player_highest_role() errors when a player has no roles", function(env) + local players = setup(env) + env.Roles.receive_role_updates{ + { id = env.R("Player").id, name = "Player", permissions = {}, meta = { id = 0, order = 5 }, is_deleted = true }, + } + Suite.throws(function() + env.Roles.get_player_highest_role(players.carol) + end, "Player has no roles", "no default role and no held roles errors") +end) + +test("player_has_permission()", function(env) + local players = setup(env) + check(env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "granted by a held role") + check(env.Roles.player_has_permission(players.alice, "exp_scenario.gui.readme"), "granted by the default role") + check(not env.Roles.player_has_permission(players.bob, "exp_scenario.command.jail"), "not granted") +end) + +test("player_has_any_permission() and player_has_all_permission()", function(env) + local players = setup(env) + local jail, kill = "exp_scenario.command.jail", "exp_scenario.command.kill" + check(env.Roles.player_has_any_permission(players.bob, jail, kill), "any passes when one is granted") + check(not env.Roles.player_has_any_permission(players.bob, jail, "exp_scenario.command.assign_role"), + "any fails when none are granted") + check(not env.Roles.player_has_any_permission(players.bob), "any of none is false") + check(env.Roles.player_has_all_permission(players.alice, jail, kill), "all passes when every one is granted") + check(not env.Roles.player_has_all_permission(players.bob, jail, kill), "all fails when one is missing") + check(env.Roles.player_has_all_permission(players.bob), "all of none is true") + check(env.Roles.player_has_any_permission(nil, "anything.at.all"), "the server passes any") + check(env.Roles.player_has_all_permission(nil, "anything.at.all", "and.this.too"), "the server passes all") +end) + +test(".has_permission()", function(env) + setup(env) + check(env.R("Moderator"):has_permission("exp_scenario.command.kill"), "granted") + check(not env.R("Regular"):has_permission("exp_scenario.command.jail"), "not granted") + check(env.R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything") +end) + +test(".has_player()", function(env) + local players = setup(env) + check(env.R("Moderator"):has_player(players.alice), "a held role") + check(not env.R("Moderator"):has_player(players.bob), "a role not held") + check(env.R("Player"):has_player(players.carol), "everyone has the default role") +end) + +test("player_outranks()", function(env) + local players = setup(env) + check(env.Roles.player_outranks(players.alice, players.bob), "a higher role outranks a lower one") + check(not env.Roles.player_outranks(players.bob, players.alice), "a lower role does not outrank a higher one") + check(not env.Roles.player_outranks(players.alice, players.alice), "nobody outranks themselves") + check(env.Roles.player_outranks(nil, players.alice), "the server outranks everyone") + check(not env.Roles.player_outranks(players.alice, nil), "nobody outranks the server") +end) + +return Suite.run() diff --git a/exp_roles/test/module/sync.lua b/exp_roles/test/module/sync.lua new file mode 100644 index 00000000..dc04fcbd --- /dev/null +++ b/exp_roles/test/module/sync.lua @@ -0,0 +1,174 @@ +--- Tests for the sync entry points of module/control.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua +local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty + +--- The state given by the admin trigger, reset by setup for every test +local admin_state = {} --- @type table + +--- alice is a moderator and carol has only the default role, both connected +local function setup(env) + local players = { + alice = env.add_player("alice"), + carol = env.add_player("carol"), + } + admin_state = {} + env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) + admin_state[player.name] = state + end) + env.initialise{ + env.assignment("alice", { "Moderator" }), + env.assignment("zed", { "Moderator", "Regular" }), -- has never joined this map + } + env.Roles.set_emit_events(true) + return players +end + +test("initialise() applies triggers and raises empty events", function(env) + setup(env) + eq(admin_state, { alice = true, carol = false }, "triggers run for connected players") + eq(#env.events, 2, "the event is raised once per connected player") + for _, event in ipairs(env.events) do + empty(event.assigned, "the events assign nothing") + empty(event.unassigned, "the events unassign nothing") + end + empty(env.printed, "initialise is silent") + empty(env.sent, "initialise sends nothing") + empty(env.sounds, "initialise plays no sounds") +end) + +test("initialise() skips deleted assignments", function(env) + local players = setup(env) + local record = env.assignment("carol", { "Moderator" }) + record.is_deleted = true + env.initialise{ record } + check(not env.R("Moderator"):has_player(players.carol), "a deleted assignment is ignored") +end) + +test("initialise() is authoritative for pending roles", function(env) + local players = setup(env) + env.R("Moderator"):assign(players.carol) + env.R("Jail"):assign(players.carol, { silent = true, local_only = true }) + + local sd = env.script_data() + eq(sd.pending.carol, { env.R("Moderator").id }, "the role is pending before initialise") + + env.reset_log() + env.initialise{ env.assignment("alice", { "Moderator" }) } + eq(sd.pending.carol, nil, "pending roles are cleared") + check(not env.R("Moderator"):has_player(players.carol), "an unconfirmed role is given up") + eq(sd.local_players.carol, { env.R("Jail").id }, "local only roles are kept") + empty(env.sent, "initialise sends nothing") +end) + +test("receive_assignment_updates() applies controller changes", function(env) + local players = setup(env) + env.reset_log() + env.Roles.receive_assignment_updates{ env.assignment("carol", { "Moderator" }) } + check(env.R("Moderator"):has_player(players.carol), "the assignment applies") + eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = players.carol.index, + by_player_index = 0, + assigned = { env.R("Moderator").id }, + unassigned = {}, + }, + }, "the event carries the change with no by player") + eq(env.printed, { + { "exp-roles.game-message-assign", "carol", "Moderator", "" }, + }, "the change is announced by the server") + check(admin_state.carol == true, "triggers follow the assignment") + + env.reset_log() + env.Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } } + check(not env.R("Moderator"):has_player(players.carol), "a removal applies") + eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = players.carol.index, + by_player_index = 0, + assigned = {}, + unassigned = { env.R("Moderator").id }, + }, + }, "the removal raises the event") + check(admin_state.carol == false, "triggers follow the removal") +end) + +test("receive_assignment_updates() for players not on this map raises nothing", function(env) + setup(env) + env.reset_log() + env.Roles.receive_assignment_updates{ env.assignment("zed", { "Moderator" }) } + empty(env.events, "no event is raised") + empty(env.printed, "nothing is announced") +end) + +test("receive_role_updates() applies to the holders", function(env) + setup(env) + env.reset_log() + local regular_id = env.R("Regular").id + env.Roles.receive_role_updates{ + { id = regular_id, name = "Regular", permissions = { "exp_scenario.command.jail" }, meta = { id = 0, order = 3 } }, + } + check(env.R("Regular"):has_permission("exp_scenario.command.jail"), "the permission change applies") + eq(#env.events, 2, "a role change raises for every connected player") + for _, event in ipairs(env.events) do + empty(event.assigned, "role change events are empty") + end + + env.Roles.receive_role_updates{ + { id = regular_id, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true }, + } + eq(env.Roles.get_role(regular_id), nil, "a deleted role is removed") +end) + +test("receive_role_updates() moves the default role", function(env) + local players = setup(env) + env.Roles.receive_role_updates{ + { id = env.R("Player").id, name = "Player", permissions = {}, meta = { id = 0, order = 5 } }, + { id = 8, name = "Guest", permissions = {}, meta = { id = 0, order = 7 }, is_default = true }, + } + check(env.Roles.get_default_role() == env.R("Guest"), "the new default role is known") + eq(Suite.names(env.Roles.get_player_roles(players.carol)), { "Guest" }, "players pick up the new default") +end) + +test("set_emit_events() gates what is sent and defaults to enabled", function(env) + local players = setup(env) + env.Roles.set_emit_events(false) + env.reset_log() + env.R("Regular"):assign(players.alice) + empty(env.sent, "nothing is sent while disabled") + check(env.R("Regular"):has_player(players.alice), "the assignment still applies locally") + + env.Roles.set_emit_events() + env.R("Jail"):assign(players.alice, { silent = true }) + eq(env.sent, { + { channel = "exp_roles:assignment_update", data = { name = "alice", assign = { env.R("Jail").id } } }, + }, "no argument enables sending") +end) + +test("on_load() restores the state after a save and load", function(env) + local players = setup(env) + env.R("Jail"):assign(players.carol, { silent = true, local_only = true }) + env.save_load() + env.Roles.on_load() + + check(env.R("Moderator"):is_higher_than(env.R("Regular")), "role methods survive through the registered metatable") + check(env.R("Moderator"):has_player(players.alice), "synced roles survive") + check(env.R("Jail"):has_player(players.carol), "local roles survive") + check(env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "permission checks still answer") +end) + +test("on_player_joined_game() runs the triggers", function(env) + local players = setup(env) + admin_state.alice = nil + env.Roles.on_player_joined_game{ + name = defines.events.on_player_joined_game, + tick = game.tick, + player_index = players.alice.index, + } + check(admin_state.alice ~= nil, "triggers run when a player joins") +end) + +return Suite.run() diff --git a/exp_roles/test/seed.test.js b/exp_roles/test/seed.test.js new file mode 100644 index 00000000..adcb0987 --- /dev/null +++ b/exp_roles/test/seed.test.js @@ -0,0 +1,62 @@ +"use strict"; +const t = require("tap"); +const lib = require("@clusterio/lib"); +const { seedRoles, flattenSeedPermissions } = require("../dist/node/seed"); + +// Importing this defines the exp_scenario permissions the seed grants +require("@expcluster/scenario/dist/node/permissions"); + +t.test("seedRoles[] grant only defined permissions", t2 => { + for (const role of seedRoles) { + for (const permission of role.permissions) { + t2.ok(lib.permissions.has(permission), `${role.name} grants defined permission ${permission}`); + } + } + t2.end(); +}); + +t.test("seedRoles[] are unique with one default and one admin", t2 => { + const names = seedRoles.map(role => role.name); + t2.strictSame(names.length, new Set(names).size, "names are unique"); + t2.strictSame(seedRoles.filter(role => role.isDefault).length, 1, "one default role"); + t2.strictSame(seedRoles.filter(role => role.isAdmin).length, 1, "one admin role"); + t2.end(); +}); + +t.test("flattenSeedPermissions() inherits permissions from parent roles", t2 => { + const byName = new Map(seedRoles.map(role => [role.name, role])); + for (const role of seedRoles) { + if (role.parent === undefined) { + continue; + } + + const parent = byName.get(role.parent); + t2.ok(parent, `${role.name} has parent ${role.parent}`); + + const flattened = flattenSeedPermissions(role); + for (const permission of flattenSeedPermissions(parent)) { + t2.ok(flattened.has(permission), `${role.name} inherits ${permission}`); + } + } + t2.end(); +}); + +t.test("flattenSeedPermissions() does not inherit permissions when parent is undefined", t2 => { + for (const role of seedRoles) { + if (role.parent !== undefined) { + continue; + } + + const flattened = flattenSeedPermissions(role); + for (const permission of role.permissions) { + t2.ok(flattened.has(permission), `${role.name} contains ${permission}`); + } + + t2.equal( + flattened.size, + role.permissions.length, + `${role.name} has no inherited permissions`, + ); + } + t2.end(); +}); \ No newline at end of file diff --git a/exp_roles/web/components/SeedRoles.tsx b/exp_roles/web/components/SeedRoles.tsx new file mode 100644 index 00000000..9b7c52b2 --- /dev/null +++ b/exp_roles/web/components/SeedRoles.tsx @@ -0,0 +1,33 @@ +import React, { useContext, useState } from "react"; +import { Button, Popconfirm } from "antd"; + +import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui"; + +import { SeedRolesRequest } from "../../messages"; + +/** Button on the roles page which creates the roles the scenario shipped with. */ +export default function SeedRoles() { + const control = useContext(ControlContext); + const account = useAccount(); + const [seeding, setSeeding] = useState(false); + + if (!account.hasPermission("core.role.create")) { + return null; + } + + return { + setSeeding(true); + control.send(new SeedRolesRequest()) + .catch(notifyErrorHandler("Error seeding roles")) + .finally(() => setSeeding(false)); + }} + > + + } + />; +} diff --git a/exp_roles/web/index.tsx b/exp_roles/web/index.tsx index 644cd10d..1bd55066 100644 --- a/exp_roles/web/index.tsx +++ b/exp_roles/web/index.tsx @@ -5,6 +5,7 @@ import * as lib from "@clusterio/lib"; import * as messages from "../messages"; import RoleProperties from "./components/RoleProperties"; +import SeedRoles from "./components/SeedRoles"; export class WebPlugin extends BaseWebPlugin { roles = new lib.MapSubscriber(messages.RoleUpdatedEvent, this.control); @@ -14,6 +15,7 @@ export class WebPlugin extends BaseWebPlugin { // does not carry in its type this.componentExtra = { RoleViewPage: RoleProperties as React.ComponentType, + RolesPage: SeedRoles, }; } diff --git a/exp_scenario/module/commands/_authorities.lua b/exp_scenario/module/commands/_authorities.lua index dc6f9383..37093ad8 100644 --- a/exp_scenario/module/commands/_authorities.lua +++ b/exp_scenario/module/commands/_authorities.lua @@ -1,26 +1,28 @@ --[[-- Command Authorities - Roles -Adds a permission authority for exp roles +Adds a permission authority which checks the clusterio permission for a command ]] local Commands = require("modules/exp_commands") local add, allow, deny = Commands.add_permission_authority, Commands.status.success, Commands.status.unauthorised -local Roles = require("modules/exp_legacy/expcore/roles") -local player_allowed = Roles.player_allowed +local Roles = require("modules/exp_roles") +local player_has_permission = Roles.player_has_permission local authorities = {} ---- If a command has the flag "character_only" then the command can only be used outside of remote view +--- Every command requires the permission `exp_scenario.command.`, with +--- hyphens replaced by underscores, see permissions.ts for their definitions authorities.exp_permission = add(function(player, command) - if not player_allowed(player, command.flags.exp_permission or ("command/" .. command.name)) then + local permission = "exp_scenario.command." .. command.name:gsub("%-", "_") + if not player_has_permission(player, permission) then return deny{ "exp-commands-authorities_role.deny" } else return allow() end end) -Roles.define_flag_trigger("is_system", function(player, state) +Roles.define_permission_trigger("core.admin", function(player, state) if state then Commands.unlock_system_commands(player.name) else diff --git a/exp_scenario/module/commands/_rcon.lua b/exp_scenario/module/commands/_rcon.lua index 7ce7ed0d..ef3febbc 100644 --- a/exp_scenario/module/commands/_rcon.lua +++ b/exp_scenario/module/commands/_rcon.lua @@ -8,6 +8,6 @@ local add_static, add_dynamic = Commands.add_rcon_static, Commands.add_rcon_dyna add_static("Gui", require("modules/exp_gui")) add_static("Group", require("modules.exp_legacy.expcore.permission_groups")) -add_static("Roles", require("modules.exp_legacy.expcore.roles")) +add_static("Roles", require("modules/exp_roles")) add_static("Datastore", require("modules.exp_legacy.expcore.datastore")) add_static("External", require("modules.exp_legacy.expcore.external")) diff --git a/exp_scenario/module/commands/_types.lua b/exp_scenario/module/commands/_types.lua index f23ff943..a3395e41 100644 --- a/exp_scenario/module/commands/_types.lua +++ b/exp_scenario/module/commands/_types.lua @@ -1,4 +1,3 @@ - --[[-- Command Types - Roles The data types that are used with exp_roles A lower role index indicates it is more privileged @@ -11,28 +10,43 @@ Adds parsers for: lower_role_player_alive ]] +local ExpUtil = require("modules/exp_util") +local auto_complete = ExpUtil.auto_complete + local Commands = require("modules/exp_commands") local add, parse = Commands.add_data_type, Commands.parse_input local valid, invalid = Commands.status.success, Commands.status.invalid_input -local Roles = require("modules.exp_legacy.expcore.roles") -local highest_role = Roles.get_player_highest_role +local Roles = require("modules/exp_roles") +local player_outranks = Roles.player_outranks +local player_has_permission = Roles.player_has_permission local types = {} --- @class (partial) Commands.types ---- A role defined by exp roles -types.role = add("role", Commands.types.key_of(Roles.config.roles)) +--- A role known to exp roles, matched on its name +types.role = + add("role", function(input) + local names = {} + for index, role in ipairs(Roles.get_ordered_roles()) do + names[index] = role.name + end + + local name = auto_complete(names, input) + if name == nil then + return invalid{ "exp-commands-parse.string-options", table.concat(names, ", ") } + else + return valid(Roles.get_role_by_name(name)) + end + end) --- A role which is lower than the players highest role types.lower_role = add("lower_role", function(input, player) local success, status, result = parse(input, player, types.role) if not success then return status, result end - --- @cast result any TODO role is not a defined type + --- @cast result ExpRoles.Role - local player_highest = highest_role(player) - local is_root = Roles.config.internal.root == player_highest.name - if not is_root and player_highest.index >= result.index then + if not player_has_permission(player, "core.admin") and not Roles.get_player_highest_role(player):is_higher_than(result) then return invalid{ "exp-commands-parse_role.lower-role" } else return valid(result) @@ -46,10 +60,7 @@ types.lower_role_player = if not success then return status, result end --- @cast result LuaPlayer - local other_highest = highest_role(result) - local player_highest = highest_role(player) - local is_root = Roles.config.internal.root == player_highest.name - if not is_root and player_highest.index >= other_highest.index then + if not player_outranks(player, result) then return invalid{ "exp-commands-parse_role.lower-role-player" } else return valid(result) @@ -63,10 +74,7 @@ types.lower_role_player_online = if not success then return status, result end --- @cast result LuaPlayer - local other_highest = highest_role(result) - local player_highest = highest_role(player) - local is_root = Roles.config.internal.root == player_highest.name - if not is_root and player_highest.index >= other_highest.index then + if not player_outranks(player, result) then return invalid{ "exp-commands-parse_role.lower-role-player" } else return valid(result) @@ -80,10 +88,7 @@ types.lower_role_player_alive = if not success then return status, result end --- @cast result LuaPlayer - local other_highest = highest_role(result) - local player_highest = highest_role(player) - local is_root = Roles.config.internal.root == player_highest.name - if not is_root and player_highest.index >= other_highest.index then + if not player_outranks(player, result) then return invalid{ "exp-commands-parse_role.lower-role-player" } else return valid(result) diff --git a/exp_scenario/module/commands/kill.lua b/exp_scenario/module/commands/kill.lua index a71528ed..9d655ff9 100644 --- a/exp_scenario/module/commands/kill.lua +++ b/exp_scenario/module/commands/kill.lua @@ -4,8 +4,8 @@ Adds a command that allows players to kill themselves and others local Commands = require("modules/exp_commands") -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles -local highest_role = Roles.get_player_highest_role +local Roles = require("modules/exp_roles") +local player_outranks = Roles.player_outranks --- Kills yourself or another player. Commands.new("kill", { "exp-commands_kill.description" }) @@ -20,7 +20,7 @@ Commands.new("kill", { "exp-commands_kill.description" }) if other_player == nil then -- Can only be nil if the target is the player and they are already dead return Commands.status.error{ "exp-commands_kill.already-dead" } - elseif (other_player == player) or (highest_role(player).index < highest_role(other_player).index) then + elseif other_player == player or player_outranks(player, other_player) then -- You can always kill yourself or can kill lower role players if script.active_mods["space-age"] then other_player.surface.create_entity{ name = "lightning", position = { other_player.position.x, other_player.position.y - 16 }, target = other_player.character } diff --git a/exp_scenario/module/commands/protected_entities.lua b/exp_scenario/module/commands/protected_entities.lua index 7f906413..11c24fe8 100644 --- a/exp_scenario/module/commands/protected_entities.lua +++ b/exp_scenario/module/commands/protected_entities.lua @@ -11,7 +11,7 @@ local expand_area = AABB.expand local Commands = require("modules/exp_commands") local format_player_name = Commands.format_player_name_locale -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles +local Roles = require("modules/exp_roles") local EntityProtection = require("modules.exp_legacy.modules.control.protection") --- @dep modules.control.protection local format_string = string.format @@ -218,13 +218,16 @@ SelectEntities:on_stop(on_player_selection_stop) --- When there is a repeat offence print it in chat local function on_repeat_violation(event) - Roles.print_to_roles_higher("Regular", { - "exp-commands_entity-protection.repeat-offence", - format_player_name(event.player_index), - event.entity.localised_name, - event.entity.position.x, - event.entity.position.y - }) + local regular = Roles.get_role_by_name("Regular") + for _, role in ipairs(regular and Roles.get_higher_roles(regular) or {}) do + role:print{ + "exp-commands_entity-protection.repeat-offence", + format_player_name(event.player_index), + event.entity.localised_name, + event.entity.position.x, + event.entity.position.y + } + end end return { diff --git a/exp_scenario/module/commands/reports.lua b/exp_scenario/module/commands/reports.lua index aab4351c..166d4b64 100644 --- a/exp_scenario/module/commands/reports.lua +++ b/exp_scenario/module/commands/reports.lua @@ -6,8 +6,8 @@ local Commands = require("modules/exp_commands") local format_player_name = Commands.format_player_name_locale local parse_input = Commands.parse_input -local Roles = require("modules.exp_legacy.expcore.roles") -local player_has_flag = Roles.player_has_flag +local Roles = require("modules/exp_roles") +local player_has_permission = Roles.player_has_permission local Reports = require("modules.exp_legacy.modules.control.reports") --- @dep modules.control.reports @@ -20,7 +20,7 @@ local function reportable_player(input, player) if not success then return status, result end --- @cast result LuaPlayer - if player_has_flag(input, "report-immune") then + if player_has_permission(input, "exp_scenario.bypass.reports") then return Commands.status.invalid_input{ "exp-commands_reports.player-immune" } elseif player == input then return Commands.status.invalid_input{ "exp-commands_reports.self-report" } diff --git a/exp_scenario/module/commands/roles.lua b/exp_scenario/module/commands/roles.lua index b8a510ad..60502966 100644 --- a/exp_scenario/module/commands/roles.lua +++ b/exp_scenario/module/commands/roles.lua @@ -6,8 +6,8 @@ local Commands = require("modules/exp_commands") local format_player_name = Commands.format_player_name_locale local format_text = Commands.format_rich_text_color_locale -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles -local get_roles_ordered = Roles.get_roles_ordered +local Roles = require("modules/exp_roles") +local get_ordered_roles = Roles.get_ordered_roles local get_player_roles = Roles.get_player_roles --- Assigns a role to a player @@ -18,8 +18,8 @@ Commands.new("assign-role", { "exp-commands_roles.description-assign" }) :add_flags{ "admin_only" } :register(function(player, other_player, role) --- @cast other_player LuaPlayer - --- @cast role any -- TODO - Roles.assign_player(other_player, role, player.name) + --- @cast role ExpRoles.Role + role:assign(other_player, { by_player_name = player.name }) end) --- Unassigns a role to a player @@ -30,8 +30,8 @@ Commands.new("unassign-role", { "exp-commands_roles.description-unassign" }) :add_flags{ "admin_only" } :register(function(player, other_player, role) --- @cast other_player LuaPlayer - --- @cast role any -- TODO - Roles.unassign_player(other_player, role, player.name) + --- @cast role ExpRoles.Role + role:unassign(other_player, { by_player_name = player.name }) end) --- Lists all roles in they correct order @@ -40,17 +40,17 @@ Commands.new("get-roles", { "exp-commands_roles.description-get" }) :add_aliases{ "roles" } :register(function(player, other_player) --- @cast other_player LuaPlayer? - local roles = get_roles_ordered() + local roles = get_ordered_roles() local roles_formatted = { "" } --- @type LocalisedString local response = { "exp-commands_roles.list-roles", roles_formatted } --[[@as LocalisedString]] if other_player then - roles = get_player_roles(other_player) + roles = Roles.sort_roles(get_player_roles(other_player)) response[1] = "exp-commands_roles.list-player" response[3] = format_player_name(other_player) end for index, role in ipairs(roles) do - local role_name = format_text(role.name, role.custom_color or Commands.color.white) + local role_name = format_text(role.name, role.color or Commands.color.white) roles_formatted[index + 1] = { "exp-commands_roles.list-element", role_name } end diff --git a/exp_scenario/module/commands/teleport.lua b/exp_scenario/module/commands/teleport.lua index a7892047..c4acf1f7 100644 --- a/exp_scenario/module/commands/teleport.lua +++ b/exp_scenario/module/commands/teleport.lua @@ -7,8 +7,8 @@ local teleport_player = ExpUtil.teleport_player local Commands = require("modules/exp_commands") -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles -local player_allowed = Roles.player_allowed +local Roles = require("modules/exp_roles") +local player_has_permission = Roles.player_has_permission --- @class ExpCommands_Teleport.commands local commands = {} @@ -85,7 +85,7 @@ commands.spawn = Commands.new("spawn", { "exp-commands_teleport.description-spaw if not teleport_player(player, game.surfaces.nauvis, { 0, 0 }, "dismount") then return Commands.status.error{ "exp-commands_teleport.unavailable" } end - elseif player_allowed(player, "command/spawn/always") then + elseif player_has_permission(player, "exp_scenario.command.spawn.always") then if not teleport_player(other_player, game.surfaces.nauvis, { 0, 0 }, "dismount") then return Commands.status.error{ "exp-commands_teleport.unavailable" } end diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index 7f62c5ce..aee9623c 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -63,6 +63,7 @@ add(require("modules/exp_scenario/control/pollution_grading")) add(require("modules/exp_scenario/control/protection_jail")) add(require("modules/exp_scenario/control/report_jail")) add(require("modules/exp_scenario/control/research")) +add(require("modules/exp_scenario/control/roles")) add(require("modules/exp_scenario/control/spawn_area")) add(require("modules/exp_scenario/control/station_auto_name")) diff --git a/exp_scenario/module/control/bonus.lua b/exp_scenario/module/control/bonus.lua index f8b532cb..8d0a192d 100644 --- a/exp_scenario/module/control/bonus.lua +++ b/exp_scenario/module/control/bonus.lua @@ -4,7 +4,7 @@ Various bonus related event handlers TODO Refactor this fully, this is temp to get it out of the player bonus gui file ]] -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/bonus") --- @param event EventData.on_force_created @@ -26,7 +26,7 @@ end --- @param event EventData.on_player_died local function fast_respawn(event) local player = assert(game.get_player(event.player_index)) - if Roles.player_has_flag(player, "instant-respawn") then + if Roles.player_has_permission(player, "exp_scenario.player.instant_respawn") then player.ticks_to_respawn = 120 end end diff --git a/exp_scenario/module/control/chat_auto_reply.lua b/exp_scenario/module/control/chat_auto_reply.lua index 8d5ccc8f..4e60ca40 100644 --- a/exp_scenario/module/control/chat_auto_reply.lua +++ b/exp_scenario/module/control/chat_auto_reply.lua @@ -2,7 +2,7 @@ Adds auto replies to chat messages, as well as chat commands ]] -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.chat_reply") local prefix = config.command_prefix local prefix_len = string.len(prefix) @@ -20,7 +20,7 @@ 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 config.command_permission and not Roles.player_allowed(player, config.command_permission) then commands_allowed = false end + if config.command_permission and not Roles.player_has_permission(player, config.command_permission) then commands_allowed = false end -- Check if a key word appears in the message for key_word, reply in pairs(config.messages) do diff --git a/exp_scenario/module/control/deconstruction_log.lua b/exp_scenario/module/control/deconstruction_log.lua index b6d2b05d..ddfeb979 100644 --- a/exp_scenario/module/control/deconstruction_log.lua +++ b/exp_scenario/module/control/deconstruction_log.lua @@ -3,7 +3,7 @@ Log certain actions into a file when events are triggered ]] local ExpUtil = require("modules/exp_util") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.deconlog") local seconds_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true, seconds = true } @@ -79,7 +79,7 @@ end local function get_log_player(event) local player = assert(game.get_player(event.player_index)) - if Roles.player_has_flag(player, "deconlog-bypass") then + if Roles.player_has_permission(player, "exp_scenario.bypass.deconstruction_log") then return nil end diff --git a/exp_scenario/module/control/fast_deconstruction.lua b/exp_scenario/module/control/fast_deconstruction.lua index acd83069..34544496 100644 --- a/exp_scenario/module/control/fast_deconstruction.lua +++ b/exp_scenario/module/control/fast_deconstruction.lua @@ -4,7 +4,7 @@ Makes trees which are marked for decon "decay" quickly to allow faster building local Gui = require("modules/exp_gui") local Async = require("modules/exp_util/async") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local PlayerData = require("modules.exp_legacy.expcore.player_data") local HasEnabledDecon = PlayerData.Settings:combine("HasEnabledDecon") @@ -57,9 +57,9 @@ local remove_trees_async = --- @param player LuaPlayer --- @return "fast" | "allow" | "disallow" local function get_permission(player) - if Roles.player_allowed(player, "fast-tree-decon") then + if Roles.player_has_permission(player, "exp_scenario.decon.fast_trees") then return HasEnabledDecon:get(player) and "fast" or "allow" - elseif Roles.player_allowed(player, "standard-decon") then + elseif Roles.player_has_permission(player, "exp_scenario.decon.standard") then return "allow" else return "disallow" @@ -101,7 +101,7 @@ Gui.toolbar.create_button{ tooltip = { "exp_fast-decon.tooltip-main" }, auto_toggle = true, visible = function(player, _) - return Roles.player_allowed(player, "fast-tree-decon") + return Roles.player_has_permission(player, "exp_scenario.decon.fast_trees") end }:on_click(function(def, player, element) local state = Gui.toolbar.get_button_toggled_state(def, player) diff --git a/exp_scenario/module/control/nuke_protection.lua b/exp_scenario/module/control/nuke_protection.lua index cfff046b..ba8f4320 100644 --- a/exp_scenario/module/control/nuke_protection.lua +++ b/exp_scenario/module/control/nuke_protection.lua @@ -3,7 +3,7 @@ Disable new players from having certain items in their inventory, most commonly ]] local ExpUtil = require("modules/exp_util") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.nukeprotect") --- Check all items in the given inventory @@ -12,14 +12,14 @@ local config = require("modules.exp_legacy.config.nukeprotect") --- @param banned_items string[] local function check_items(player, type, banned_items) -- If the player has perms to be ignored, then they should be - if config.ignore_permission and Roles.player_allowed(player, config.ignore_permission) then return end + if config.ignore_permission and Roles.player_has_permission(player, config.ignore_permission) then return end if config.ignore_admins and player.admin then return end local items = {} --- @type LuaItemStack[] local inventory = assert(player.get_inventory(type)) -- Check what items the player has for i = 1, #inventory do - local item = inventory[i] + local item = inventory[i --[[@as uint]]] if item.valid_for_read and banned_items[item.name] then player.print{ "exp_nuke-protection.chat-found", item.prototype.localised_name } items[#items + 1] = item diff --git a/exp_scenario/module/control/roles.lua b/exp_scenario/module/control/roles.lua new file mode 100644 index 00000000..1c4ecf66 --- /dev/null +++ b/exp_scenario/module/control/roles.lua @@ -0,0 +1,21 @@ +--[[-- Control - Roles +Applies the in game effects of the player permissions defined by the scenario +]] + +local Gui = require("modules/exp_gui") +local Roles = require("modules/exp_roles") + +Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) + player.admin = state +end) + +Roles.define_permission_trigger("exp_scenario.player.spectator", function(player, state) + player.spectator = state +end) + +return { + events = { + --- @diagnostic disable-next-line: access-invisible + [Roles.events.on_player_roles_changed] = Gui._ensure_consistency, + } +} diff --git a/exp_scenario/module/control/spawn_area.lua b/exp_scenario/module/control/spawn_area.lua index aab08d17..d4c23275 100644 --- a/exp_scenario/module/control/spawn_area.lua +++ b/exp_scenario/module/control/spawn_area.lua @@ -5,8 +5,8 @@ Adds a custom spawn area with chests and afk turrets local config = require("modules.exp_legacy.config.spawn_area") --- Apply an offset to a LuaPosition ---- @param position MapPosition ---- @param offset MapPosition +--- @param position MapPosition.struct +--- @param offset MapPosition.struct --- @return MapPosition.struct local function apply_offset(position, offset) return { @@ -17,7 +17,7 @@ end --- Apply offset to an array of positions --- @param positions table ---- @param offset MapPosition +--- @param offset MapPosition.struct --- @param x_index number --- @param y_index number local function apply_offset_to_array(positions, offset, x_index, y_index) @@ -97,14 +97,19 @@ local belt_details = { --- Makes a 2x2 afk belt at the locations in the config --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_belts(surface, offset) local belt_type = config.afk_belts.belt_type + local entity_pos = { x = 0, y = 0 } + local group_offset = { x = 0, y = 0 } for _, position in pairs(config.afk_belts.locations) do - position = apply_offset(position, offset) + group_offset.x = position[1] + offset.x + group_offset.y = position[2] + offset.y for _, belt in pairs(belt_details) do - local pos = apply_offset(position, belt) + entity_pos.x = belt[1] + entity_pos.y = belt[2] + local pos = apply_offset(entity_pos, group_offset) local entity = surface.create_entity{ name = belt_type, position = pos, force = "neutral", direction = belt[3] } if entity and config.afk_belts.protected then protect_entity(entity) @@ -115,13 +120,16 @@ end -- Generates extra tiles in a set pattern as defined in the config --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_pattern_tiles(surface, offset) local tiles_to_make = {} local pattern_tile = config.pattern.pattern_tile + local tile_pos = { x = 0, y = 0 } for index, position in pairs(config.pattern.locations) do - tiles_to_make[index] = { name = pattern_tile, position = apply_offset(position, offset) } + tile_pos.x = position[1] + tile_pos.y = position[2] + tiles_to_make[index] = { name = pattern_tile, position = apply_offset(tile_pos, offset) } end surface.set_tiles(tiles_to_make) @@ -129,13 +137,16 @@ end -- Generates extra water as defined in the config --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_water_tiles(surface, offset) local tiles_to_make = {} local water_tile = config.water.water_tile + local tile_pos = { x = 0, y = 0 } for _, position in pairs(config.water.locations) do - table.insert(tiles_to_make, { name = water_tile, position = apply_offset(position, offset) }) + tile_pos.x = position[1] + tile_pos.y = position[2] + table.insert(tiles_to_make, { name = water_tile, position = apply_offset(tile_pos, offset) }) end surface.set_tiles(tiles_to_make) @@ -143,11 +154,17 @@ end --- Generates the entities that are in the config --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_entities(surface, offset) + local entity_pos = { x = 0, y = 0 } for _, entity_details in pairs(config.entities.locations) do - local pos = apply_offset({ entity_details[2], entity_details[3] }, offset) - local entity = surface.create_entity{ name = entity_details[1], position = pos, force = "neutral" } + entity_pos.x = entity_details[2] + entity_pos.y = entity_details[3] + local entity = surface.create_entity{ + name = entity_details[1], + position = apply_offset(entity_pos, offset), + force = "neutral" + } if entity then if config.entities.protected then @@ -161,7 +178,7 @@ end --- Generates an area with no water or entities, no water area is larger --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function clear_spawn_area(surface, offset) local get_tile = surface.get_tile @@ -182,7 +199,7 @@ local function clear_spawn_area(surface, offset) for y = -fill_radius, fill_radius do -- loop over y local y_sqr = (y + 0.5) ^ 2 local dst = x_sqr + y_sqr - local pos = apply_offset({ x, y }, offset) + local pos = apply_offset({ x = x, y = y }, offset) if dst < tile_radius_sqr then -- If it is inside the decon radius always set the tile tiles_to_make[#tiles_to_make + 1] = { name = decon_tile, position = pos } @@ -204,14 +221,14 @@ end --- Spawn the resource tiles --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_resources_tiles(surface, offset) for _, resource in ipairs(config.resource_tiles.resources) do if resource.enabled then local pos = apply_offset(resource.offset, offset) for x = pos.x, pos.x + resource.size[1] do for y = pos.y, pos.y + resource.size[2] do - surface.create_entity{ name = resource.name, amount = resource.amount, position = { x, y } } + surface.create_entity{ name = resource.name, amount = resource.amount, position = { x = x, y = y } } end end end @@ -220,7 +237,7 @@ end --- Spawn the resource entities --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_resource_patches(surface, offset) for _, resource in ipairs(config.resource_patches.resources) do if resource.enabled then diff --git a/exp_scenario/module/gui/autofill.lua b/exp_scenario/module/gui/autofill.lua index 3dc16175..8c663af4 100644 --- a/exp_scenario/module/gui/autofill.lua +++ b/exp_scenario/module/gui/autofill.lua @@ -3,7 +3,7 @@ Adds a config menu for setting autofill of placed entities ]] local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.gui.autofill") local FlyingText = require("modules/exp_util/flying_text") @@ -330,7 +330,7 @@ Gui.toolbar.create_button{ sprite = config.icon, tooltip = { "exp-gui_autofill.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/autofill") + return Roles.player_has_permission(player, "exp_scenario.gui.autofill") end } diff --git a/exp_scenario/module/gui/module_inserter.lua b/exp_scenario/module/gui/module_inserter.lua index 33011cec..f930bd34 100644 --- a/exp_scenario/module/gui/module_inserter.lua +++ b/exp_scenario/module/gui/module_inserter.lua @@ -4,7 +4,7 @@ Adds a Gui which creates an selection planner to insert modules into buildings local Gui = require("modules/exp_gui") local AABB = require("modules/exp_util/aabb") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local Selection = require("modules/exp_util/selection") local SelectArea = Selection.connect("ModuleArea") @@ -259,7 +259,7 @@ Gui.toolbar.create_button{ sprite = "item/productivity-module-3", tooltip = { "exp-gui_module-inserter.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/module") + return Roles.player_has_permission(player, "exp_scenario.gui.module") end } @@ -459,7 +459,7 @@ local function on_entity_settings_pasted(event) -- Get the modules and add them to the planner local all_modules = {} for i = 1, #module_inventory do - local slot = module_inventory[i] + local slot = module_inventory[i --[[@as uint]]] if slot.valid_for_read and slot.count > 0 then all_modules[i] = { name = slot.name, quality = slot.quality.name } else diff --git a/exp_scenario/module/gui/player_bonus.lua b/exp_scenario/module/gui/player_bonus.lua index 054c3088..13317530 100644 --- a/exp_scenario/module/gui/player_bonus.lua +++ b/exp_scenario/module/gui/player_bonus.lua @@ -3,7 +3,7 @@ Adds a gui that allows players to apply various bonuses ]] local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/bonus") local vlayer = require("modules/exp_legacy/modules/control/vlayer") local format_number = require("util").format_number @@ -68,7 +68,11 @@ do local _points_limit = {} --- @type table --- @param player LuaPlayer --- @return number function Elements.bonus_used._calculate_points_limit(player) - local role_diff = Roles.get_role_by_name(config.points.role_name).index - Roles.get_player_highest_role(player).index + local roles = Roles.get_roles() + local positions = {} + for index, role in ipairs(roles) do positions[role.id] = index end + local role = assert(Roles.get_role_by_name(config.points.role_name), "Bonus points role does not exist") + local role_diff = positions[role.id] - positions[Roles.get_player_highest_role(player).id] local points_limit = math.floor(config.points.base * (1 + config.points.increase_percentage_per_role_level * role_diff)) _points_limit[player.index] = points_limit return points_limit @@ -458,19 +462,19 @@ Gui.toolbar.create_button{ sprite = "item/exoskeleton-equipment", tooltip = { "exp-gui_player-bonus.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/bonus") + return Roles.player_has_permission(player, "exp_scenario.gui.bonus") end } --- Recalculate and apply the bonus for a player local function recalculate_bonus(event) local player = Gui.get_player(event) - if event.name == Roles.events.on_role_assigned or event.name == Roles.events.on_role_unassigned then + if event.name == Roles.events.on_player_roles_changed then -- If the player's roles changed then we will need to recalculate their limit Elements.bonus_used._clear_points_limit_cache(player) local bonus_cost = Elements.container.calculate_cost(player) local within_limit = Elements.bonus_used.refresh_player(player, bonus_cost) - if not within_limit or not Roles.player_allowed(player, "gui/bonus") then + if not within_limit or not Roles.player_has_permission(player, "exp_scenario.gui.bonus") then Elements.container.clear_player_bonus(player) return end @@ -512,7 +516,7 @@ end --- Apply the periodic bonus to all players local function apply_periodic_bonus_online() for _, player in pairs(game.connected_players) do - if player.character and Roles.player_allowed(player, "gui/bonus") then + if player.character and Roles.player_has_permission(player, "exp_scenario.gui.bonus") then apply_personal_battery_recharge(player) end end @@ -524,8 +528,7 @@ return { elements = Elements, events = { [e.on_player_respawned] = recalculate_bonus, - [Roles.events.on_role_assigned] = recalculate_bonus, - [Roles.events.on_role_unassigned] = recalculate_bonus, + [Roles.events.on_player_roles_changed] = recalculate_bonus, }, on_nth_tick = { [config.periodic_bonus_rate] = apply_periodic_bonus_online, diff --git a/exp_scenario/module/gui/player_list.lua b/exp_scenario/module/gui/player_list.lua index 905b5be2..e6fc9058 100644 --- a/exp_scenario/module/gui/player_list.lua +++ b/exp_scenario/module/gui/player_list.lua @@ -4,7 +4,7 @@ Adds a player list to show names and play time; also includes action buttons whi local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/gui/player_list_actions") --- @class ExpGui_PlayerList.elements @@ -203,7 +203,8 @@ function Elements.player_table.calculate_row_data() -- Flatten the roles into a single ordered list local count = 0 local row_data = {} - for _, role_name in pairs(Roles.config.order) do + for _, role in ipairs(Roles.get_ordered_roles()) do + local role_name = role.name if players[role_name] then for _, player in pairs(players[role_name]) do count = count + 1 @@ -360,7 +361,7 @@ function Elements.action_bar.refresh(action_bar, player, selected_player) if buttons.auth and not buttons.auth(player, selected_player) then flow.visible = false else - flow.visible = Roles.player_allowed(player, action_name) + flow.visible = Roles.player_has_permission(player, action_name) end end end @@ -492,7 +493,7 @@ Gui.toolbar.create_button{ sprite = "entity/character", tooltip = { "exp-gui_player-list.main-tooltip" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/player-list") + return Roles.player_has_permission(player, "exp_scenario.gui.player_list") end } @@ -534,8 +535,7 @@ return { events = { [e.on_player_joined_game] = redraw_player_list, [e.on_player_left_game] = on_player_left_game, - [Roles.events.on_role_assigned] = redraw_player_list, - [Roles.events.on_role_unassigned] = redraw_player_list, + [Roles.events.on_player_roles_changed] = redraw_player_list, }, on_nth_tick = { [1800] = refresh_player_times, diff --git a/exp_scenario/module/gui/player_stats.lua b/exp_scenario/module/gui/player_stats.lua index b0a57df3..5740aa2a 100644 --- a/exp_scenario/module/gui/player_stats.lua +++ b/exp_scenario/module/gui/player_stats.lua @@ -5,7 +5,7 @@ Displays the player data for a player local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local ElementsExtra = require("modules/exp_scenario/gui/elements") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") require("modules/exp_legacy/modules/data/statistics") local PlayerData = require("modules/exp_legacy/expcore/player_data") @@ -214,7 +214,7 @@ Gui.toolbar.create_button{ tooltip = { "exp-gui_player-stats.tooltip-main" }, left_element = Elements.container, visible = function(player, element) - return Roles.player_allowed(player, "gui/playerdata") + return Roles.player_has_permission(player, "exp_scenario.gui.playerdata") end } diff --git a/exp_scenario/module/gui/production_stats.lua b/exp_scenario/module/gui/production_stats.lua index 94b49562..e8728055 100644 --- a/exp_scenario/module/gui/production_stats.lua +++ b/exp_scenario/module/gui/production_stats.lua @@ -3,7 +3,7 @@ Adds a Gui for displaying item production stats ]] local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") --- @class ExpGui_ProductionStats.elements local Elements = {} @@ -250,7 +250,7 @@ Gui.toolbar.create_button{ sprite = "entity/assembling-machine-3", tooltip = { "exp-gui_production-stats.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/production") + return Roles.player_has_permission(player, "exp_scenario.gui.production") end } diff --git a/exp_scenario/module/gui/quick_actions.lua b/exp_scenario/module/gui/quick_actions.lua index 92d358aa..2d9c7cdb 100644 --- a/exp_scenario/module/gui/quick_actions.lua +++ b/exp_scenario/module/gui/quick_actions.lua @@ -4,7 +4,7 @@ Adds a few buttons for common actions local Gui = require("modules/exp_gui") local Commands = require("modules/exp_commands") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local addon_artillery = require("modules/exp_scenario/commands/artillery") local addon_trains = require("modules/exp_scenario/commands/trains") @@ -116,7 +116,7 @@ Gui.toolbar.create_button{ sprite = "item/repair-pack", tooltip = { "exp-gui_quick-actions.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/tool") + return Roles.player_has_permission(player, "exp_scenario.gui.tool") end } @@ -129,7 +129,6 @@ end return { elements = Elements, events = { - [Roles.events.on_role_assigned] = on_role_changed, - [Roles.events.on_role_unassigned] = on_role_changed, + [Roles.events.on_player_roles_changed] = on_role_changed, } } diff --git a/exp_scenario/module/gui/readme.lua b/exp_scenario/module/gui/readme.lua index 5a7749b6..5f8c2729 100644 --- a/exp_scenario/module/gui/readme.lua +++ b/exp_scenario/module/gui/readme.lua @@ -4,7 +4,7 @@ Adds a main gui that contains important information about the server. local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local Commands = require("modules/exp_commands") local PlayerData = require("modules.exp_legacy.expcore.player_data") local External = require("modules.exp_legacy.expcore.external") @@ -214,7 +214,7 @@ define_tab( container.add{ type = "flow" }.style.height = 4 local role_names = {} - for i, role in ipairs(Roles.get_player_roles(player)) do + for i, role in ipairs(Roles.sort_roles(Roles.get_player_roles(player))) do role_names[i] = role.name end @@ -392,14 +392,16 @@ define_tab( local done = {} - -- Fill groups from configured roles - for player_name, player_roles in pairs(Roles.config.players) do - for _, group in ipairs(groups) do - for _, role_name in ipairs(group.roles) do - if table.contains(player_roles, role_name) then + -- Fill groups from the players who hold the roles, a player can be in more than one group + for _, group in ipairs(groups) do + local seen = {} + for _, role_name in ipairs(group.roles) do + local role = Roles.get_role_by_name(role_name) + for _, player_name in ipairs(role and role:get_player_names() or {}) do + if not seen[player_name] then + seen[player_name] = true done[player_name] = true group.players[#group.players + 1] = player_name - break end end end @@ -463,7 +465,7 @@ local function render_data_category(opts) for name, child in pairs(opts.children) do local metadata = child.metadata - if not metadata.permission or Roles.player_allowed(opts.player, metadata.permission) then + if not metadata.permission or Roles.player_has_permission(opts.player, metadata.permission) then local value = child:get(opts.player_name) if value ~= nil or metadata.show_always then @@ -663,7 +665,7 @@ Elements.toggle_button = Gui.toolbar.create_button{ sprite = "virtual-signal/signal-info", tooltip = { "exp-gui_readme.main-tooltip" }, visible = function(player) - return Roles.player_allowed(player, "gui/readme") + return Roles.player_has_permission(player, "exp_scenario.gui.readme") end, } :on_click(function(_, player) diff --git a/exp_scenario/module/gui/research_milestones.lua b/exp_scenario/module/gui/research_milestones.lua index 4652edcd..a7e06846 100644 --- a/exp_scenario/module/gui/research_milestones.lua +++ b/exp_scenario/module/gui/research_milestones.lua @@ -4,7 +4,7 @@ Adds a gui for tracking research milestones local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/research") local table_to_json = helpers.table_to_json @@ -294,9 +294,9 @@ end --- Get the achieved time for a force --- @param force LuaForce --- @param research_index number ---- @return number +--- @return number? function Elements.container.get_achieved_time(force, research_index) - return assert(Elements.container.data[force][research_index]) + return assert(Elements.container.data[force])[research_index] end --- Calculate the starting research index for a force @@ -339,7 +339,7 @@ Gui.toolbar.create_button{ sprite = "item/space-science-pack", tooltip = { "exp-gui_research-milestones.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/research") + return Roles.player_has_permission(player, "exp_scenario.gui.research") end } diff --git a/exp_scenario/module/gui/rocket_info.lua b/exp_scenario/module/gui/rocket_info.lua index d7999db3..e0b9074a 100644 --- a/exp_scenario/module/gui/rocket_info.lua +++ b/exp_scenario/module/gui/rocket_info.lua @@ -4,7 +4,7 @@ Adds a rocket information gui which shows general stats, milestones and build pr local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/gui/rockets") --- @class ExpGui_RocketInfo.elements @@ -651,7 +651,7 @@ Gui.toolbar.create_button{ sprite = "item/rocket-silo", tooltip = { "exp-gui_rocket-info.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/rocket-info") + return Roles.player_has_permission(player, "exp_scenario.gui.rocket_info") end } diff --git a/exp_scenario/module/gui/science_production.lua b/exp_scenario/module/gui/science_production.lua index ad7772af..c2ef7273 100644 --- a/exp_scenario/module/gui/science_production.lua +++ b/exp_scenario/module/gui/science_production.lua @@ -5,7 +5,7 @@ Adds a science info gui that shows production usage and net for the different sc local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local Colors = require("modules/exp_util/include/color") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/gui/science") local _format_number = require("util").format_number @@ -571,7 +571,7 @@ Gui.toolbar.create_button{ sprite = "entity/lab", tooltip = { "exp-gui_science-production.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/science-info") + return Roles.player_has_permission(player, "exp_scenario.gui.science_info") end } diff --git a/exp_scenario/module/gui/surveillance.lua b/exp_scenario/module/gui/surveillance.lua index 0fd65924..e9910723 100644 --- a/exp_scenario/module/gui/surveillance.lua +++ b/exp_scenario/module/gui/surveillance.lua @@ -4,7 +4,7 @@ Adds cameras which can be used to view players and locations local Gui = require("modules/exp_gui") local ElementsExtra = require("modules/exp_scenario/gui/elements") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") --- @class ExpGui_Surveillance.elements local Elements = {} @@ -234,7 +234,7 @@ Gui.toolbar.create_button{ sprite = "entity/radar", tooltip = { "exp-gui_surveillance.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/surveillance") + return Roles.player_has_permission(player, "exp_scenario.gui.surveillance") end }:on_click(function(def, player, element, event) Elements.container(player.gui.screen) diff --git a/exp_scenario/module/gui/task_list.lua b/exp_scenario/module/gui/task_list.lua index 75858bad..c628063a 100644 --- a/exp_scenario/module/gui/task_list.lua +++ b/exp_scenario/module/gui/task_list.lua @@ -3,7 +3,7 @@ Adds a task list to the game which players can add, remove and edit items on ]] local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.gui.tasks") local ExpUtil = require("modules/exp_util") @@ -43,8 +43,8 @@ local function has_permission_create_task(player) return true elseif allow_add_task == "admin" then return player.admin - elseif allow_add_task == "expcore.roles" then - return Roles.player_allowed(player, config.expcore_roles_allow_add_task) + elseif allow_add_task == "exp_roles" then + return Roles.player_has_permission(player, config.exp_roles_allow_add_task) end return false @@ -67,8 +67,8 @@ local function has_permission_edit_task(player, task) return true elseif allow_edit_task == "admin" then return player.admin - elseif allow_edit_task == "expcore.roles" then - return Roles.player_allowed(player, config.expcore_roles_allow_edit_task) + elseif allow_edit_task == "exp_roles" then + return Roles.player_has_permission(player, config.exp_roles_allow_edit_task) end return false @@ -922,7 +922,7 @@ Gui.toolbar.create_button{ sprite = "utility/not_enough_repair_packs_icon", tooltip = { "exp-gui_task-list.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/task-list") + return Roles.player_has_permission(player, "exp_scenario.gui.task_list") end } @@ -950,7 +950,6 @@ return { events = { [e.on_player_joined_game] = refresh_player_tasks, [e.on_player_changed_force] = refresh_player_tasks, - [Roles.events.on_role_assigned] = refresh_player_permissions, - [Roles.events.on_role_unassigned] = refresh_player_permissions, + [Roles.events.on_player_roles_changed] = refresh_player_permissions, } } diff --git a/exp_scenario/module/module.json b/exp_scenario/module/module.json index 56b58778..23b572f0 100644 --- a/exp_scenario/module/module.json +++ b/exp_scenario/module/module.json @@ -8,6 +8,7 @@ "dependencies": { "clusterio": "*", "exp_util": "*", + "exp_roles": "*", "exp_gui": "*", "exp_commands": "*" } diff --git a/exp_scenario/permissions.ts b/exp_scenario/permissions.ts index 567e0c3a..9d604b58 100644 --- a/exp_scenario/permissions.ts +++ b/exp_scenario/permissions.ts @@ -1,160 +1,130 @@ import * as lib from "@clusterio/lib"; /** - * Convert a legacy role action into its clusterio permission name. + * Permissions checked by the scenario in game through exp_roles. * - * The exp_roles lua module applies the same transform, so existing call sites such as - * `Roles.player_allowed(player, "gui/warp-list/add")` keep working unchanged. - * - * @param action - Legacy action such as `gui/warp-list/add` or `fast-tree-decon`. - * @returns Permission name such as `exp_scenario.gui.warp_list.add`. + * Commands are checked as `exp_scenario.command.` with hyphens replaced by + * underscores, see module/commands/_authorities.lua. Everything else is checked + * by name at its call site. */ -export function permissionFromAction(action: string) { - const body = action.replace(/-/g, "_").replace(/\//g, "."); - return `exp_scenario.${body.includes(".") ? body : `action.${body}`}`; -} +type Definition = [name: string, title: string, description: string, grantByDefault?: boolean]; -/** - * Convert a legacy role flag into its clusterio permission name. - * - * @param flag - Legacy flag such as `report-immune`. - * @returns Permission name such as `exp_scenario.flag.report_immune`. - */ -export function permissionFromFlag(flag: string) { - return `exp_scenario.flag.${flag.replace(/-/g, "_")}`; -} - -/** Legacy actions which map onto core clusterio permissions rather than scenario ones. */ -export const coreActionPermissions: Record = { - "command/assign-role": "core.user.update_roles", - "command/unassign-role": "core.user.update_roles", - "command/get-roles": "core.role.list", -}; - -/** Legacy action, user facing title, description, and whether every player gets it. */ -type ActionDefinition = [action: string, title: string, description: string, grantByDefault?: boolean]; - -const actions: ActionDefinition[] = [ - ["bypass-entity-protection", "Bypass entity protection", "Remove entities that the protection filter would block."], - ["bypass-nukeprotect", "Bypass nuke protection", "Use nukes without the nuke protection restrictions."], - ["command/_rcon", "/_rcon", "Execute arbitrary code within a custom environment."], - ["command/admin-chat", "/admin-chat", "Sends a message in chat that only admins can see."], - ["command/artillery", "/artillery", "Automaticly select enemy target with artillery."], - ["command/ban", "/ban", "Ban a player from the cluster via the player list."], - ["command/bring", "/bring", "Teleports a player to you."], - ["command/chat-commands", "Chat commands", "Use the chat command prefix to trigger auto replies."], - ["command/clear-blueprints", "/clear-blueprints", "Clear all blueprints."], - ["command/clear-blueprints-surface", "/clear-blueprints-surface", "Clear all blueprints on the current surface."], - ["command/clear-ground-items", "/clear-ground-items", "Clear all items on the ground."], - ["command/clear-inventory", "/clear-inventory", "Clear a player's inventory, moving all items to spawn."], - ["command/clear-last-warnings", "/clear-last-warnings", "Clears the last warning from a player."], - ["command/clear-pollution", "/clear-pollution", "Clear pollution from your current surface, or another surface."], - ["command/clear-reports", "/clear-reports", "Clears all reports from a player or just the report from one player."], - ["command/clear-script-warnings", "/clear-script-warnings", "Clears all script warnings from a player."], - ["command/clear-tag/always", "/clear-tag (any player)", "Clear the tag of any player, not just your own."], - ["command/clear-warnings", "/clear-warnings", "Clears all warnings (and script warnings) from a player."], - ["command/collectdata", "/collectdata", "Collect data for RCON usage."], - ["command/commands", "/commands", "List and search all commands for a keyword.", true], - ["command/connect", "/connect", "Connect to another server.", true], - ["command/connect-all", "/connect-all", "Connect all players to another server."], - ["command/connect-player", "/connect-player", "Connect a player to a different server."], - ["command/create-report", "/create-report", "Reports a player and notifies moderators.", true], - ["command/create-warning", "/create-warning", "Gives a warning to a player; may lead to automatic script action."], - ["command/data-preference", "/data-preference", "Allows you to set/get your data saving preference.", true], - ["command/debug", "/debug", "Opens the debug gui."], - ["command/follow", "/follow", "Start following a player in spectator."], - ["command/get-home", "/get-home", "Returns your current home location."], - ["command/get-reports", "/get-reports", "List the reports against a player, or against every player."], - ["command/get-warnings", "/get-warnings", "List the warnings against a player, or against every player."], - ["command/give-warning", "/give-warning", "Give a player a warning via the player list."], - ["command/goto", "/goto", "Teleports you to a player."], - ["command/home", "/home", "Teleports you to your home location."], - ["command/jail", "/jail", "Puts a player into jail and removes all other roles."], - ["command/kick", "/kick", "Kick a player from the server via the player list."], - ["command/kill", "/kill", "Kills yourself or another player."], - ["command/kill-enemies", "/kill-enemies", "Kill all enemy units."], - ["command/kill/always", "/kill (any player)", "Kill any player, not just yourself."], - ["command/lawnmower", "/lawnmower", "Clean up biter corpse, decoratives and nuclear hole."], - ["command/locate", "/locate", "Opens remote view at the location of the player's last location.", true], - ["command/me", "/me", "Sends an action message in the chat."], - ["command/protect-area", "/protect-area", "Toggles area protection selection, hold shift to remove protection."], - ["command/protect-entity", "/protect-entity", "Toggles entity protection selection, hold shift to remove protection."], - ["command/protect-tag", "/protect-tag", "Toggles protected tag mode, edit and create protected map tags."], - ["command/rainbow", "/rainbow", "Sends an rainbow message in the chat."], - ["command/ratio", "/ratio", "Get the input and output ratios of the selected machine.", true], - ["command/remove-enemies", "/remove-enemies", "Remove all enemy spawners."], - ["command/remove-join-message", "/remove-join-message", "Removes you custom join message."], - ["command/repair", "/repair", "Repairs entities on your force around you."], - ["command/research-all", "/research-all", "Research all technology for your force, or another force."], - ["command/return", "/return", "Teleports you to previous location."], - ["command/save-data", "/save-data", "Writes all your player data to a file on your computer.", true], - ["command/save-quickbar", "/save-quickbar", "Saves your Quickbar preset items to file."], - ["command/search", "/search", "Display players sorted by the quantity of an item held and playtime."], - ["command/search-amount", "/search-amount", "Display players sorted by the quantity of an item held."], - ["command/search-online", "/search-online", "Display online players sorted by item count and playtime."], - ["command/search-recent", "/search-recent", "Display players who hold an item sorted by join time."], - ["command/server-ups", "/server-ups", "Toggle the server UPS display.", true], - ["command/set-always-day", "/set-always-day", "Set always day for your current surface, or another surface."], - ["command/set-bot-queue", "/set-bot-queue", "Get / Set the construction bot queue limits."], - ["command/set-cheat-mode", "/set-cheat-mode", "Set cheat mode for your player, or another player."], - ["command/set-friendly-fire", "/set-friendly-fire", "Set friendly fire for your force, or another force."], - ["command/set-game-speed", "/set-game-speed", "Set or get the current game speed."], - ["command/set-home", "/set-home", "Sets your home location to your current position."], - ["command/set-join-message", "/set-join-message", "Sets / Gets your custom join message."], - ["command/set-pollution-enabled", "/set-pollution-enabled", "Set polution enabled state for this game."], - ["command/set-trains-to-automatic", "/set-trains-to-automatic", "Set all trains without passengers to automatic."], - ["command/spawn", "/spawn", "Teleport to spawn."], - ["command/spawn/always", "/spawn (any player)", "Teleport any player to spawn, not just yourself."], - ["command/spectate", "/spectate", "Toggles spectator mode."], - ["command/tag", "/tag", "Sets your player tag.", true], - ["command/tag-clear", "/tag-clear", "Clears your tag. Or another player if you are admin.", true], - ["command/tag-color", "/tag-color", "Sets your player tag color."], - ["command/teleport", "/teleport", "Teleports a player to another player."], - ["command/unjail", "/unjail", "Removes a player from jail and restores their previous roles."], - ["command/vlayer-info", "/vlayer-info", "Print all vlayer information."], - ["command/waterfill", "/waterfill", "Replace tiles with shallow water."], - ["fast-tree-decon", "Fast tree deconstruction", "Deconstruct trees and rocks instantly over a large area."], - ["gui/autofill", "Autofill", "Open the autofill GUI, which fills placed entities from your inventory.", true], - ["gui/bonus", "Bonus", "Open the bonus GUI to adjust your personal bonuses."], - ["gui/module", "Module inserter", "Open the module inserter GUI.", true], - ["gui/player-list", "Player list", "Open the player list GUI.", true], - ["gui/playerdata", "Player statistics", "Open the player statistics GUI."], - ["gui/production", "Production statistics", "Open the production statistics GUI.", true], - ["gui/readme", "Readme", "Open the server readme GUI.", true], - ["gui/research", "Research milestones", "Open the research milestones GUI.", true], - ["gui/rocket-info", "Rocket info", "Open the rocket info GUI.", true], - ["gui/rocket-info/remote_launch", "Rocket info: remote launch", "Launch rockets remotely from the rocket info GUI."], - ["gui/rocket-info/toggle-active", "Rocket info: toggle active", "Toggle whether a silo launches automatically."], - ["gui/science-info", "Science production", "Open the science production GUI.", true], - ["gui/surveillance", "Surveillance", "Open the surveillance GUI showing remote camera views."], - ["gui/task-list", "Task list", "Open the task list GUI.", true], - ["gui/task-list/add", "Task list: add", "Add new tasks to the task list."], - ["gui/task-list/edit", "Task list: edit", "Edit and remove existing tasks on the task list."], - ["gui/tool", "Quick actions", "Open the quick actions GUI."], - ["gui/vlayer", "Virtual layer", "Open the virtual layer GUI.", true], - ["gui/vlayer-edit", "Virtual layer: edit", "Use the edit controls in the virtual layer GUI."], - ["gui/warp-list", "Warp list", "Open the warp list GUI and warp between locations.", true], - ["gui/warp-list/add", "Warp list: add", "Add new warp points to the warp list."], - ["gui/warp-list/bypass-cooldown", "Warp list: bypass cooldown", "Warp without waiting for the warp cooldown."], - ["gui/warp-list/bypass-proximity", "Warp list: bypass proximity", "Warp without standing near a warp point."], - ["gui/warp-list/edit", "Warp list: edit", "Edit and remove existing warp points."], - ["standard-decon", "Standard deconstruction", "Deconstruct trees and rocks."], +const definitions: Definition[] = [ + ["exp_scenario.bypass.deconstruction_log", "Deconstruction log bypass", "Be excluded from the deconstruction log."], + ["exp_scenario.bypass.entity_protection", "Bypass entity protection", "Remove entities that the protection filter would block."], + ["exp_scenario.bypass.nuke_protection", "Bypass nuke protection", "Use nukes without the nuke protection restrictions."], + ["exp_scenario.bypass.reports", "Report immune", "Be immune to being reported by other players."], + ["exp_scenario.chat.commands", "Chat commands", "Use the chat command prefix to trigger auto replies."], + ["exp_scenario.command._ipc", "/_ipc", "Send an IPC message on the selected channel."], + ["exp_scenario.command._rcon", "/_rcon", "Execute arbitrary code within a custom environment."], + ["exp_scenario.command._sudo", "/_sudo", "Run a command as another player."], + ["exp_scenario.command.admin_chat", "/admin-chat", "Sends a message in chat that only admins can see."], + ["exp_scenario.command.artillery", "/artillery", "Automaticly select enemy target with artillery."], + ["exp_scenario.command.assign_role", "/assign-role", "Assigns a role lower than your own to a player."], + ["exp_scenario.command.bring", "/bring", "Teleports a player to you."], + ["exp_scenario.command.clear_blueprints", "/clear-blueprints", "Clear all blueprints."], + ["exp_scenario.command.clear_blueprints_surface", "/clear-blueprints-surface", "Clear all blueprints on the current surface."], + ["exp_scenario.command.clear_ground_items", "/clear-ground-items", "Clear all items on the ground."], + ["exp_scenario.command.clear_inventory", "/clear-inventory", "Clear a player's inventory, moving all items to spawn."], + ["exp_scenario.command.clear_last_warnings", "/clear-last-warnings", "Clears the last warning from a player."], + ["exp_scenario.command.clear_pollution", "/clear-pollution", "Clear pollution from your current surface, or another surface."], + ["exp_scenario.command.clear_reports", "/clear-reports", "Clears all reports from a player or just the report from one player."], + ["exp_scenario.command.clear_script_warnings", "/clear-script-warnings", "Clears all script warnings from a player."], + ["exp_scenario.command.clear_warnings", "/clear-warnings", "Clears all warnings (and script warnings) from a player."], + ["exp_scenario.command.collectdata", "/collectdata", "Collect data for RCON usage."], + ["exp_scenario.command.commands", "/commands", "List and search all commands for a keyword.", true], + ["exp_scenario.command.connect", "/connect", "Connect to another server.", true], + ["exp_scenario.command.connect_all", "/connect-all", "Connect all players to another server."], + ["exp_scenario.command.connect_player", "/connect-player", "Connect a player to a different server."], + ["exp_scenario.command.create_report", "/create-report", "Reports a player and notifies moderators.", true], + ["exp_scenario.command.create_warning", "/create-warning", "Gives a warning to a player; may lead to automatic script action."], + ["exp_scenario.command.data_preference", "/data-preference", "Allows you to set/get your data saving preference.", true], + ["exp_scenario.command.debug", "/debug", "Opens the debug gui."], + ["exp_scenario.command.follow", "/follow", "Start following a player in spectator."], + ["exp_scenario.command.get_home", "/get-home", "Returns your current home location."], + ["exp_scenario.command.get_reports", "/get-reports", "List the reports against a player, or against every player."], + ["exp_scenario.command.get_roles", "/get-roles", "Get all roles that a player has, if no player provided it lists all roles.", true], + ["exp_scenario.command.get_warnings", "/get-warnings", "List the warnings against a player, or against every player."], + ["exp_scenario.command.goto", "/goto", "Teleports you to a player."], + ["exp_scenario.command.home", "/home", "Teleports you to your home location."], + ["exp_scenario.command.jail", "/jail", "Puts a player into jail, which suppresses all of their other roles."], + ["exp_scenario.command.kill", "/kill", "Kills yourself or another player."], + ["exp_scenario.command.kill.always", "/kill (any player)", "Kill any player, not just yourself."], + ["exp_scenario.command.kill_enemies", "/kill-enemies", "Kill all enemy units."], + ["exp_scenario.command.lawnmower", "/lawnmower", "Clean up biter corpse, decoratives and nuclear hole."], + ["exp_scenario.command.locate", "/locate", "Opens remote view at the location of the player's last location.", true], + ["exp_scenario.command.me", "/me", "Sends an action message in the chat."], + ["exp_scenario.command.protect_area", "/protect-area", "Toggles area protection selection, hold shift to remove protection."], + ["exp_scenario.command.protect_entity", "/protect-entity", "Toggles entity protection selection, hold shift to remove protection."], + ["exp_scenario.command.protect_tag", "/protect-tag", "Toggles protected tag mode, edit and create protected map tags."], + ["exp_scenario.command.rainbow", "/rainbow", "Sends an rainbow message in the chat."], + ["exp_scenario.command.ratio", "/ratio", "Get the input and output ratios of the selected machine.", true], + ["exp_scenario.command.remove_enemies", "/remove-enemies", "Remove all enemy spawners."], + ["exp_scenario.command.remove_join_message", "/remove-join-message", "Removes you custom join message."], + ["exp_scenario.command.repair", "/repair", "Repairs entities on your force around you."], + ["exp_scenario.command.research_all", "/research-all", "Research all technology for your force, or another force."], + ["exp_scenario.command.return", "/return", "Teleports you to previous location."], + ["exp_scenario.command.save_data", "/save-data", "Writes all your player data to a file on your computer.", true], + ["exp_scenario.command.save_quickbar", "/save-quickbar", "Saves your Quickbar preset items to file."], + ["exp_scenario.command.search", "/search", "Display players sorted by the quantity of an item held and playtime."], + ["exp_scenario.command.search_amount", "/search-amount", "Display players sorted by the quantity of an item held."], + ["exp_scenario.command.search_online", "/search-online", "Display online players sorted by item count and playtime."], + ["exp_scenario.command.search_recent", "/search-recent", "Display players who hold an item sorted by join time."], + ["exp_scenario.command.server_ups", "/server-ups", "Toggle the server UPS display.", true], + ["exp_scenario.command.set_always_day", "/set-always-day", "Set always day for your current surface, or another surface."], + ["exp_scenario.command.set_bot_queue", "/set-bot-queue", "Get / Set the construction bot queue limits."], + ["exp_scenario.command.set_cheat_mode", "/set-cheat-mode", "Set cheat mode for your player, or another player."], + ["exp_scenario.command.set_friendly_fire", "/set-friendly-fire", "Set friendly fire for your force, or another force."], + ["exp_scenario.command.set_game_speed", "/set-game-speed", "Set or get the current game speed."], + ["exp_scenario.command.set_home", "/set-home", "Sets your home location to your current position."], + ["exp_scenario.command.set_join_message", "/set-join-message", "Sets / Gets your custom join message."], + ["exp_scenario.command.set_pollution_enabled", "/set-pollution-enabled", "Set polution enabled state for this game."], + ["exp_scenario.command.set_trains_to_automatic", "/set-trains-to-automatic", "Set all trains without passengers to automatic."], + ["exp_scenario.command.spawn", "/spawn", "Teleport to spawn."], + ["exp_scenario.command.spawn.always", "/spawn (any player)", "Teleport any player to spawn, not just yourself."], + ["exp_scenario.command.spectate", "/spectate", "Toggles spectator mode."], + ["exp_scenario.command.tag", "/tag", "Sets your player tag.", true], + ["exp_scenario.command.tag_clear", "/tag-clear", "Clears your tag. Or another player if you are admin.", true], + ["exp_scenario.command.tag_clear.always", "/tag-clear (any player)", "Clear the tag of any player, not just your own."], + ["exp_scenario.command.tag_color", "/tag-color", "Sets your player tag color."], + ["exp_scenario.command.teleport", "/teleport", "Teleports a player to another player."], + ["exp_scenario.command.unassign_role", "/unassign-role", "Unassigns a role lower than your own from a player."], + ["exp_scenario.command.unjail", "/unjail", "Removes a player from jail, restoring their other roles."], + ["exp_scenario.command.vlayer_info", "/vlayer-info", "Print all vlayer information."], + ["exp_scenario.command.waterfill", "/waterfill", "Replace tiles with shallow water."], + ["exp_scenario.decon.fast_trees", "Fast tree deconstruction", "Trees and rocks marked for deconstruction are removed instantly, when enabled from the toolbar."], + ["exp_scenario.decon.standard", "Standard deconstruction", "Mark entities placed by other players for deconstruction."], + ["exp_scenario.gui.autofill", "Autofill", "Open the autofill GUI, which fills placed entities from your inventory.", true], + ["exp_scenario.gui.bonus", "Bonus", "Open the bonus GUI to adjust your personal bonuses."], + ["exp_scenario.gui.module", "Module inserter", "Open the module inserter GUI.", true], + ["exp_scenario.gui.player_list", "Player list", "Open the player list GUI.", true], + ["exp_scenario.gui.player_list.ban", "Player list: ban", "Ban a player from the cluster via the player list."], + ["exp_scenario.gui.player_list.kick", "Player list: kick", "Kick a player from the server via the player list."], + ["exp_scenario.gui.playerdata", "Player statistics", "Open the player statistics GUI."], + ["exp_scenario.gui.production", "Production statistics", "Open the production statistics GUI.", true], + ["exp_scenario.gui.readme", "Readme", "Open the server readme GUI.", true], + ["exp_scenario.gui.research", "Research milestones", "Open the research milestones GUI.", true], + ["exp_scenario.gui.rocket_info", "Rocket info", "Open the rocket info GUI.", true], + ["exp_scenario.gui.rocket_info.remote_launch", "Rocket info: remote launch", "Launch rockets remotely from the rocket info GUI."], + ["exp_scenario.gui.rocket_info.toggle_active", "Rocket info: toggle active", "Toggle whether a silo launches automatically."], + ["exp_scenario.gui.science_info", "Science production", "Open the science production GUI.", true], + ["exp_scenario.gui.surveillance", "Surveillance", "Open the surveillance GUI showing remote camera views."], + ["exp_scenario.gui.task_list", "Task list", "Open the task list GUI.", true], + ["exp_scenario.gui.task_list.add", "Task list: add", "Add new tasks to the task list."], + ["exp_scenario.gui.task_list.edit", "Task list: edit", "Edit and remove existing tasks on the task list."], + ["exp_scenario.gui.tool", "Quick actions", "Open the quick actions GUI."], + ["exp_scenario.gui.vlayer", "Virtual layer", "Open the virtual layer GUI.", true], + ["exp_scenario.gui.vlayer_edit", "Virtual layer: edit", "Use the edit controls in the virtual layer GUI."], + ["exp_scenario.gui.warp_list", "Warp list", "Open the warp list GUI and warp between locations.", true], + ["exp_scenario.gui.warp_list.add", "Warp list: add", "Add new warp points to the warp list."], + ["exp_scenario.gui.warp_list.bypass_cooldown", "Warp list: bypass cooldown", "Warp without waiting for the warp cooldown."], + ["exp_scenario.gui.warp_list.bypass_proximity", "Warp list: bypass proximity", "Warp without standing near a warp point."], + ["exp_scenario.gui.warp_list.edit", "Warp list: edit", "Edit and remove existing warp points."], + ["exp_scenario.player.admin", "Factorio admin", "Be promoted to Factorio admin while holding a role with this permission."], + ["exp_scenario.player.instant_respawn", "Instant respawn", "Respawn after two seconds instead of the default delay."], + ["exp_scenario.player.spectator", "Spectator", "Remove the zoom to world noise effect, as Factorio does for spectators."], ]; -const flags: ActionDefinition[] = [ - ["deconlog-bypass", "Deconstruction log bypass", "Be excluded from the deconstruction log."], - ["defer_role_changes", "Defer role changes", "Hold back role changes until this role is removed; used by Jail."], - ["instant-respawn", "Instant respawn", "Respawn after two seconds instead of the default delay."], - ["is_admin", "Factorio admin", "Be promoted to Factorio admin while holding a role with this flag."], - ["is_spectator", "Spectator", "Be placed into Factorio spectator mode."], - ["is_system", "System commands", "Unlock system commands such as /_rcon and /_sudo."], - ["report-immune", "Report immune", "Be immune to being reported by other players."], -]; - -for (const [action, title, description, grantByDefault] of actions) { - lib.definePermission({ name: permissionFromAction(action), title, description, grantByDefault }); -} - -for (const [flag, title, description] of flags) { - lib.definePermission({ name: permissionFromFlag(flag), title, description }); +for (const [name, title, description, grantByDefault] of definitions) { + lib.definePermission({ name, title, description, grantByDefault }); } diff --git a/exp_server_ups/module/control.lua b/exp_server_ups/module/control.lua index 6d51c9d3..cd2d5b19 100644 --- a/exp_server_ups/module/control.lua +++ b/exp_server_ups/module/control.lua @@ -14,7 +14,7 @@ local PlayerData = require("modules/exp_legacy/expcore/player_data") local UsesServerUps = PlayerData.Settings:combine("UsesServerUps") UsesServerUps:set_default(false) UsesServerUps:set_metadata{ - permission = "command/server-ups", + permission = "exp_scenario.command.server_ups", stringify = function(value) return value and "Visible" or "Hidden" end, } diff --git a/test/common.js b/test/common.js new file mode 100644 index 00000000..bf63b030 --- /dev/null +++ b/test/common.js @@ -0,0 +1,55 @@ +"use strict"; +const { compile } = require("@clusterio/lib"); + +/** + * Generate a flat array of tests from a matrix of inputs. + * + * @template {any[][]} T + * @param {T} arrays - A list of arrays representing the different values for each argument + * @returns {Array<{ [K in keyof T]: T[K][number] }>} + * An array of tuples, each containing one value from each input array. + */ +function testMatrix(...arrays) { + return arrays.reduce((acc, curr) => acc.flatMap(a => curr.map(b => [...a, b])), [[]]); +} + +/** + * Test that a class is round trip JSON serialisable across multiple test cases. + * + * @template {any[]} T - Constructor arguments + * @param {import("tap").Test} t - Parent test. + * @param {{new(...args: T): object}} Class - The class which has toJSON and fromJSON methods. + * @param {T[]} tests - The test inputs to pass to the class constructor. + */ +function testRoundTripJsonSerialisable(t, Class, tests) { + const validate = compile(Class.jsonSchema); + + for (const test of tests) { + const name = JSON.stringify(test); + + try { + const original = new Class(...test); + const jsonObject = JSON.parse(JSON.stringify(original)); + const reconstructed = Class.fromJSON(jsonObject); + + if (!validate(jsonObject)) { + t.fail(`schema validation: ${name}`, { + diagnostic: { + json: jsonObject, + errors: validate.errors, + }, + }); + continue; + } + + t.strictSame(reconstructed, original, `round trip: ${name}`); + } catch (error) { + t.fail(`round trip: ${name}`, { error }); + } + } +} + +module.exports = { + testMatrix, + testRoundTripJsonSerialisable, +}; diff --git a/test/lua/framework.lua b/test/lua/framework.lua new file mode 100644 index 00000000..c9918cd0 --- /dev/null +++ b/test/lua/framework.lua @@ -0,0 +1,168 @@ +--[[-- Test framework for plugin module tests +A suite is a collection of named tests. Test files declare them with +`Suite.test(name, function(env) ... end)`, naming each test after the function +or method it covers. Every test function receives a fresh environment: the +stubs extended by the function the suite was created with, so tests are +independent and can not leak state into each other. +]] + +local source = assert(debug.getinfo(1, "S")).source +local shared_root = assert(source:match("^@(.*)/framework%.lua$")) +local Stubs = assert(loadfile(shared_root .. "/stubs.lua"))() + +--- @class Framework +local Framework = {} + +--- Turn a value into a string for failure messages +local function repr(value, depth) + if type(value) ~= "table" then return tostring(value) end + if depth > 3 then return "{...}" end + + local parts = {} + for key, entry in pairs(value) do + parts[#parts + 1] = tostring(key) .. " = " .. repr(entry, depth + 1) + end + return "{ " .. table.concat(parts, ", ") .. " }" +end + +--- Create a suite of tests, one is made per test file +--- @generic T : Stubs +--- @param extend_env fun(env: Stubs): T Extends the stubs given to each test +--- @return Suite +function Framework.suite(extend_env) + --- @class Suite + local Suite = { + tests = {}, --- @type { name: string, fn: fun(env: T) }[] In declaration order + results = {}, --- @type { name: string, ok: boolean, detail: string? }[] Accumulated across tests + } + + local current_test --- @type string? + + --- Declare a named test, its function receives a fresh environment + --- @param name string + --- @param fn fun(env: T) + function Suite.test(name, fn) + Suite.tests[#Suite.tests + 1] = { name = name, fn = fn } + end + + --- Record a passing check within the current test + --- @param name string + function Suite.pass(name) + Suite.results[#Suite.results + 1] = { + name = current_test and (current_test .. ": " .. name) or name, + ok = true, + } + end + + --- Record a failing check within the current test + --- @param name string + --- @param detail string? + function Suite.fail(name, detail) + Suite.results[#Suite.results + 1] = { + name = current_test and (current_test .. ": " .. name) or name, + ok = false, + detail = detail, + } + end + + --- Check a condition + --- @param ok any Truthy when the check passed + --- @param name string + --- @param detail string? + function Suite.check(ok, name, detail) + if ok then + Suite.pass(name) + else + Suite.fail(name, detail) + end + end + + --- Recursive table equality, keys checked from both sides + local function deep_equal(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return a == b end + for key, value in pairs(a) do + if not deep_equal(value, b[key]) then return false end + end + for key in pairs(b) do + if a[key] == nil then return false end + end + return true + end + + --- Check two values are equal, comparing tables recursively + function Suite.eq(a, b, name) + Suite.check(deep_equal(a, b), name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1))) + end + + --- Check a table has no entries + function Suite.empty(value, name) + Suite.check(next(value) == nil, name, repr(value, 1)) + end + + --- Check a function raises an error containing the message + --- @param fn fun() + --- @param message string + --- @param name string + function Suite.throws(fn, message, name) + local ok, err = pcall(fn) + if ok then + Suite.fail(name, "did not error") + elseif not tostring(err):find(message, 1, true) then + Suite.fail(name, ("%s does not contain %s"):format(tostring(err), message)) + else + Suite.pass(name) + end + end + + --- Names of an array of values with a name property, such as roles, + --- players, forces, or events + --- @param values { name: string }[] + --- @return string[] + function Suite.names(values) + local rtn = {} + for index, value in ipairs(values) do + rtn[index] = value.name + end + return rtn + end + + --- Sorted copy of an array of strings + function Suite.sorted(list) + local rtn = {} + for index, value in ipairs(list) do rtn[index] = value end + table.sort(rtn) + return rtn + end + + --- Run every declared test, each against a fresh environment, and return + --- the results as JSON for the javascript side + function Suite.run() + for _, test in ipairs(Suite.tests) do + current_test = test.name + local ok, err = pcall(function() + test.fn(extend_env(Stubs.new())) + end) + if not ok then + Suite.fail("did not error", tostring(err)) + end + end + current_test = nil + + local function escape(value) + return (value:gsub('[%c"\\]', function(c) + return string.format("\\u%04x", c:byte()) + end)) + end + + local parts = {} + for index, result in ipairs(Suite.results) do + local detail = result.detail and string.format(',"detail":"%s"', escape(result.detail)) or "" + parts[index] = string.format('{"name":"%s","ok":%s%s}', escape(result.name), tostring(result.ok), detail) + end + return "[" .. table.concat(parts, ",") .. "]" + end + + return Suite +end + +return Framework diff --git a/test/lua/runner.js b/test/lua/runner.js new file mode 100644 index 00000000..51019609 --- /dev/null +++ b/test/lua/runner.js @@ -0,0 +1,63 @@ +"use strict"; +const path = require("node:path"); +const fs = require("node:fs"); +const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari"); + +/** + * Run one lua test file in its own lua state and return its results. + * + * The state is created here, on first use by the caller, so every test file + * gets an independent set of stubs. Three chunks run in order, each taking one + * argument and returning one value: + * + * 1. The plugin's env.lua, receiving this directory so it can load the shared + * stubs and framework, returning its environment module. + * 2. The test file, receiving the environment module, declaring its tests. + * 3. The tests then run, each against a fresh environment, and the results + * are returned as JSON. + * + * @param {string} envFile - Absolute path of the plugin's test/lua/env.lua. + * @param {string} testFile - Absolute path of the lua test file to run. + * @returns {{ name: string, ok: boolean, detail?: string }[]} + */ +function runLuaTests(envFile, testFile) { + const L = lauxlib.luaL_newstate(); + lualib.luaL_openlibs(L); + + const load = (file) => { + const code = fs.readFileSync(file); + const status = lauxlib.luaL_loadbuffer(L, code, code.length, to_luastring(`@${file}`)); + if (status !== lua.LUA_OK) { + throw new Error(to_jsstring(lua.lua_tostring(L, -1))); + } + }; + const call = () => { + if (lua.lua_pcall(L, 1, 1, 0) !== lua.LUA_OK) { + throw new Error(to_jsstring(lua.lua_tostring(L, -1))); + } + }; + + load(envFile); + lua.lua_pushstring(L, to_luastring(__dirname)); + call(); + + // The environment stays on the stack and is passed to the test chunk + load(testFile); + lua.lua_insert(L, -2); + call(); + + const json = to_jsstring(lua.lua_tostring(L, -1)); + return JSON.parse(json); +} + +/** Report the results of a lua test file through a tap test object. */ +function reportLuaTests(t, envFile, testFile) { + const results = runLuaTests(envFile, testFile); + t.ok(results.length > 0, `${path.basename(testFile)} produced results`); + for (const result of results) { + t.ok(result.ok, result.name, result.detail ? { detail: result.detail } : {}); + } + t.end(); +} + +module.exports = { runLuaTests, reportLuaTests }; diff --git a/test/lua/stubs.lua b/test/lua/stubs.lua new file mode 100644 index 00000000..b18d13ef --- /dev/null +++ b/test/lua/stubs.lua @@ -0,0 +1,195 @@ +--[[-- Generic factorio stubs for plugin module tests +Provides the surface a clusterio module touches, recording what the module does +to it. Every stub raises an error when an unimplemented property is read, which +mirrors the game api and catches mistakes in tests early. + +Plugins compose these from their own test/lua/env.lua, adding their own stubs +with the extend methods rather than by mutating the tables directly. +]] + +local Stubs = {} + +--- Give a table an index metamethod which raises on unknown properties +--- @generic T : table +--- @param name string Shown in the error message +--- @param tbl T +--- @param allowed_nil string[]? Properties which may be read while unset +--- @return T +function Stubs.strict(name, tbl, allowed_nil) + local allowed = {} + for _, key in pairs(allowed_nil or {}) do + allowed[key] = true + end + + return setmetatable(tbl, { + __index = function(_, key) + if allowed[key] then return nil end + error(name .. " does not implement: " .. tostring(key), 2) + end, + }) +end + +--- Merge extra into target recursively, raising when a value already exists +--- @generic T : table +--- @param target T +--- @param extra table +--- @return T +function Stubs.extend(target, extra) + for key, value in pairs(extra) do + local existing = rawget(target, key) + if existing == nil then + rawset(target, key, value) + elseif type(existing) == "table" and type(value) == "table" then + Stubs.extend(existing, value) + else + error("Stub already implements: " .. tostring(key), 2) + end + end + return target +end + +--- Create an independent set of stubs, installed as the lua globals +function Stubs.new() + --- @class Stubs + --- @field events table[] Events raised through script.raise_event, name and tick filled in + --- @field printed (LocalisedString | { to: string, [1]: LocalisedString })[] Messages from game.print and player.print + --- @field sent { channel: string, data: table }[] Payloads sent through the clusterio api + --- @field sounds string[] Sounds played to players, as "player:path" + local stubs = { + events = {}, + printed = {}, + sent = {}, + sounds = {}, + } + + local next_event_id = 100 + local registered_metatables = {} --- @type table + script = Stubs.strict("LuaBootstrap", { + generate_event_name = function() + next_event_id = next_event_id + 1 + return next_event_id + end, + raise_event = function(id, data) + -- Factorio fills in the name and tick of the event + data.name = id + data.tick = game.tick + stubs.events[#stubs.events + 1] = data + end, + register_metatable = function(_, metatable) + registered_metatables[metatable] = true + end, + }) + + defines = Stubs.strict("defines", { + events = Stubs.strict("defines.events", { + on_player_joined_game = 1, + on_multiplayer_init = 2, + }), + }) + + -- Stored by name, with an index metamethod so players are also found by + -- their player index, the same way game.players works + local players_by_index = {} + local players = setmetatable({}, { __index = players_by_index }) + local connected = {} + game = Stubs.strict("LuaGameScript", { + tick = 1, + players = players, + connected_players = connected, + print = function(message) stubs.printed[#stubs.printed + 1] = message end, + get_player = function(key) return players[key] end, + }, { "player" }) + + --- Add a player to the stubbed game, indexes are assigned in join order + --- @param name string + --- @param is_connected boolean? Defaults to connected + --- @return Stubs.Player + function stubs.add_player(name, is_connected) + local index = #players_by_index + 1 + --- @class Stubs.Player + local player = Stubs.strict("LuaPlayer " .. name, { + name = name, + index = index, + connected = is_connected ~= false, + valid = true, + play_sound = function(opts) + stubs.sounds[#stubs.sounds + 1] = name .. ":" .. opts.path + end, + print = function(message) + stubs.printed[#stubs.printed + 1] = { to = name, message } + end, + }) + rawset(players, name, player) + players_by_index[index] = player + if player.connected then connected[#connected + 1] = player end + return player + end + + --- A player object which represents the server + --- @type Stubs.Player + stubs.server = Stubs.strict("LuaPlayer ", { index = 0, name = "" }) + + --- Modules resolved by the stubbed require, add to them with extend_requires + local requires = { + ["modules/clusterio/api"] = Stubs.strict("clusterio api", { + send_json = function(channel, data) + stubs.sent[#stubs.sent + 1] = { channel = channel, data = data } + end, + }), + ["modules/clusterio/compat"] = Stubs.strict("clusterio compat", { script_data = {} }), + ["modules/exp_util/async"] = Stubs.strict("exp_util async", { + register = function(callback) + return function(...) return callback(...) end + end, + }), + } + require = function(name) + return assert(rawget(requires, name), "Unexpected require: " .. name) + end + + --- Add modules or properties to the stubbed require + function stubs.extend_requires(extra) return Stubs.extend(requires, extra) end + + --- Add properties to the script, game, and defines globals + function stubs.extend_script(extra) return Stubs.extend(script, extra) end + function stubs.extend_game(extra) return Stubs.extend(game, extra) end + function stubs.extend_defines(extra) return Stubs.extend(defines, extra) end + + --- Copy a value the way factorio saves script data: functions are refused + --- and only metatables registered with script.register_metatable survive + local function save_load_copy(value, copies) + if type(value) == "function" then + error("Functions can not be stored in script data", 0) + end + if type(value) ~= "table" then return value end + if copies[value] then return copies[value] end + + local copy = {} + copies[value] = copy + for key, entry in pairs(value) do + copy[save_load_copy(key, copies)] = save_load_copy(entry, copies) + end + + local metatable = getmetatable(value) + if metatable ~= nil and registered_metatables[metatable] then + setmetatable(copy, metatable) + end + return copy + end + + --- Replace the script data with a copy of itself as if the map was saved + --- and loaded, the module's on_load handler should be called afterwards + function stubs.save_load() + local compat = requires["modules/clusterio/compat"] + compat.script_data = save_load_copy(compat.script_data, {}) + end + + --- Forget everything recorded so far + function stubs.reset_log() + stubs.events, stubs.printed, stubs.sent, stubs.sounds = {}, {}, {}, {} + end + + return stubs +end + +return Stubs