This commit is contained in:
2026-09-03 17:07:07 +09:00
82 changed files with 3021 additions and 2116 deletions
+1
View File
@@ -2,3 +2,4 @@ dist/
node_modules/
.vscode
.luarc.json
.tap/
@@ -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
}
+3 -2
View File
@@ -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,
}
+1 -1
View File
@@ -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<string, LocalisedString | fun(player: LuaPlayer, is_command: boolean): LocalisedString>
commands = { --- @setting commands will trigger only when command prefix is given
@@ -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,
},
+2 -2
View File
@@ -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
},
}
+4 -4
View File
@@ -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
}
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
-8
View File
@@ -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.
@@ -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=把用戶資料寫入電腦
@@ -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=把用戶資料寫入電腦
+1
View File
@@ -8,6 +8,7 @@
"dependencies": {
"clusterio": "*",
"exp_util": "*",
"exp_roles": "*",
"exp_gui": "*"
}
}
+18 -18
View File
@@ -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)
@@ -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<string, any>
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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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
@@ -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)
+5 -6
View File
@@ -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()
+5 -6
View File
@@ -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)
+64
View File
@@ -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
*/
+1
View File
@@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = {
messages.RoleListRequest,
messages.RoleMetaUpdateRequest,
messages.SeedRolesRequest,
messages.AssignmentListRequest,
messages.AssignmentUpdateRequest,
+4
View File
@@ -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()),
+12
View File
@@ -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
*/
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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")
+3
View File
@@ -0,0 +1,3 @@
[exp-roles]
game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__
game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__
+3
View File
@@ -0,0 +1,3 @@
[exp-roles]
game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__
game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__
+6 -1
View File
@@ -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:"
},
+268
View File
@@ -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<string>();
const seen = new Set<string>();
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;
}
+250
View File
@@ -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();
});
+185
View File
@@ -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();
});
+116
View File
@@ -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();
});
+14
View File
@@ -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();
});
+192
View File
@@ -0,0 +1,192 @@
--- Tests for the assignment methods of module/control.lua
local Suite = ... --- @type Suite<ExpRoles.TestEnv> 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 = "<server>", 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 = "<server>", 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()
+99
View File
@@ -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)
+68
View File
@@ -0,0 +1,68 @@
--- Tests for listing and printing to the players who hold a role
local Suite = ... --- @type Suite<ExpRoles.TestEnv> 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()
+71
View File
@@ -0,0 +1,71 @@
--- Tests for the role lookup functions of module/control.lua
local Suite = ... --- @type Suite<ExpRoles.TestEnv> 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()
+95
View File
@@ -0,0 +1,95 @@
--- Tests for the player checks of module/control.lua
local Suite = ... --- @type Suite<ExpRoles.TestEnv> 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)), { "<server>" }, "nil is the server")
eq(Suite.names(env.Roles.get_player_roles(env.server)), { "<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()
+174
View File
@@ -0,0 +1,174 @@
--- Tests for the sync entry points of module/control.lua
local Suite = ... --- @type Suite<ExpRoles.TestEnv> 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<string, boolean>
--- 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", "<server>" },
}, "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()
+62
View File
@@ -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();
});
+33
View File
@@ -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 <SectionHeader
title="ExpGaming Roles"
extra={<Popconfirm
title="Create the ExpGaming roles?"
description="Roles which already exist by name are kept and only gain permissions."
onConfirm={() => {
setSeeding(true);
control.send(new SeedRolesRequest())
.catch(notifyErrorHandler("Error seeding roles"))
.finally(() => setSeeding(false));
}}
>
<Button loading={seeding}>Seed roles</Button>
</Popconfirm>}
/>;
}
+2
View File
@@ -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,
};
}
@@ -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.<name>`, 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
+1 -1
View File
@@ -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"))
+26 -21
View File
@@ -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)
+3 -3
View File
@@ -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 }
@@ -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 {
+3 -3
View File
@@ -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" }
+9 -9
View File
@@ -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
+3 -3
View File
@@ -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
+1
View File
@@ -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"))
+2 -2
View File
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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
+21
View File
@@ -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,
}
}
+35 -18
View File
@@ -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
+2 -2
View File
@@ -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
}
+3 -3
View File
@@ -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
+11 -8
View File
@@ -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<number, number>
--- @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,
+6 -6
View File
@@ -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,
+2 -2
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+3 -4
View File
@@ -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,
}
}
+12 -10
View File
@@ -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)
@@ -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
}
+2 -2
View File
@@ -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
}
@@ -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
}
+2 -2
View File
@@ -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)
+7 -8
View File
@@ -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,
}
}
+1
View File
@@ -8,6 +8,7 @@
"dependencies": {
"clusterio": "*",
"exp_util": "*",
"exp_roles": "*",
"exp_gui": "*",
"exp_commands": "*"
}
+121 -151
View File
@@ -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.<name>` 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<string, string> = {
"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 });
}
+1 -1
View File
@@ -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,
}
+55
View File
@@ -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,
};
+168
View File
@@ -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<T>
function Framework.suite(extend_env)
--- @class Suite<T>
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
+63
View File
@@ -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 };
+195
View File
@@ -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<table, true>
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 <server>", { index = 0, name = "<server>" })
--- 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