From ad9a072661c2919eea3b1582fcaf2728c4c2f2e5 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:36:58 +0000 Subject: [PATCH 01/16] Redesign the exp_roles lua api around permissions The module no longer presents the interface of the legacy expcore.roles module. Nothing outside this repository depends on it, so rather than carry the legacy action strings and the transform which mapped them onto permission names, call sites now check the clusterio permission name directly. That removes the one invariant which silently broke every check if the lua and typescript transforms drifted, and makes a check in lua greppable against its definition. - player_allowed and player_has_flag become player_has_permission; flags were only permissions with a change trigger, which define_permission_trigger now provides for any permission. - on_role_assigned and on_role_unassigned become one on_player_roles_changed event carrying the assigned and unassigned names. Every consumer registered both for the same handler. It is also raised for connected players when a role is edited on the controller, which the old events never were, and for changes made on the controller to the roles a player holds. - player_outranks and player_outranks_role replace the repeated comparison of highest role indexes, and apply the core.admin bypass consistently, which two of the six call sites did not. - get_role takes a name, clusterio id, or role; get_roles replaces get_roles_ordered. The config views of the roles are gone, with role:get_player_names covering the one use of config.players. - Roles carry a permission group, which the legacy system mapped roles to and the first version of the plugin dropped. A player is moved into the group of their most privileged role which names one. It is edited with the other in game properties. - skip_checks is dropped from assign_player and unassign_player. A player object with index 0 is treated as the server, which is how exp_commands represents rcon. Co-Authored-By: Claude Fable 5 --- exp_roles/instance.ts | 4 + exp_roles/messages.ts | 11 + exp_roles/module/control.lua | 615 ++++++++++---------- exp_roles/module/events.lua | 4 +- exp_roles/module/locale/zh-CN.cfg | 3 + exp_roles/module/locale/zh-TW.cfg | 3 + exp_roles/web/components/RoleProperties.tsx | 16 + 7 files changed, 343 insertions(+), 313 deletions(-) create mode 100644 exp_roles/module/locale/zh-CN.cfg create mode 100644 exp_roles/module/locale/zh-TW.cfg diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts index 615fe7c2..208a0982 100644 --- a/exp_roles/instance.ts +++ b/exp_roles/instance.ts @@ -48,6 +48,10 @@ export class InstancePlugin extends BaseInstancePlugin { this.instance.sendTo("controller", new messages.AssignmentListRequest()), ]); + if (!roles.some(role => role.isDefault)) { + this.logger.warn("No default role is set on the controller, players will hold no roles in game"); + } + await this.luaSend("initialise", { ...messages.encodeRolesForLua(roles), assignments: assignments.map(assignment => assignment.toJSON()), diff --git a/exp_roles/messages.ts b/exp_roles/messages.ts index 3886970b..25b47565 100644 --- a/exp_roles/messages.ts +++ b/exp_roles/messages.ts @@ -51,6 +51,11 @@ export class RoleMetaRecord { public tag: string = "", /** Colour used for the role in game, null uses the default colour. */ public color: RoleColor | null = null, + /** + * Factorio permission group players are moved into while this is their + * most privileged role which names one, empty leaves the group alone. + */ + public permissionGroup: string = "", /** Online time after which this role is granted, null never grants it. */ public autoAssignOnlineTimeMs: number | null = null, /** When true holders of this role are never granted roles automatically. */ @@ -66,6 +71,7 @@ export class RoleMetaRecord { short_hand: Type.Optional(Type.String()), tag: Type.Optional(Type.String()), color: Type.Optional(Type.Union([RoleColor.jsonSchema, Type.Null()])), + permission_group: Type.Optional(Type.String()), auto_assign_online_time_ms: Type.Optional(Type.Union([Type.Number(), Type.Null()])), block_auto_assign: Type.Optional(Type.Boolean()), updated_at_ms: Type.Optional(Type.Number()), @@ -94,6 +100,10 @@ export class RoleMetaRecord { json.color = this.color.toJSON(); } + if (this.permissionGroup) { + json.permission_group = this.permissionGroup; + } + if (this.autoAssignOnlineTimeMs !== null) { json.auto_assign_online_time_ms = this.autoAssignOnlineTimeMs; } @@ -121,6 +131,7 @@ export class RoleMetaRecord { json.short_hand ?? "", json.tag ?? "", json.color ? RoleColor.fromJSON(json.color) : null, + json.permission_group ?? "", json.auto_assign_online_time_ms ?? null, json.block_auto_assign ?? false, json.updated_at_ms ?? 0, diff --git a/exp_roles/module/control.lua b/exp_roles/module/control.lua index f0622a0b..ebe1f6fc 100644 --- a/exp_roles/module/control.lua +++ b/exp_roles/module/control.lua @@ -1,14 +1,19 @@ --[[-- ExpRoles -Replaces the legacy role system with clusterio's roles and permissions. +Mirrors clusterio's roles and permissions into the game. -Roles and the players who hold them are owned by the controller. This module -mirrors that state into the game and presents the same interface the legacy -`expcore.roles` module did, so existing call sites keep working. +Roles, the permissions they grant, and the players who hold them are owned by +the controller. This module keeps a copy of that state and answers permission +checks from it, so a check such as `Roles.player_has_permission(player, +"exp_scenario.command.kill")` is answered from the same data the web ui shows. Assignments made in game are applied locally first and then sent to the controller, which avoids callers having to deal with the round trip. A local assignment which is never sent, such as one earned from time on this map, can be made with `assign_player_local`. + +Only the roles with the highest priority a player holds are considered. Jail +sits above every other role, so holding it suppresses the rest, including the +default role every player has. ]] local clusterio_api = require("modules/clusterio/api") @@ -17,22 +22,21 @@ local Async = require("modules/exp_util/async") --- @class ExpRoles local ExpRoles = { - config = { - --- Role names in order, a lower index is a more privileged role - order = {}, --- @type string[] - --- Roles indexed by name, this table is never replaced - roles = {}, --- @type table - --- Role names held by each player, includes local assignments - players = {}, --- @type table - --- Async handles run when a flag is gained or lost - flags = {}, --- @type table - }, events = { - on_role_assigned = script.generate_event_name(), - on_role_unassigned = script.generate_event_name(), + --- Raised when the roles a player holds change, or when a role they + --- hold is changed on the controller. In the second case `assigned` and + --- `unassigned` are both empty. + --- @type EventData.ExpRoles.on_player_roles_changed + on_player_roles_changed = script.generate_event_name(), }, } +--- @class EventData.ExpRoles.on_player_roles_changed : EventData +--- @field player_index uint +--- @field by_player_index uint 0 when the change did not come from a player +--- @field assigned string[] Names of the roles which were assigned +--- @field unassigned string[] Names of the roles which were unassigned + --- Methods shared by every role, kept apart from the fields so that defining --- them does not count as injecting fields into the role itself --- @class ExpRoles.RolePrototype @@ -46,10 +50,10 @@ ExpRoles._prototype = Role --- @field order number Order given by the controller, which can have gaps --- @field index number Position within the role order, with no gaps --- @field priority number Only the highest priority roles a player holds apply ---- @field custom_tag string ---- @field custom_color Color? ---- @field allowed_actions table Permission names granted by this role ---- @field flags table Legacy flag names granted by this role +--- @field tag string +--- @field color Color? +--- @field permissions table Permission names granted by this role +--- @field permission_group string? Factorio permission group holders are moved to --- @field block_auto_assign boolean --- @class ExpRoles.ScriptData @@ -61,60 +65,28 @@ ExpRoles._prototype = Role --- @field emit_updates boolean local script_data = {} ---[[ - Permission names -]] +--- Roles in order, the most privileged first, rebuilt from script data +local ordered_roles = {} --- @type ExpRoles.Role[] +--- Roles indexed by name, rebuilt from script data +local roles_by_name = {} --- @type table +--- Async handlers run when the state of a permission may have changed +local permission_triggers = {} --- @type table ---- Legacy actions which map onto core clusterio permissions -local core_action_permissions = { - ["command/assign-role"] = "core.user.update_roles", - ["command/unassign-role"] = "core.user.update_roles", - ["command/get-roles"] = "core.role.list", -} - ---- Cache for the action to permission mapping, this is a hot path -local action_permissions = {} --- @type table -local flag_permissions = {} --- @type table - ---- Convert a legacy action such as `gui/warp-list/add` into a permission name ---- This must match permissionFromAction in exp_scenario/permissions.ts ---- @param action string ---- @return string -local function permission_from_action(action) - local permission = action_permissions[action] - if permission then return permission end - - permission = core_action_permissions[action] - if not permission then - local body = action:gsub("%-", "_"):gsub("/", ".") - if not body:find(".", 1, true) then - body = "action." .. body - end - permission = "exp_scenario." .. body +--- Move a player into a permission group, done async because the game does not +--- allow it from within every event +local set_permission_group_async = Async.register(function(player, group) + --- @cast player LuaPlayer + --- @cast group LuaPermissionGroup + if player.valid and group.valid then + group.add_player(player) end - - action_permissions[action] = permission - return permission -end - ---- Convert a legacy flag such as `report-immune` into a permission name ---- This must match permissionFromFlag in exp_scenario/permissions.ts ---- @param flag string ---- @return string -local function permission_from_flag(flag) - local permission = flag_permissions[flag] - if permission then return permission end - - permission = "exp_scenario.flag." .. (flag:gsub("%-", "_")) - flag_permissions[flag] = permission - return permission -end +end) --[[ Role state ]] ---- Rebuild config.order and config.roles from the synced roles, in place +--- Rebuild the ordered and by name views of the roles local function rebuild_role_views() local roles = {} for _, role in pairs(script_data.roles) do @@ -128,14 +100,11 @@ local function rebuild_role_views() return a.order < b.order end) - local order, by_name = ExpRoles.config.order, ExpRoles.config.roles - for key in pairs(order) do order[key] = nil end - for key in pairs(by_name) do by_name[key] = nil end - + ordered_roles, roles_by_name = {}, {} for index, role in ipairs(roles) do role.index = index - order[index] = role.name - by_name[role.name] = role + ordered_roles[index] = role + roles_by_name[role.name] = role end end @@ -145,23 +114,20 @@ end --- @return ExpRoles.Role local function decode_role(record, names) local meta = record.meta - local allowed_actions = {} + local permissions = {} if names then -- Indexes are zero based, having come from javascript for _, index in pairs(record.permissions) do - allowed_actions[names[index + 1]] = true + permissions[names[index + 1]] = true end else for _, permission in pairs(record.permissions) do - allowed_actions[permission] = true + permissions[permission] = true end end - local flags = {} - for permission in pairs(allowed_actions) do - local flag = permission:match("^exp_scenario%.flag%.(.+)$") - if flag then flags[flag] = true end - end + local group = meta.permission_group + if group == "" then group = nil end --- @type ExpRoles.Role return setmetatable({ @@ -171,99 +137,103 @@ local function decode_role(record, names) order = meta.order or record.id, index = meta.order or record.id, priority = meta.priority or 0, - custom_tag = meta.tag or "", - custom_color = meta.color, - allowed_actions = allowed_actions, - flags = flags, + tag = meta.tag or "", + color = meta.color, + permissions = permissions, + permission_group = group, block_auto_assign = meta.block_auto_assign or false, }, { __index = ExpRoles._prototype }) end ---- Refresh the legacy view of the roles a player holds +--- Get the name of a player from a player or a name, nil for the server +--- The server is represented by nil, or by a player object with index 0 +--- @param player LuaPlayer | string | nil +--- @return string? +local function player_name_of(player) + if type(player) == "string" then return player end + if player and player.index ~= 0 then return player.name end + return nil +end + +--- Role ids a player has been given, without the default role or priority applied --- @param player_name string -local function rebuild_player_view(player_name) - local role_ids = {} - for _, role_id in pairs(script_data.synced_players[player_name] or {}) do - role_ids[#role_ids + 1] = role_id +--- @return number[] +local function get_held_role_ids(player_name) + local role_ids, seen = {}, {} + for _, list in pairs{ script_data.synced_players[player_name], script_data.local_players[player_name] } do + for _, role_id in pairs(list) do + if not seen[role_id] then + seen[role_id] = true + role_ids[#role_ids + 1] = role_id + end + end end - for _, role_id in pairs(script_data.local_players[player_name] or {}) do - role_ids[#role_ids + 1] = role_id + return role_ids +end + +--- Role ids which apply to a player, with the default role and priority applied +--- @param player_name string +--- @return table +local function get_effective_role_ids(player_name) + local role_ids = get_held_role_ids(player_name) + if script_data.default_role_id then + role_ids[#role_ids + 1] = script_data.default_role_id end - if #role_ids == 0 then - ExpRoles.config.players[player_name] = nil - return - end - - local names, seen = {}, {} + local highest_priority for _, role_id in pairs(role_ids) do local role = script_data.roles[role_id] - if role and not seen[role.name] then - seen[role.name] = true - names[#names + 1] = role.name + if role and (highest_priority == nil or role.priority > highest_priority) then + highest_priority = role.priority end end - ExpRoles.config.players[player_name] = names -end - ---- Rebuild the legacy view for every known player -local function rebuild_all_player_views() - local players = ExpRoles.config.players - for key in pairs(players) do players[key] = nil end - - local seen = {} - for player_name in pairs(script_data.synced_players) do seen[player_name] = true end - for player_name in pairs(script_data.local_players) do seen[player_name] = true end - for player_name in pairs(seen) do rebuild_player_view(player_name) end + local rtn = {} + for _, role_id in pairs(role_ids) do + local role = script_data.roles[role_id] + if role and role.priority == highest_priority then + rtn[role_id] = true + end + end + return rtn end --[[ Role lookup ]] ---- Get a role by its name ---- @param name string ---- @return ExpRoles.Role? -function ExpRoles.get_role_by_name(name) - return ExpRoles.config.roles[name] -end - ---- Get a role by its position in the role order ---- @param index number ---- @return ExpRoles.Role? -function ExpRoles.get_role_by_order(index) - local name = ExpRoles.config.order[index] - return name and ExpRoles.config.roles[name] -end - ---- Get a role from a name, order index, or role +--- Get a role from its name, its clusterio id, or a role --- @param any string | number | ExpRoles.Role --- @return ExpRoles.Role? -function ExpRoles.get_role_from_any(any) +function ExpRoles.get_role(any) local t_any = type(any) - local as_number = tonumber(any) - if as_number then - return ExpRoles.get_role_by_order(as_number) - elseif t_any == "string" then - return ExpRoles.get_role_by_name(any) + if t_any == "string" then + return roles_by_name[any] + elseif t_any == "number" then + return script_data.roles[any] elseif t_any == "table" then - return ExpRoles.get_role_by_name(any.name) + return script_data.roles[any.id] end end ---- Get all roles in order, the most privileged first +--- Get every role in order, the most privileged first --- @return ExpRoles.Role[] -function ExpRoles.get_roles_ordered() +function ExpRoles.get_roles() local rtn = {} - for index, role_name in ipairs(ExpRoles.config.order) do - rtn[index] = ExpRoles.config.roles[role_name] + for index, role in ipairs(ordered_roles) do + rtn[index] = role end return rtn end +--- Get the role every player holds +--- @return ExpRoles.Role? +function ExpRoles.get_default_role() + return script_data.default_role_id and script_data.roles[script_data.default_role_id] or nil +end + --- Role used when there is no player, such as for commands run by the server ---- It bypasses every permission check the same way the legacy root role did +--- It has core.admin so it passes every permission check local server_role = setmetatable({ id = -1, name = "", @@ -271,77 +241,52 @@ local server_role = setmetatable({ order = 0, index = 0, priority = 0, - custom_tag = "", - custom_color = nil, - allowed_actions = { ["core.admin"] = true }, - flags = {}, + tag = "", + color = nil, + permissions = { ["core.admin"] = true }, + permission_group = nil, block_auto_assign = true, }, { __index = ExpRoles._prototype }) ---- Get the roles a player holds, including the default role +--- Get the roles which apply to a player, including the default role --- Only the roles with the highest priority are returned, which lets a role --- such as Jail suppress every other role a player holds --- @param player LuaPlayer | string | nil --- @return ExpRoles.Role[] function ExpRoles.get_player_roles(player) - local player_name = type(player) == "table" and player.name or player --[[@as string?]] + local player_name = player_name_of(player) -- The server is not a player and is allowed to do anything if player_name == nil then return { server_role } end - local role_ids = {} - if script_data.default_role_id then - role_ids[#role_ids + 1] = script_data.default_role_id - end - for _, role_id in pairs(script_data.synced_players[player_name] or {}) do - role_ids[#role_ids + 1] = role_id - end - for _, role_id in pairs(script_data.local_players[player_name] or {}) do - role_ids[#role_ids + 1] = role_id - end - - local roles, highest_priority, seen = {}, nil, {} - for _, role_id in pairs(role_ids) do - local role = script_data.roles[role_id] - if role and not seen[role_id] then - seen[role_id] = true - if highest_priority == nil or role.priority > highest_priority then - highest_priority = role.priority - end - roles[#roles + 1] = role - end - end - local rtn = {} - for _, role in pairs(roles) do - if role.priority == highest_priority then - rtn[#rtn + 1] = role - end + for role_id in pairs(get_effective_role_ids(player_name)) do + rtn[#rtn + 1] = script_data.roles[role_id] end table.sort(rtn, function(a, b) return a.index < b.index end) return rtn end ---- Get the most privileged role a player holds +--- Get the most privileged role which applies to a player --- @param player LuaPlayer | string | nil ---- @return ExpRoles.Role? +--- @return ExpRoles.Role function ExpRoles.get_player_highest_role(player) - return ExpRoles.get_player_roles(player)[1] + local role = ExpRoles.get_player_roles(player)[1] + return (assert(role, "Player has no roles, is the default role set and exp_roles syncing?")) end --[[ Permission checks ]] ---- Check if a player is allowed to perform an action +--- Check if a player has a permission through any of their roles --- @param player LuaPlayer | string | nil ---- @param action string A legacy action such as `command/kill` +--- @param permission string A clusterio permission such as `exp_scenario.command.kill` --- @return boolean -function ExpRoles.player_allowed(player, action) - local permission = permission_from_action(action) +function ExpRoles.player_has_permission(player, permission) for _, role in pairs(ExpRoles.get_player_roles(player)) do - local allowed = role.allowed_actions - if allowed["core.admin"] or allowed[permission] then + local permissions = role.permissions + if permissions["core.admin"] or permissions[permission] then return true end end @@ -349,91 +294,101 @@ function ExpRoles.player_allowed(player, action) return false end ---- Check if a player has a flag set by at least one of their roles ---- @param player LuaPlayer | string | nil ---- @param flag_name string ---- @return boolean -function ExpRoles.player_has_flag(player, flag_name) - local permission = permission_from_flag(flag_name) - for _, role in pairs(ExpRoles.get_player_roles(player)) do - if role.allowed_actions[permission] then - return true - end - end - - return false -end - ---- Check if a player holds a role +--- Check if a role applies to a player --- @param player LuaPlayer | string | nil --- @param search_role string | number | ExpRoles.Role --- @return boolean function ExpRoles.player_has_role(player, search_role) - local role = ExpRoles.get_role_from_any(search_role) + local role = ExpRoles.get_role(search_role) if not role then return false end for _, player_role in pairs(ExpRoles.get_player_roles(player)) do - if player_role.name == role.name then return true end + if player_role.id == role.id then return true end end return false end ---- Check if a player bypasses all permission checks ---- This replaces the legacy root role +--- Check if a player is more privileged than a role +--- A player with core.admin, which includes the server, outranks every role --- @param player LuaPlayer | string | nil +--- @param role string | number | ExpRoles.Role --- @return boolean -function ExpRoles.is_root(player) - for _, role in pairs(ExpRoles.get_player_roles(player)) do - if role.allowed_actions["core.admin"] then return true end - end +function ExpRoles.player_outranks_role(player, role) + local resolved = ExpRoles.get_role(role) + if not resolved then return false end + if ExpRoles.player_has_permission(player, "core.admin") then return true end + local highest = ExpRoles.get_player_roles(player)[1] + return highest ~= nil and highest.index < resolved.index +end - return false +--- Check if a player is more privileged than another player +--- A player with core.admin, which includes the server, outranks every player +--- @param player LuaPlayer | string | nil +--- @param other LuaPlayer | string | nil +--- @return boolean +function ExpRoles.player_outranks(player, other) + if ExpRoles.player_has_permission(player, "core.admin") then return true end + local highest = ExpRoles.get_player_roles(player)[1] + local other_highest = ExpRoles.get_player_roles(other)[1] + if highest == nil then return false end + return other_highest == nil or highest.index < other_highest.index end --[[ Role prototype ]] ---- Check if this role allows an action +--- Check if this role grants a permission --- @param self ExpRoles.Role ---- @param action string +--- @param permission string --- @return boolean -function Role.is_allowed(self, action) - local allowed = self.allowed_actions - return allowed["core.admin"] or allowed[permission_from_action(action)] or false +function Role.has_permission(self, permission) + local permissions = self.permissions + return permissions["core.admin"] or permissions[permission] or false end ---- Check if this role sets a flag +--- Get the names of every player who has been given this role +--- This includes players who have never joined this map, and is not affected +--- by priority, so a jailed moderator is still listed under moderator --- @param self ExpRoles.Role ---- @param name string ---- @return boolean -function Role.has_flag(self, name) - return self.allowed_actions[permission_from_flag(name)] or false +--- @return string[] +function Role.get_player_names(self) + local names, seen = {}, {} + for _, players in pairs{ script_data.synced_players, script_data.local_players } do + for player_name, role_ids in pairs(players) do + if not seen[player_name] then + for _, role_id in pairs(role_ids) do + if role_id == self.id then + seen[player_name] = true + names[#names + 1] = player_name + break + end + end + end + end + end + + return names end ---- Get the players who hold this role +--- Get the players on this map who have been given this role --- @param self ExpRoles.Role --- @param online boolean? When given, filter by connected state --- @return LuaPlayer[] function Role.get_players(self, online) local players = {} - for player_name, role_names in pairs(ExpRoles.config.players) do - for _, role_name in pairs(role_names) do - if role_name == self.name then - local player = game.players[player_name] - if player and (online == nil or player.connected == online) then - players[#players + 1] = player - end - break - end + for _, player_name in pairs(self:get_player_names()) do + local player = game.get_player(player_name) + if player and (online == nil or player.connected == online) then + players[#players + 1] = player end end return players end ---- Print a message to every online player who holds this role +--- Print a message to every online player who has been given this role --- @param self ExpRoles.Role --- @param message LocalisedString --- @return number # Number of players the message was sent to @@ -455,22 +410,23 @@ end --- @param message LocalisedString function ExpRoles.print_to_roles(roles, message) for _, role in pairs(roles) do - local resolved = ExpRoles.get_role_from_any(role) + local resolved = ExpRoles.get_role(role) if resolved then resolved:print(message) end end end --- Print a message to every player holding the given role or a more privileged one +--- The default role is never included --- @param role string | number | ExpRoles.Role --- @param message LocalisedString function ExpRoles.print_to_roles_higher(role, message) - local resolved = ExpRoles.get_role_from_any(role) + local resolved = ExpRoles.get_role(role) if not resolved then return end local roles = {} - for index, role_name in ipairs(ExpRoles.config.order) do - if index <= resolved.index and role_name ~= ExpRoles.get_default_role_name() then - roles[#roles + 1] = role_name + for _, other in ipairs(ordered_roles) do + if other.index <= resolved.index and other.id ~= script_data.default_role_id then + roles[#roles + 1] = other end end @@ -478,45 +434,51 @@ function ExpRoles.print_to_roles_higher(role, message) end --- Print a message to every player holding the given role or a less privileged one +--- The default role is never included --- @param role string | number | ExpRoles.Role --- @param message LocalisedString function ExpRoles.print_to_roles_lower(role, message) - local resolved = ExpRoles.get_role_from_any(role) + local resolved = ExpRoles.get_role(role) if not resolved then return end local roles = {} - for index, role_name in ipairs(ExpRoles.config.order) do - if index >= resolved.index and role_name ~= ExpRoles.get_default_role_name() then - roles[#roles + 1] = role_name + for _, other in ipairs(ordered_roles) do + if other.index >= resolved.index and other.id ~= script_data.default_role_id then + roles[#roles + 1] = other end end ExpRoles.print_to_roles(roles, message) end ---- Get the name of the role every player holds ---- @return string? -function ExpRoles.get_default_role_name() - local role = script_data.default_role_id and script_data.roles[script_data.default_role_id] - return role and role.name or nil -end - --[[ - Flags + Permission triggers ]] ---- Register a callback run when a player gains or loses a flag ---- @param name string +--- Register a callback run with the state of a permission whenever the roles +--- of a player may have changed, and when they join the game +--- @param permission string --- @param callback fun(player: LuaPlayer, state: boolean) -function ExpRoles.define_flag_trigger(name, callback) - ExpRoles.config.flags[name] = Async.register(callback) +function ExpRoles.define_permission_trigger(permission, callback) + permission_triggers[permission] = Async.register(callback) end ---- Run every flag trigger for a player +--- Run every permission trigger for a player and move them into the permission +--- group of their most privileged role which names one --- @param player LuaPlayer -local function apply_flag_triggers(player) - for flag, async_function in pairs(ExpRoles.config.flags) do - async_function(player, ExpRoles.player_has_flag(player, flag)) +local function apply_player_state(player) + for permission, async_function in pairs(permission_triggers) do + async_function(player, ExpRoles.player_has_permission(player, permission)) + end + + for _, role in ipairs(ExpRoles.get_player_roles(player)) do + if role.permission_group then + local group = game.permissions.get_group(role.permission_group) + if group and (not player.permission_group or player.permission_group.name ~= group.name) then + set_permission_group_async(player, group) + end + break + end end end @@ -524,44 +486,82 @@ end Assignment ]] ---- Raise the assigned or unassigned event and tell the player what changed +--- Raise the roles changed event and tell the player what changed --- @param player LuaPlayer ---- @param change_type "assign" | "unassign" ---- @param roles ExpRoles.Role[] +--- @param assigned ExpRoles.Role[] +--- @param unassigned ExpRoles.Role[] --- @param by_player_name string? --- @param silent boolean? -local function emit_player_roles_updated(player, change_type, roles, by_player_name, silent) +local function emit_player_roles_changed(player, assigned, unassigned, by_player_name, silent) by_player_name = by_player_name or (game.player and game.player.name) or "" - local by_player = game.players[by_player_name] + local by_player = game.get_player(by_player_name) - local role_names = {} - for index, role in ipairs(roles) do - role_names[index] = role.name - end + local assigned_names, unassigned_names = {}, {} + for index, role in ipairs(assigned) do assigned_names[index] = role.name end + for index, role in ipairs(unassigned) do unassigned_names[index] = role.name end if not silent then - local joined = table.concat(role_names, ", ") - game.print(change_type == "assign" - and { "exp-roles.game-message-assign", player.name, joined, by_player_name } - or { "exp-roles.game-message-unassign", player.name, joined, by_player_name }) + if #assigned_names > 0 then + game.print{ "exp-roles.game-message-assign", player.name, table.concat(assigned_names, ", "), by_player_name } + end + if #unassigned_names > 0 then + game.print{ "exp-roles.game-message-unassign", player.name, table.concat(unassigned_names, ", "), by_player_name } + end end - if change_type == "assign" then + if #assigned_names > 0 then player.play_sound{ path = "utility/achievement_unlocked" } - else + elseif #unassigned_names > 0 then player.play_sound{ path = "utility/game_lost" } end - local event = change_type == "assign" and ExpRoles.events.on_role_assigned or ExpRoles.events.on_role_unassigned - script.raise_event(event, { - name = event, + script.raise_event(ExpRoles.events.on_player_roles_changed, { + name = ExpRoles.events.on_player_roles_changed, tick = game.tick, player_index = player.index, by_player_index = by_player and by_player.index or 0, - roles = role_names, + assigned = assigned_names, + unassigned = unassigned_names, }) - apply_flag_triggers(player) + apply_player_state(player) +end + +--- Role ids a player has been given, as a set +--- @param player_name string +--- @return table +local function get_held_role_set(player_name) + local rtn = {} + for _, role_id in pairs(get_held_role_ids(player_name)) do + rtn[role_id] = true + end + return rtn +end + +--- Compare the roles a player had been given before and after a change and +--- raise the event for what differs +--- @param player_name string +--- @param before table +--- @param by_player_name string? +--- @param silent boolean? +local function emit_held_diff(player_name, before, by_player_name, silent) + local player = game.get_player(player_name) + if not player then return end + + local after = get_held_role_set(player_name) + local assigned, unassigned = {}, {} + for role_id in pairs(after) do + if not before[role_id] then assigned[#assigned + 1] = script_data.roles[role_id] end + end + for role_id in pairs(before) do + if not after[role_id] then unassigned[#unassigned + 1] = script_data.roles[role_id] end + end + + if #assigned > 0 or #unassigned > 0 then + table.sort(assigned, function(a, b) return a.index < b.index end) + table.sort(unassigned, function(a, b) return a.index < b.index end) + emit_player_roles_changed(player, assigned, unassigned, by_player_name, silent) + end end --- Remove a role id from a list, returns true when it was present @@ -601,14 +601,14 @@ local function resolve_roles(roles) local rtn = {} for _, role in pairs(roles) do - local resolved = ExpRoles.get_role_from_any(role) + local resolved = ExpRoles.get_role(role) if resolved then rtn[#rtn + 1] = resolved end end return rtn end ---- Apply a role change locally and tell the controller about it +--- Apply a role change to the local state --- @param player_name string --- @param roles ExpRoles.Role[] --- @param change_type "assign" | "unassign" @@ -646,7 +646,6 @@ local function apply_local_change(player_name, roles, change_type, sync) script_data.local_players[player_name] = next(local_roles) and local_roles or nil script_data.pending[player_name] = next(pending) and pending or nil - rebuild_player_view(player_name) return changed end @@ -678,12 +677,13 @@ end --- @param silent boolean? --- @param sync boolean local function change_player_roles(player, roles, change_type, by_player_name, silent, sync) - local player_name = type(player) == "table" and player.name or player --[[@as string?]] + local player_name = player_name_of(player) if not player_name then return end local role_objects = resolve_roles(roles) if #role_objects == 0 then return end + local before = get_held_role_set(player_name) local changed = apply_local_change(player_name, role_objects, change_type, sync) if #changed == 0 then return end @@ -691,29 +691,24 @@ local function change_player_roles(player, roles, change_type, by_player_name, s emit_assignment_update(player_name, changed, change_type) end - local valid_player = game.get_player(player_name) - if valid_player then - emit_player_roles_updated(valid_player, change_type, changed, by_player_name, silent) - end + emit_held_diff(player_name, before, by_player_name, silent) end --- Give a player one or more roles, the change is sent to the controller --- @param player LuaPlayer | string --- @param roles any A role, role name, or array of either ---- @param by_player_name string? ---- @param skip_checks boolean? Unused, kept for compatibility ---- @param silent boolean? -function ExpRoles.assign_player(player, roles, by_player_name, skip_checks, silent) +--- @param by_player_name string? Shown in the game message, defaults to the current player or the server +--- @param silent boolean? When true no game message is printed +function ExpRoles.assign_player(player, roles, by_player_name, silent) change_player_roles(player, roles, "assign", by_player_name, silent, true) end --- Take one or more roles from a player, the change is sent to the controller --- @param player LuaPlayer | string --- @param roles any A role, role name, or array of either ---- @param by_player_name string? ---- @param skip_checks boolean? Unused, kept for compatibility ---- @param silent boolean? -function ExpRoles.unassign_player(player, roles, by_player_name, skip_checks, silent) +--- @param by_player_name string? Shown in the game message, defaults to the current player or the server +--- @param silent boolean? When true no game message is printed +function ExpRoles.unassign_player(player, roles, by_player_name, silent) change_player_roles(player, roles, "unassign", by_player_name, silent, true) end @@ -721,8 +716,8 @@ end --- Use this for roles earned from progress which does not leave this map --- @param player LuaPlayer | string --- @param roles any A role, role name, or array of either ---- @param by_player_name string? ---- @param silent boolean? +--- @param by_player_name string? Shown in the game message, defaults to the current player or the server +--- @param silent boolean? When true no game message is printed function ExpRoles.assign_player_local(player, roles, by_player_name, silent) change_player_roles(player, roles, "assign", by_player_name, silent, false) end @@ -730,8 +725,8 @@ end --- Take one or more local roles from a player, the controller is not told --- @param player LuaPlayer | string --- @param roles any A role, role name, or array of either ---- @param by_player_name string? ---- @param silent boolean? +--- @param by_player_name string? Shown in the game message, defaults to the current player or the server +--- @param silent boolean? When true no game message is printed function ExpRoles.unassign_player_local(player, roles, by_player_name, silent) change_player_roles(player, roles, "unassign", by_player_name, silent, false) end @@ -765,7 +760,6 @@ function ExpRoles.on_load() script_data = compat.script_data["exp_roles"] if script_data then rebuild_role_views() - rebuild_all_player_views() end end @@ -775,6 +769,14 @@ function ExpRoles.set_emit_events(enabled) script_data.emit_updates = enabled ~= false end +--- Apply the state of every connected player, and raise the event so guis +--- can refresh, after the roles themselves have changed +local function roles_changed() + for _, player in pairs(game.connected_players) do + emit_player_roles_changed(player, {}, {}, "", true) + end +end + --- Replace all local state with the state held by the controller --- Permission names are sent once and referenced by index, see encodeRolesForLua --- @param payload { permission_names: string[], roles: table[], assignments: table[] } @@ -817,11 +819,7 @@ function ExpRoles.initialise(payload) script_data.pending = {} rebuild_role_views() - rebuild_all_player_views() - - for _, player in pairs(game.connected_players) do - apply_flag_triggers(player) - end + roles_changed() script_data.emit_updates = emit_updates end @@ -846,11 +844,7 @@ function ExpRoles.receive_role_updates(records) end rebuild_role_views() - rebuild_all_player_views() - - for _, player in pairs(game.connected_players) do - apply_flag_triggers(player) - end + roles_changed() end --- Receive changes to the roles held by players from the controller @@ -858,6 +852,8 @@ end function ExpRoles.receive_assignment_updates(records) for _, record in pairs(records) do local player_name = record.name + local before = get_held_role_set(player_name) + if record.is_deleted then script_data.synced_players[player_name] = nil else @@ -865,10 +861,7 @@ function ExpRoles.receive_assignment_updates(records) end drop_confirmed_local(player_name) - rebuild_player_view(player_name) - - local player = game.get_player(player_name) - if player then apply_flag_triggers(player) end + emit_held_diff(player_name, before, "") end end @@ -915,11 +908,11 @@ function ExpRoles.on_server_startup() ExpRoles.on_load() end ---- Apply the flag triggers for a player who just joined +--- Apply the permission triggers and group for a player who just joined --- @param event EventData.on_player_joined_game function ExpRoles.on_player_joined_game(event) local player = game.get_player(event.player_index) - if player then apply_flag_triggers(player) end + if player then apply_player_state(player) end end return ExpRoles diff --git a/exp_roles/module/events.lua b/exp_roles/module/events.lua index 12d8456d..c3029c49 100644 --- a/exp_roles/module/events.lua +++ b/exp_roles/module/events.lua @@ -2,8 +2,8 @@ Wires the role sync into the factorio event handler. This is kept apart from control.lua because `ExpRoles.events` holds the ids of -the events this module raises, which is the interface the legacy role system -had, and event_handler expects `events` to be the handlers to register. +the events this module raises, while event_handler expects `events` to be the +handlers to register. ]] local clusterio_api = require("modules/clusterio/api") diff --git a/exp_roles/module/locale/zh-CN.cfg b/exp_roles/module/locale/zh-CN.cfg new file mode 100644 index 00000000..23b5caea --- /dev/null +++ b/exp_roles/module/locale/zh-CN.cfg @@ -0,0 +1,3 @@ +[exp-roles] +game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ +game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ diff --git a/exp_roles/module/locale/zh-TW.cfg b/exp_roles/module/locale/zh-TW.cfg new file mode 100644 index 00000000..23b5caea --- /dev/null +++ b/exp_roles/module/locale/zh-TW.cfg @@ -0,0 +1,3 @@ +[exp-roles] +game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ +game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ diff --git a/exp_roles/web/components/RoleProperties.tsx b/exp_roles/web/components/RoleProperties.tsx index b63ae707..cce29d5e 100644 --- a/exp_roles/web/components/RoleProperties.tsx +++ b/exp_roles/web/components/RoleProperties.tsx @@ -15,6 +15,7 @@ type RoleFormValues = { shortHand: string; tag: string; color: string | { toRgb(): { r: number, g: number, b: number } } | null; + permissionGroup: string; autoAssignHours: number | null; blockAutoAssign: boolean; }; @@ -46,6 +47,7 @@ export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Ro shortHand: meta.shortHand, tag: meta.tag, color: meta.color ? `rgb(${meta.color.r}, ${meta.color.g}, ${meta.color.b})` : null, + permissionGroup: meta.permissionGroup, autoAssignHours: meta.autoAssignOnlineTimeMs === null ? null : meta.autoAssignOnlineTimeMs / MS_PER_HOUR, @@ -78,6 +80,7 @@ export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Ro values.shortHand ?? "", values.tag ?? "", color, + values.permissionGroup ?? "", values.autoAssignHours === null || values.autoAssignHours === undefined ? null : values.autoAssignHours * MS_PER_HOUR, @@ -158,6 +161,19 @@ export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Ro + + + + + + Date: Wed, 19 Aug 2026 11:36:58 +0000 Subject: [PATCH 02/16] Define the scenario permissions by name With the lua side checking permission names directly there is no transform to derive them from, so each permission is listed with its name. This was also the chance to drop the legacy action and flag buckets, which only reflected how the old config was written: - exp_scenario.bypass.* for entity protection, nuke protection, the deconstruction log, and reports. - exp_scenario.decon.* for the two deconstruction levels, with descriptions which say what they gate. - exp_scenario.player.* for admin, spectator, instant respawn, and system commands. - exp_scenario.chat.commands, and exp_scenario.gui.player_list.kick and .ban for the player list buttons which were never commands. Commands derive their permission as exp_scenario.command., so assign-role, unassign-role, and get-roles get scenario permissions rather than the core ones they mapped to before. The in game command is bounded by the lower role check, while core.user.update_roles is not, so granting it to moderators would have let them change any role from the web ui. Dropped: defer_role_changes, which priority replaced; command/give-warning, which no role held and the player list now checks create_warning for; and command/report, which was never defined. clear-tag/always is renamed to tag_clear.always to match the command it belongs to. _ipc and _sudo are added so every command has a definition. Co-Authored-By: Claude Fable 5 --- exp_scenario/permissions.ts | 273 ++++++++++++++++-------------------- 1 file changed, 122 insertions(+), 151 deletions(-) diff --git a/exp_scenario/permissions.ts b/exp_scenario/permissions.ts index 567e0c3a..d5f04b0f 100644 --- a/exp_scenario/permissions.ts +++ b/exp_scenario/permissions.ts @@ -1,160 +1,131 @@ import * as lib from "@clusterio/lib"; /** - * Convert a legacy role action into its clusterio permission name. + * Permissions checked by the scenario in game through exp_roles. * - * The exp_roles lua module applies the same transform, so existing call sites such as - * `Roles.player_allowed(player, "gui/warp-list/add")` keep working unchanged. - * - * @param action - Legacy action such as `gui/warp-list/add` or `fast-tree-decon`. - * @returns Permission name such as `exp_scenario.gui.warp_list.add`. + * Commands are checked as `exp_scenario.command.` with hyphens replaced by + * underscores, see module/commands/_authorities.lua. Everything else is checked + * by name at its call site. */ -export function permissionFromAction(action: string) { - const body = action.replace(/-/g, "_").replace(/\//g, "."); - return `exp_scenario.${body.includes(".") ? body : `action.${body}`}`; -} +type Definition = [name: string, title: string, description: string, grantByDefault?: boolean]; -/** - * Convert a legacy role flag into its clusterio permission name. - * - * @param flag - Legacy flag such as `report-immune`. - * @returns Permission name such as `exp_scenario.flag.report_immune`. - */ -export function permissionFromFlag(flag: string) { - return `exp_scenario.flag.${flag.replace(/-/g, "_")}`; -} - -/** Legacy actions which map onto core clusterio permissions rather than scenario ones. */ -export const coreActionPermissions: Record = { - "command/assign-role": "core.user.update_roles", - "command/unassign-role": "core.user.update_roles", - "command/get-roles": "core.role.list", -}; - -/** Legacy action, user facing title, description, and whether every player gets it. */ -type ActionDefinition = [action: string, title: string, description: string, grantByDefault?: boolean]; - -const actions: ActionDefinition[] = [ - ["bypass-entity-protection", "Bypass entity protection", "Remove entities that the protection filter would block."], - ["bypass-nukeprotect", "Bypass nuke protection", "Use nukes without the nuke protection restrictions."], - ["command/_rcon", "/_rcon", "Execute arbitrary code within a custom environment."], - ["command/admin-chat", "/admin-chat", "Sends a message in chat that only admins can see."], - ["command/artillery", "/artillery", "Automaticly select enemy target with artillery."], - ["command/ban", "/ban", "Ban a player from the cluster via the player list."], - ["command/bring", "/bring", "Teleports a player to you."], - ["command/chat-commands", "Chat commands", "Use the chat command prefix to trigger auto replies."], - ["command/clear-blueprints", "/clear-blueprints", "Clear all blueprints."], - ["command/clear-blueprints-surface", "/clear-blueprints-surface", "Clear all blueprints on the current surface."], - ["command/clear-ground-items", "/clear-ground-items", "Clear all items on the ground."], - ["command/clear-inventory", "/clear-inventory", "Clear a player's inventory, moving all items to spawn."], - ["command/clear-last-warnings", "/clear-last-warnings", "Clears the last warning from a player."], - ["command/clear-pollution", "/clear-pollution", "Clear pollution from your current surface, or another surface."], - ["command/clear-reports", "/clear-reports", "Clears all reports from a player or just the report from one player."], - ["command/clear-script-warnings", "/clear-script-warnings", "Clears all script warnings from a player."], - ["command/clear-tag/always", "/clear-tag (any player)", "Clear the tag of any player, not just your own."], - ["command/clear-warnings", "/clear-warnings", "Clears all warnings (and script warnings) from a player."], - ["command/collectdata", "/collectdata", "Collect data for RCON usage."], - ["command/commands", "/commands", "List and search all commands for a keyword.", true], - ["command/connect", "/connect", "Connect to another server.", true], - ["command/connect-all", "/connect-all", "Connect all players to another server."], - ["command/connect-player", "/connect-player", "Connect a player to a different server."], - ["command/create-report", "/create-report", "Reports a player and notifies moderators.", true], - ["command/create-warning", "/create-warning", "Gives a warning to a player; may lead to automatic script action."], - ["command/data-preference", "/data-preference", "Allows you to set/get your data saving preference.", true], - ["command/debug", "/debug", "Opens the debug gui."], - ["command/follow", "/follow", "Start following a player in spectator."], - ["command/get-home", "/get-home", "Returns your current home location."], - ["command/get-reports", "/get-reports", "List the reports against a player, or against every player."], - ["command/get-warnings", "/get-warnings", "List the warnings against a player, or against every player."], - ["command/give-warning", "/give-warning", "Give a player a warning via the player list."], - ["command/goto", "/goto", "Teleports you to a player."], - ["command/home", "/home", "Teleports you to your home location."], - ["command/jail", "/jail", "Puts a player into jail and removes all other roles."], - ["command/kick", "/kick", "Kick a player from the server via the player list."], - ["command/kill", "/kill", "Kills yourself or another player."], - ["command/kill-enemies", "/kill-enemies", "Kill all enemy units."], - ["command/kill/always", "/kill (any player)", "Kill any player, not just yourself."], - ["command/lawnmower", "/lawnmower", "Clean up biter corpse, decoratives and nuclear hole."], - ["command/locate", "/locate", "Opens remote view at the location of the player's last location.", true], - ["command/me", "/me", "Sends an action message in the chat."], - ["command/protect-area", "/protect-area", "Toggles area protection selection, hold shift to remove protection."], - ["command/protect-entity", "/protect-entity", "Toggles entity protection selection, hold shift to remove protection."], - ["command/protect-tag", "/protect-tag", "Toggles protected tag mode, edit and create protected map tags."], - ["command/rainbow", "/rainbow", "Sends an rainbow message in the chat."], - ["command/ratio", "/ratio", "Get the input and output ratios of the selected machine.", true], - ["command/remove-enemies", "/remove-enemies", "Remove all enemy spawners."], - ["command/remove-join-message", "/remove-join-message", "Removes you custom join message."], - ["command/repair", "/repair", "Repairs entities on your force around you."], - ["command/research-all", "/research-all", "Research all technology for your force, or another force."], - ["command/return", "/return", "Teleports you to previous location."], - ["command/save-data", "/save-data", "Writes all your player data to a file on your computer.", true], - ["command/save-quickbar", "/save-quickbar", "Saves your Quickbar preset items to file."], - ["command/search", "/search", "Display players sorted by the quantity of an item held and playtime."], - ["command/search-amount", "/search-amount", "Display players sorted by the quantity of an item held."], - ["command/search-online", "/search-online", "Display online players sorted by item count and playtime."], - ["command/search-recent", "/search-recent", "Display players who hold an item sorted by join time."], - ["command/server-ups", "/server-ups", "Toggle the server UPS display.", true], - ["command/set-always-day", "/set-always-day", "Set always day for your current surface, or another surface."], - ["command/set-bot-queue", "/set-bot-queue", "Get / Set the construction bot queue limits."], - ["command/set-cheat-mode", "/set-cheat-mode", "Set cheat mode for your player, or another player."], - ["command/set-friendly-fire", "/set-friendly-fire", "Set friendly fire for your force, or another force."], - ["command/set-game-speed", "/set-game-speed", "Set or get the current game speed."], - ["command/set-home", "/set-home", "Sets your home location to your current position."], - ["command/set-join-message", "/set-join-message", "Sets / Gets your custom join message."], - ["command/set-pollution-enabled", "/set-pollution-enabled", "Set polution enabled state for this game."], - ["command/set-trains-to-automatic", "/set-trains-to-automatic", "Set all trains without passengers to automatic."], - ["command/spawn", "/spawn", "Teleport to spawn."], - ["command/spawn/always", "/spawn (any player)", "Teleport any player to spawn, not just yourself."], - ["command/spectate", "/spectate", "Toggles spectator mode."], - ["command/tag", "/tag", "Sets your player tag.", true], - ["command/tag-clear", "/tag-clear", "Clears your tag. Or another player if you are admin.", true], - ["command/tag-color", "/tag-color", "Sets your player tag color."], - ["command/teleport", "/teleport", "Teleports a player to another player."], - ["command/unjail", "/unjail", "Removes a player from jail and restores their previous roles."], - ["command/vlayer-info", "/vlayer-info", "Print all vlayer information."], - ["command/waterfill", "/waterfill", "Replace tiles with shallow water."], - ["fast-tree-decon", "Fast tree deconstruction", "Deconstruct trees and rocks instantly over a large area."], - ["gui/autofill", "Autofill", "Open the autofill GUI, which fills placed entities from your inventory.", true], - ["gui/bonus", "Bonus", "Open the bonus GUI to adjust your personal bonuses."], - ["gui/module", "Module inserter", "Open the module inserter GUI.", true], - ["gui/player-list", "Player list", "Open the player list GUI.", true], - ["gui/playerdata", "Player statistics", "Open the player statistics GUI."], - ["gui/production", "Production statistics", "Open the production statistics GUI.", true], - ["gui/readme", "Readme", "Open the server readme GUI.", true], - ["gui/research", "Research milestones", "Open the research milestones GUI.", true], - ["gui/rocket-info", "Rocket info", "Open the rocket info GUI.", true], - ["gui/rocket-info/remote_launch", "Rocket info: remote launch", "Launch rockets remotely from the rocket info GUI."], - ["gui/rocket-info/toggle-active", "Rocket info: toggle active", "Toggle whether a silo launches automatically."], - ["gui/science-info", "Science production", "Open the science production GUI.", true], - ["gui/surveillance", "Surveillance", "Open the surveillance GUI showing remote camera views."], - ["gui/task-list", "Task list", "Open the task list GUI.", true], - ["gui/task-list/add", "Task list: add", "Add new tasks to the task list."], - ["gui/task-list/edit", "Task list: edit", "Edit and remove existing tasks on the task list."], - ["gui/tool", "Quick actions", "Open the quick actions GUI."], - ["gui/vlayer", "Virtual layer", "Open the virtual layer GUI.", true], - ["gui/vlayer-edit", "Virtual layer: edit", "Use the edit controls in the virtual layer GUI."], - ["gui/warp-list", "Warp list", "Open the warp list GUI and warp between locations.", true], - ["gui/warp-list/add", "Warp list: add", "Add new warp points to the warp list."], - ["gui/warp-list/bypass-cooldown", "Warp list: bypass cooldown", "Warp without waiting for the warp cooldown."], - ["gui/warp-list/bypass-proximity", "Warp list: bypass proximity", "Warp without standing near a warp point."], - ["gui/warp-list/edit", "Warp list: edit", "Edit and remove existing warp points."], - ["standard-decon", "Standard deconstruction", "Deconstruct trees and rocks."], +const definitions: Definition[] = [ + ["exp_scenario.bypass.deconstruction_log", "Deconstruction log bypass", "Be excluded from the deconstruction log."], + ["exp_scenario.bypass.entity_protection", "Bypass entity protection", "Remove entities that the protection filter would block."], + ["exp_scenario.bypass.nuke_protection", "Bypass nuke protection", "Use nukes without the nuke protection restrictions."], + ["exp_scenario.bypass.reports", "Report immune", "Be immune to being reported by other players."], + ["exp_scenario.chat.commands", "Chat commands", "Use the chat command prefix to trigger auto replies."], + ["exp_scenario.command._ipc", "/_ipc", "Send an IPC message on the selected channel."], + ["exp_scenario.command._rcon", "/_rcon", "Execute arbitrary code within a custom environment."], + ["exp_scenario.command._sudo", "/_sudo", "Run a command as another player."], + ["exp_scenario.command.admin_chat", "/admin-chat", "Sends a message in chat that only admins can see."], + ["exp_scenario.command.artillery", "/artillery", "Automaticly select enemy target with artillery."], + ["exp_scenario.command.assign_role", "/assign-role", "Assigns a role lower than your own to a player."], + ["exp_scenario.command.bring", "/bring", "Teleports a player to you."], + ["exp_scenario.command.clear_blueprints", "/clear-blueprints", "Clear all blueprints."], + ["exp_scenario.command.clear_blueprints_surface", "/clear-blueprints-surface", "Clear all blueprints on the current surface."], + ["exp_scenario.command.clear_ground_items", "/clear-ground-items", "Clear all items on the ground."], + ["exp_scenario.command.clear_inventory", "/clear-inventory", "Clear a player's inventory, moving all items to spawn."], + ["exp_scenario.command.clear_last_warnings", "/clear-last-warnings", "Clears the last warning from a player."], + ["exp_scenario.command.clear_pollution", "/clear-pollution", "Clear pollution from your current surface, or another surface."], + ["exp_scenario.command.clear_reports", "/clear-reports", "Clears all reports from a player or just the report from one player."], + ["exp_scenario.command.clear_script_warnings", "/clear-script-warnings", "Clears all script warnings from a player."], + ["exp_scenario.command.clear_warnings", "/clear-warnings", "Clears all warnings (and script warnings) from a player."], + ["exp_scenario.command.collectdata", "/collectdata", "Collect data for RCON usage."], + ["exp_scenario.command.commands", "/commands", "List and search all commands for a keyword.", true], + ["exp_scenario.command.connect", "/connect", "Connect to another server.", true], + ["exp_scenario.command.connect_all", "/connect-all", "Connect all players to another server."], + ["exp_scenario.command.connect_player", "/connect-player", "Connect a player to a different server."], + ["exp_scenario.command.create_report", "/create-report", "Reports a player and notifies moderators.", true], + ["exp_scenario.command.create_warning", "/create-warning", "Gives a warning to a player; may lead to automatic script action."], + ["exp_scenario.command.data_preference", "/data-preference", "Allows you to set/get your data saving preference.", true], + ["exp_scenario.command.debug", "/debug", "Opens the debug gui."], + ["exp_scenario.command.follow", "/follow", "Start following a player in spectator."], + ["exp_scenario.command.get_home", "/get-home", "Returns your current home location."], + ["exp_scenario.command.get_reports", "/get-reports", "List the reports against a player, or against every player."], + ["exp_scenario.command.get_roles", "/get-roles", "Get all roles that a player has, if no player provided it lists all roles.", true], + ["exp_scenario.command.get_warnings", "/get-warnings", "List the warnings against a player, or against every player."], + ["exp_scenario.command.goto", "/goto", "Teleports you to a player."], + ["exp_scenario.command.home", "/home", "Teleports you to your home location."], + ["exp_scenario.command.jail", "/jail", "Puts a player into jail, which suppresses all of their other roles."], + ["exp_scenario.command.kill", "/kill", "Kills yourself or another player."], + ["exp_scenario.command.kill.always", "/kill (any player)", "Kill any player, not just yourself."], + ["exp_scenario.command.kill_enemies", "/kill-enemies", "Kill all enemy units."], + ["exp_scenario.command.lawnmower", "/lawnmower", "Clean up biter corpse, decoratives and nuclear hole."], + ["exp_scenario.command.locate", "/locate", "Opens remote view at the location of the player's last location.", true], + ["exp_scenario.command.me", "/me", "Sends an action message in the chat."], + ["exp_scenario.command.protect_area", "/protect-area", "Toggles area protection selection, hold shift to remove protection."], + ["exp_scenario.command.protect_entity", "/protect-entity", "Toggles entity protection selection, hold shift to remove protection."], + ["exp_scenario.command.protect_tag", "/protect-tag", "Toggles protected tag mode, edit and create protected map tags."], + ["exp_scenario.command.rainbow", "/rainbow", "Sends an rainbow message in the chat."], + ["exp_scenario.command.ratio", "/ratio", "Get the input and output ratios of the selected machine.", true], + ["exp_scenario.command.remove_enemies", "/remove-enemies", "Remove all enemy spawners."], + ["exp_scenario.command.remove_join_message", "/remove-join-message", "Removes you custom join message."], + ["exp_scenario.command.repair", "/repair", "Repairs entities on your force around you."], + ["exp_scenario.command.research_all", "/research-all", "Research all technology for your force, or another force."], + ["exp_scenario.command.return", "/return", "Teleports you to previous location."], + ["exp_scenario.command.save_data", "/save-data", "Writes all your player data to a file on your computer.", true], + ["exp_scenario.command.save_quickbar", "/save-quickbar", "Saves your Quickbar preset items to file."], + ["exp_scenario.command.search", "/search", "Display players sorted by the quantity of an item held and playtime."], + ["exp_scenario.command.search_amount", "/search-amount", "Display players sorted by the quantity of an item held."], + ["exp_scenario.command.search_online", "/search-online", "Display online players sorted by item count and playtime."], + ["exp_scenario.command.search_recent", "/search-recent", "Display players who hold an item sorted by join time."], + ["exp_scenario.command.server_ups", "/server-ups", "Toggle the server UPS display.", true], + ["exp_scenario.command.set_always_day", "/set-always-day", "Set always day for your current surface, or another surface."], + ["exp_scenario.command.set_bot_queue", "/set-bot-queue", "Get / Set the construction bot queue limits."], + ["exp_scenario.command.set_cheat_mode", "/set-cheat-mode", "Set cheat mode for your player, or another player."], + ["exp_scenario.command.set_friendly_fire", "/set-friendly-fire", "Set friendly fire for your force, or another force."], + ["exp_scenario.command.set_game_speed", "/set-game-speed", "Set or get the current game speed."], + ["exp_scenario.command.set_home", "/set-home", "Sets your home location to your current position."], + ["exp_scenario.command.set_join_message", "/set-join-message", "Sets / Gets your custom join message."], + ["exp_scenario.command.set_pollution_enabled", "/set-pollution-enabled", "Set polution enabled state for this game."], + ["exp_scenario.command.set_trains_to_automatic", "/set-trains-to-automatic", "Set all trains without passengers to automatic."], + ["exp_scenario.command.spawn", "/spawn", "Teleport to spawn."], + ["exp_scenario.command.spawn.always", "/spawn (any player)", "Teleport any player to spawn, not just yourself."], + ["exp_scenario.command.spectate", "/spectate", "Toggles spectator mode."], + ["exp_scenario.command.tag", "/tag", "Sets your player tag.", true], + ["exp_scenario.command.tag_clear", "/tag-clear", "Clears your tag. Or another player if you are admin.", true], + ["exp_scenario.command.tag_clear.always", "/tag-clear (any player)", "Clear the tag of any player, not just your own."], + ["exp_scenario.command.tag_color", "/tag-color", "Sets your player tag color."], + ["exp_scenario.command.teleport", "/teleport", "Teleports a player to another player."], + ["exp_scenario.command.unassign_role", "/unassign-role", "Unassigns a role lower than your own from a player."], + ["exp_scenario.command.unjail", "/unjail", "Removes a player from jail, restoring their other roles."], + ["exp_scenario.command.vlayer_info", "/vlayer-info", "Print all vlayer information."], + ["exp_scenario.command.waterfill", "/waterfill", "Replace tiles with shallow water."], + ["exp_scenario.decon.fast_trees", "Fast tree deconstruction", "Trees and rocks marked for deconstruction are removed instantly, when enabled from the toolbar."], + ["exp_scenario.decon.standard", "Standard deconstruction", "Mark entities placed by other players for deconstruction."], + ["exp_scenario.gui.autofill", "Autofill", "Open the autofill GUI, which fills placed entities from your inventory.", true], + ["exp_scenario.gui.bonus", "Bonus", "Open the bonus GUI to adjust your personal bonuses."], + ["exp_scenario.gui.module", "Module inserter", "Open the module inserter GUI.", true], + ["exp_scenario.gui.player_list", "Player list", "Open the player list GUI.", true], + ["exp_scenario.gui.player_list.ban", "Player list: ban", "Ban a player from the cluster via the player list."], + ["exp_scenario.gui.player_list.kick", "Player list: kick", "Kick a player from the server via the player list."], + ["exp_scenario.gui.playerdata", "Player statistics", "Open the player statistics GUI."], + ["exp_scenario.gui.production", "Production statistics", "Open the production statistics GUI.", true], + ["exp_scenario.gui.readme", "Readme", "Open the server readme GUI.", true], + ["exp_scenario.gui.research", "Research milestones", "Open the research milestones GUI.", true], + ["exp_scenario.gui.rocket_info", "Rocket info", "Open the rocket info GUI.", true], + ["exp_scenario.gui.rocket_info.remote_launch", "Rocket info: remote launch", "Launch rockets remotely from the rocket info GUI."], + ["exp_scenario.gui.rocket_info.toggle_active", "Rocket info: toggle active", "Toggle whether a silo launches automatically."], + ["exp_scenario.gui.science_info", "Science production", "Open the science production GUI.", true], + ["exp_scenario.gui.surveillance", "Surveillance", "Open the surveillance GUI showing remote camera views."], + ["exp_scenario.gui.task_list", "Task list", "Open the task list GUI.", true], + ["exp_scenario.gui.task_list.add", "Task list: add", "Add new tasks to the task list."], + ["exp_scenario.gui.task_list.edit", "Task list: edit", "Edit and remove existing tasks on the task list."], + ["exp_scenario.gui.tool", "Quick actions", "Open the quick actions GUI."], + ["exp_scenario.gui.vlayer", "Virtual layer", "Open the virtual layer GUI.", true], + ["exp_scenario.gui.vlayer_edit", "Virtual layer: edit", "Use the edit controls in the virtual layer GUI."], + ["exp_scenario.gui.warp_list", "Warp list", "Open the warp list GUI and warp between locations.", true], + ["exp_scenario.gui.warp_list.add", "Warp list: add", "Add new warp points to the warp list."], + ["exp_scenario.gui.warp_list.bypass_cooldown", "Warp list: bypass cooldown", "Warp without waiting for the warp cooldown."], + ["exp_scenario.gui.warp_list.bypass_proximity", "Warp list: bypass proximity", "Warp without standing near a warp point."], + ["exp_scenario.gui.warp_list.edit", "Warp list: edit", "Edit and remove existing warp points."], + ["exp_scenario.player.admin", "Factorio admin", "Be promoted to Factorio admin while holding a role with this permission."], + ["exp_scenario.player.instant_respawn", "Instant respawn", "Respawn after two seconds instead of the default delay."], + ["exp_scenario.player.spectator", "Spectator", "Remove the zoom to world noise effect, as Factorio does for spectators."], + ["exp_scenario.player.system_commands", "System commands", "Unlock commands flagged system only, such as /_rcon and /_sudo."], ]; -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 }); } From 7034f17b5d8308e7124e39a7d7f720dfff6e1329 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:36:58 +0000 Subject: [PATCH 03/16] Move the scenario onto exp_roles and remove the legacy role system Every call site of expcore.roles now uses exp_roles, and the legacy module, its config, and the glue which refreshed guis on role events are deleted. Where a file only renamed the require and the permission strings the change is mechanical; the rest: - Jail is now "give the Jail role" and unjail "take it away". The role has a higher priority than every other so holding it suppresses them, which is what stashing and restoring the roles was for. - The command role authority derives exp_scenario.command. from the command name, and the role parsers use player_outranks rather than comparing indexes with their own root check. - The admin and spectator triggers, and the gui refresh on role changes, live in exp_scenario/control/roles.lua; the system commands trigger stays with the command authority. - The player list warn button is keyed on create_warning, the permission the command behind it already required, and report on create_report. Both were keyed on names no role held, so only root ever saw them. - The warps and tasks configs say exp_roles where they said expcore.roles. - The role tables the readme and player list read are replaced by get_player_names and get_roles. Co-Authored-By: Claude Fable 5 --- exp_legacy/module/config/_file_loader.lua | 2 - exp_legacy/module/config/afk_kick.lua | 5 +- exp_legacy/module/config/chat_reply.lua | 2 +- exp_legacy/module/config/expcore/roles.lua | 363 ----- .../module/config/gui/player_list_actions.lua | 34 +- exp_legacy/module/config/gui/rockets.lua | 4 +- exp_legacy/module/config/gui/tasks.lua | 8 +- exp_legacy/module/config/gui/warps.lua | 16 +- exp_legacy/module/config/nukeprotect.lua | 2 +- exp_legacy/module/config/protection.lua | 2 +- exp_legacy/module/expcore/roles.lua | 1192 ----------------- exp_legacy/module/locale/en/expcore.cfg | 8 - exp_legacy/module/locale/zh-CN/expcore.cfg | 8 - exp_legacy/module/locale/zh-TW/expcore.cfg | 8 - exp_legacy/module/module.json | 1 + exp_legacy/module/modules/control/jail.lua | 31 +- .../module/modules/control/protection.lua | 8 +- exp_legacy/module/modules/data/greetings.lua | 2 +- exp_legacy/module/modules/data/quickbar.lua | 2 +- exp_legacy/module/modules/data/tag.lua | 8 +- .../module/modules/gui/_role_updates.lua | 7 - exp_legacy/module/modules/gui/vlayer.lua | 11 +- exp_legacy/module/modules/gui/warp-list.lua | 11 +- exp_scenario/module/commands/_authorities.lua | 14 +- exp_scenario/module/commands/_rcon.lua | 2 +- exp_scenario/module/commands/_types.lua | 46 +- exp_scenario/module/commands/kill.lua | 6 +- .../module/commands/protected_entities.lua | 2 +- exp_scenario/module/commands/reports.lua | 6 +- exp_scenario/module/commands/roles.lua | 12 +- exp_scenario/module/commands/teleport.lua | 6 +- exp_scenario/module/control.lua | 1 + exp_scenario/module/control/bonus.lua | 4 +- .../module/control/chat_auto_reply.lua | 4 +- .../module/control/deconstruction_log.lua | 4 +- .../module/control/fast_deconstruction.lua | 8 +- .../module/control/nuke_protection.lua | 4 +- exp_scenario/module/control/roles.lua | 21 + exp_scenario/module/gui/autofill.lua | 4 +- exp_scenario/module/gui/module_inserter.lua | 4 +- exp_scenario/module/gui/player_bonus.lua | 16 +- exp_scenario/module/gui/player_list.lua | 12 +- exp_scenario/module/gui/player_stats.lua | 4 +- exp_scenario/module/gui/production_stats.lua | 4 +- exp_scenario/module/gui/quick_actions.lua | 7 +- exp_scenario/module/gui/readme.lua | 20 +- .../module/gui/research_milestones.lua | 4 +- exp_scenario/module/gui/rocket_info.lua | 4 +- .../module/gui/science_production.lua | 4 +- exp_scenario/module/gui/surveillance.lua | 4 +- exp_scenario/module/gui/task_list.lua | 15 +- exp_scenario/module/module.json | 1 + exp_server_ups/module/control.lua | 2 +- 53 files changed, 204 insertions(+), 1776 deletions(-) delete mode 100644 exp_legacy/module/config/expcore/roles.lua delete mode 100644 exp_legacy/module/expcore/roles.lua delete mode 100644 exp_legacy/module/modules/gui/_role_updates.lua create mode 100644 exp_scenario/module/control/roles.lua diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua index aaf9984a..88fdec5e 100644 --- a/exp_legacy/module/config/_file_loader.lua +++ b/exp_legacy/module/config/_file_loader.lua @@ -22,10 +22,8 @@ return { --- GUI "modules.gui.warp-list", "modules.gui.vlayer", - "modules.gui._role_updates", "modules.graftorio.require", -- graftorio --- Config Files "config.expcore.permission_groups", -- loads some predefined permission groups - "config.expcore.roles", -- loads some predefined roles } diff --git a/exp_legacy/module/config/afk_kick.lua b/exp_legacy/module/config/afk_kick.lua index cb982ade..f02be428 100644 --- a/exp_legacy/module/config/afk_kick.lua +++ b/exp_legacy/module/config/afk_kick.lua @@ -1,4 +1,4 @@ -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") return { admin_as_active = true, --- @setting admin_as_active When true admins will be treated as active regardless of afk time @@ -8,6 +8,7 @@ return { trust_time = 3600 * 60 * 10, --- @setting trust_time The time in ticks that a player must be online for to count as trusted update_time = 3600 * 30, --- @setting update_time How often in ticks the script checks for active players custom_active_check = function(player) - return Roles.get_player_highest_role(player).index <= Roles.get_role_from_any("Veteran").index + local veteran = Roles.get_role("Veteran") + return veteran ~= nil and Roles.get_player_highest_role(player).index <= veteran.index end, } diff --git a/exp_legacy/module/config/chat_reply.lua b/exp_legacy/module/config/chat_reply.lua index 5eabb22d..97e035d7 100644 --- a/exp_legacy/module/config/chat_reply.lua +++ b/exp_legacy/module/config/chat_reply.lua @@ -65,7 +65,7 @@ return { }, allow_command_prefix_for_messages = true, --- @setting allow_command_prefix_for_messages when true any message trigger will print to all player when prefixed command_admin_only = false, --- @setting command_admin_only when true will only allow chat commands for admins - command_permission = "command/chat-commands", --- @setting command_permission the permission used to allow command prefixes + command_permission = "exp_scenario.chat.commands", --- @setting command_permission the permission used to allow command prefixes command_prefix = "!", --- @setting command_prefix prefix used for commands below and to print to all players (if enabled above) --- @type table commands = { --- @setting commands will trigger only when command prefix is given diff --git a/exp_legacy/module/config/expcore/roles.lua b/exp_legacy/module/config/expcore/roles.lua deleted file mode 100644 index 8ffbb53f..00000000 --- a/exp_legacy/module/config/expcore/roles.lua +++ /dev/null @@ -1,363 +0,0 @@ ---- This is the main config file for the role system; file includes defines for roles and role flags and default values --- @config Roles - -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles -local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data -local Statistics = PlayerData.Statistics - ---- Role flags that will run when a player changes roles -Roles.define_flag_trigger("is_admin", function(player, state) - player.admin = state -end) -Roles.define_flag_trigger("is_spectator", function(player, state) - player.spectator = state -end) -Roles.define_flag_trigger("is_jail", function(player, state) - if player.character then - player.character.disabled_by_script = not state - end -end) - ---- Admin Roles -Roles.new_role("System", "SYS") - :set_permission_group("Default", true) - :set_flag("is_admin") - :set_flag("is_system") - :set_flag("is_spectator") - :set_flag("report-immune") - :set_flag("instant-respawn") - :set_allow_all() - -Roles.new_role("Senior Administrator", "SAdmin") - :set_permission_group("Admin") - :set_custom_color{ r = 233, g = 63, b = 233 } - :set_flag("is_admin") - :set_flag("is_system") - :set_flag("is_spectator") - :set_flag("report-immune") - :set_flag("instant-respawn") - :set_parent("Administrator") - :allow{ - "command/_rcon", - "command/debug", - "command/set-cheat-mode", - "command/research-all", - } - -Roles.new_role("Administrator", "Admin") - :set_permission_group("Admin") - :set_custom_color{ r = 233, g = 63, b = 233 } - :set_flag("is_admin") - :set_flag("is_spectator") - :set_flag("report-immune") - :set_flag("instant-respawn") - :set_parent("Moderator") - :allow{ - "gui/warp-list/bypass-proximity", - "gui/warp-list/bypass-cooldown", - "command/connect-all", - } - -Roles.new_role("Moderator", "Mod") - :set_permission_group("Admin") - :set_custom_color{ r = 0, g = 170, b = 0 } - :set_flag("is_admin") - :set_flag("is_spectator") - :set_flag("report-immune") - :set_flag("instant-respawn") - :set_parent("Trainee") - :allow{ - "command/assign-role", - "command/unassign-role", - "command/repair", - "command/kill/always", - "command/clear-tag/always", - "command/spawn/always", - "command/clear-reports", - "command/clear-warnings", - "command/clear-script-warnings", - "command/clear-last-warnings", - "command/clear-inventory", - "command/kill-enemies", - "command/remove-enemies", - "command/home", - "command/set-home", - "command/get-home", - "command/return", - "command/connect-player", - "command/set-bot-queue", - "command/set-game-speed", - "command/set-friendly-fire", - "command/set-always-day", - "command/set-pollution-enabled", - "command/clear-pollution", - "gui/rocket-info/toggle-active", - "gui/rocket-info/remote_launch", - "gui/bonus", - "fast-tree-decon", - } - -Roles.new_role("Trainee", "TrMod") - :set_permission_group("Admin") - :set_custom_color{ r = 0, g = 170, b = 0 } - :set_flag("is_admin") - :set_flag("is_spectator") - :set_flag("report-immune") - :set_parent("Veteran") - :allow{ - "command/admin-chat", - "command/goto", - "command/teleport", - "command/bring", - "command/create-warning", - "command/get-warnings", - "command/get-reports", - "command/protect-entity", - "command/protect-area", - "command/protect-tag", - "command/jail", - "command/unjail", - "command/kick", - "command/ban", - "command/spectate", - "command/follow", - "command/search", - "command/search-online", - "command/search-amount", - "command/search-recent", - "command/clear-blueprints-surface", - "gui/playerdata", - } - ---- Trusted Roles -Roles.new_role("Board Member", "Board") - :set_permission_group("Trusted") - :set_custom_color{ r = 247, g = 246, b = 54 } - :set_flag("is_spectator") - :set_flag("report-immune") - :set_flag("instant-respawn") - :set_parent("Sponsor") - :allow{ - "command/goto", - "command/repair", - "command/spectate", - "command/follow", - "gui/playerdata", - } - -Roles.new_role("Senior Backer", "Backer") - :set_permission_group("Trusted") - :set_custom_color{ r = 238, g = 172, b = 44 } - :set_flag("is_spectator") - :set_flag("report-immune") - :set_flag("instant-respawn") - :set_parent("Sponsor") - :allow{ - } - -Roles.new_role("Sponsor", "Spon") - :set_permission_group("Trusted") - :set_custom_color{ r = 238, g = 172, b = 44 } - :set_flag("is_spectator") - :set_flag("report-immune") - :set_flag("instant-respawn") - :set_parent("Supporter") - :allow{ - "gui/rocket-info/toggle-active", - "gui/rocket-info/remote_launch", - "gui/bonus", - "command/home", - "command/set-home", - "command/get-home", - "command/return", - "fast-tree-decon", - } - -Roles.new_role("Supporter", "Sup") - :set_permission_group("Trusted") - :set_custom_color{ r = 230, g = 99, b = 34 } - :set_flag("is_spectator") - :set_parent("Veteran") - :allow{ - "command/tag-color", - "command/jail", - "command/unjail", - "command/set-join-message", - "command/remove-join-message", - } - -Roles.new_role("Partner", "Part") - :set_permission_group("Trusted") - :set_custom_color{ r = 140, g = 120, b = 200 } - :set_flag("is_spectator") - :set_parent("Veteran") - :allow{ - "command/jail", - "command/unjail", - } - -local hours10, hours250 = 10 * 216000, 250 * 60 -Roles.new_role("Veteran", "Vet") - :set_permission_group("Trusted") - :set_custom_color{ r = 140, g = 120, b = 200 } - :set_parent("Member") - :allow{ - "command/chat-commands", - "command/clear-ground-items", - "command/clear-blueprints", - "command/set-trains-to-automatic", - } - :set_auto_assign_condition(function(player) - if player.online_time >= hours10 then - return true - else - local stats = Statistics:get(player, {}) - local playtime, afk_time, map_count = stats.Playtime or 0, stats.AfkTime or 0, stats.MapsPlayed or 0 - return playtime - afk_time >= hours250 and map_count >= 25 - end - end) - ---- Standard User Roles -Roles.new_role("Member", "Mem") - :set_permission_group("Standard") - :set_custom_color{ r = 24, g = 172, b = 188 } - :set_flag("deconlog-bypass") - :set_parent("Regular") - :allow{ - "gui/task-list/add", - "gui/task-list/edit", - "gui/warp-list/add", - "gui/warp-list/edit", - "gui/surveillance", - "gui/vlayer-edit", - "gui/tool", - "command/save-quickbar", - "command/vlayer-info", - "command/lawnmower", - "command/waterfill", - "command/artillery", - } - -local hours3, hours15 = 3 * 216000, 15 * 60 -Roles.new_role("Regular", "Reg") - :set_permission_group("Standard") - :set_custom_color{ r = 79, g = 155, b = 163 } - :set_parent("Guest") - :allow{ - "command/kill", - "command/rainbow", - "command/spawn", - "command/me", - "standard-decon", - "bypass-entity-protection", - "bypass-nukeprotect", - } - :set_auto_assign_condition(function(player) - if player.online_time >= hours3 then - return true - else - local stats = Statistics:get(player, {}) - local playtime, afk_time, map_count = stats.Playtime or 0, stats.AfkTime or 0, stats.MapsPlayed or 0 - return playtime - afk_time >= hours15 and map_count >= 5 - end - end) - ---- Guest/Default role -local default = Roles.new_role("Guest", "") - :set_permission_group("Guest") - :set_custom_color{ r = 185, g = 187, b = 160 } - :allow{ - "command/tag", - "command/tag-clear", - "command/commands", - "command/get-roles", - "command/locate", - "command/create-report", - "command/ratio", - "command/server-ups", - "command/save-data", - "command/data-preference", - "command/connect", - "gui/player-list", - "gui/rocket-info", - "gui/science-info", - "gui/task-list", - "gui/warp-list", - "gui/readme", - "gui/vlayer", - "gui/research", - "gui/autofill", - "gui/module", - "gui/production", - } - ---- Jail role -Roles.new_role("Jail") - :set_permission_group("Restricted") - :set_custom_color{ r = 50, g = 50, b = 50 } - :set_block_auto_assign(true) - :set_flag("defer_role_changes") - :disallow(default.allowed) - ---- System defaults which are required to be set -Roles.set_root("System") -Roles.set_default("Guest") - -Roles.define_role_order{ - "System", -- Best to keep root at top - "Senior Administrator", - "Administrator", - "Moderator", - "Trainee", - "Board Member", - "Senior Backer", - "Sponsor", - "Supporter", - "Partner", - "Veteran", - "Member", - "Regular", - "Jail", - "Guest", -- Default must be last if you want to apply restrictions to other roles -} - -Roles.override_player_roles{ - ["PHIDIAS0303"] = { "Moderator", "Board Member", "Member" }, - ["aldldl"] = { "Administrator", "Moderator", "Member" }, - ["arty714"] = { "Senior Administrator", "Moderator", "Member" }, - ["Cooldude2606"] = { "Senior Administrator", "Moderator", "Member" }, - ["Drahc_pro"] = { "Administrator", "Moderator", "Member" }, - ["mark9064"] = { "Administrator", "Moderator", "Member" }, - ["7h3w1z4rd"] = { "Moderator", "Member" }, - ["FlipHalfling90"] = { "Moderator", "Member" }, - ["hamsterbryan"] = { "Moderator", "Member" }, - ["HunterOfGames"] = { "Moderator", "Member" }, - ["NextIdea"] = { "Moderator", "Member" }, - ["TheKernel32"] = { "Moderator", "Member" }, - ["TheKernel64"] = { "Moderator", "Member" }, - ["tovernaar123"] = { "Moderator", "Member" }, - ["UUBlueFire"] = { "Moderator", "Member" }, - ["AssemblyStorm"] = { "Moderator", "Member" }, - ["banakeg"] = { "Moderator", "Member" }, - ["connormkii"] = { "Moderator", "Member" }, - ["cydes"] = { "Moderator", "Member" }, - ["darklich14"] = { "Moderator", "Member" }, - ["facere"] = { "Moderator", "Member" }, - ["freek18"] = { "Moderator", "Member" }, - ["Gizan"] = { "Moderator", "Member" }, - ["LoicB"] = { "Moderator", "Member" }, - ["M74132"] = { "Moderator", "Member" }, - ["mafisch3"] = { "Moderator", "Member" }, - ["maplesyrup01"] = { "Moderator", "Member" }, - ["ookl"] = { "Moderator", "Member" }, - ["Phoenix27833"] = { "Moderator", "Member" }, - ["porelos"] = { "Moderator", "Member" }, - ["Ruuyji"] = { "Moderator", "Member" }, - ["samy115"] = { "Moderator", "Member" }, - ["SilentLog"] = { "Moderator", "Member" }, - ["Tcheko"] = { "Moderator", "Member" }, - ["thadius856"] = { "Moderator", "Member" }, - ["whoami32"] = { "Moderator", "Member" }, - ["Windbomb"] = { "Moderator", "Member" }, - ["XenoCyber"] = { "Moderator", "Member" }, -} diff --git a/exp_legacy/module/config/gui/player_list_actions.lua b/exp_legacy/module/config/gui/player_list_actions.lua index c6bb968c..b3e265dd 100644 --- a/exp_legacy/module/config/gui/player_list_actions.lua +++ b/exp_legacy/module/config/gui/player_list_actions.lua @@ -7,7 +7,7 @@ local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles +local Roles = require("modules/exp_roles") local Reports = require("modules.exp_legacy.modules.control.reports") --- @dep modules.control.reports local Warnings = require("modules.exp_legacy.modules.control.warnings") --- @dep modules.control.warnings local Jail = require("modules.exp_legacy.modules.control.jail") --- @dep modules.control.jail @@ -23,11 +23,7 @@ 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 + return Roles.player_outranks(player, selected_player_name) end -- gets the action player and a coloured name for the action to be used on @@ -94,7 +90,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) @@ -110,7 +106,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 +124,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 +139,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 +151,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,39 +162,39 @@ 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"] = { + ["exp_scenario.command.create_warning"] = { auth = auth_lower_role, -- warn a lower user, replaces report reason_callback = warn_player_callback, warn_player, }, - ["command/jail"] = { + ["exp_scenario.command.jail"] = { auth = auth_lower_role, reason_callback = jail_player_callback, jail_player, }, - ["command/kick"] = { + ["exp_scenario.gui.player_list.kick"] = { auth = auth_lower_role, reason_callback = kick_player_callback, kick_player, }, - ["command/ban"] = { + ["exp_scenario.gui.player_list.ban"] = { auth = auth_lower_role, reason_callback = ban_player_callback, ban_player, diff --git a/exp_legacy/module/config/gui/rockets.lua b/exp_legacy/module/config/gui/rockets.lua index 2d90778d..c1c7e53f 100644 --- a/exp_legacy/module/config/gui/rockets.lua +++ b/exp_legacy/module/config/gui/rockets.lua @@ -39,9 +39,9 @@ return { allow_zoom_to_map = true, --- @setting allow_zoom_to_map false will disable the zoom to map feature allow_remote_launch = true, --- @setting allow_remote_launch false removes the remote launch button for all players remote_launch_admins_only = false, --- @setting remote_launch_admins_only true will remove the remote launch button for all non (game) admins - remote_launch_role_permission = "gui/rocket-info/remote_launch", --- @setting remote_launch_role_permission value used by custom permission system to allow or disallow the button + remote_launch_role_permission = "exp_scenario.gui.rocket_info.remote_launch", --- @setting remote_launch_role_permission value used by custom permission system to allow or disallow the button allow_toggle_active = true, --- @setting allow_toggle_active false removes the remote toggle auto launch button for all players toggle_active_admins_only = false, --- @setting toggle_active_admins_only true will remove the toggle auto launch button for all non (game) admins - toggle_active_role_permission = "gui/rocket-info/toggle-active", --- @setting toggle_active_role_permission value used by custom permission system to allow or disallow the button + toggle_active_role_permission = "exp_scenario.gui.rocket_info.toggle_active", --- @setting toggle_active_role_permission value used by custom permission system to allow or disallow the button }, } diff --git a/exp_legacy/module/config/gui/tasks.lua b/exp_legacy/module/config/gui/tasks.lua index 91f6bdcd..969b6ab3 100644 --- a/exp_legacy/module/config/gui/tasks.lua +++ b/exp_legacy/module/config/gui/tasks.lua @@ -3,11 +3,11 @@ return { -- Adding tasks - allow_add_task = "all", --- @setting allow_add_task dictates who is allowed to add new tasks; values: all, admin, expcore.roles, none - expcore_roles_allow_add_task = "gui/task-list/add", --- @setting expcore_roles_allow_add_task if expcore.roles is used then this is the required permission + allow_add_task = "all", --- @setting allow_add_task dictates who is allowed to add new tasks; values: all, admin, exp_roles, none + exp_roles_allow_add_task = "exp_scenario.gui.task_list.add", --- @setting exp_roles_allow_add_task if exp_roles is used then this is the required permission -- Editing tasks - allow_edit_task = "expcore.roles", --- @setting allow_edit_task dictates who is allowed to edit existing tasks; values: all, admin, expcore.roles, none - expcore_roles_allow_edit_task = "gui/task-list/edit", --- @setting expcore_roles_allow_edit_task if expcore.roles is used then this is the required permission + allow_edit_task = "exp_roles", --- @setting allow_edit_task dictates who is allowed to edit existing tasks; values: all, admin, exp_roles, none + exp_roles_allow_edit_task = "exp_scenario.gui.task_list.edit", --- @setting exp_roles_allow_edit_task if exp_roles is used then this is the required permission user_can_edit_own_tasks = true, --- @settings if true then the user who made the task can edit it regardless of the allow_edit_task setting } diff --git a/exp_legacy/module/config/gui/warps.lua b/exp_legacy/module/config/gui/warps.lua index af1a8bab..fa6c142b 100644 --- a/exp_legacy/module/config/gui/warps.lua +++ b/exp_legacy/module/config/gui/warps.lua @@ -8,23 +8,23 @@ return { default_icon = { type = "item", name = "discharge-defense-equipment" }, --- @setting default_icon the default icon that will be used for warps -- Warp cooldowns - bypass_warp_cooldown = "expcore.roles", --- @setting bypass_warp_cooldown dictates who the warp cooldown is applied to; values: all, admin, expcore.roles, none - expcore_roles_bypass_warp_cooldown = "gui/warp-list/bypass-cooldown", --- @setting expcore_roles_bypass_warp_cooldown if expcore.roles is used then this is the required permission + bypass_warp_cooldown = "exp_roles", --- @setting bypass_warp_cooldown dictates who the warp cooldown is applied to; values: all, admin, exp_roles, none + exp_roles_bypass_warp_cooldown = "exp_scenario.gui.warp_list.bypass_cooldown", --- @setting exp_roles_bypass_warp_cooldown if exp_roles is used then this is the required permission cooldown_duration = 60, --- @setting cooldown_duration the duration of the warp cooldown in seconds -- Warp proximity - bypass_warp_proximity = "expcore.roles", --- @setting bypass_warp_proximity dictates who the warp proximity is applied to; values: all, admin, expcore.roles, none - expcore_roles_bypass_warp_proximity = "gui/warp-list/bypass-proximity", --- @setting expcore_roles_bypass_warp_proximity if expcore.roles is used then this is the required permission + bypass_warp_proximity = "exp_roles", --- @setting bypass_warp_proximity dictates who the warp proximity is applied to; values: all, admin, exp_roles, none + exp_roles_bypass_warp_proximity = "exp_scenario.gui.warp_list.bypass_proximity", --- @setting exp_roles_bypass_warp_proximity if exp_roles is used then this is the required permission standard_proximity_radius = 4, --- @setting standard_proximity_radius the minimum distance a player is allowed to be to a warp in order to use it spawn_proximity_radius = 20, --- @setting spawn_proximity_radius the minimum distance a player is allowed to be from they spawn point to use warps -- Adding warps - allow_add_warp = "expcore.roles", --- @setting allow_add_warp dictates who is allowed to add warps; values: all, admin, expcore.roles, none - expcore_roles_allow_add_warp = "gui/warp-list/add", --- @setting expcore_roles_allow_add_warp if expcore.roles is used then this is the required permission + allow_add_warp = "exp_roles", --- @setting allow_add_warp dictates who is allowed to add warps; values: all, admin, exp_roles, none + exp_roles_allow_add_warp = "exp_scenario.gui.warp_list.add", --- @setting exp_roles_allow_add_warp if exp_roles is used then this is the required permission -- Editing warps - allow_edit_warp = "expcore.roles", --- @setting allow_edit_warp dictates who is allowed to edit warps; values: all, admin, expcore.roles, none - expcore_roles_allow_edit_warp = "gui/warp-list/edit", --- @setting expcore_roles_allow_edit_warp if expcore.roles is used then this is the required permission + allow_edit_warp = "exp_roles", --- @setting allow_edit_warp dictates who is allowed to edit warps; values: all, admin, exp_roles, none + exp_roles_allow_edit_warp = "exp_scenario.gui.warp_list.edit", --- @setting exp_roles_allow_edit_warp if exp_roles is used then this is the required permission user_can_edit_own_warps = false, --- @settings user_can_edit_own_warps if true then the user who made the warp can edit it regardless of the allow_edit_warp setting -- Warp area generation diff --git a/exp_legacy/module/config/nukeprotect.lua b/exp_legacy/module/config/nukeprotect.lua index a49c4e12..197ddb00 100644 --- a/exp_legacy/module/config/nukeprotect.lua +++ b/exp_legacy/module/config/nukeprotect.lua @@ -25,6 +25,6 @@ return { }, }, }, - ignore_permission = "bypass-nukeprotect", -- @setting ignore_permission The permission that nukeprotect will ignore + ignore_permission = "exp_scenario.bypass.nuke_protection", -- @setting ignore_permission The permission that nukeprotect will ignore ignore_admins = true, -- @setting ignore_admins Ignore admins, true by default. Allows usage outside of the roles module } diff --git a/exp_legacy/module/config/protection.lua b/exp_legacy/module/config/protection.lua index d598fb65..d645d04b 100644 --- a/exp_legacy/module/config/protection.lua +++ b/exp_legacy/module/config/protection.lua @@ -1,6 +1,6 @@ return { ignore_admins = true, --- @setting ignore_admins If admins are ignored by the protection filter - ignore_permission = "bypass-entity-protection", --- @setting ignore_permission Players with this permission will be ignored by the protection filter, leave nil if expcore.roles is not used + ignore_permission = "exp_scenario.bypass.entity_protection", --- @setting ignore_permission Players with this permission will be ignored by the protection filter, leave nil to protect against every player repeat_count = 5, --- @setting repeat_count Number of protected entities that must be removed within repeat_lifetime in order to trigger repeated removal protection repeat_lifetime = 3600 * 20, --- @setting repeat_lifetime The length of time, in ticks, that protected removals will be remembered for refresh_rate = 3600 * 5, --- @setting refresh_rate How often the age of protected removals are checked against repeat_lifetime diff --git a/exp_legacy/module/expcore/roles.lua b/exp_legacy/module/expcore/roles.lua deleted file mode 100644 index 641eeaea..00000000 --- a/exp_legacy/module/expcore/roles.lua +++ /dev/null @@ -1,1192 +0,0 @@ ---[[-- Core Module - Roles -- Factorio role system to manage custom permissions. -@core Roles -@alias Roles - -@usage--- Using Role System (assignment): ---When a map first starts you will want to define on mass all the players you expect to join and the roles to give them: -Roles.override_player_roles{ - Cooldude2606 = {'Owner', 'Admin', 'Member'}, - NotCooldude2606 = {'Member'} -} - ---Once the game is running you still want to be able to give role and remove them which is when you would use: -Roles.assign_player(player, 'Admin', by_player_name) -- this will give the "Admin" role to the player -Roles.unassign_player(player, {'Admin', 'Moderator'}, by_player_name) -- this will remove "Admin" and "Moderator" role in one go - -@usage--- Using Role System (role testing): ---To comparer two players you can comparer the index of they highest roles, can be used when you want to allow a "write" down type system: -Roles.get_player_highest_role(playerOne).index < Roles.get_player_highest_role(playerTwo).index -- remember that less means a higher role - ---Listing all of a players roles can also be useful which is when you would want to use: -Roles.get_player_roles(player) -- the return is an array that can be looped over however this is not in particular order - ---Finally you may want to test if a player has a certain role, flag or action allowed which is when you would use: -Roles.player_has_role(player, 'Admin') -- you can provide a role name if you only want a name based system -Roles.player_has_flag(player, 'is_donator') -- your roles can be grouped together with flags such as is_donator -Roles.player_allowed(player, 'game modifiers') -- or you can have an action based system where each action is something the player can do - -@usage--- Example Flag Define: ---Flags can be used to group multiple roles and actions under one catch all, for example if you want a piece of code to only ---be active for your donators then you would add a "is_donator" flag to all your donator roles and then in the code test if ---a player has that tag present: - --- give you donators a speed boost when they join; these functions aren't required but can be useful -Roles.define_flag_trigger('is_donator', function(player, state) - if state then - player.character_running_speed_modifier = 1.5 - else - player.character_running_speed_modifier = 1 - end -end) - --- then on all your donator roles you would add -Roles.new_role('Donator') -:set_flag('is_donator') - --- and in your code you would test for -if Roles.player_has_flag(player, 'is_donator') then - -- some donator only code -end - -@usage--- Example Role Define: ---You can't use a role system without any roles so first you must define your roles; each role has a minimum of a name with ---the option for a shorthand: -Roles.new_role('Administrator', 'Admin') - ---Next you will want to add any extras you want to have, such as a tag, colour, permission group or any custom flags: -Roles.new_role('Administrator', 'Admin') -:set_custom_tag('[Admin]') -:set_custom_color('red') -- this can be {r=0, g=0, b=0} or a predefined value -:set_permission_group('Staff') -- a second argument can be added if you have not used the custom permission group config -:set_flag('is_admin') - ---You will then want to decide if you want to allow all actions, this should of course be used sparely: -Roles.new_role('Administrator', 'Admin') -...extras... -:set_allow_all() - ---If you don't do this want this as i would advise you do then you will want to define what the role can do; this comes with ---an optional inheritance system if you like those sort of things in which case disallow may also be of some use to you: -Roles.new_role('Administrator', 'Admin') -...extras... -:set_parent('Moderator') -- the admin can do anything that a moderator can do -:allow{ -- these actions can be anything just try to keep them without conflicts - 'command/kill', - 'gui/game settings' -} - ---Here is what the finished admin role would look like: -Roles.new_role('Administrator', 'Admin') -:set_custom_tag('[Admin]') -:set_custom_color('red') -:set_permission_group('Staff') -:set_flag('is_admin') -:set_parent('Moderator') -:allow{ - 'command/kill', - 'gui/game settings' -} - -@usage--- Example System Define: ---Once all roles are defined these steps must be done to ensure the system is ready to use, this includes setting a default ---role, assigning a root (all permission) role that the server/system will use and the linear order that the roles fall into: - -Roles.set_default('Guest') -Roles.set_root('System') - -Roles.define_role_order{ - 'System', - 'Administrator', - 'Moderator', - 'Donator', - 'Guest' -} - ---Just remember that in this example all these roles have not been defined; so make sure all your roles that are used are defined ---before hand; a config file on load is useful for this to ensure that its loaded before the first player even joins. - -]] - -local ExpUtil = require("modules/exp_util") -local Async = require("modules/exp_util/async") -local Storage = require("modules/exp_util/storage") -local Event = require("modules/exp_legacy/utils/event") -local Groups = require("modules.exp_legacy.expcore.permission_groups") - -local Colours = ExpUtil.color -local write_json = ExpUtil.write_json - - ---- @class Roles.Role ---- @field name string ---- @field short_hand string ---- @field index number Position within the role order ---- @field allowed_actions table ---- @field allow_all_actions boolean ---- @field flags table ---- @field disallowed_actions table? ---- @field permission_group ([boolean, string] | string)? ---- @field custom_tag string? ---- @field custom_color Color? ---- @field parent string? ---- @field block_auto_assign boolean? ---- @field auto_assign_condition function? ---- @field set_allow_all fun(self: Roles.Role, state: boolean?): Roles.Role Defined on Roles._prototype - -local Roles = { - _prototype = {}, - config = { - order = {}, --- @type string[] Contains the order of the roles, lower index is better - roles = {}, --- @type table Contains the raw info for the roles, indexed by role name - flags = {}, -- Contains functions that run when a flag is added/removed from a player - internal = {}, -- Contains all internally accessed roles, such as root, default - players = {}, -- Contains the roles that players have - auto_assign = {}, -- Contains references to all roles which have auto assign conditions - deferred_roles = {}, -- Contains the roles that are to be assigned to players when they are unjailed - }, - events = { - on_role_assigned = script.generate_event_name(), - on_role_unassigned = script.generate_event_name(), - }, -} - ---- When global is loaded it will have the metatable re-assigned to the roles -Storage.register({ - Roles.config.players, - Roles.config.deferred_roles, -}, function(tbl) - Roles.config.players = tbl[1] - Roles.config.deferred_roles = tbl[2] -end) - ---- Getter. --- Functions which get roles --- @section get - ---- Internal function used to trigger a few different things when roles are changed --- this is the raw internal trigger as the other function is called at other times --- there is a second half called role_update which triggers after the event call, it also is called when a player joins -local function emit_player_roles_updated(player, type, roles, by_player_name, skip_game_print) - by_player_name = by_player_name or game.player and game.player.name or "" - local by_player = game.players[by_player_name] - local by_player_index = by_player and by_player.index or 0 - -- get the event id from the type of emit - local event = Roles.events.on_role_assigned - if type == "unassign" then - event = Roles.events.on_role_unassigned - end - -- Get the names of the roles - local role_names = {} - for index, role in ipairs(roles) do - role_names[index] = role.name - end - - -- output to all the different locations: game print, player sound, event trigger and role log - if not skip_game_print then - game.print({ "expcore-roles.game-message-" .. type, player.name, table.concat(role_names, ", "), by_player_name }, Colours.cyan) - end - if type == "assign" then - player.play_sound{ path = "utility/achievement_unlocked" } - else - player.play_sound{ path = "utility/game_lost" } - end - script.raise_event(event, { - name = event, - tick = game.tick, - player_index = player.index, - by_player_index = by_player_index, - roles = role_names, - }) - write_json("log/roles.log", { - player_name = player.name, - by_player_name = by_player_name, - type = type, - roles_changed = role_names, - }) -end - ---[[-- Returns a string which contains all roles in index order displaying all data for them -@treturn string the debug output string - -@usage-- Print the debug string -game.player.print(Roles.debug()) - -]] -function Roles.debug() - local output = "" - for index, role_name in ipairs(Roles.config.order) do - local role = Roles.config.roles[role_name] - local color = (role.custom_color or Colours.white) --[[@as Color.struct]] - local color_str = string.format("[color=%d, %d, %d]", color.r, color.g, color.b) - output = output .. string.format("\n%s %s) %s[/color]", color_str, index, serpent.line(role)) - end - - return output -end - ---[[-- Prints a message to all players in the given roles, may send duplicate message however factorio blocks spam -@tparam table roles table a of roles which to send the message to -@tparam string message the message to send to the players - -@usage-- Print a message to the given roles -Roles.print_to_roles({'Administrator', 'Moderator'}, 'Hello, World!') - -]] -function Roles.print_to_roles(roles, message) - for _, role in ipairs(roles) do - role = Roles.get_role_from_any(role) - if role then role:print(message) end - end -end - ---[[-- Prints a message to all players who have the given role or one which is higher (excluding default) -@tparam string role the name of the role to send the message to -@tparam string message the message to send to the players - -@usage-- Print a message to the roles above this role, includes the given role -Roles.print_to_roles_higher('Moderator', 'Hello, World!') - -]] -function Roles.print_to_roles_higher(role, message) - role = Roles.get_role_from_any(role) - if not role then return end - local roles = {} - for index, role_name in ipairs(Roles.config.order) do - if index <= role.index and role_name ~= Roles.config.internal.default then - roles[#roles + 1] = role_name - end - end - - Roles.print_to_roles(roles, message) -end - ---[[-- Prints a message to all players who have the given role or one which is lower (excluding default) -@tparam string role the name of the role to send the message to -@tparam string message the message to send to the players - -@usage-- Print a message to the roles below this role, includes the given role -Roles.print_to_roles_higher('Moderator', 'Hello, World!') - -]] -function Roles.print_to_roles_lower(role, message) - role = Roles.get_role_from_any(role) - if not role then return end - local roles = {} - for index, role_name in ipairs(Roles.config.order) do - if index >= role.index and role_name ~= Roles.config.internal.default then - roles[#roles + 1] = role_name - end - end - - Roles.print_to_roles(roles, message) -end - ---[[-- Get a role for the given name -@tparam string name the name of the role to get -@treturn Roles._prototype the role with that name or nil - -@usage-- Get a role by its name -local role = Roles.get_role_by_name('Moderator') - -]] -function Roles.get_role_by_name(name) - return Roles.config.roles[name] -end - ---[[-- Get a role with the given order index -@tparam number index the place in the order list of the role to get -@treturn Roles._prototype the role with that index in the order list or nil - -@usage-- Get a role by its index in the order list -local role = Roles.get_role_by_name(2) - -]] -function Roles.get_role_by_order(index) - local name = Roles.config.order[index] - if not name then return end - return Roles.config.roles[name] -end - ---[[-- Gets a role from a name, index or role object (where it is just returned) -nb: this function is used for the input for most outward facing functions -@tparam ?number|string|table any the value used to find the role -@treturn Roles._prototype the role that was found or nil see above - -@usage-- Get a role by its name or order -local role = Roles.get_role_from_any('Moderator') - -]] -function Roles.get_role_from_any(any) - local t_any = type(any) - if t_any == "number" or tonumber(any) then - any = tonumber(any) - return Roles.get_role_by_order(any) - elseif t_any == "string" then - return Roles.get_role_by_name(any) - elseif t_any == "table" then - return Roles.get_role_by_name(any.name) - end -end - ---- Returns all roles in order of index -function Roles.get_roles_ordered() - local rtn = {} - for index, role_name in ipairs(Roles.config.order) do - rtn[index] = Roles.config.roles[role_name] - end - return rtn -end - ---[[-- Gets all the roles of the given player, this will always contain the default role -@tparam LuaPlayer player the player to get the roles of -@treturn table a table where the values are the roles which the player has - -@usage-- Get the roles that a player has -local roles = Roles.get_player_roles(game.player) - -]] -function Roles.get_player_roles(player) - if not player or player.index == 0 then return { Roles.config.roles[Roles.config.internal.root] } end - local roles = Roles.config.players[player.name] or {} - local default = Roles.config.roles[Roles.config.internal.default] - local rtn = { default } - for index, role_name in ipairs(roles) do - rtn[index + 1] = Roles.config.roles[role_name] - end - - return rtn -end - ---[[-- Gets the highest role which the player has, can be used to compeer one player to another -@tparam LuaPlayer player the player to get the highest role of -@treturn the role with the highest order index which this player has - -@usage-- Get the highest role that a player has -local role = Roles.get_player_highest_role(game.player) - -]] ---- @return Roles.Role -function Roles.get_player_highest_role(player) - local roles = Roles.get_player_roles(player) - local highest --- @type Roles.Role? - for _, role in ipairs(roles) do - if not highest or role.index < highest.index then - highest = role - end - end - - return (assert(highest, "Player has no roles")) -end - ---- Assignment. --- Functions for changing player's roles --- @section assignment - ---[[-- Gives a player the given role(s) with an option to pass a by player name used in the log -@tparam LuaPlayer player the player that will be assigned the roles -@tparam table roles table a of roles that the player will be given, can be one role and can be role names -@tparam[opt=] string by_player_name the name of the player that will be shown in the log -@tparam[opt=false] boolean skip_checks when true there will be no checks are done for if the player is valid -@tparam[opt=false] boolean silent when true there will be no game message printed - -@usage-- Assign a player to the Moderator role -Roles.assign_player(game.player, 'Moderator') - -@usage-- Assign a player to the Moderator role, even if the player has never been on the map -Roles.assign_player('Cooldude2606', 'Moderator', nil, true) - -]] -function Roles.assign_player(player, roles, by_player_name, skip_checks, silent) - local valid_player = type(player) == "userdata" and player or game.get_player(player) - if not skip_checks and not valid_player then return end - - -- Convert the roles into a table (allows for optional array) - if type(roles) ~= "table" or roles.name then - roles = { roles } - end - - -- Convert to role objects - local role_objects = {} - for _, role in ipairs(roles) do - local role_object = Roles.get_role_from_any(role) - if role_object then table.insert(role_objects, role_object) end - end - - -- If the player has a role that needs to defer the role changes, save the roles that need to be assigned later into a table - if valid_player and Roles.player_has_flag(valid_player, "defer_role_changes") then - local assign_later = Roles.config.deferred_roles[valid_player.name] or {} - for _, role in ipairs(role_objects) do - local role_change = assign_later[role.name] - if role_change then - role_change.count = role_change.count + 1 - if role_change.count == 1 then - role_change.by_player_name = by_player_name or "" - role_change.silent = silent - end - else - assign_later[role.name] = { - count = 1, by_player_name = by_player_name or "", silent = silent, - } - end - end - - Roles.config.deferred_roles[valid_player.name] = assign_later - return - end - - for _, role in ipairs(role_objects) do - role:add_player(valid_player or player, valid_player == nil, true) - end - - if valid_player then - emit_player_roles_updated(valid_player, "assign", role_objects, by_player_name, silent) - end -end - ---[[-- Removes a player from the given role(s) with an option to pass a by player name used in the log -@tparam LuaPlayer player the player that will have the roles removed -@tparam table roles table a of roles to be removed from the player, can be one role and can be role names -@tparam[opt=] string by_player_name the name of the player that will be shown in the logs -@tparam[opt=false] boolean skip_checks when true there will be no checks are done for if the player is valid -@tparam[opt=false] boolean silent when true there will be no game message printed - -@usage-- Unassign a player from the Moderator role -Roles.unassign_player(game.player, 'Moderator') - -@usage-- Unassign a player from the Moderator role, even if the player has never been on the map -Roles.unassign_player('Cooldude2606', 'Moderator', nil, true) - -]] -function Roles.unassign_player(player, roles, by_player_name, skip_checks, silent) - local valid_player = type(player) == "userdata" and player or game.get_player(player) - if not skip_checks and not valid_player then return end - if not player then return end - - -- Convert the roles into a table (allows for optional array) - if type(roles) ~= "table" or roles.name then - roles = { roles } - end - - -- Convert to role objects - local role_objects = {} - for _, role in ipairs(roles) do - local role_object = Roles.get_role_from_any(role) - if role_object then table.insert(role_objects, role_object) end - end - - -- If the player has a role that needs to defer the role changes, save the roles that need to be unassigned later into a table - local defer_changes = Roles.player_has_flag(player, "defer_role_changes") - if defer_changes and valid_player then - local assign_later = Roles.config.deferred_roles[valid_player.name] or {} - for _, role in ipairs(role_objects) do - local role_change = assign_later[role.name] - if role_change then - role_change.count = role_change.count - 1 - if role_change.count == -1 then - role_change.by_player_name = by_player_name or "" - role_change.silent = silent - end - else - assign_later[role.name] = { - count = -1, by_player_name = by_player_name or "", silent = silent, - } - end - end - - Roles.config.deferred_roles[valid_player.name] = assign_later - end - - -- Remove the player from roles - local role_changes = {} - for _, role in ipairs(role_objects) do - if not defer_changes or role:has_flag("defer_role_changes") then - role:remove_player(valid_player or player, valid_player == nil, true) - table.insert(role_changes, role) - end - end - - -- If there are deferred role changes, apply them now - if defer_changes and not Roles.player_has_flag(valid_player, "defer_role_changes") then - local assign_later = Roles.config.deferred_roles[player.name] or {} - local assigns, unassigns = {}, {} - for role_name, details in pairs(assign_later) do - local role = Roles.get_role_from_any(role_name) - if role then - if details.count > 0 then - role:add_player(valid_player or player, valid_player == nil, true) - if not assigns[details.by_player_name] then assigns[details.by_player_name] = {} end - if not details.silent then table.insert(assigns[details.by_player_name], role) end - elseif details.count < 0 then - role:remove_player(valid_player or player, valid_player == nil, true) - if not unassigns[details.by_player_name] then unassigns[details.by_player_name] = {} end - if not details.silent then table.insert(unassigns[details.by_player_name], role) end - end - end - end - - for assign_by_player_name, assign_roles in pairs(assigns) do - if #assign_roles > 0 then emit_player_roles_updated(valid_player, "assign", assign_roles, assign_by_player_name) end - end - - for unassign_by_player_name, unassign_roles in pairs(unassigns) do - if #unassign_roles > 0 then emit_player_roles_updated(valid_player, "unassign", unassign_roles, unassign_by_player_name) end - end - - Roles.config.deferred_roles[player.name] = nil - end - - if valid_player and #role_changes > 0 then - emit_player_roles_updated(valid_player, "unassign", role_changes, by_player_name, silent) - end -end - ---[[-- Overrides all player roles with the given table of roles, useful to mass set roles on game start -@tparam[opt] string player_name the player to set the roles for, if not given all roles are overriden -@tparam table roles table a which is indexed by case sensitive player names and has the value of a table of role names - -@usage-- Override the roles of a single player, other users are not effected -Roles.override_player_roles('Cooldude2606', {'Moderator'}) - -@usage-- Override all existing roles, effects all users not just ones listed -Roles.override_player_roles{ - ['Cooldude2606'] = {'Administrator', 'Moderator'}, - ['arty714'] = {'Administrator', 'Moderator'}, -} - -]] -function Roles.override_player_roles(player_name, roles) - local player_roles = Roles.config.players - if not roles then - for k in pairs(player_roles) do player_roles[k] = nil end - - for k, new_roles in pairs(player_name) do player_roles[k] = new_roles end - else - Roles.config.players[player_name] = roles - end -end - ---- Checks. --- Functions for checking player's roles --- @section checks - ---[[-- A test for weather a player has the given role -@tparam LuaPlayer player the player to test the roles of -@tparam ?string|number|table search_role a pointer to the role that is being searched for -@treturn boolean true if the player has the role, false otherwise, nil for errors - -@usage-- Test if a player has a role -local has_role = Roles.player_has_role(game.player, 'Moderator') - -]] -function Roles.player_has_role(player, search_role) - local roles = Roles.get_player_roles(player) - if not roles then return end - search_role = Roles.get_role_from_any(search_role) - if not search_role then return end - for _, role in ipairs(roles) do - if role.name == search_role.name then return true end - end - - return false -end - ---[[-- A test for weather a player has the given flag true for at least one of they roles -@tparam LuaPlayer player the player to test the roles of -@tparam string flag_name the name of the flag that is being looked for -@treturn boolean true if the player has at least one role which has the flag set to true, false otherwise, nil for errors - -@usage-- Test if a player has a role -local has_flag = Roles.player_has_flag(game.player, 'is_donator') - -]] -function Roles.player_has_flag(player, flag_name) - local roles = Roles.get_player_roles(player) - if not roles then return end - for _, role in ipairs(roles) do - if role:has_flag(flag_name) then - return true - end - end - - return false -end - ---[[-- A test for weather a player has at least one role which is allowed the given action -@tparam LuaPlayer player the player to test the roles of -@tparam string action the name of the action that is being tested for -@treturn boolean true if the player has at least one role which is allowed this action, false otherwise, nil for errors - -@usage-- Test if a player has a role -local has_flag = Roles.player_has_flag(game.player, 'is_donator') - -]] -function Roles.player_allowed(player, action) - local roles = Roles.get_player_roles(player) - for _, role in ipairs(roles) do - if role:is_allowed(action) then - return true - end - end - - return false -end - ---- Definitions. --- Functions which are used to define roles --- @section checks - ---[[-- Used to set the role order, higher in the list is better, must be called at least once in config --- nb: function also re links parents due to expected position in the config file -@tparam table order table a which is keyed only by numbers (start 1) and values are roles in order with highest first - -@usage-- Define which roles are higher than others -Roles.define_role_order{ - 'System', - 'Administrator', - 'Moderator', - 'Donator', - 'Guest' -} - -]] -function Roles.define_role_order(order) - -- Clears and then rebuilds the order table - ExpUtil.assert_not_runtime() - Roles.config.order = {} - local done = {} - for index, role in ipairs(order) do - if type(role) == "table" and role.name then - done[role.name] = true - Roles.config.order[index] = role.name - else - done[role] = true - Roles.config.order[index] = role - end - end - - -- Check no roles were missed - for role_name in pairs(Roles.config.roles) do - if not done[role_name] then - error("Role missing " .. role_name .. " from role order, all defined roles must be included.", 2) - end - end - - -- Re-links roles to they parents as this is called at the end of the config - for index, role_name in pairs(Roles.config.order) do - local role = Roles.config.roles[role_name] - if not role then - error("Role with name " .. role_name .. " has not beed defined, either define it or remove it from the order list.", 2) - end - role.index = index - local parent = Roles.config.roles[role.parent] - if parent then - setmetatable(role.allowed_actions, { __index = parent.allowed_actions }) - end - end -end - ---[[-- Defines a new trigger for when a tag is added or removed from a player -@tparam string name the name of the flag which the roles will have -@tparam function callback the function that is called when roles are assigned - -@usage-- Defining a flag trigger -Roles.define_flag_trigger('is_donator', function(player, state) - player.character_running_speed_modifier = state and 1.5 or 1 -end) - -]] -function Roles.define_flag_trigger(name, callback) - Roles.config.flags[name] = Async.register(callback) -end - ---[[-- Sets the default role which every player will have, this needs to be called at least once -@tparam string name the name of the default role - -@usage-- Setting the default role -Roles.set_default('Guest') - -]] -function Roles.set_default(name) - local role = Roles.config.roles[name] - if not role then return end - Roles.config.internal.default = name -end - ---[[-- Sets the root role which will always have all permissions, any server actions act from this role -@tparam string name the name of the root role - -@usage-- Setting the root role -Roles.set_root('System') - -]] -function Roles.set_root(name) - local role = Roles.config.roles[name] - if not role then return end - role:set_allow_all(true) - Roles.config.internal.root = name -end - ---[[-- Defines a new role and returns the prototype to allow configuration -@tparam string name the name of the new role, must be unique -@tparam[opt=name] string short_hand the shortened version of the name -@treturn Roles._prototype the start of the config chain for this role - -@usage-- Defining a new role -local role = Roles.new_role('Moderator', 'Mod') - -]] -function Roles.new_role(name, short_hand) - ExpUtil.assert_not_runtime() - if Roles.config.roles[name] then error("Role name is non unique") end - local role = setmetatable({ - name = name, - short_hand = short_hand or name, - allowed_actions = {}, - allow_all_actions = false, - flags = {}, - }, { __index = Roles._prototype }) - Roles.config.roles[name] = role - return role -end - ---- Role Actions. --- Functions for using the role action system --- @section actions - ---[[-- Sets the default allow state of the role, true will allow all actions -@tparam[opt=true] boolean state true will allow all actions -@treturn Roles._prototype allows chaining - -@usage-- Allow all actions for this role, useful for root like roles -role:set_allow_all() - -]] -function Roles._prototype:set_allow_all(state) - if state == nil then state = true end - self.allow_all_actions = not not state -- not not forces a boolean value - return self -end - ---[[-- Sets the allow actions for this role, actions in this list will be allowed for this role -@tparam table actions indexed with numbers and is an array of action names, order has no effect -@treturn Roles._prototype allows chaining - -@usage-- Allow some actions for a role -role:allow{ - 'command/kill', - 'gui/game settings' -} - -]] -function Roles._prototype:allow(actions) - if type(actions) ~= "table" then - actions = { actions } - end - for _, action in ipairs(actions) do - self.allowed_actions[action] = true - end - - return self -end - ---[[-- Sets the disallow actions for this role, will prevent actions from being allowed regardless of inheritance -@tparam table actions indexed with numbers and is an array of action names, order has no effect -@treturn Roles._prototype allows chaining - -@usage-- Disallow an action for a role, useful if inherit an action from a parent -role:disallow{ - 'command/kill', - 'gui/game settings' -} - -]] -function Roles._prototype:disallow(actions) - if type(actions) ~= "table" then - actions = { actions } - end - for _, action in ipairs(actions) do - self.allowed_actions[action] = false - end - - return self -end - ---[[-- Test for if a role is allowed the given action, mostly internal see Roles.player_allowed -@tparam string action the name of the action to test if it is allowed -@treturn boolean true if action is allowed, false otherwise - -@usage-- Test if a role is allowed an action -local allowed = role:is_allowed('command/kill') - -]] -function Roles._prototype:is_allowed(action) - local is_root = Roles.config.internal.root == self.name - return self.allowed_actions[action] or self.allow_all_actions or is_root -end - ---- Role Flags. --- Functions for using the role flag system --- @section flags - ---[[-- Sets the state of a flag for a role, flags can be used to apply effects to players -@tparam string name the name of the flag to set the value of -@tparam[opt=true] boolean value the state to set the flag to -@treturn Roles._prototype allows chaining - -@usage-- Set a flag for a role -role:set_flag('is_admin') - -]] -function Roles._prototype:set_flag(name, value) - if value == nil then value = true end - self.flags[name] = not not value -- not not forces a boolean value - return self -end - ---[[-- Clears all flags from this role, individual flags can be removed with set_flag(name, false) -@treturn Roles._prototype allows chaining - -@usage-- Remove all flags from a role -role:clear_flags() - -]] -function Roles._prototype:clear_flags() - self.flags = {} - return self -end - ---[[-- A test for if the role has a flag set -@tparam string name the name of the flag to test for -@treturn boolean true if the flag is set, false otherwise - -@usage-- Test if a role has a flag -local has_flag = role:has_flag('is_admin') - -]] -function Roles._prototype:has_flag(name) - return self.flags[name] or false -end - ---- Role Properties. --- Functions for changing other properties --- @section properties - ---[[-- Sets a custom player tag for the role, can be accessed by other code -@tparam string tag the value that the tag will be -@treturn Roles._prototype allows chaining - -@usage-- Set a custom tag for this role, other code is required to set the tag -role:set_custom_tag('Mod') - -]] -function Roles._prototype:set_custom_tag(tag) - self.custom_tag = tag - return self -end - ---[[-- Sets a custom colour for the role, can be accessed by other code -@tparam table color ?string|table can either be and rgb colour or the name of a colour defined in the presets -@treturn Roles._prototype allows chaining - -@usage-- Set a custom colour for this role, other code is required to use this value -role:set_custom_color{ r=255, g=100, b=100} - -]] -function Roles._prototype:set_custom_color(color) - if type(color) ~= "table" then - color = Colours[color] - end - self.custom_color = color - return self -end - ---[[-- Sets the permission group for this role, players will be moved to the group of they highest role -@tparam string name the name of the permission group to have players moved to -@tparam[opt=false] boolean use_factorio_api when true the custom permission group module is ignored -@treturn Roles._prototype allows chaining - -@usage-- Set the permission group for this role, see permission_groups.lua -role:set_permission_group('Admin') - -]] -function Roles._prototype:set_permission_group(name, use_factorio_api) - ExpUtil.assert_not_runtime() - if use_factorio_api then - self.permission_group = { true, name } --[[@as [boolean, string] ]] - else - assert(Groups.get_group_by_name(name), "Permission group not found: " .. name) - self.permission_group = name - end - return self -end - ---[[-- Sets the parent for a role, any action not in allow or disallow will be looked for in its parents -nb: this is a recursive action, and changing the allows and disallows will effect all children roles -@tparam string role the name of the role that will be the parent; has imminent effect if role is already defined -@treturn Roles._prototype allows chaining - -@usage-- Set the parent for this role to inherit all actions allowed -role:set_parent('Guest') - -]] -function Roles._prototype:set_parent(role) - ExpUtil.assert_not_runtime() - self.parent = role - role = Roles.get_role_from_any(role) - if not role then return self end - setmetatable(self.allowed_actions, { __index = role.allowed_actions }) - return self -end - ---[[-- Sets an auto assign condition that is checked every 60 seconds, if true is returned then the player will receive the role -nb: this is one way, failing false after already gaining the role will not revoke the role -@tparam function callback receives only one param which is player to assign, return true to assign the player -@treturn Roles._prototype allows chaining - -@usage-- Give this role to a user if there are admin, ran every 60 seconds -role:set_auto_assign_condition(function(player) - return player.admin -end) - -]] -function Roles._prototype:set_auto_assign_condition(callback) - ExpUtil.assert_not_runtime() - self.auto_assign_condition = true - Roles.config.auto_assign[self.name] = callback - return self -end - ---[[-- Get the auto assign condition for this role, returns nil if no condition is set -@treturn function The callback which was assigned as the auto assign condition - -@usage-- Give this role to a user if there are admin, ran every 60 seconds -local condition = role:get_auto_assign_condition() - -]] -function Roles._prototype:get_auto_assign_condition() - return Roles.config.auto_assign[self.name] -end - ---[[-- Sets the role to not allow players to have auto assign effect them, useful to keep people locked to a role -@tparam[opt=true] boolean state when true the players with this role will not be auto assigned to other roles -@treturn Roles._prototype allows chaining - -@usage-- Make a role stop players from being auto assigned to other roles -role:set_block_auto_assign() - -]] -function Roles._prototype:set_block_auto_assign(state) - if state == nil then state = true end - self.block_auto_assign = not not state -- forces a boolean value - return self -end - ---- Role Players. --- Functions that control players in a role --- @section players - ---[[-- Adds a player to this role, players can have more than one role at a time, used internally see Roles.assign -@tparam LuaPlayer player the player that will be given this role -@tparam boolean skip_check when true player will be taken as the player name (use when player has not yet joined) -@tparam boolean skip_event when true the event emit will be skipped, this is used internally with Roles.assign -@treturn boolean true if the player was added successfully - -@usage-- Assign a player to this role -role:add_player(game.player) - -]] -function Roles._prototype:add_player(player, skip_check, skip_event) - local valid_player = type(player) == "userdata" and player or game.get_player(player) - -- Default role cant have players added or removed - if self.name == Roles.config.internal.default then return end - -- Check the player is valid, can be skipped but a name must be given - local player_name - if valid_player then - player_name = valid_player.name - elseif skip_check then - player_name = player - else - return false - end - -- Add the role name to the player's roles - local player_roles = Roles.config.players[player_name] - if player_roles then - for _, role_name in ipairs(player_roles) do - if role_name == self.name then return false end - end - - player_roles[#player_roles + 1] = self.name - else - Roles.config.players[player_name] = { self.name } - end - -- Emits event if required - if valid_player and not skip_event then - emit_player_roles_updated(valid_player, "assign", { self }) - end - return true -end - ---[[-- Removes a player from this role, players can have more than one role at a time, used internally see Roles.unassign -@tparam LuaPlayer player the player that will lose this role -@tparam boolean skip_check when true player will be taken as the player name (use when player has not yet joined) -@tparam boolean skip_event when true the event emit will be skipped, this is used internally with Roles.unassign -@treturn boolean true if the player was removed successfully - -@usage-- Unassign a player from this role -role:remove_player(game.player) - -]] -function Roles._prototype:remove_player(player, skip_check, skip_event) - local valid_player = type(player) == "userdata" and player or game.get_player(player) - -- Default role cant have players added or removed - if self.name == Roles.config.internal.default then return end - -- Check the player is valid, can be skipped but a name must be given - local player_name - if valid_player then - player_name = valid_player.name - elseif skip_check then - player_name = player - else - return false - end - -- Remove the role from the players roles - local player_roles = Roles.config.players[player_name] - local found = false - if player_roles then - for index, role_name in ipairs(player_roles) do - if role_name == self.name then - player_roles[index] = player_roles[#player_roles] - player_roles[#player_roles] = nil - found = true - break - end - end - - if #player_roles == 0 then - Roles.config.players[player_name] = nil - end - end - -- Emits event if required - if valid_player and not skip_event then - emit_player_roles_updated(valid_player, "unassign", { self }) - end - return found -end - ---[[-- Returns an array of all the players who have this role, can be filtered by online status -@tparam[opt=nil] boolean online when given will filter by this online state, nil will return all players -@treturn table all the players who have this role, indexed order is meaningless - -@usage-- Get all the players with this role -local players = role:get_players() - -@usage-- Get all online players with this role -local players = role:get_players(true) - -]] -function Roles._prototype:get_players(online) - local players = {} - -- Search all players to check if they have this role - for player_name, player_roles in pairs(Roles.config.players) do - for _, role_name in ipairs(player_roles) do - if role_name == self.name then - local player = game.players[player_name] - -- Filter by online state if required - if player and (online == nil or player.connected == online) then - players[#players + 1] = player - end - break - end - end - end - - return players -end - ---[[-- Will print a message to all players with this role -@tparam string message the message that will be printed to the players -@treturn number the number of players who received the message - -@usage-- Print a message to all players with this role -role:print('Hello, World!') - -]] -function Roles._prototype:print(message) - local players = self:get_players(true) - for _, player in ipairs(players) do - player.print(message) - end - - return #players -end - ---- Used internally to be the first trigger on an event change, would be messy to include this in 4 different places -local function role_update(event) - local player = game.players[event.player_index] - -- Updates flags given to the player - for flag, async_function in pairs(Roles.config.flags) do - local state = Roles.player_has_flag(player, flag) - async_function(player, state) - end - - -- Updates the players permission group - local highest = Roles.get_player_highest_role(player) - if highest.permission_group then - local permission_group = highest.permission_group --[[@as [boolean, string] ]] - if permission_group[1] then - local group = game.permissions.get_group(permission_group[2]) - if group then - Groups.add_to_permission_group_async(group, player) - end - else - Groups.set_player_group(player, highest.permission_group) - end - end -end - ---- Used internally to test if a player should be auto assigned a role -local function auto_assign(event) - local player = game.players[event.player_index] - local roles = Roles.config.players[player.name] or {} - - local lookup = {} - -- Create a lookup table for existing roles, and check for block auto assign - for _, role in ipairs(roles) do - if role.block_auto_assign then return end - lookup[role] = true - end - - local assigns, ctn = {}, 0 - -- Check roles with auto assign conditions and run the handler - for role, condition in pairs(Roles.config.auto_assign) do - if not lookup[role] then - local success, rtn = pcall(condition, player) - if not success then - log{ "expcore-roles.error-log-format-assign", role.name, rtn } - elseif rtn == true then - ctn = ctn + 1 - assigns[ctn] = role - end - end - end - - -- Assign new roles if any were passed - if ctn > 0 then Roles.assign_player(player, assigns) end -end - ---- When a player joined or has a role change then the update is triggered -Event.add(Roles.events.on_role_assigned, role_update) -Event.add(Roles.events.on_role_unassigned, role_update) -Event.add(defines.events.on_player_joined_game, role_update) - ---- Every 60 seconds and on join auto role assignment is checked -Event.add(defines.events.on_player_joined_game, auto_assign) -Event.on_nth_tick(3600, function() - for _, player in ipairs(game.connected_players) do - auto_assign{ player_index = player.index } - end -end) - --- Return Roles -return Roles diff --git a/exp_legacy/module/locale/en/expcore.cfg b/exp_legacy/module/locale/en/expcore.cfg index 0225b9f8..ed40d1ca 100644 --- a/exp_legacy/module/locale/en/expcore.cfg +++ b/exp_legacy/module/locale/en/expcore.cfg @@ -1,11 +1,3 @@ -[expcore-roles] -error-log-format-flag=[ERROR] roleFlag/__1__ :: __2__ -error-log-format-assign=[ERROR] rolePromote/__1__ :: __2__ -game-message-assign=__1__ has been assigned to __2__ by __3__ -game-message-unassign=__1__ has been unassigned from __2__ by __3__ -reject-role=Invalid Role Name. -reject-player-role=Player has a higher role. - [expcore-data] description-preference=Allows you to set/get your data saving preference. description-data=Writes all your player data to a file on your computer. diff --git a/exp_legacy/module/locale/zh-CN/expcore.cfg b/exp_legacy/module/locale/zh-CN/expcore.cfg index 5f4c5f12..01466073 100644 --- a/exp_legacy/module/locale/zh-CN/expcore.cfg +++ b/exp_legacy/module/locale/zh-CN/expcore.cfg @@ -1,11 +1,3 @@ -[expcore-roles] -error-log-format-flag=[ERROR] roleFlag/__1__ :: __2__ -error-log-format-assign=[ERROR] rolePromote/__1__ :: __2__ -game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ -game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ -reject-role=無效的身分組。 -reject-player-role=用戶已有更高的身分組。 - [expcore-data] description-preference=允許寫入資料。 description-data=把用戶資料寫入電腦 diff --git a/exp_legacy/module/locale/zh-TW/expcore.cfg b/exp_legacy/module/locale/zh-TW/expcore.cfg index 5f4c5f12..01466073 100644 --- a/exp_legacy/module/locale/zh-TW/expcore.cfg +++ b/exp_legacy/module/locale/zh-TW/expcore.cfg @@ -1,11 +1,3 @@ -[expcore-roles] -error-log-format-flag=[ERROR] roleFlag/__1__ :: __2__ -error-log-format-assign=[ERROR] rolePromote/__1__ :: __2__ -game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ -game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ -reject-role=無效的身分組。 -reject-player-role=用戶已有更高的身分組。 - [expcore-data] description-preference=允許寫入資料。 description-data=把用戶資料寫入電腦 diff --git a/exp_legacy/module/module.json b/exp_legacy/module/module.json index 2a2ec59e..bf21f097 100644 --- a/exp_legacy/module/module.json +++ b/exp_legacy/module/module.json @@ -8,6 +8,7 @@ "dependencies": { "clusterio": "*", "exp_util": "*", + "exp_roles": "*", "exp_gui": "*" } } diff --git a/exp_legacy/module/modules/control/jail.lua b/exp_legacy/module/modules/control/jail.lua index a60f7ab4..3409619b 100644 --- a/exp_legacy/module/modules/control/jail.lua +++ b/exp_legacy/module/modules/control/jail.lua @@ -7,25 +7,23 @@ -- 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 which is given to jailed players, it has a higher priority than every other role +local jail_role = "Jail" local Jail = { - old_roles = {}, events = { --- When a player is assigned to jail -- @event on_player_jailed @@ -64,10 +62,10 @@ 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") + return Roles.player_has_role(valid_player(player), jail_role) 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 +77,7 @@ 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) + if Roles.player_has_role(player, jail_role) 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 +86,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) + Roles.assign_player(player, jail_role, by_player_name, 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 +102,9 @@ 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 + if not Roles.player_has_role(player, jail_role) then return end - unassign_roles(player, "Jail", by_player_name, nil, true) + Roles.unassign_player(player, jail_role, by_player_name, true) event_emit(Jail.events.on_player_unjailed, player, by_player_name) diff --git a/exp_legacy/module/modules/control/protection.lua b/exp_legacy/module/modules/control/protection.lua index 506d889d..b0c35a0b 100644 --- a/exp_legacy/module/modules/control/protection.lua +++ b/exp_legacy/module/modules/control/protection.lua @@ -33,11 +33,7 @@ for _, config_key in ipairs{ "always_protected_names", "always_protected_types", end end --- Require roles if a permission is assigned in the config -local Roles --- @type table -if config.ignore_permission then - Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles -end +local Roles = require("modules/exp_roles") ----- Storage Variables ----- --- Variables stored in the global table @@ -159,7 +155,7 @@ Event.add(defines.events.on_pre_player_mined_item, function(event) -- Check if the player should be ignored if config.ignore_admins and player.admin then return end if entity.last_user == nil or entity.last_user.index == player.index then return end - if config.ignore_permission and Roles.player_allowed(player, config.ignore_permission) then return end + if config.ignore_permission and Roles.player_has_permission(player, config.ignore_permission) then return end -- Check if the entity is protected if EntityProtection.is_entity_protected(entity) diff --git a/exp_legacy/module/modules/data/greetings.lua b/exp_legacy/module/modules/data/greetings.lua index e8eade90..d4b159c2 100644 --- a/exp_legacy/module/modules/data/greetings.lua +++ b/exp_legacy/module/modules/data/greetings.lua @@ -8,7 +8,7 @@ local Commands = require("modules/exp_commands") local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data local CustomMessages = PlayerData.Settings:combine("JoinMessage") CustomMessages:set_metadata{ - permission = "command/join-message", + permission = "exp_scenario.command.set_join_message", } --- When a players data loads show their message diff --git a/exp_legacy/module/modules/data/quickbar.lua b/exp_legacy/module/modules/data/quickbar.lua index 52f8a4be..8550cae9 100644 --- a/exp_legacy/module/modules/data/quickbar.lua +++ b/exp_legacy/module/modules/data/quickbar.lua @@ -10,7 +10,7 @@ local config = require("modules.exp_legacy.config.preset_player_quickbar") --- @ local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data local PlayerFilters = PlayerData.Settings:combine("QuickbarFilters") PlayerFilters:set_metadata{ - permission = "command/save-quickbar", + permission = "exp_scenario.command.save_quickbar", stringify = function(value) if not value then return "No filters set" end local count = 0 diff --git a/exp_legacy/module/modules/data/tag.lua b/exp_legacy/module/modules/data/tag.lua index 89329ad0..e12e4229 100644 --- a/exp_legacy/module/modules/data/tag.lua +++ b/exp_legacy/module/modules/data/tag.lua @@ -4,17 +4,17 @@ ]] local Commands = require("modules/exp_commands") -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles +local Roles = require("modules/exp_roles") --- Stores the tag for a player local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data local PlayerTags = PlayerData.Settings:combine("Tag") local PlayerTagColors = PlayerData.Settings:combine("TagColor") PlayerTags:set_metadata{ - permission = "command/tag", + permission = "exp_scenario.command.tag", } PlayerTagColors:set_metadata{ - permission = "command/tag-color", + permission = "exp_scenario.command.tag_color", } local set_tag = function(player, tag, color) @@ -72,7 +72,7 @@ Commands.new("tag-clear", "Clears your tag. Or another player if you are admin." if other_player == player then -- No player given so removes your tag PlayerTags:remove(other_player) - elseif Roles.player_allowed(player, "command/clear-tag/always") then + elseif Roles.player_has_permission(player, "exp_scenario.command.tag_clear.always") then -- Player given and user is admin so clears that player's tag PlayerTags:remove(other_player) else diff --git a/exp_legacy/module/modules/gui/_role_updates.lua b/exp_legacy/module/modules/gui/_role_updates.lua deleted file mode 100644 index ac171b49..00000000 --- a/exp_legacy/module/modules/gui/_role_updates.lua +++ /dev/null @@ -1,7 +0,0 @@ -local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") -local Event = require("modules/exp_legacy/utils/event") - ---- @diagnostic disable: access-invisible -Event.add(Roles.events.on_role_assigned, Gui._ensure_consistency) -Event.add(Roles.events.on_role_unassigned, Gui._ensure_consistency) diff --git a/exp_legacy/module/modules/gui/vlayer.lua b/exp_legacy/module/modules/gui/vlayer.lua index 727f9b9d..5cd33356 100644 --- a/exp_legacy/module/modules/gui/vlayer.lua +++ b/exp_legacy/module/modules/gui/vlayer.lua @@ -5,7 +5,7 @@ ]] local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles +local Roles = require("modules/exp_roles") local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event local format_number = require("util").format_number --- @dep util local config = require("modules.exp_legacy.config.vlayer") --- @dep config.vlayer @@ -457,7 +457,7 @@ local vlayer_control_set = Gui.define("vlayer_control_set") vlayer_gui_control_see(disp) local b = vlayer_gui_control_build(disp) local r = vlayer_gui_control_remove(disp) - local v = Roles.player_allowed(player, "gui/vlayer-edit") + local v = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit") b.visible = v r.visible = v @@ -484,22 +484,21 @@ Gui.toolbar.create_button{ sprite = "entity/solar-panel", tooltip = { "vlayer.main-tooltip" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/vlayer") + return Roles.player_has_permission(player, "exp_scenario.gui.vlayer") end } --- Update the visibly of the buttons based on a players roles local function role_update_event(event) local player = game.players[event.player_index] - local visible = Roles.player_allowed(player, "gui/vlayer-edit") + local visible = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit") local container = Gui.get_left_element(vlayer_container, player) local disp = container.frame["vlayer_st_2"].disp.table disp[vlayer_gui_control_build.name].visible = visible disp[vlayer_gui_control_remove.name].visible = visible end -Event.add(Roles.events.on_role_assigned, role_update_event) -Event.add(Roles.events.on_role_unassigned, role_update_event) +Event.add(Roles.events.on_player_roles_changed, role_update_event) Event.on_nth_tick(config.update_tick_gui, function(_) local stats = vlayer.get_statistics() diff --git a/exp_legacy/module/modules/gui/warp-list.lua b/exp_legacy/module/modules/gui/warp-list.lua index 15359dbc..5c456558 100644 --- a/exp_legacy/module/modules/gui/warp-list.lua +++ b/exp_legacy/module/modules/gui/warp-list.lua @@ -9,7 +9,7 @@ local Gui = require("modules/exp_gui") local Datastore = require("modules.exp_legacy.expcore.datastore") --- @dep expcore.datastore local Storage = require("modules/exp_util/storage") local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles +local Roles = require("modules/exp_roles") local Colors = require("modules/exp_util/include/color") local config = require("modules.exp_legacy.config.gui.warps") --- @dep config.gui.warps local Warps = require("modules.exp_legacy.modules.control.warps") --- @dep modules.control.warps @@ -68,8 +68,8 @@ local function check_player_permissions(player, action, warp) return true elseif action_config == "admin" then return player.admin - elseif action_config == "expcore.roles" then - return Roles.player_allowed(player, config["expcore_roles_" .. action]) + elseif action_config == "exp_roles" then + return Roles.player_has_permission(player, config["exp_roles_" .. action]) end -- Return false as all other conditions have not been met @@ -708,7 +708,7 @@ Gui.toolbar.create_button{ sprite = config.default_icon.type .. "/" .. config.default_icon.name, tooltip = { "warp-list.main-tooltip" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/warp-list") + return Roles.player_has_permission(player, "exp_scenario.gui.warp_list") end }:on_click(function(def, player, element) -- Set gui keep open state for player that clicked the button: true if visible, false if invisible @@ -885,8 +885,7 @@ local function role_update_event(event) add_new_warp_element.visible = allow_add_warp end -Event.add(Roles.events.on_role_assigned, role_update_event) -Event.add(Roles.events.on_role_unassigned, role_update_event) +Event.add(Roles.events.on_player_roles_changed, role_update_event) --- When a chart tag is removed or edited make sure it is not one that belongs to a warp local function maintain_tag(event) diff --git a/exp_scenario/module/commands/_authorities.lua b/exp_scenario/module/commands/_authorities.lua index dc6f9383..41e41cf9 100644 --- a/exp_scenario/module/commands/_authorities.lua +++ b/exp_scenario/module/commands/_authorities.lua @@ -1,26 +1,28 @@ --[[-- Command Authorities - Roles -Adds a permission authority for exp roles +Adds a permission authority which checks the clusterio permission for a command ]] local Commands = require("modules/exp_commands") local add, allow, deny = Commands.add_permission_authority, Commands.status.success, Commands.status.unauthorised -local Roles = require("modules/exp_legacy/expcore/roles") -local player_allowed = Roles.player_allowed +local Roles = require("modules/exp_roles") +local player_has_permission = Roles.player_has_permission local authorities = {} ---- If a command has the flag "character_only" then the command can only be used outside of remote view +--- Every command requires the permission `exp_scenario.command.`, with +--- hyphens replaced by underscores, see permissions.ts for their definitions authorities.exp_permission = add(function(player, command) - if not player_allowed(player, command.flags.exp_permission or ("command/" .. command.name)) then + local permission = "exp_scenario.command." .. command.name:gsub("%-", "_") + if not player_has_permission(player, permission) then return deny{ "exp-commands-authorities_role.deny" } else return allow() end end) -Roles.define_flag_trigger("is_system", function(player, state) +Roles.define_permission_trigger("exp_scenario.player.system_commands", function(player, state) if state then Commands.unlock_system_commands(player.name) else diff --git a/exp_scenario/module/commands/_rcon.lua b/exp_scenario/module/commands/_rcon.lua index 7ce7ed0d..ef3febbc 100644 --- a/exp_scenario/module/commands/_rcon.lua +++ b/exp_scenario/module/commands/_rcon.lua @@ -8,6 +8,6 @@ local add_static, add_dynamic = Commands.add_rcon_static, Commands.add_rcon_dyna add_static("Gui", require("modules/exp_gui")) add_static("Group", require("modules.exp_legacy.expcore.permission_groups")) -add_static("Roles", require("modules.exp_legacy.expcore.roles")) +add_static("Roles", require("modules/exp_roles")) add_static("Datastore", require("modules.exp_legacy.expcore.datastore")) add_static("External", require("modules.exp_legacy.expcore.external")) diff --git a/exp_scenario/module/commands/_types.lua b/exp_scenario/module/commands/_types.lua index f23ff943..4c18de0f 100644 --- a/exp_scenario/module/commands/_types.lua +++ b/exp_scenario/module/commands/_types.lua @@ -1,4 +1,3 @@ - --[[-- Command Types - Roles The data types that are used with exp_roles A lower role index indicates it is more privileged @@ -11,28 +10,42 @@ 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 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_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(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 Roles.player_outranks_role(player, result) then return invalid{ "exp-commands-parse_role.lower-role" } else return valid(result) @@ -46,10 +59,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 +73,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 +87,7 @@ types.lower_role_player_alive = if not success then return status, result end --- @cast result LuaPlayer - local other_highest = highest_role(result) - local player_highest = highest_role(player) - local is_root = Roles.config.internal.root == player_highest.name - if not is_root and player_highest.index >= other_highest.index then + if not player_outranks(player, result) then return invalid{ "exp-commands-parse_role.lower-role-player" } else return valid(result) diff --git a/exp_scenario/module/commands/kill.lua b/exp_scenario/module/commands/kill.lua index a71528ed..9d655ff9 100644 --- a/exp_scenario/module/commands/kill.lua +++ b/exp_scenario/module/commands/kill.lua @@ -4,8 +4,8 @@ Adds a command that allows players to kill themselves and others local Commands = require("modules/exp_commands") -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles -local highest_role = Roles.get_player_highest_role +local Roles = require("modules/exp_roles") +local player_outranks = Roles.player_outranks --- Kills yourself or another player. Commands.new("kill", { "exp-commands_kill.description" }) @@ -20,7 +20,7 @@ Commands.new("kill", { "exp-commands_kill.description" }) if other_player == nil then -- Can only be nil if the target is the player and they are already dead return Commands.status.error{ "exp-commands_kill.already-dead" } - elseif (other_player == player) or (highest_role(player).index < highest_role(other_player).index) then + elseif other_player == player or player_outranks(player, other_player) then -- You can always kill yourself or can kill lower role players if script.active_mods["space-age"] then other_player.surface.create_entity{ name = "lightning", position = { other_player.position.x, other_player.position.y - 16 }, target = other_player.character } diff --git a/exp_scenario/module/commands/protected_entities.lua b/exp_scenario/module/commands/protected_entities.lua index 7f906413..9f810634 100644 --- a/exp_scenario/module/commands/protected_entities.lua +++ b/exp_scenario/module/commands/protected_entities.lua @@ -11,7 +11,7 @@ local expand_area = AABB.expand local Commands = require("modules/exp_commands") local format_player_name = Commands.format_player_name_locale -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles +local Roles = require("modules/exp_roles") local EntityProtection = require("modules.exp_legacy.modules.control.protection") --- @dep modules.control.protection local format_string = string.format diff --git a/exp_scenario/module/commands/reports.lua b/exp_scenario/module/commands/reports.lua index aab4351c..166d4b64 100644 --- a/exp_scenario/module/commands/reports.lua +++ b/exp_scenario/module/commands/reports.lua @@ -6,8 +6,8 @@ local Commands = require("modules/exp_commands") local format_player_name = Commands.format_player_name_locale local parse_input = Commands.parse_input -local Roles = require("modules.exp_legacy.expcore.roles") -local player_has_flag = Roles.player_has_flag +local Roles = require("modules/exp_roles") +local player_has_permission = Roles.player_has_permission local Reports = require("modules.exp_legacy.modules.control.reports") --- @dep modules.control.reports @@ -20,7 +20,7 @@ local function reportable_player(input, player) if not success then return status, result end --- @cast result LuaPlayer - if player_has_flag(input, "report-immune") then + if player_has_permission(input, "exp_scenario.bypass.reports") then return Commands.status.invalid_input{ "exp-commands_reports.player-immune" } elseif player == input then return Commands.status.invalid_input{ "exp-commands_reports.self-report" } diff --git a/exp_scenario/module/commands/roles.lua b/exp_scenario/module/commands/roles.lua index b8a510ad..b8231ebd 100644 --- a/exp_scenario/module/commands/roles.lua +++ b/exp_scenario/module/commands/roles.lua @@ -6,8 +6,8 @@ local Commands = require("modules/exp_commands") local format_player_name = Commands.format_player_name_locale local format_text = Commands.format_rich_text_color_locale -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles -local get_roles_ordered = Roles.get_roles_ordered +local Roles = require("modules/exp_roles") +local get_roles = Roles.get_roles local get_player_roles = Roles.get_player_roles --- Assigns a role to a player @@ -18,7 +18,7 @@ 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 + --- @cast role ExpRoles.Role Roles.assign_player(other_player, role, player.name) end) @@ -30,7 +30,7 @@ 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 + --- @cast role ExpRoles.Role Roles.unassign_player(other_player, role, player.name) end) @@ -40,7 +40,7 @@ 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_roles() local roles_formatted = { "" } --- @type LocalisedString local response = { "exp-commands_roles.list-roles", roles_formatted } --[[@as LocalisedString]] if other_player then @@ -50,7 +50,7 @@ Commands.new("get-roles", { "exp-commands_roles.description-get" }) end for index, role in ipairs(roles) do - local role_name = format_text(role.name, role.custom_color or Commands.color.white) + local role_name = format_text(role.name, role.color or Commands.color.white) roles_formatted[index + 1] = { "exp-commands_roles.list-element", role_name } end diff --git a/exp_scenario/module/commands/teleport.lua b/exp_scenario/module/commands/teleport.lua index a7892047..c4acf1f7 100644 --- a/exp_scenario/module/commands/teleport.lua +++ b/exp_scenario/module/commands/teleport.lua @@ -7,8 +7,8 @@ local teleport_player = ExpUtil.teleport_player local Commands = require("modules/exp_commands") -local Roles = require("modules.exp_legacy.expcore.roles") --- @dep expcore.roles -local player_allowed = Roles.player_allowed +local Roles = require("modules/exp_roles") +local player_has_permission = Roles.player_has_permission --- @class ExpCommands_Teleport.commands local commands = {} @@ -85,7 +85,7 @@ commands.spawn = Commands.new("spawn", { "exp-commands_teleport.description-spaw if not teleport_player(player, game.surfaces.nauvis, { 0, 0 }, "dismount") then return Commands.status.error{ "exp-commands_teleport.unavailable" } end - elseif player_allowed(player, "command/spawn/always") then + elseif player_has_permission(player, "exp_scenario.command.spawn.always") then if not teleport_player(other_player, game.surfaces.nauvis, { 0, 0 }, "dismount") then return Commands.status.error{ "exp-commands_teleport.unavailable" } end diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index 3b00dcde..2e04e552 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -63,6 +63,7 @@ add(require("modules/exp_scenario/control/pollution_grading")) add(require("modules/exp_scenario/control/protection_jail")) add(require("modules/exp_scenario/control/report_jail")) add(require("modules/exp_scenario/control/research")) +add(require("modules/exp_scenario/control/roles")) add(require("modules/exp_scenario/control/spawn_area")) add(require("modules/exp_scenario/control/station_auto_name")) diff --git a/exp_scenario/module/control/bonus.lua b/exp_scenario/module/control/bonus.lua index f8b532cb..8d0a192d 100644 --- a/exp_scenario/module/control/bonus.lua +++ b/exp_scenario/module/control/bonus.lua @@ -4,7 +4,7 @@ Various bonus related event handlers TODO Refactor this fully, this is temp to get it out of the player bonus gui file ]] -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/bonus") --- @param event EventData.on_force_created @@ -26,7 +26,7 @@ end --- @param event EventData.on_player_died local function fast_respawn(event) local player = assert(game.get_player(event.player_index)) - if Roles.player_has_flag(player, "instant-respawn") then + if Roles.player_has_permission(player, "exp_scenario.player.instant_respawn") then player.ticks_to_respawn = 120 end end diff --git a/exp_scenario/module/control/chat_auto_reply.lua b/exp_scenario/module/control/chat_auto_reply.lua index 8d5ccc8f..4e60ca40 100644 --- a/exp_scenario/module/control/chat_auto_reply.lua +++ b/exp_scenario/module/control/chat_auto_reply.lua @@ -2,7 +2,7 @@ Adds auto replies to chat messages, as well as chat commands ]] -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.chat_reply") local prefix = config.command_prefix local prefix_len = string.len(prefix) @@ -20,7 +20,7 @@ local function on_console_chat(event) -- Check if the player can chat commands local commands_allowed = true if config.command_admin_only and not player.admin then commands_allowed = false end - if config.command_permission and not Roles.player_allowed(player, config.command_permission) then commands_allowed = false end + if config.command_permission and not Roles.player_has_permission(player, config.command_permission) then commands_allowed = false end -- Check if a key word appears in the message for key_word, reply in pairs(config.messages) do diff --git a/exp_scenario/module/control/deconstruction_log.lua b/exp_scenario/module/control/deconstruction_log.lua index b6d2b05d..ddfeb979 100644 --- a/exp_scenario/module/control/deconstruction_log.lua +++ b/exp_scenario/module/control/deconstruction_log.lua @@ -3,7 +3,7 @@ Log certain actions into a file when events are triggered ]] local ExpUtil = require("modules/exp_util") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.deconlog") local seconds_time_format = ExpUtil.format_time_factory{ format = "short", hours = true, minutes = true, seconds = true } @@ -79,7 +79,7 @@ end local function get_log_player(event) local player = assert(game.get_player(event.player_index)) - if Roles.player_has_flag(player, "deconlog-bypass") then + if Roles.player_has_permission(player, "exp_scenario.bypass.deconstruction_log") then return nil end diff --git a/exp_scenario/module/control/fast_deconstruction.lua b/exp_scenario/module/control/fast_deconstruction.lua index acd83069..34544496 100644 --- a/exp_scenario/module/control/fast_deconstruction.lua +++ b/exp_scenario/module/control/fast_deconstruction.lua @@ -4,7 +4,7 @@ Makes trees which are marked for decon "decay" quickly to allow faster building local Gui = require("modules/exp_gui") local Async = require("modules/exp_util/async") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local PlayerData = require("modules.exp_legacy.expcore.player_data") local HasEnabledDecon = PlayerData.Settings:combine("HasEnabledDecon") @@ -57,9 +57,9 @@ local remove_trees_async = --- @param player LuaPlayer --- @return "fast" | "allow" | "disallow" local function get_permission(player) - if Roles.player_allowed(player, "fast-tree-decon") then + if Roles.player_has_permission(player, "exp_scenario.decon.fast_trees") then return HasEnabledDecon:get(player) and "fast" or "allow" - elseif Roles.player_allowed(player, "standard-decon") then + elseif Roles.player_has_permission(player, "exp_scenario.decon.standard") then return "allow" else return "disallow" @@ -101,7 +101,7 @@ Gui.toolbar.create_button{ tooltip = { "exp_fast-decon.tooltip-main" }, auto_toggle = true, visible = function(player, _) - return Roles.player_allowed(player, "fast-tree-decon") + return Roles.player_has_permission(player, "exp_scenario.decon.fast_trees") end }:on_click(function(def, player, element) local state = Gui.toolbar.get_button_toggled_state(def, player) diff --git a/exp_scenario/module/control/nuke_protection.lua b/exp_scenario/module/control/nuke_protection.lua index cfff046b..c8798388 100644 --- a/exp_scenario/module/control/nuke_protection.lua +++ b/exp_scenario/module/control/nuke_protection.lua @@ -3,7 +3,7 @@ Disable new players from having certain items in their inventory, most commonly ]] local ExpUtil = require("modules/exp_util") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.nukeprotect") --- Check all items in the given inventory @@ -12,7 +12,7 @@ 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[] diff --git a/exp_scenario/module/control/roles.lua b/exp_scenario/module/control/roles.lua new file mode 100644 index 00000000..1c4ecf66 --- /dev/null +++ b/exp_scenario/module/control/roles.lua @@ -0,0 +1,21 @@ +--[[-- Control - Roles +Applies the in game effects of the player permissions defined by the scenario +]] + +local Gui = require("modules/exp_gui") +local Roles = require("modules/exp_roles") + +Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) + player.admin = state +end) + +Roles.define_permission_trigger("exp_scenario.player.spectator", function(player, state) + player.spectator = state +end) + +return { + events = { + --- @diagnostic disable-next-line: access-invisible + [Roles.events.on_player_roles_changed] = Gui._ensure_consistency, + } +} diff --git a/exp_scenario/module/gui/autofill.lua b/exp_scenario/module/gui/autofill.lua index 3dc16175..8c663af4 100644 --- a/exp_scenario/module/gui/autofill.lua +++ b/exp_scenario/module/gui/autofill.lua @@ -3,7 +3,7 @@ Adds a config menu for setting autofill of placed entities ]] local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.gui.autofill") local FlyingText = require("modules/exp_util/flying_text") @@ -330,7 +330,7 @@ Gui.toolbar.create_button{ sprite = config.icon, tooltip = { "exp-gui_autofill.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/autofill") + return Roles.player_has_permission(player, "exp_scenario.gui.autofill") end } diff --git a/exp_scenario/module/gui/module_inserter.lua b/exp_scenario/module/gui/module_inserter.lua index 33011cec..ed320137 100644 --- a/exp_scenario/module/gui/module_inserter.lua +++ b/exp_scenario/module/gui/module_inserter.lua @@ -4,7 +4,7 @@ Adds a Gui which creates an selection planner to insert modules into buildings local Gui = require("modules/exp_gui") local AABB = require("modules/exp_util/aabb") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local Selection = require("modules/exp_util/selection") local SelectArea = Selection.connect("ModuleArea") @@ -259,7 +259,7 @@ Gui.toolbar.create_button{ sprite = "item/productivity-module-3", tooltip = { "exp-gui_module-inserter.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/module") + return Roles.player_has_permission(player, "exp_scenario.gui.module") end } diff --git a/exp_scenario/module/gui/player_bonus.lua b/exp_scenario/module/gui/player_bonus.lua index 054c3088..10cdc212 100644 --- a/exp_scenario/module/gui/player_bonus.lua +++ b/exp_scenario/module/gui/player_bonus.lua @@ -3,7 +3,7 @@ Adds a gui that allows players to apply various bonuses ]] local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/bonus") local vlayer = require("modules/exp_legacy/modules/control/vlayer") local format_number = require("util").format_number @@ -68,7 +68,8 @@ do local _points_limit = {} --- @type table --- @param player LuaPlayer --- @return number function Elements.bonus_used._calculate_points_limit(player) - local role_diff = Roles.get_role_by_name(config.points.role_name).index - Roles.get_player_highest_role(player).index + local role = assert(Roles.get_role(config.points.role_name), "Bonus points role does not exist") + local role_diff = role.index - Roles.get_player_highest_role(player).index 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 +459,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 +513,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 +525,7 @@ return { elements = Elements, events = { [e.on_player_respawned] = recalculate_bonus, - [Roles.events.on_role_assigned] = recalculate_bonus, - [Roles.events.on_role_unassigned] = recalculate_bonus, + [Roles.events.on_player_roles_changed] = recalculate_bonus, }, on_nth_tick = { [config.periodic_bonus_rate] = apply_periodic_bonus_online, diff --git a/exp_scenario/module/gui/player_list.lua b/exp_scenario/module/gui/player_list.lua index 905b5be2..42258e30 100644 --- a/exp_scenario/module/gui/player_list.lua +++ b/exp_scenario/module/gui/player_list.lua @@ -4,7 +4,7 @@ Adds a player list to show names and play time; also includes action buttons whi local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/gui/player_list_actions") --- @class ExpGui_PlayerList.elements @@ -203,7 +203,8 @@ function Elements.player_table.calculate_row_data() -- Flatten the roles into a single ordered list local count = 0 local row_data = {} - for _, role_name in pairs(Roles.config.order) do + for _, role in ipairs(Roles.get_roles()) do + local role_name = role.name if players[role_name] then for _, player in pairs(players[role_name]) do count = count + 1 @@ -360,7 +361,7 @@ function Elements.action_bar.refresh(action_bar, player, selected_player) if buttons.auth and not buttons.auth(player, selected_player) then flow.visible = false else - flow.visible = Roles.player_allowed(player, action_name) + flow.visible = Roles.player_has_permission(player, action_name) end end end @@ -492,7 +493,7 @@ Gui.toolbar.create_button{ sprite = "entity/character", tooltip = { "exp-gui_player-list.main-tooltip" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/player-list") + return Roles.player_has_permission(player, "exp_scenario.gui.player_list") end } @@ -534,8 +535,7 @@ return { events = { [e.on_player_joined_game] = redraw_player_list, [e.on_player_left_game] = on_player_left_game, - [Roles.events.on_role_assigned] = redraw_player_list, - [Roles.events.on_role_unassigned] = redraw_player_list, + [Roles.events.on_player_roles_changed] = redraw_player_list, }, on_nth_tick = { [1800] = refresh_player_times, diff --git a/exp_scenario/module/gui/player_stats.lua b/exp_scenario/module/gui/player_stats.lua index b0a57df3..5740aa2a 100644 --- a/exp_scenario/module/gui/player_stats.lua +++ b/exp_scenario/module/gui/player_stats.lua @@ -5,7 +5,7 @@ Displays the player data for a player local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local ElementsExtra = require("modules/exp_scenario/gui/elements") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") require("modules/exp_legacy/modules/data/statistics") local PlayerData = require("modules/exp_legacy/expcore/player_data") @@ -214,7 +214,7 @@ Gui.toolbar.create_button{ tooltip = { "exp-gui_player-stats.tooltip-main" }, left_element = Elements.container, visible = function(player, element) - return Roles.player_allowed(player, "gui/playerdata") + return Roles.player_has_permission(player, "exp_scenario.gui.playerdata") end } diff --git a/exp_scenario/module/gui/production_stats.lua b/exp_scenario/module/gui/production_stats.lua index 94b49562..e8728055 100644 --- a/exp_scenario/module/gui/production_stats.lua +++ b/exp_scenario/module/gui/production_stats.lua @@ -3,7 +3,7 @@ Adds a Gui for displaying item production stats ]] local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") --- @class ExpGui_ProductionStats.elements local Elements = {} @@ -250,7 +250,7 @@ Gui.toolbar.create_button{ sprite = "entity/assembling-machine-3", tooltip = { "exp-gui_production-stats.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/production") + return Roles.player_has_permission(player, "exp_scenario.gui.production") end } diff --git a/exp_scenario/module/gui/quick_actions.lua b/exp_scenario/module/gui/quick_actions.lua index 92d358aa..2d9c7cdb 100644 --- a/exp_scenario/module/gui/quick_actions.lua +++ b/exp_scenario/module/gui/quick_actions.lua @@ -4,7 +4,7 @@ Adds a few buttons for common actions local Gui = require("modules/exp_gui") local Commands = require("modules/exp_commands") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local addon_artillery = require("modules/exp_scenario/commands/artillery") local addon_trains = require("modules/exp_scenario/commands/trains") @@ -116,7 +116,7 @@ Gui.toolbar.create_button{ sprite = "item/repair-pack", tooltip = { "exp-gui_quick-actions.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/tool") + return Roles.player_has_permission(player, "exp_scenario.gui.tool") end } @@ -129,7 +129,6 @@ end return { elements = Elements, events = { - [Roles.events.on_role_assigned] = on_role_changed, - [Roles.events.on_role_unassigned] = on_role_changed, + [Roles.events.on_player_roles_changed] = on_role_changed, } } diff --git a/exp_scenario/module/gui/readme.lua b/exp_scenario/module/gui/readme.lua index 5a7749b6..4a3ebc05 100644 --- a/exp_scenario/module/gui/readme.lua +++ b/exp_scenario/module/gui/readme.lua @@ -4,7 +4,7 @@ Adds a main gui that contains important information about the server. local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local Commands = require("modules/exp_commands") local PlayerData = require("modules.exp_legacy.expcore.player_data") local External = require("modules.exp_legacy.expcore.external") @@ -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(role_name) + for _, player_name in ipairs(role and role:get_player_names() or {}) do + if not seen[player_name] then + seen[player_name] = true done[player_name] = true group.players[#group.players + 1] = player_name - break end end end @@ -463,7 +465,7 @@ local function render_data_category(opts) for name, child in pairs(opts.children) do local metadata = child.metadata - if not metadata.permission or Roles.player_allowed(opts.player, metadata.permission) then + if not metadata.permission or Roles.player_has_permission(opts.player, metadata.permission) then local value = child:get(opts.player_name) if value ~= nil or metadata.show_always then @@ -663,7 +665,7 @@ Elements.toggle_button = Gui.toolbar.create_button{ sprite = "virtual-signal/signal-info", tooltip = { "exp-gui_readme.main-tooltip" }, visible = function(player) - return Roles.player_allowed(player, "gui/readme") + return Roles.player_has_permission(player, "exp_scenario.gui.readme") end, } :on_click(function(_, player) diff --git a/exp_scenario/module/gui/research_milestones.lua b/exp_scenario/module/gui/research_milestones.lua index 4652edcd..390867c8 100644 --- a/exp_scenario/module/gui/research_milestones.lua +++ b/exp_scenario/module/gui/research_milestones.lua @@ -4,7 +4,7 @@ Adds a gui for tracking research milestones local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/research") local table_to_json = helpers.table_to_json @@ -339,7 +339,7 @@ Gui.toolbar.create_button{ sprite = "item/space-science-pack", tooltip = { "exp-gui_research-milestones.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/research") + return Roles.player_has_permission(player, "exp_scenario.gui.research") end } diff --git a/exp_scenario/module/gui/rocket_info.lua b/exp_scenario/module/gui/rocket_info.lua index d7999db3..e0b9074a 100644 --- a/exp_scenario/module/gui/rocket_info.lua +++ b/exp_scenario/module/gui/rocket_info.lua @@ -4,7 +4,7 @@ Adds a rocket information gui which shows general stats, milestones and build pr local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/gui/rockets") --- @class ExpGui_RocketInfo.elements @@ -651,7 +651,7 @@ Gui.toolbar.create_button{ sprite = "item/rocket-silo", tooltip = { "exp-gui_rocket-info.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/rocket-info") + return Roles.player_has_permission(player, "exp_scenario.gui.rocket_info") end } diff --git a/exp_scenario/module/gui/science_production.lua b/exp_scenario/module/gui/science_production.lua index ad7772af..c2ef7273 100644 --- a/exp_scenario/module/gui/science_production.lua +++ b/exp_scenario/module/gui/science_production.lua @@ -5,7 +5,7 @@ Adds a science info gui that shows production usage and net for the different sc local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local Colors = require("modules/exp_util/include/color") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/gui/science") local _format_number = require("util").format_number @@ -571,7 +571,7 @@ Gui.toolbar.create_button{ sprite = "entity/lab", tooltip = { "exp-gui_science-production.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/science-info") + return Roles.player_has_permission(player, "exp_scenario.gui.science_info") end } diff --git a/exp_scenario/module/gui/surveillance.lua b/exp_scenario/module/gui/surveillance.lua index 0fd65924..e9910723 100644 --- a/exp_scenario/module/gui/surveillance.lua +++ b/exp_scenario/module/gui/surveillance.lua @@ -4,7 +4,7 @@ Adds cameras which can be used to view players and locations local Gui = require("modules/exp_gui") local ElementsExtra = require("modules/exp_scenario/gui/elements") -local Roles = require("modules/exp_legacy/expcore/roles") +local Roles = require("modules/exp_roles") --- @class ExpGui_Surveillance.elements local Elements = {} @@ -234,7 +234,7 @@ Gui.toolbar.create_button{ sprite = "entity/radar", tooltip = { "exp-gui_surveillance.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/surveillance") + return Roles.player_has_permission(player, "exp_scenario.gui.surveillance") end }:on_click(function(def, player, element, event) Elements.container(player.gui.screen) diff --git a/exp_scenario/module/gui/task_list.lua b/exp_scenario/module/gui/task_list.lua index 75858bad..c628063a 100644 --- a/exp_scenario/module/gui/task_list.lua +++ b/exp_scenario/module/gui/task_list.lua @@ -3,7 +3,7 @@ Adds a task list to the game which players can add, remove and edit items on ]] local Gui = require("modules/exp_gui") -local Roles = require("modules.exp_legacy.expcore.roles") +local Roles = require("modules/exp_roles") local config = require("modules.exp_legacy.config.gui.tasks") local ExpUtil = require("modules/exp_util") @@ -43,8 +43,8 @@ local function has_permission_create_task(player) return true elseif allow_add_task == "admin" then return player.admin - elseif allow_add_task == "expcore.roles" then - return Roles.player_allowed(player, config.expcore_roles_allow_add_task) + elseif allow_add_task == "exp_roles" then + return Roles.player_has_permission(player, config.exp_roles_allow_add_task) end return false @@ -67,8 +67,8 @@ local function has_permission_edit_task(player, task) return true elseif allow_edit_task == "admin" then return player.admin - elseif allow_edit_task == "expcore.roles" then - return Roles.player_allowed(player, config.expcore_roles_allow_edit_task) + elseif allow_edit_task == "exp_roles" then + return Roles.player_has_permission(player, config.exp_roles_allow_edit_task) end return false @@ -922,7 +922,7 @@ Gui.toolbar.create_button{ sprite = "utility/not_enough_repair_packs_icon", tooltip = { "exp-gui_task-list.tooltip-main" }, visible = function(player, element) - return Roles.player_allowed(player, "gui/task-list") + return Roles.player_has_permission(player, "exp_scenario.gui.task_list") end } @@ -950,7 +950,6 @@ return { events = { [e.on_player_joined_game] = refresh_player_tasks, [e.on_player_changed_force] = refresh_player_tasks, - [Roles.events.on_role_assigned] = refresh_player_permissions, - [Roles.events.on_role_unassigned] = refresh_player_permissions, + [Roles.events.on_player_roles_changed] = refresh_player_permissions, } } diff --git a/exp_scenario/module/module.json b/exp_scenario/module/module.json index 56b58778..23b572f0 100644 --- a/exp_scenario/module/module.json +++ b/exp_scenario/module/module.json @@ -8,6 +8,7 @@ "dependencies": { "clusterio": "*", "exp_util": "*", + "exp_roles": "*", "exp_gui": "*", "exp_commands": "*" } diff --git a/exp_server_ups/module/control.lua b/exp_server_ups/module/control.lua index 6d51c9d3..cd2d5b19 100644 --- a/exp_server_ups/module/control.lua +++ b/exp_server_ups/module/control.lua @@ -14,7 +14,7 @@ local PlayerData = require("modules/exp_legacy/expcore/player_data") local UsesServerUps = PlayerData.Settings:combine("UsesServerUps") UsesServerUps:set_default(false) UsesServerUps:set_metadata{ - permission = "command/server-ups", + permission = "exp_scenario.command.server_ups", stringify = function(value) return value and "Visible" or "Hidden" end, } From 48d06ee9968cc087c06b5288c9fe21dda18b666a Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:36:58 +0000 Subject: [PATCH 04/16] Seed the legacy roles the first time the plugin runs When the role properties datastore is empty the plugin has not run before, so the roles the scenario used to define are created on the controller, along with the players the config listed. This replaces the role config which was loaded into every map. The seed keeps the parent relationships of the old config and flattens them into the permissions of each role, since clusterio roles do not inherit. The default and admin roles already exist, so those entries only set the in game properties; the default role gets its permissions through grantByDefault. Roles which already exist by name are reused and only gain the seed permissions, so seeding an existing cluster is safe. Permissions which are not defined are logged rather than refused, in case a plugin is not loaded. Co-Authored-By: Claude Fable 5 --- exp_roles/controller.ts | 79 ++++++++++ exp_roles/seed.ts | 334 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 exp_roles/seed.ts diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index b9bf3a8b..8d0d8594 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -1,6 +1,7 @@ import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages"; +import { seedRoles, seedAssignments, flattenSeedPermissions } from "./seed"; import * as path from "node:path"; export class ControllerPlugin extends BaseControllerPlugin { @@ -16,6 +17,12 @@ export class ControllerPlugin extends BaseControllerPlugin { ).bootstrap() ); + // An empty datastore means the plugin has not run before, so the roles + // the scenario used to define are created on the controller + if (this.roleMeta.size === 0) { + this.seed(); + } + // The datastore can be out of step with the roles, either because the // plugin was installed after they were created or because it was // uninstalled while they were deleted @@ -43,6 +50,78 @@ export class ControllerPlugin extends BaseControllerPlugin { await this.roleMeta.save(); } + /* + Seeding + */ + + /** + * Create the roles the scenario shipped with and give the players it listed + * their roles. Roles which already exist by name are reused. + */ + seed() { + const defaultRoleId = this.controller.config.get("controller.default_role_id"); + const roles = this.controller.roles; + let nextId = Math.max(5, ...[...roles.keys()].map(id => id + 1)); + const idsByName = new Map(); + + for (const [order, seedRole] of seedRoles.entries()) { + 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: lib.Role | undefined; + if (seedRole.isAdmin) { + role = roles.getMutable(lib.Role.DefaultAdminRoleId); + } else if (seedRole.isDefault) { + role = defaultRoleId !== null ? roles.getMutable(defaultRoleId) : undefined; + } else { + role = [...roles.valuesMutable()].find(other => other.name === seedRole.name); + if (!role) { + role = new lib.Role(nextId, seedRole.name, "", permissions); + nextId += 1; + this.logger.info(`Created role ${seedRole.name}`); + } else { + for (const permission of permissions) { + role.permissions.add(permission); + } + } + roles.set(role); + } + + if (!role) { + continue; + } + + idsByName.set(seedRole.name, role.id); + this.roleMeta.set(new messages.RoleMetaRecord( + role.id, + order + 1, + seedRole.priority ?? 0, + seedRole.shortHand, + "", + seedRole.color, + seedRole.permissionGroup, + seedRole.autoAssignHours === undefined ? null : seedRole.autoAssignHours * 3600000, + seedRole.blockAutoAssign ?? false, + )); + } + + for (const [name, roleNames] of Object.entries(seedAssignments)) { + const roleIds = roleNames.map(roleName => idsByName.get(roleName)).filter(id => id !== undefined); + if (roleIds.length !== roleNames.length) { + this.logger.warn(`Seed assignment for ${name} names a role which does not exist`); + } + const user = this.controller.users.getOrCreateUser(name); + user.set("roleIds", new Set([...user.roleIds, ...roleIds])); + this.controller.userPermissionsUpdated(user); + } + + this.logger.info(`Seeded ${seedRoles.length} roles and ${Object.keys(seedAssignments).length} assignments`); + } + /* Role properties */ diff --git a/exp_roles/seed.ts b/exp_roles/seed.ts new file mode 100644 index 00000000..430c5ec7 --- /dev/null +++ b/exp_roles/seed.ts @@ -0,0 +1,334 @@ +import { RoleColor } from "./messages"; + +/** + * A role created the first time the plugin runs, 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; + /** Factorio permission group, see exp_legacy/module/config/expcore/permission_groups.lua */ + permissionGroup: string; + 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[]; +} + +const admin = ["exp_scenario.player.admin", "exp_scenario.player.spectator", "exp_scenario.bypass.reports"]; +const instantRespawn = ["exp_scenario.player.instant_respawn"]; +const trusted = ["exp_scenario.player.spectator", "exp_scenario.bypass.reports", ...instantRespawn]; + +export const seedRoles: SeedRole[] = [ + { + name: "System", + shortHand: "SYS", + color: null, + permissionGroup: "Default", + isAdmin: true, + permissions: [], + }, + { + name: "Senior Administrator", + shortHand: "SAdmin", + color: new RoleColor(233, 63, 233), + permissionGroup: "Admin", + parent: "Administrator", + permissions: [ + ...admin, ...instantRespawn, + "exp_scenario.player.system_commands", + "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), + permissionGroup: "Admin", + parent: "Moderator", + permissions: [ + ...admin, ...instantRespawn, + "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), + permissionGroup: "Admin", + parent: "Trainee", + permissions: [ + ...admin, ...instantRespawn, + "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), + permissionGroup: "Admin", + parent: "Veteran", + permissions: [ + ...admin, + "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), + permissionGroup: "Trusted", + parent: "Sponsor", + permissions: [ + ...trusted, + "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), + permissionGroup: "Trusted", + parent: "Sponsor", + permissions: [ + ...trusted, + ], + }, + { + name: "Sponsor", + shortHand: "Spon", + color: new RoleColor(238, 172, 44), + permissionGroup: "Trusted", + parent: "Supporter", + permissions: [ + ...trusted, + "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), + permissionGroup: "Trusted", + 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), + permissionGroup: "Trusted", + 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), + permissionGroup: "Trusted", + 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), + permissionGroup: "Standard", + 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), + permissionGroup: "Standard", + 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), + permissionGroup: "Restricted", + priority: 1, + blockAutoAssign: true, + permissions: [], + }, + { + name: "Guest", + shortHand: "", + color: new RoleColor(185, 187, 160), + permissionGroup: "Guest", + isDefault: true, + permissions: [], + }, +]; + +/** Players given roles the first time the plugin runs, by role name. */ +export const seedAssignments: Record = { + "PHIDIAS0303": ["Moderator", "Board Member", "Member"], + "aldldl": ["Administrator", "Moderator", "Member"], + "arty714": ["Senior Administrator", "Moderator", "Member"], + "Cooldude2606": ["Senior Administrator", "Moderator", "Member"], + "Drahc_pro": ["Administrator", "Moderator", "Member"], + "mark9064": ["Administrator", "Moderator", "Member"], + "7h3w1z4rd": ["Moderator", "Member"], + "FlipHalfling90": ["Moderator", "Member"], + "hamsterbryan": ["Moderator", "Member"], + "HunterOfGames": ["Moderator", "Member"], + "NextIdea": ["Moderator", "Member"], + "TheKernel32": ["Moderator", "Member"], + "TheKernel64": ["Moderator", "Member"], + "tovernaar123": ["Moderator", "Member"], + "UUBlueFire": ["Moderator", "Member"], + "AssemblyStorm": ["Moderator", "Member"], + "banakeg": ["Moderator", "Member"], + "connormkii": ["Moderator", "Member"], + "cydes": ["Moderator", "Member"], + "darklich14": ["Moderator", "Member"], + "facere": ["Moderator", "Member"], + "freek18": ["Moderator", "Member"], + "Gizan": ["Moderator", "Member"], + "LoicB": ["Moderator", "Member"], + "M74132": ["Moderator", "Member"], + "mafisch3": ["Moderator", "Member"], + "maplesyrup01": ["Moderator", "Member"], + "ookl": ["Moderator", "Member"], + "Phoenix27833": ["Moderator", "Member"], + "porelos": ["Moderator", "Member"], + "Ruuyji": ["Moderator", "Member"], + "samy115": ["Moderator", "Member"], + "SilentLog": ["Moderator", "Member"], + "Tcheko": ["Moderator", "Member"], + "thadius856": ["Moderator", "Member"], + "whoami32": ["Moderator", "Member"], + "Windbomb": ["Moderator", "Member"], + "XenoCyber": ["Moderator", "Member"], +}; + +/** The permissions a seed role grants, including those of its parents. */ +export function flattenSeedPermissions(role: SeedRole, roles = seedRoles) { + const permissions = new Set(); + const seen = new Set(); + let current: SeedRole | undefined = role; + while (current && !seen.has(current.name)) { + seen.add(current.name); + for (const permission of current.permissions) { + permissions.add(permission); + } + current = roles.find(other => other.name === current!.parent); + } + return permissions; +} From 78a533a6f619e7d1d9af37f58d6ea5898e5e336e Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:39:51 +0000 Subject: [PATCH 05/16] Split the seed into the role and assignment halves Co-Authored-By: Claude Fable 5 --- exp_roles/controller.ts | 69 +++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index 8d0d8594..72337c07 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -1,7 +1,7 @@ import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages"; -import { seedRoles, seedAssignments, flattenSeedPermissions } from "./seed"; +import { SeedRole, seedRoles, seedAssignments, flattenSeedPermissions } from "./seed"; import * as path from "node:path"; export class ControllerPlugin extends BaseControllerPlugin { @@ -59,38 +59,9 @@ export class ControllerPlugin extends BaseControllerPlugin { * their roles. Roles which already exist by name are reused. */ seed() { - const defaultRoleId = this.controller.config.get("controller.default_role_id"); - const roles = this.controller.roles; - let nextId = Math.max(5, ...[...roles.keys()].map(id => id + 1)); const idsByName = new Map(); - - for (const [order, seedRole] of seedRoles.entries()) { - 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: lib.Role | undefined; - if (seedRole.isAdmin) { - role = roles.getMutable(lib.Role.DefaultAdminRoleId); - } else if (seedRole.isDefault) { - role = defaultRoleId !== null ? roles.getMutable(defaultRoleId) : undefined; - } else { - role = [...roles.valuesMutable()].find(other => other.name === seedRole.name); - if (!role) { - role = new lib.Role(nextId, seedRole.name, "", permissions); - nextId += 1; - this.logger.info(`Created role ${seedRole.name}`); - } else { - for (const permission of permissions) { - role.permissions.add(permission); - } - } - roles.set(role); - } - + for (const [index, seedRole] of seedRoles.entries()) { + const role = this.seedRole(seedRole); if (!role) { continue; } @@ -98,7 +69,7 @@ export class ControllerPlugin extends BaseControllerPlugin { idsByName.set(seedRole.name, role.id); this.roleMeta.set(new messages.RoleMetaRecord( role.id, - order + 1, + index + 1, seedRole.priority ?? 0, seedRole.shortHand, "", @@ -122,6 +93,38 @@ export class ControllerPlugin extends BaseControllerPlugin { this.logger.info(`Seeded ${seedRoles.length} roles and ${Object.keys(seedAssignments).length} assignments`); } + /** 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 */ From bea8d46c82fb516e3fe12a2bc859cb5ae52cc0b7 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:52:50 +0000 Subject: [PATCH 06/16] Address review on the role api - Roles are objects and everything done to or with a role is a method on it: assign, unassign, has_player, has_permission, is_higher_than, is_lower_than, get_players, get_player_names, print. Assignment has one entry point, role:assign(player, options), with local_only as an option rather than a second function. - Roles are looked up by clusterio id with get_role; get_role_by_name searches the list for the few places, such as configs, which only know a name. The name map and the ordered list are gone, get_roles sorts on demand and the index field is replaced by the comparison methods. - Players are LuaPlayer objects only, with nil or index 0 for the server. - get_higher_roles and get_lower_roles replace print_to_roles_higher and print_to_roles_lower, call sites loop over them with role:print. - Permission groups are removed from roles again, exp_groups owns the mapping from roles to groups. - Seeding is a SeedRolesRequest behind a button on the roles page rather than running on first start, and creates only the roles; the player assignments are dropped. The seed lists each permission once at the lowest role which has it and lets the parent chain carry it up. - System commands unlock for core.admin rather than a permission of their own. - Role metatables are registered with Storage.register_metatable so the methods survive save and load, which the role records in storage needed. - The player list auth uses Roles.player_outranks directly, and the event carries role ids rather than names. Co-Authored-By: Claude Fable 5 --- exp_legacy/module/config/afk_kick.lua | 4 +- .../module/config/gui/player_list_actions.lua | 18 +- exp_legacy/module/modules/control/jail.lua | 19 +- exp_roles/controller.ts | 30 +- exp_roles/index.ts | 1 + exp_roles/messages.ts | 23 +- exp_roles/module/control.lua | 568 +++++++----------- exp_roles/seed.ts | 84 +-- exp_roles/web/components/RoleProperties.tsx | 16 - exp_roles/web/components/SeedRoles.tsx | 33 + exp_roles/web/index.tsx | 2 + exp_scenario/module/commands/_authorities.lua | 2 +- exp_scenario/module/commands/_types.lua | 5 +- .../module/commands/protected_entities.lua | 17 +- exp_scenario/module/commands/roles.lua | 4 +- exp_scenario/module/gui/player_bonus.lua | 7 +- exp_scenario/module/gui/readme.lua | 2 +- exp_scenario/permissions.ts | 1 - 18 files changed, 330 insertions(+), 506 deletions(-) create mode 100644 exp_roles/web/components/SeedRoles.tsx diff --git a/exp_legacy/module/config/afk_kick.lua b/exp_legacy/module/config/afk_kick.lua index f02be428..3e52552a 100644 --- a/exp_legacy/module/config/afk_kick.lua +++ b/exp_legacy/module/config/afk_kick.lua @@ -8,7 +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) - local veteran = Roles.get_role("Veteran") - return veteran ~= nil and Roles.get_player_highest_role(player).index <= veteran.index + local veteran = Roles.get_role_by_name("Veteran") + return veteran ~= nil and not Roles.get_player_highest_role(player):is_lower_than(veteran) end, } diff --git a/exp_legacy/module/config/gui/player_list_actions.lua b/exp_legacy/module/config/gui/player_list_actions.lua index b3e265dd..146338aa 100644 --- a/exp_legacy/module/config/gui/player_list_actions.lua +++ b/exp_legacy/module/config/gui/player_list_actions.lua @@ -21,11 +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) - return Roles.player_outranks(player, selected_player_name) -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]] @@ -98,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 @@ -180,22 +178,22 @@ return { report_player, }, ["exp_scenario.command.create_warning"] = { - auth = auth_lower_role, -- warn a lower user, replaces report + auth = Roles.player_outranks, -- warn a lower user, replaces report reason_callback = warn_player_callback, warn_player, }, ["exp_scenario.command.jail"] = { - auth = auth_lower_role, + auth = Roles.player_outranks, reason_callback = jail_player_callback, jail_player, }, ["exp_scenario.gui.player_list.kick"] = { - auth = auth_lower_role, + auth = Roles.player_outranks, reason_callback = kick_player_callback, kick_player, }, ["exp_scenario.gui.player_list.ban"] = { - auth = auth_lower_role, + auth = Roles.player_outranks, reason_callback = ban_player_callback, ban_player, }, diff --git a/exp_legacy/module/modules/control/jail.lua b/exp_legacy/module/modules/control/jail.lua index 3409619b..0d9b6cfe 100644 --- a/exp_legacy/module/modules/control/jail.lua +++ b/exp_legacy/module/modules/control/jail.lua @@ -20,8 +20,10 @@ local Roles = require("modules/exp_roles") local valid_player = function(p) return type(p) == "userdata" and p or game.get_player(p) end ---- The role which is given to jailed players, it has a higher priority than every other role -local jail_role = "Jail" +--- 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 = { events = { @@ -62,7 +64,8 @@ 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 Roles.player_has_role(valid_player(player), jail_role) + local valid = valid_player(player) + return valid ~= nil and jail_role():has_player(valid) end --- Moves a player to jail, which suppresses all of their other roles @@ -77,7 +80,8 @@ function Jail.jail_player(player, by_player_name, reason) reason = reason or "Non given." - if Roles.player_has_role(player, jail_role) then return end + 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 } @@ -86,7 +90,7 @@ function Jail.jail_player(player, by_player_name, reason) player.picking_state = false player.repair_state = { repairing = false, position = player.repair_state.position } - Roles.assign_player(player, jail_role, by_player_name, true) + role:assign(player, { by_player_name = by_player_name, silent = true }) event_emit(Jail.events.on_player_jailed, player, by_player_name, reason) @@ -102,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 Roles.player_has_role(player, jail_role) then return end + local role = jail_role() + if not role:has_player(player) then return end - Roles.unassign_player(player, jail_role, by_player_name, true) + role:unassign(player, { by_player_name = by_player_name, silent = true }) event_emit(Jail.events.on_player_unjailed, player, by_player_name) diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index 72337c07..45b79890 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -1,7 +1,7 @@ import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages"; -import { SeedRole, seedRoles, seedAssignments, flattenSeedPermissions } from "./seed"; +import { SeedRole, seedRoles, flattenSeedPermissions } from "./seed"; import * as path from "node:path"; export class ControllerPlugin extends BaseControllerPlugin { @@ -17,12 +17,6 @@ export class ControllerPlugin extends BaseControllerPlugin { ).bootstrap() ); - // An empty datastore means the plugin has not run before, so the roles - // the scenario used to define are created on the controller - if (this.roleMeta.size === 0) { - this.seed(); - } - // The datastore can be out of step with the roles, either because the // plugin was installed after they were created or because it was // uninstalled while they were deleted @@ -41,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)); @@ -55,18 +50,16 @@ export class ControllerPlugin extends BaseControllerPlugin { */ /** - * Create the roles the scenario shipped with and give the players it listed - * their roles. Roles which already exist by name are reused. + * Create the roles the scenario shipped with. Roles which already exist + * by name are reused and only gain the seed permissions. */ - seed() { - const idsByName = new Map(); + async handleSeedRolesRequest() { for (const [index, seedRole] of seedRoles.entries()) { const role = this.seedRole(seedRole); if (!role) { continue; } - idsByName.set(seedRole.name, role.id); this.roleMeta.set(new messages.RoleMetaRecord( role.id, index + 1, @@ -74,23 +67,12 @@ export class ControllerPlugin extends BaseControllerPlugin { seedRole.shortHand, "", seedRole.color, - seedRole.permissionGroup, seedRole.autoAssignHours === undefined ? null : seedRole.autoAssignHours * 3600000, seedRole.blockAutoAssign ?? false, )); } - for (const [name, roleNames] of Object.entries(seedAssignments)) { - const roleIds = roleNames.map(roleName => idsByName.get(roleName)).filter(id => id !== undefined); - if (roleIds.length !== roleNames.length) { - this.logger.warn(`Seed assignment for ${name} names a role which does not exist`); - } - const user = this.controller.users.getOrCreateUser(name); - user.set("roleIds", new Set([...user.roleIds, ...roleIds])); - this.controller.userPermissionsUpdated(user); - } - - this.logger.info(`Seeded ${seedRoles.length} roles and ${Object.keys(seedAssignments).length} assignments`); + 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. */ diff --git a/exp_roles/index.ts b/exp_roles/index.ts index e3cb4093..d2c522fe 100644 --- a/exp_roles/index.ts +++ b/exp_roles/index.ts @@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = { messages.RoleListRequest, messages.RoleMetaUpdateRequest, + messages.SeedRolesRequest, messages.AssignmentListRequest, messages.AssignmentUpdateRequest, diff --git a/exp_roles/messages.ts b/exp_roles/messages.ts index 25b47565..7ba71459 100644 --- a/exp_roles/messages.ts +++ b/exp_roles/messages.ts @@ -51,11 +51,6 @@ export class RoleMetaRecord { public tag: string = "", /** Colour used for the role in game, null uses the default colour. */ public color: RoleColor | null = null, - /** - * Factorio permission group players are moved into while this is their - * most privileged role which names one, empty leaves the group alone. - */ - public permissionGroup: string = "", /** Online time after which this role is granted, null never grants it. */ public autoAssignOnlineTimeMs: number | null = null, /** When true holders of this role are never granted roles automatically. */ @@ -71,7 +66,6 @@ export class RoleMetaRecord { short_hand: Type.Optional(Type.String()), tag: Type.Optional(Type.String()), color: Type.Optional(Type.Union([RoleColor.jsonSchema, Type.Null()])), - permission_group: Type.Optional(Type.String()), auto_assign_online_time_ms: Type.Optional(Type.Union([Type.Number(), Type.Null()])), block_auto_assign: Type.Optional(Type.Boolean()), updated_at_ms: Type.Optional(Type.Number()), @@ -100,10 +94,6 @@ export class RoleMetaRecord { json.color = this.color.toJSON(); } - if (this.permissionGroup) { - json.permission_group = this.permissionGroup; - } - if (this.autoAssignOnlineTimeMs !== null) { json.auto_assign_online_time_ms = this.autoAssignOnlineTimeMs; } @@ -131,7 +121,6 @@ export class RoleMetaRecord { json.short_hand ?? "", json.tag ?? "", json.color ? RoleColor.fromJSON(json.color) : null, - json.permission_group ?? "", json.auto_assign_online_time_ms ?? null, json.block_auto_assign ?? false, json.updated_at_ms ?? 0, @@ -376,6 +365,18 @@ export class RoleMetaUpdateRequest { } } +/** Create the roles the scenario shipped with, see seed.ts. */ +export class SeedRolesRequest { + declare ["constructor"]: typeof SeedRolesRequest; + static plugin = "exp_roles" as const; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.role.create" as const; + + constructor() {} +} + /* Assignment requests */ diff --git a/exp_roles/module/control.lua b/exp_roles/module/control.lua index ebe1f6fc..45412512 100644 --- a/exp_roles/module/control.lua +++ b/exp_roles/module/control.lua @@ -6,19 +6,20 @@ the controller. This module keeps a copy of that state and answers permission checks from it, so a check such as `Roles.player_has_permission(player, "exp_scenario.command.kill")` is answered from the same data the web ui shows. -Assignments made in game are applied locally first and then sent to the -controller, which avoids callers having to deal with the round trip. A local -assignment which is never sent, such as one earned from time on this map, can be -made with `assign_player_local`. +Roles are objects, looked up by their clusterio id, and everything done to or +with a role is a method on it. Assignments made in game are applied locally +first and then sent to the controller, which avoids callers having to deal with +the round trip. -Only the roles with the highest priority a player holds are considered. Jail -sits above every other role, so holding it suppresses the rest, including the -default role every player has. +Only the roles with the highest priority a player holds apply. Jail sits above +every other role, so holding it suppresses the rest, including the default role +every player has. ]] local clusterio_api = require("modules/clusterio/api") local compat = require("modules/clusterio/compat") --[[@as LibCompat]] local Async = require("modules/exp_util/async") +local Storage = require("modules/exp_util/storage") --- @class ExpRoles local ExpRoles = { @@ -34,8 +35,8 @@ local ExpRoles = { --- @class EventData.ExpRoles.on_player_roles_changed : EventData --- @field player_index uint --- @field by_player_index uint 0 when the change did not come from a player ---- @field assigned string[] Names of the roles which were assigned ---- @field unassigned string[] Names of the roles which were unassigned +--- @field assigned number[] Ids of the roles which were assigned +--- @field unassigned number[] Ids of the roles which were unassigned --- Methods shared by every role, kept apart from the fields so that defining --- them does not count as injecting fields into the role itself @@ -43,19 +44,25 @@ local ExpRoles = { local Role = {} ExpRoles._prototype = Role +--- Registered so roles keep their methods across save and load +local role_metatable = Storage.register_metatable("Role", { __index = Role }) + --- @class ExpRoles.Role : ExpRoles.RolePrototype --- @field id number Clusterio role id --- @field name string --- @field short_hand string ---- @field order number Order given by the controller, which can have gaps ---- @field index number Position within the role order, with no gaps +--- @field order number Position given by the controller, compare roles with the methods rather than this --- @field priority number Only the highest priority roles a player holds apply --- @field tag string --- @field color Color? --- @field permissions table Permission names granted by this role ---- @field permission_group string? Factorio permission group holders are moved to --- @field block_auto_assign boolean +--- @class ExpRoles.AssignOptions +--- @field by_player_name string? Shown in the game message, defaults to the current player or the server +--- @field silent boolean? When true no game message is printed +--- @field local_only boolean? When true the controller is not told, for roles earned on this map only + --- @class ExpRoles.ScriptData --- @field roles table Roles indexed by clusterio id --- @field synced_players table Role ids from the controller @@ -65,49 +72,13 @@ ExpRoles._prototype = Role --- @field emit_updates boolean local script_data = {} ---- Roles in order, the most privileged first, rebuilt from script data -local ordered_roles = {} --- @type ExpRoles.Role[] ---- Roles indexed by name, rebuilt from script data -local roles_by_name = {} --- @type table --- Async handlers run when the state of a permission may have changed local permission_triggers = {} --- @type table ---- Move a player into a permission group, done async because the game does not ---- allow it from within every event -local set_permission_group_async = Async.register(function(player, group) - --- @cast player LuaPlayer - --- @cast group LuaPermissionGroup - if player.valid and group.valid then - group.add_player(player) - end -end) - --[[ Role state ]] ---- Rebuild the ordered and by name views of the roles -local function rebuild_role_views() - local roles = {} - for _, role in pairs(script_data.roles) do - roles[#roles + 1] = role - end - - -- Sorted on the order given by the controller, which can have gaps, the - -- index each role is given is its position within that order - table.sort(roles, function(a, b) - if a.order == b.order then return a.id < b.id end - return a.order < b.order - end) - - ordered_roles, roles_by_name = {}, {} - for index, role in ipairs(roles) do - role.index = index - ordered_roles[index] = role - roles_by_name[role.name] = role - end -end - --- Build a role from the record sent by the controller --- @param record table --- @param names string[]? When given, record.permissions holds indexes into it @@ -126,33 +97,37 @@ local function decode_role(record, names) end end - local group = meta.permission_group - if group == "" then group = nil end - --- @type ExpRoles.Role return setmetatable({ id = record.id, name = record.name, short_hand = meta.short_hand or record.name, order = meta.order or record.id, - index = meta.order or record.id, priority = meta.priority or 0, tag = meta.tag or "", color = meta.color, permissions = permissions, - permission_group = group, block_auto_assign = meta.block_auto_assign or false, - }, { __index = ExpRoles._prototype }) + }, role_metatable) +end + +--- Sort roles in place, the most privileged first +--- @param roles ExpRoles.Role[] +--- @return ExpRoles.Role[] +local function sort_roles(roles) + table.sort(roles, function(a, b) + if a.order == b.order then return a.id < b.id end + return a.order < b.order + end) + return roles end ---- Get the name of a player from a player or a name, nil for the server --- The server is represented by nil, or by a player object with index 0 ---- @param player LuaPlayer | string | nil ---- @return string? -local function player_name_of(player) - if type(player) == "string" then return player end - if player and player.index ~= 0 then return player.name end - return nil +--- @param player LuaPlayer? +--- @return LuaPlayer? # The player when it is not the server +local function not_server(player) + if player == nil or player.index == 0 then return nil end + return player end --- Role ids a player has been given, without the default role or priority applied @@ -171,59 +146,75 @@ local function get_held_role_ids(player_name) return role_ids end ---- Role ids which apply to a player, with the default role and priority applied +--- Role ids a player has been given, as a set --- @param player_name string --- @return table -local function get_effective_role_ids(player_name) +local function get_held_role_set(player_name) + local rtn = {} + for _, role_id in pairs(get_held_role_ids(player_name)) do + rtn[role_id] = true + end + return rtn +end + +--- Roles which apply to a player, with the default role and priority applied +--- @param player_name string +--- @return ExpRoles.Role[] +local function get_effective_roles(player_name) local role_ids = get_held_role_ids(player_name) if script_data.default_role_id then role_ids[#role_ids + 1] = script_data.default_role_id end - local highest_priority + local roles, highest_priority = {}, nil for _, role_id in pairs(role_ids) do local role = script_data.roles[role_id] - if role and (highest_priority == nil or role.priority > highest_priority) then - highest_priority = role.priority + if role then + roles[#roles + 1] = role + if highest_priority == nil or role.priority > highest_priority then + highest_priority = role.priority + end end end local rtn = {} - for _, role_id in pairs(role_ids) do - local role = script_data.roles[role_id] - if role and role.priority == highest_priority then - rtn[role_id] = true + for _, role in pairs(roles) do + if role.priority == highest_priority then + rtn[#rtn + 1] = role end end - return rtn + return sort_roles(rtn) end --[[ Role lookup ]] ---- Get a role from its name, its clusterio id, or a role ---- @param any string | number | ExpRoles.Role +--- Get a role from its clusterio id +--- @param role_id number --- @return ExpRoles.Role? -function ExpRoles.get_role(any) - local t_any = type(any) - if t_any == "string" then - return roles_by_name[any] - elseif t_any == "number" then - return script_data.roles[any] - elseif t_any == "table" then - return script_data.roles[any.id] - end +function ExpRoles.get_role(role_id) + return script_data.roles[role_id] end ---- Get every role in order, the most privileged first +--- Get a role from its name, roles should be referred to by id where possible +--- @param name string +--- @return ExpRoles.Role? +function ExpRoles.get_role_by_name(name) + for _, role in pairs(script_data.roles) do + if role.name == name then return role end + end + return nil +end + +--- Get every role, the most privileged first --- @return ExpRoles.Role[] function ExpRoles.get_roles() local rtn = {} - for index, role in ipairs(ordered_roles) do - rtn[index] = role + for _, role in pairs(script_data.roles) do + rtn[#rtn + 1] = role end - return rtn + return sort_roles(rtn) end --- Get the role every player holds @@ -232,43 +223,60 @@ function ExpRoles.get_default_role() return script_data.default_role_id and script_data.roles[script_data.default_role_id] or nil end +--- Get a role and every role more privileged than it, the default role excluded +--- @param role ExpRoles.Role +--- @return ExpRoles.Role[] +function ExpRoles.get_higher_roles(role) + local rtn = {} + for _, other in pairs(script_data.roles) do + if not other:is_lower_than(role) and other.id ~= script_data.default_role_id then + rtn[#rtn + 1] = other + end + end + return sort_roles(rtn) +end + +--- Get a role and every role less privileged than it, the default role excluded +--- @param role ExpRoles.Role +--- @return ExpRoles.Role[] +function ExpRoles.get_lower_roles(role) + local rtn = {} + for _, other in pairs(script_data.roles) do + if not other:is_higher_than(role) and other.id ~= script_data.default_role_id then + rtn[#rtn + 1] = other + end + end + return sort_roles(rtn) +end + --- Role used when there is no player, such as for commands run by the server --- It has core.admin so it passes every permission check local server_role = setmetatable({ id = -1, name = "", short_hand = "SRV", - order = 0, - index = 0, + order = -math.huge, priority = 0, tag = "", color = nil, permissions = { ["core.admin"] = true }, - permission_group = nil, block_auto_assign = true, -}, { __index = ExpRoles._prototype }) +}, role_metatable) --- Get the roles which apply to a player, including the default role --- Only the roles with the highest priority are returned, which lets a role --- such as Jail suppress every other role a player holds ---- @param player LuaPlayer | string | nil +--- @param player LuaPlayer? nil for the server --- @return ExpRoles.Role[] function ExpRoles.get_player_roles(player) - local player_name = player_name_of(player) -- The server is not a player and is allowed to do anything - if player_name == nil then return { server_role } end - - local rtn = {} - for role_id in pairs(get_effective_role_ids(player_name)) do - rtn[#rtn + 1] = script_data.roles[role_id] - end - - table.sort(rtn, function(a, b) return a.index < b.index end) - return rtn + local valid = not_server(player) + if not valid then return { server_role } end + return get_effective_roles(valid.name) end --- Get the most privileged role which applies to a player ---- @param player LuaPlayer | string | nil +--- @param player LuaPlayer? nil for the server --- @return ExpRoles.Role function ExpRoles.get_player_highest_role(player) local role = ExpRoles.get_player_roles(player)[1] @@ -280,7 +288,7 @@ end ]] --- Check if a player has a permission through any of their roles ---- @param player LuaPlayer | string | nil +--- @param player LuaPlayer? nil for the server --- @param permission string A clusterio permission such as `exp_scenario.command.kill` --- @return boolean function ExpRoles.player_has_permission(player, permission) @@ -294,49 +302,21 @@ function ExpRoles.player_has_permission(player, permission) return false end ---- Check if a role applies to a player ---- @param player LuaPlayer | string | nil ---- @param search_role string | number | ExpRoles.Role ---- @return boolean -function ExpRoles.player_has_role(player, search_role) - local role = ExpRoles.get_role(search_role) - if not role then return false end - - for _, player_role in pairs(ExpRoles.get_player_roles(player)) do - if player_role.id == role.id then return true end - end - - return false -end - ---- Check if a player is more privileged than a role ---- A player with core.admin, which includes the server, outranks every role ---- @param player LuaPlayer | string | nil ---- @param role string | number | ExpRoles.Role ---- @return boolean -function ExpRoles.player_outranks_role(player, role) - local resolved = ExpRoles.get_role(role) - if not resolved then return false end - if ExpRoles.player_has_permission(player, "core.admin") then return true end - local highest = ExpRoles.get_player_roles(player)[1] - return highest ~= nil and highest.index < resolved.index -end - --- Check if a player is more privileged than another player --- A player with core.admin, which includes the server, outranks every player ---- @param player LuaPlayer | string | nil ---- @param other LuaPlayer | string | nil +--- @param player LuaPlayer? nil for the server +--- @param other LuaPlayer? nil for the server --- @return boolean function ExpRoles.player_outranks(player, other) if ExpRoles.player_has_permission(player, "core.admin") then return true end local highest = ExpRoles.get_player_roles(player)[1] local other_highest = ExpRoles.get_player_roles(other)[1] if highest == nil then return false end - return other_highest == nil or highest.index < other_highest.index + return other_highest == nil or highest:is_higher_than(other_highest) end --[[ - Role prototype + Role methods ]] --- Check if this role grants a permission @@ -348,6 +328,35 @@ function Role.has_permission(self, permission) return permissions["core.admin"] or permissions[permission] or false end +--- Check if this role is more privileged than another +--- @param self ExpRoles.Role +--- @param other ExpRoles.Role +--- @return boolean +function Role.is_higher_than(self, other) + if self.order == other.order then return self.id < other.id end + return self.order < other.order +end + +--- Check if this role is less privileged than another +--- @param self ExpRoles.Role +--- @param other ExpRoles.Role +--- @return boolean +function Role.is_lower_than(self, other) + return other:is_higher_than(self) +end + +--- Check if a player has been given this role, or it is the default role +--- A role a player holds does not always apply, see ExpRoles.get_player_roles +--- @param self ExpRoles.Role +--- @param player LuaPlayer? nil for the server +--- @return boolean +function Role.has_player(self, player) + local valid = not_server(player) + if not valid then return self == server_role end + if self.id == script_data.default_role_id then return true end + return get_held_role_set(valid.name)[self.id] == true +end + --- Get the names of every player who has been given this role --- This includes players who have never joined this map, and is not affected --- by priority, so a jailed moderator is still listed under moderator @@ -401,56 +410,6 @@ function Role.print(self, message) return #players end ---[[ - Printing to roles -]] - ---- Print a message to every player holding one of the given roles ---- @param roles (string | number | ExpRoles.Role)[] ---- @param message LocalisedString -function ExpRoles.print_to_roles(roles, message) - for _, role in pairs(roles) do - local resolved = ExpRoles.get_role(role) - if resolved then resolved:print(message) end - end -end - ---- Print a message to every player holding the given role or a more privileged one ---- The default role is never included ---- @param role string | number | ExpRoles.Role ---- @param message LocalisedString -function ExpRoles.print_to_roles_higher(role, message) - local resolved = ExpRoles.get_role(role) - if not resolved then return end - - local roles = {} - for _, other in ipairs(ordered_roles) do - if other.index <= resolved.index and other.id ~= script_data.default_role_id then - roles[#roles + 1] = other - end - end - - ExpRoles.print_to_roles(roles, message) -end - ---- Print a message to every player holding the given role or a less privileged one ---- The default role is never included ---- @param role string | number | ExpRoles.Role ---- @param message LocalisedString -function ExpRoles.print_to_roles_lower(role, message) - local resolved = ExpRoles.get_role(role) - if not resolved then return end - - local roles = {} - for _, other in ipairs(ordered_roles) do - if other.index >= resolved.index and other.id ~= script_data.default_role_id then - roles[#roles + 1] = other - end - end - - ExpRoles.print_to_roles(roles, message) -end - --[[ Permission triggers ]] @@ -463,23 +422,12 @@ function ExpRoles.define_permission_trigger(permission, callback) permission_triggers[permission] = Async.register(callback) end ---- Run every permission trigger for a player and move them into the permission ---- group of their most privileged role which names one +--- Run every permission trigger for a player --- @param player LuaPlayer -local function apply_player_state(player) +local function apply_permission_triggers(player) for permission, async_function in pairs(permission_triggers) do async_function(player, ExpRoles.player_has_permission(player, permission)) end - - for _, role in ipairs(ExpRoles.get_player_roles(player)) do - if role.permission_group then - local group = game.permissions.get_group(role.permission_group) - if group and (not player.permission_group or player.permission_group.name ~= group.name) then - set_permission_group_async(player, group) - end - break - end - end end --[[ @@ -496,22 +444,27 @@ local function emit_player_roles_changed(player, assigned, unassigned, by_player by_player_name = by_player_name or (game.player and game.player.name) or "" local by_player = game.get_player(by_player_name) + local assigned_ids, unassigned_ids = {}, {} local assigned_names, unassigned_names = {}, {} - for index, role in ipairs(assigned) do assigned_names[index] = role.name end - for index, role in ipairs(unassigned) do unassigned_names[index] = role.name end + for index, role in ipairs(assigned) do + assigned_ids[index], assigned_names[index] = role.id, role.name + end + for index, role in ipairs(unassigned) do + unassigned_ids[index], unassigned_names[index] = role.id, role.name + end if not silent then - if #assigned_names > 0 then + if #assigned > 0 then game.print{ "exp-roles.game-message-assign", player.name, table.concat(assigned_names, ", "), by_player_name } end - if #unassigned_names > 0 then + if #unassigned > 0 then game.print{ "exp-roles.game-message-unassign", player.name, table.concat(unassigned_names, ", "), by_player_name } end end - if #assigned_names > 0 then + if #assigned > 0 then player.play_sound{ path = "utility/achievement_unlocked" } - elseif #unassigned_names > 0 then + elseif #unassigned > 0 then player.play_sound{ path = "utility/game_lost" } end @@ -520,22 +473,11 @@ local function emit_player_roles_changed(player, assigned, unassigned, by_player tick = game.tick, player_index = player.index, by_player_index = by_player and by_player.index or 0, - assigned = assigned_names, - unassigned = unassigned_names, + assigned = assigned_ids, + unassigned = unassigned_ids, }) - apply_player_state(player) -end - ---- Role ids a player has been given, as a set ---- @param player_name string ---- @return table -local function get_held_role_set(player_name) - local rtn = {} - for _, role_id in pairs(get_held_role_ids(player_name)) do - rtn[role_id] = true - end - return rtn + apply_permission_triggers(player) end --- Compare the roles a player had been given before and after a change and @@ -558,9 +500,7 @@ local function emit_held_diff(player_name, before, by_player_name, silent) end if #assigned > 0 or #unassigned > 0 then - table.sort(assigned, function(a, b) return a.index < b.index end) - table.sort(unassigned, function(a, b) return a.index < b.index end) - emit_player_roles_changed(player, assigned, unassigned, by_player_name, silent) + emit_player_roles_changed(player, sort_roles(assigned), sort_roles(unassigned), by_player_name, silent) end end @@ -591,56 +531,32 @@ local function add_role_id(list, role_id) return true end ---- Convert a role, role name, or array of either into an array of roles ---- @param roles any ---- @return ExpRoles.Role[] -local function resolve_roles(roles) - if type(roles) ~= "table" or roles.name then - roles = { roles } - end - - local rtn = {} - for _, role in pairs(roles) do - local resolved = ExpRoles.get_role(role) - if resolved then rtn[#rtn + 1] = resolved end - end - - return rtn -end - ---- Apply a role change to the local state +--- Apply a role change to the local state, returns true when something changed --- @param player_name string ---- @param roles ExpRoles.Role[] ---- @param change_type "assign" | "unassign" +--- @param role ExpRoles.Role +--- @param assign boolean --- @param sync boolean ---- @return ExpRoles.Role[] # The roles which actually changed -local function apply_local_change(player_name, roles, change_type, sync) +--- @return boolean +local function apply_local_change(player_name, role, assign, sync) local local_roles = script_data.local_players[player_name] or {} local pending = script_data.pending[player_name] or {} - local changed = {} + local changed - for _, role in pairs(roles) do - if change_type == "assign" then - local synced = script_data.synced_players[player_name] or {} - local already_synced = false - for _, role_id in pairs(synced) do - if role_id == role.id then already_synced = true break end - end - if not already_synced and add_role_id(local_roles, role.id) then - changed[#changed + 1] = role - if sync then add_role_id(pending, role.id) end - end - else - local removed = remove_role_id(local_roles, role.id) - remove_role_id(pending, role.id) - -- A synced role is only taken away here when the controller is - -- being told as well, otherwise the next update would restore it - if sync then - removed = remove_role_id(script_data.synced_players[player_name], role.id) or removed - end - if removed then - changed[#changed + 1] = role - end + if assign then + local synced = script_data.synced_players[player_name] or {} + local already_synced = false + for _, role_id in pairs(synced) do + if role_id == role.id then already_synced = true break end + end + changed = not already_synced and add_role_id(local_roles, role.id) + if changed and sync then add_role_id(pending, role.id) end + else + changed = remove_role_id(local_roles, role.id) + remove_role_id(pending, role.id) + -- A synced role is only taken away here when the controller is + -- being told as well, otherwise the next update would restore it + if sync then + changed = remove_role_id(script_data.synced_players[player_name], role.id) or changed end end @@ -652,83 +568,53 @@ end --- Send a role change to the controller --- @param player_name string ---- @param roles ExpRoles.Role[] ---- @param change_type "assign" | "unassign" -local function emit_assignment_update(player_name, roles, change_type) +--- @param role ExpRoles.Role +--- @param assign boolean +local function emit_assignment_update(player_name, role, assign) if not script_data.emit_updates then return end - local role_ids = {} - for index, role in ipairs(roles) do - role_ids[index] = role.id - end - clusterio_api.send_json("exp_roles:assignment_update", { name = player_name, - assign = change_type == "assign" and role_ids or nil, - unassign = change_type == "unassign" and role_ids or nil, + assign = assign and { role.id } or nil, + unassign = not assign and { role.id } or nil, }) end ---- Change the roles a player holds ---- @param player LuaPlayer | string ---- @param roles any A role, role name, or array of either ---- @param change_type "assign" | "unassign" ---- @param by_player_name string? ---- @param silent boolean? ---- @param sync boolean -local function change_player_roles(player, roles, change_type, by_player_name, silent, sync) - local player_name = player_name_of(player) - if not player_name then return end - - local role_objects = resolve_roles(roles) - if #role_objects == 0 then return end +--- Change whether a player holds a role +--- @param role ExpRoles.Role +--- @param player LuaPlayer +--- @param assign boolean +--- @param options ExpRoles.AssignOptions? +local function change_player_role(role, player, assign, options) + local valid = not_server(player) + if not valid then return end + options = options or {} + local player_name = valid.name local before = get_held_role_set(player_name) - local changed = apply_local_change(player_name, role_objects, change_type, sync) - if #changed == 0 then return end + if not apply_local_change(player_name, role, assign, not options.local_only) then return end - if sync then - emit_assignment_update(player_name, changed, change_type) + if not options.local_only then + emit_assignment_update(player_name, role, assign) end - emit_held_diff(player_name, before, by_player_name, silent) + emit_held_diff(player_name, before, options.by_player_name, options.silent) end ---- Give a player one or more roles, the change is sent to the controller ---- @param player LuaPlayer | string ---- @param roles any A role, role name, or array of either ---- @param by_player_name string? Shown in the game message, defaults to the current player or the server ---- @param silent boolean? When true no game message is printed -function ExpRoles.assign_player(player, roles, by_player_name, silent) - change_player_roles(player, roles, "assign", by_player_name, silent, true) +--- Give a player this role, the change is sent to the controller unless local_only is set +--- @param self ExpRoles.Role +--- @param player LuaPlayer +--- @param options ExpRoles.AssignOptions? +function Role.assign(self, player, options) + change_player_role(self, player, true, options) end ---- Take one or more roles from a player, the change is sent to the controller ---- @param player LuaPlayer | string ---- @param roles any A role, role name, or array of either ---- @param by_player_name string? Shown in the game message, defaults to the current player or the server ---- @param silent boolean? When true no game message is printed -function ExpRoles.unassign_player(player, roles, by_player_name, silent) - change_player_roles(player, roles, "unassign", by_player_name, silent, true) -end - ---- Give a player one or more roles on this map only, the controller is not told ---- Use this for roles earned from progress which does not leave this map ---- @param player LuaPlayer | string ---- @param roles any A role, role name, or array of either ---- @param by_player_name string? Shown in the game message, defaults to the current player or the server ---- @param silent boolean? When true no game message is printed -function ExpRoles.assign_player_local(player, roles, by_player_name, silent) - change_player_roles(player, roles, "assign", by_player_name, silent, false) -end - ---- Take one or more local roles from a player, the controller is not told ---- @param player LuaPlayer | string ---- @param roles any A role, role name, or array of either ---- @param by_player_name string? Shown in the game message, defaults to the current player or the server ---- @param silent boolean? When true no game message is printed -function ExpRoles.unassign_player_local(player, roles, by_player_name, silent) - change_player_roles(player, roles, "unassign", by_player_name, silent, false) +--- Take this role from a player, the change is sent to the controller unless local_only is set +--- @param self ExpRoles.Role +--- @param player LuaPlayer +--- @param options ExpRoles.AssignOptions? +function Role.unassign(self, player, options) + change_player_role(self, player, false, options) end --[[ @@ -736,7 +622,7 @@ end ]] --- Stop holding a pending role locally once the controller has confirmed it ---- Roles assigned with assign_player_local are never pending so are untouched +--- Roles assigned with local_only are never pending so are untouched --- @param player_name string local function drop_confirmed_local(player_name) local pending = script_data.pending[player_name] @@ -758,9 +644,6 @@ end --- Restore local references to persistent script data after load function ExpRoles.on_load() script_data = compat.script_data["exp_roles"] - if script_data then - rebuild_role_views() - end end --- Enable or disable sending role changes back to the controller @@ -769,7 +652,7 @@ function ExpRoles.set_emit_events(enabled) script_data.emit_updates = enabled ~= false end ---- Apply the state of every connected player, and raise the event so guis +--- Apply the triggers of every connected player, and raise the event so guis --- can refresh, after the roles themselves have changed local function roles_changed() for _, player in pairs(game.connected_players) do @@ -818,9 +701,7 @@ function ExpRoles.initialise(payload) end script_data.pending = {} - rebuild_role_views() roles_changed() - script_data.emit_updates = emit_updates end @@ -843,7 +724,6 @@ function ExpRoles.receive_role_updates(records) end end - rebuild_role_views() roles_changed() end @@ -868,17 +748,15 @@ end --- Roll back a local assignment which the controller rejected --- @param payload { name: string, role_ids: number[] } function ExpRoles.reject_assignment(payload) - local player_name = payload.name - local roles = {} + local player = game.get_player(payload.name) for _, role_id in pairs(payload.role_ids) do local role = script_data.roles[role_id] - if role then roles[#roles + 1] = role end + if role and player then + role:unassign(player, { by_player_name = "", silent = true, local_only = true }) + elseif role then + apply_local_change(payload.name, role, false, false) + end end - - if #roles == 0 then return end - - -- Sync is false, so this is never sent back to the controller - change_player_roles(player_name, roles, "unassign", "", true, false) end --- Get the current script data for debugging purposes @@ -908,11 +786,11 @@ function ExpRoles.on_server_startup() ExpRoles.on_load() end ---- Apply the permission triggers and group for a player who just joined +--- Apply the permission triggers for a player who just joined --- @param event EventData.on_player_joined_game function ExpRoles.on_player_joined_game(event) local player = game.get_player(event.player_index) - if player then apply_player_state(player) end + if player then apply_permission_triggers(player) end end return ExpRoles diff --git a/exp_roles/seed.ts b/exp_roles/seed.ts index 430c5ec7..97325245 100644 --- a/exp_roles/seed.ts +++ b/exp_roles/seed.ts @@ -1,8 +1,8 @@ import { RoleColor } from "./messages"; /** - * A role created the first time the plugin runs, as the scenario defined it - * before roles moved to the controller. `parent` stands in for the inheritance + * 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. */ @@ -10,8 +10,6 @@ export interface SeedRole { name: string; shortHand: string; color: RoleColor | null; - /** Factorio permission group, see exp_legacy/module/config/expcore/permission_groups.lua */ - permissionGroup: string; priority?: number; autoAssignHours?: number; blockAutoAssign?: boolean; @@ -24,16 +22,11 @@ export interface SeedRole { permissions: string[]; } -const admin = ["exp_scenario.player.admin", "exp_scenario.player.spectator", "exp_scenario.bypass.reports"]; -const instantRespawn = ["exp_scenario.player.instant_respawn"]; -const trusted = ["exp_scenario.player.spectator", "exp_scenario.bypass.reports", ...instantRespawn]; - export const seedRoles: SeedRole[] = [ { name: "System", shortHand: "SYS", color: null, - permissionGroup: "Default", isAdmin: true, permissions: [], }, @@ -41,11 +34,8 @@ export const seedRoles: SeedRole[] = [ name: "Senior Administrator", shortHand: "SAdmin", color: new RoleColor(233, 63, 233), - permissionGroup: "Admin", parent: "Administrator", permissions: [ - ...admin, ...instantRespawn, - "exp_scenario.player.system_commands", "exp_scenario.command._rcon", "exp_scenario.command.debug", "exp_scenario.command.set_cheat_mode", @@ -56,10 +46,8 @@ export const seedRoles: SeedRole[] = [ name: "Administrator", shortHand: "Admin", color: new RoleColor(233, 63, 233), - permissionGroup: "Admin", parent: "Moderator", permissions: [ - ...admin, ...instantRespawn, "exp_scenario.gui.warp_list.bypass_proximity", "exp_scenario.gui.warp_list.bypass_cooldown", "exp_scenario.command.connect_all", @@ -69,10 +57,9 @@ export const seedRoles: SeedRole[] = [ name: "Moderator", shortHand: "Mod", color: new RoleColor(0, 170, 0), - permissionGroup: "Admin", parent: "Trainee", permissions: [ - ...admin, ...instantRespawn, + "exp_scenario.player.instant_respawn", "exp_scenario.command.assign_role", "exp_scenario.command.unassign_role", "exp_scenario.command.repair", @@ -107,10 +94,11 @@ export const seedRoles: SeedRole[] = [ name: "Trainee", shortHand: "TrMod", color: new RoleColor(0, 170, 0), - permissionGroup: "Admin", parent: "Veteran", permissions: [ - ...admin, + "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", @@ -139,10 +127,8 @@ export const seedRoles: SeedRole[] = [ name: "Board Member", shortHand: "Board", color: new RoleColor(247, 246, 54), - permissionGroup: "Trusted", parent: "Sponsor", permissions: [ - ...trusted, "exp_scenario.command.goto", "exp_scenario.command.repair", "exp_scenario.command.spectate", @@ -154,20 +140,17 @@ export const seedRoles: SeedRole[] = [ name: "Senior Backer", shortHand: "Backer", color: new RoleColor(238, 172, 44), - permissionGroup: "Trusted", parent: "Sponsor", - permissions: [ - ...trusted, - ], + permissions: [], }, { name: "Sponsor", shortHand: "Spon", color: new RoleColor(238, 172, 44), - permissionGroup: "Trusted", parent: "Supporter", permissions: [ - ...trusted, + "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", @@ -182,7 +165,6 @@ export const seedRoles: SeedRole[] = [ name: "Supporter", shortHand: "Sup", color: new RoleColor(230, 99, 34), - permissionGroup: "Trusted", parent: "Veteran", permissions: [ "exp_scenario.player.spectator", @@ -197,7 +179,6 @@ export const seedRoles: SeedRole[] = [ name: "Partner", shortHand: "Part", color: new RoleColor(140, 120, 200), - permissionGroup: "Trusted", parent: "Veteran", permissions: [ "exp_scenario.player.spectator", @@ -209,7 +190,6 @@ export const seedRoles: SeedRole[] = [ name: "Veteran", shortHand: "Vet", color: new RoleColor(140, 120, 200), - permissionGroup: "Trusted", parent: "Member", autoAssignHours: 10, permissions: [ @@ -223,7 +203,6 @@ export const seedRoles: SeedRole[] = [ name: "Member", shortHand: "Mem", color: new RoleColor(24, 172, 188), - permissionGroup: "Standard", parent: "Regular", permissions: [ "exp_scenario.bypass.deconstruction_log", @@ -245,7 +224,6 @@ export const seedRoles: SeedRole[] = [ name: "Regular", shortHand: "Reg", color: new RoleColor(79, 155, 163), - permissionGroup: "Standard", autoAssignHours: 3, permissions: [ "exp_scenario.command.kill", @@ -261,7 +239,6 @@ export const seedRoles: SeedRole[] = [ name: "Jail", shortHand: "Jail", color: new RoleColor(50, 50, 50), - permissionGroup: "Restricted", priority: 1, blockAutoAssign: true, permissions: [], @@ -270,54 +247,11 @@ export const seedRoles: SeedRole[] = [ name: "Guest", shortHand: "", color: new RoleColor(185, 187, 160), - permissionGroup: "Guest", isDefault: true, permissions: [], }, ]; -/** Players given roles the first time the plugin runs, by role name. */ -export const seedAssignments: Record = { - "PHIDIAS0303": ["Moderator", "Board Member", "Member"], - "aldldl": ["Administrator", "Moderator", "Member"], - "arty714": ["Senior Administrator", "Moderator", "Member"], - "Cooldude2606": ["Senior Administrator", "Moderator", "Member"], - "Drahc_pro": ["Administrator", "Moderator", "Member"], - "mark9064": ["Administrator", "Moderator", "Member"], - "7h3w1z4rd": ["Moderator", "Member"], - "FlipHalfling90": ["Moderator", "Member"], - "hamsterbryan": ["Moderator", "Member"], - "HunterOfGames": ["Moderator", "Member"], - "NextIdea": ["Moderator", "Member"], - "TheKernel32": ["Moderator", "Member"], - "TheKernel64": ["Moderator", "Member"], - "tovernaar123": ["Moderator", "Member"], - "UUBlueFire": ["Moderator", "Member"], - "AssemblyStorm": ["Moderator", "Member"], - "banakeg": ["Moderator", "Member"], - "connormkii": ["Moderator", "Member"], - "cydes": ["Moderator", "Member"], - "darklich14": ["Moderator", "Member"], - "facere": ["Moderator", "Member"], - "freek18": ["Moderator", "Member"], - "Gizan": ["Moderator", "Member"], - "LoicB": ["Moderator", "Member"], - "M74132": ["Moderator", "Member"], - "mafisch3": ["Moderator", "Member"], - "maplesyrup01": ["Moderator", "Member"], - "ookl": ["Moderator", "Member"], - "Phoenix27833": ["Moderator", "Member"], - "porelos": ["Moderator", "Member"], - "Ruuyji": ["Moderator", "Member"], - "samy115": ["Moderator", "Member"], - "SilentLog": ["Moderator", "Member"], - "Tcheko": ["Moderator", "Member"], - "thadius856": ["Moderator", "Member"], - "whoami32": ["Moderator", "Member"], - "Windbomb": ["Moderator", "Member"], - "XenoCyber": ["Moderator", "Member"], -}; - /** The permissions a seed role grants, including those of its parents. */ export function flattenSeedPermissions(role: SeedRole, roles = seedRoles) { const permissions = new Set(); diff --git a/exp_roles/web/components/RoleProperties.tsx b/exp_roles/web/components/RoleProperties.tsx index cce29d5e..b63ae707 100644 --- a/exp_roles/web/components/RoleProperties.tsx +++ b/exp_roles/web/components/RoleProperties.tsx @@ -15,7 +15,6 @@ type RoleFormValues = { shortHand: string; tag: string; color: string | { toRgb(): { r: number, g: number, b: number } } | null; - permissionGroup: string; autoAssignHours: number | null; blockAutoAssign: boolean; }; @@ -47,7 +46,6 @@ export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Ro shortHand: meta.shortHand, tag: meta.tag, color: meta.color ? `rgb(${meta.color.r}, ${meta.color.g}, ${meta.color.b})` : null, - permissionGroup: meta.permissionGroup, autoAssignHours: meta.autoAssignOnlineTimeMs === null ? null : meta.autoAssignOnlineTimeMs / MS_PER_HOUR, @@ -80,7 +78,6 @@ export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Ro values.shortHand ?? "", values.tag ?? "", color, - values.permissionGroup ?? "", values.autoAssignHours === null || values.autoAssignHours === undefined ? null : values.autoAssignHours * MS_PER_HOUR, @@ -161,19 +158,6 @@ export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Ro - - - - - - { + setSeeding(true); + control.send(new SeedRolesRequest()) + .catch(notifyErrorHandler("Error seeding roles")) + .finally(() => setSeeding(false)); + }} + > + + } + />; +} diff --git a/exp_roles/web/index.tsx b/exp_roles/web/index.tsx index 644cd10d..1bd55066 100644 --- a/exp_roles/web/index.tsx +++ b/exp_roles/web/index.tsx @@ -5,6 +5,7 @@ import * as lib from "@clusterio/lib"; import * as messages from "../messages"; import RoleProperties from "./components/RoleProperties"; +import SeedRoles from "./components/SeedRoles"; export class WebPlugin extends BaseWebPlugin { roles = new lib.MapSubscriber(messages.RoleUpdatedEvent, this.control); @@ -14,6 +15,7 @@ export class WebPlugin extends BaseWebPlugin { // does not carry in its type this.componentExtra = { RoleViewPage: RoleProperties as React.ComponentType, + RolesPage: SeedRoles, }; } diff --git a/exp_scenario/module/commands/_authorities.lua b/exp_scenario/module/commands/_authorities.lua index 41e41cf9..37093ad8 100644 --- a/exp_scenario/module/commands/_authorities.lua +++ b/exp_scenario/module/commands/_authorities.lua @@ -22,7 +22,7 @@ authorities.exp_permission = end end) -Roles.define_permission_trigger("exp_scenario.player.system_commands", function(player, state) +Roles.define_permission_trigger("core.admin", function(player, state) if state then Commands.unlock_system_commands(player.name) else diff --git a/exp_scenario/module/commands/_types.lua b/exp_scenario/module/commands/_types.lua index 4c18de0f..bda2b334 100644 --- a/exp_scenario/module/commands/_types.lua +++ b/exp_scenario/module/commands/_types.lua @@ -19,6 +19,7 @@ local valid, invalid = Commands.status.success, Commands.status.invalid_input 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 @@ -34,7 +35,7 @@ types.role = if name == nil then return invalid{ "exp-commands-parse.string-options", table.concat(names, ", ") } else - return valid(Roles.get_role(name)) + return valid(Roles.get_role_by_name(name)) end end) @@ -45,7 +46,7 @@ types.lower_role = if not success then return status, result end --- @cast result ExpRoles.Role - if not Roles.player_outranks_role(player, result) 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) diff --git a/exp_scenario/module/commands/protected_entities.lua b/exp_scenario/module/commands/protected_entities.lua index 9f810634..11c24fe8 100644 --- a/exp_scenario/module/commands/protected_entities.lua +++ b/exp_scenario/module/commands/protected_entities.lua @@ -218,13 +218,16 @@ SelectEntities:on_stop(on_player_selection_stop) --- When there is a repeat offence print it in chat local function on_repeat_violation(event) - Roles.print_to_roles_higher("Regular", { - "exp-commands_entity-protection.repeat-offence", - format_player_name(event.player_index), - event.entity.localised_name, - event.entity.position.x, - event.entity.position.y - }) + local regular = Roles.get_role_by_name("Regular") + for _, role in ipairs(regular and Roles.get_higher_roles(regular) or {}) do + role:print{ + "exp-commands_entity-protection.repeat-offence", + format_player_name(event.player_index), + event.entity.localised_name, + event.entity.position.x, + event.entity.position.y + } + end end return { diff --git a/exp_scenario/module/commands/roles.lua b/exp_scenario/module/commands/roles.lua index b8231ebd..cf5498a9 100644 --- a/exp_scenario/module/commands/roles.lua +++ b/exp_scenario/module/commands/roles.lua @@ -19,7 +19,7 @@ Commands.new("assign-role", { "exp-commands_roles.description-assign" }) :register(function(player, other_player, role) --- @cast other_player LuaPlayer --- @cast role ExpRoles.Role - Roles.assign_player(other_player, role, player.name) + role:assign(other_player, { by_player_name = player.name }) end) --- Unassigns a role to a player @@ -31,7 +31,7 @@ Commands.new("unassign-role", { "exp-commands_roles.description-unassign" }) :register(function(player, other_player, role) --- @cast other_player LuaPlayer --- @cast role ExpRoles.Role - Roles.unassign_player(other_player, role, player.name) + role:unassign(other_player, { by_player_name = player.name }) end) --- Lists all roles in they correct order diff --git a/exp_scenario/module/gui/player_bonus.lua b/exp_scenario/module/gui/player_bonus.lua index 10cdc212..13317530 100644 --- a/exp_scenario/module/gui/player_bonus.lua +++ b/exp_scenario/module/gui/player_bonus.lua @@ -68,8 +68,11 @@ do local _points_limit = {} --- @type table --- @param player LuaPlayer --- @return number function Elements.bonus_used._calculate_points_limit(player) - local role = assert(Roles.get_role(config.points.role_name), "Bonus points role does not exist") - local role_diff = role.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 diff --git a/exp_scenario/module/gui/readme.lua b/exp_scenario/module/gui/readme.lua index 4a3ebc05..1c606b34 100644 --- a/exp_scenario/module/gui/readme.lua +++ b/exp_scenario/module/gui/readme.lua @@ -396,7 +396,7 @@ define_tab( for _, group in ipairs(groups) do local seen = {} for _, role_name in ipairs(group.roles) do - local role = Roles.get_role(role_name) + 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 diff --git a/exp_scenario/permissions.ts b/exp_scenario/permissions.ts index d5f04b0f..9d604b58 100644 --- a/exp_scenario/permissions.ts +++ b/exp_scenario/permissions.ts @@ -123,7 +123,6 @@ const definitions: Definition[] = [ ["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."], - ["exp_scenario.player.system_commands", "System commands", "Unlock commands flagged system only, such as /_rcon and /_sudo."], ]; for (const [name, title, description, grantByDefault] of definitions) { From 8a15745cf81bb5972d66661b30b3c4c9bad7fd75 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:02:51 +0000 Subject: [PATCH 07/16] Address review on the role lookups - player_has_any_permission and player_has_all_permission, matching the account checks the web ui has. - Roles are no longer sorted on the way out of every lookup. get_ordered_roles and the public sort_roles cover the two guis and the command which present roles in order, and the highest role is found with a single scan. - get_held_role_ids returns the list and the set it already built, rather than a second function rebuilding the set from the list. - get_player_names collects into a set before listing, so a player holding the role in both the synced and the local list is counted once. - The role metatable is registered directly under the plugin name, dropping the storage import. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + exp_roles/module/control.lua | 132 ++++++++++++++++-------- exp_scenario/module/commands/_types.lua | 2 +- exp_scenario/module/commands/roles.lua | 6 +- exp_scenario/module/gui/player_list.lua | 2 +- exp_scenario/module/gui/readme.lua | 2 +- 6 files changed, 96 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index bf2406f1..9ddd01dc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ dist/ node_modules/ .vscode .luarc.json +.tap/ diff --git a/exp_roles/module/control.lua b/exp_roles/module/control.lua index 45412512..f6a8aff1 100644 --- a/exp_roles/module/control.lua +++ b/exp_roles/module/control.lua @@ -19,7 +19,6 @@ every player has. local clusterio_api = require("modules/clusterio/api") local compat = require("modules/clusterio/compat") --[[@as LibCompat]] local Async = require("modules/exp_util/async") -local Storage = require("modules/exp_util/storage") --- @class ExpRoles local ExpRoles = { @@ -45,7 +44,8 @@ local Role = {} ExpRoles._prototype = Role --- Registered so roles keep their methods across save and load -local role_metatable = Storage.register_metatable("Role", { __index = Role }) +local role_metatable = { __index = Role } +script.register_metatable("exp_roles.Role", role_metatable) --- @class ExpRoles.Role : ExpRoles.RolePrototype --- @field id number Clusterio role id @@ -114,7 +114,7 @@ end --- Sort roles in place, the most privileged first --- @param roles ExpRoles.Role[] --- @return ExpRoles.Role[] -local function sort_roles(roles) +function ExpRoles.sort_roles(roles) table.sort(roles, function(a, b) if a.order == b.order then return a.id < b.id end return a.order < b.order @@ -122,6 +122,19 @@ local function sort_roles(roles) return roles end +--- Get the most privileged role of a list +--- @param roles ExpRoles.Role[] +--- @return ExpRoles.Role? +local function highest_role_of(roles) + local highest + for _, role in pairs(roles) do + if highest == nil or role:is_higher_than(highest) then + highest = role + end + end + return highest +end + --- The server is represented by nil, or by a player object with index 0 --- @param player LuaPlayer? --- @return LuaPlayer? # The player when it is not the server @@ -132,7 +145,7 @@ end --- Role ids a player has been given, without the default role or priority applied --- @param player_name string ---- @return number[] +--- @return number[], table local function get_held_role_ids(player_name) local role_ids, seen = {}, {} for _, list in pairs{ script_data.synced_players[player_name], script_data.local_players[player_name] } do @@ -143,18 +156,7 @@ local function get_held_role_ids(player_name) end end end - return role_ids -end - ---- Role ids a player has been given, as a set ---- @param player_name string ---- @return table -local function get_held_role_set(player_name) - local rtn = {} - for _, role_id in pairs(get_held_role_ids(player_name)) do - rtn[role_id] = true - end - return rtn + return role_ids, seen end --- Roles which apply to a player, with the default role and priority applied @@ -183,7 +185,7 @@ local function get_effective_roles(player_name) rtn[#rtn + 1] = role end end - return sort_roles(rtn) + return rtn end --[[ @@ -207,14 +209,20 @@ function ExpRoles.get_role_by_name(name) return nil end ---- Get every role, the most privileged first +--- Get every role, in no particular order --- @return ExpRoles.Role[] function ExpRoles.get_roles() local rtn = {} for _, role in pairs(script_data.roles) do rtn[#rtn + 1] = role end - return sort_roles(rtn) + return rtn +end + +--- Get every role, the most privileged first +--- @return ExpRoles.Role[] +function ExpRoles.get_ordered_roles() + return ExpRoles.sort_roles(ExpRoles.get_roles()) end --- Get the role every player holds @@ -233,7 +241,7 @@ function ExpRoles.get_higher_roles(role) rtn[#rtn + 1] = other end end - return sort_roles(rtn) + return rtn end --- Get a role and every role less privileged than it, the default role excluded @@ -246,7 +254,7 @@ function ExpRoles.get_lower_roles(role) rtn[#rtn + 1] = other end end - return sort_roles(rtn) + return rtn end --- Role used when there is no player, such as for commands run by the server @@ -279,7 +287,7 @@ end --- @param player LuaPlayer? nil for the server --- @return ExpRoles.Role function ExpRoles.get_player_highest_role(player) - local role = ExpRoles.get_player_roles(player)[1] + local role = highest_role_of(ExpRoles.get_player_roles(player)) return (assert(role, "Player has no roles, is the default role set and exp_roles syncing?")) end @@ -287,21 +295,56 @@ end Permission checks ]] ---- Check if a player has a permission through any of their roles ---- @param player LuaPlayer? nil for the server ---- @param permission string A clusterio permission such as `exp_scenario.command.kill` +--- Check if any of the roles grants a permission +--- @param roles ExpRoles.Role[] +--- @param permission string --- @return boolean -function ExpRoles.player_has_permission(player, permission) - for _, role in pairs(ExpRoles.get_player_roles(player)) do +local function roles_grant(roles, permission) + for _, role in pairs(roles) do local permissions = role.permissions if permissions["core.admin"] or permissions[permission] then return true end end - return false end +--- Check if a player has a permission through any of their roles +--- @param player LuaPlayer? nil for the server +--- @param permission string A clusterio permission such as `exp_scenario.command.kill` +--- @return boolean +function ExpRoles.player_has_permission(player, permission) + return roles_grant(ExpRoles.get_player_roles(player), permission) +end + +--- Check if a player has at least one of the permissions +--- @param player LuaPlayer? nil for the server +--- @param ... string Clusterio permission names +--- @return boolean +function ExpRoles.player_has_any_permission(player, ...) + local roles = ExpRoles.get_player_roles(player) + for index = 1, select("#", ...) do + if roles_grant(roles, (select(index, ...))) then + return true + end + end + return false +end + +--- Check if a player has every one of the permissions +--- @param player LuaPlayer? nil for the server +--- @param ... string Clusterio permission names +--- @return boolean +function ExpRoles.player_has_all_permission(player, ...) + local roles = ExpRoles.get_player_roles(player) + for index = 1, select("#", ...) do + if not roles_grant(roles, (select(index, ...))) then + return false + end + end + return true +end + --- Check if a player is more privileged than another player --- A player with core.admin, which includes the server, outranks every player --- @param player LuaPlayer? nil for the server @@ -309,8 +352,8 @@ end --- @return boolean function ExpRoles.player_outranks(player, other) if ExpRoles.player_has_permission(player, "core.admin") then return true end - local highest = ExpRoles.get_player_roles(player)[1] - local other_highest = ExpRoles.get_player_roles(other)[1] + local highest = highest_role_of(ExpRoles.get_player_roles(player)) + local other_highest = highest_role_of(ExpRoles.get_player_roles(other)) if highest == nil then return false end return other_highest == nil or highest:is_higher_than(other_highest) end @@ -354,7 +397,8 @@ function Role.has_player(self, player) local valid = not_server(player) if not valid then return self == server_role end if self.id == script_data.default_role_id then return true end - return get_held_role_set(valid.name)[self.id] == true + local _, held = get_held_role_ids(valid.name) + return held[self.id] == true end --- Get the names of every player who has been given this role @@ -363,21 +407,23 @@ end --- @param self ExpRoles.Role --- @return string[] function Role.get_player_names(self) - local names, seen = {}, {} + -- A player can be in both lists, so collect into a set first + local seen = {} for _, players in pairs{ script_data.synced_players, script_data.local_players } do for player_name, role_ids in pairs(players) do - if not seen[player_name] then - for _, role_id in pairs(role_ids) do - if role_id == self.id then - seen[player_name] = true - names[#names + 1] = player_name - break - end + for _, role_id in pairs(role_ids) do + if role_id == self.id then + seen[player_name] = true + break end end end end + local names = {} + for player_name in pairs(seen) do + names[#names + 1] = player_name + end return names end @@ -490,7 +536,7 @@ local function emit_held_diff(player_name, before, by_player_name, silent) local player = game.get_player(player_name) if not player then return end - local after = get_held_role_set(player_name) + local _, after = get_held_role_ids(player_name) local assigned, unassigned = {}, {} for role_id in pairs(after) do if not before[role_id] then assigned[#assigned + 1] = script_data.roles[role_id] end @@ -500,7 +546,7 @@ local function emit_held_diff(player_name, before, by_player_name, silent) end if #assigned > 0 or #unassigned > 0 then - emit_player_roles_changed(player, sort_roles(assigned), sort_roles(unassigned), by_player_name, silent) + emit_player_roles_changed(player, assigned, unassigned, by_player_name, silent) end end @@ -591,7 +637,7 @@ local function change_player_role(role, player, assign, options) options = options or {} local player_name = valid.name - local before = get_held_role_set(player_name) + local _, before = get_held_role_ids(player_name) if not apply_local_change(player_name, role, assign, not options.local_only) then return end if not options.local_only then @@ -732,7 +778,7 @@ end function ExpRoles.receive_assignment_updates(records) for _, record in pairs(records) do local player_name = record.name - local before = get_held_role_set(player_name) + local _, before = get_held_role_ids(player_name) if record.is_deleted then script_data.synced_players[player_name] = nil diff --git a/exp_scenario/module/commands/_types.lua b/exp_scenario/module/commands/_types.lua index bda2b334..a3395e41 100644 --- a/exp_scenario/module/commands/_types.lua +++ b/exp_scenario/module/commands/_types.lua @@ -27,7 +27,7 @@ local types = {} --- @class (partial) Commands.types types.role = add("role", function(input) local names = {} - for index, role in ipairs(Roles.get_roles()) do + for index, role in ipairs(Roles.get_ordered_roles()) do names[index] = role.name end diff --git a/exp_scenario/module/commands/roles.lua b/exp_scenario/module/commands/roles.lua index cf5498a9..60502966 100644 --- a/exp_scenario/module/commands/roles.lua +++ b/exp_scenario/module/commands/roles.lua @@ -7,7 +7,7 @@ local format_player_name = Commands.format_player_name_locale local format_text = Commands.format_rich_text_color_locale local Roles = require("modules/exp_roles") -local get_roles = Roles.get_roles +local get_ordered_roles = Roles.get_ordered_roles local get_player_roles = Roles.get_player_roles --- Assigns a role to a player @@ -40,11 +40,11 @@ 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() + 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 diff --git a/exp_scenario/module/gui/player_list.lua b/exp_scenario/module/gui/player_list.lua index 42258e30..e6fc9058 100644 --- a/exp_scenario/module/gui/player_list.lua +++ b/exp_scenario/module/gui/player_list.lua @@ -203,7 +203,7 @@ function Elements.player_table.calculate_row_data() -- Flatten the roles into a single ordered list local count = 0 local row_data = {} - for _, role in ipairs(Roles.get_roles()) 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 diff --git a/exp_scenario/module/gui/readme.lua b/exp_scenario/module/gui/readme.lua index 1c606b34..5f8c2729 100644 --- a/exp_scenario/module/gui/readme.lua +++ b/exp_scenario/module/gui/readme.lua @@ -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 From e79822b26e0198a22f93e23dc74c411748f61ccb Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:02:51 +0000 Subject: [PATCH 08/16] Add a test suite for the roles plugin Run with `pnpm --filter @expcluster/roles test`, using tap as the runner. The lua module is exercised for real: each test file runs in its own lua state, created on first use by the helper, with the factorio surface and the clusterio modules stubbed. The environment is passed to the chunk as an argument so no globals are involved. Covers lookups and comparisons, the permission checks, assignment with confirmation, rejection and local only roles, jail suppression, the sync entry points, and the holder listings, including a player holding a role in both the synced and the local list. The javascript side covers the message records round tripping through their schemas, the indexed permission encoding, and the seed: every permission it grants must be defined by exp_scenario, and every parent must exist and have its permissions carried into its children. Co-Authored-By: Claude Fable 5 --- exp_roles/package.json | 6 +- exp_roles/test/helpers/lua.js | 62 ++++++++++ exp_roles/test/lua/assignment.lua | 93 ++++++++++++++ exp_roles/test/lua/env.lua | 193 ++++++++++++++++++++++++++++++ exp_roles/test/lua/holders.lua | 47 ++++++++ exp_roles/test/lua/lookup.lua | 36 ++++++ exp_roles/test/lua/players.lua | 56 +++++++++ exp_roles/test/lua/sync.lua | 91 ++++++++++++++ exp_roles/test/messages.test.js | 43 +++++++ exp_roles/test/module.test.js | 8 ++ exp_roles/test/seed.test.js | 40 +++++++ 11 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 exp_roles/test/helpers/lua.js create mode 100644 exp_roles/test/lua/assignment.lua create mode 100644 exp_roles/test/lua/env.lua create mode 100644 exp_roles/test/lua/holders.lua create mode 100644 exp_roles/test/lua/lookup.lua create mode 100644 exp_roles/test/lua/players.lua create mode 100644 exp_roles/test/lua/sync.lua create mode 100644 exp_roles/test/messages.test.js create mode 100644 exp_roles/test/module.test.js create mode 100644 exp_roles/test/seed.test.js diff --git a/exp_roles/package.json b/exp_roles/package.json index d6530a11..1e462135 100644 --- a/exp_roles/package.json +++ b/exp_roles/package.json @@ -7,7 +7,8 @@ "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" }, "engines": { "node": ">=18" @@ -24,6 +25,7 @@ "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^", "@clusterio/web_ui": "workspace:^", + "@expcluster/scenario": "workspace:^", "@types/node": "catalog:", "@types/react": "catalog:", "antd": "catalog:", @@ -32,6 +34,8 @@ "react-router-dom": "catalog:", "typescript": "catalog:", "webpack": "catalog:", + "fengari": "^0.1.5", + "tap": "^21.1.0", "webpack-cli": "catalog:", "webpack-merge": "catalog:" }, diff --git a/exp_roles/test/helpers/lua.js b/exp_roles/test/helpers/lua.js new file mode 100644 index 00000000..8f8dddde --- /dev/null +++ b/exp_roles/test/helpers/lua.js @@ -0,0 +1,62 @@ +"use strict"; +const path = require("node:path"); +const fs = require("node:fs"); +const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari"); + +const moduleRoot = path.join(__dirname, "..", "..", "module"); +const luaRoot = path.join(__dirname, "..", "lua"); + +/** + * 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 environment. The file receives the environment helpers + * from env.lua and must return an array of { name, ok, detail? } results, + * encoded as JSON. + * + * @param {string} name - File in test/lua to run, such as "lookup.lua". + * @returns {{ name: string, ok: boolean, detail?: string }[]} + */ +function runLuaTests(name) { + const L = lauxlib.luaL_newstate(); + lualib.luaL_openlibs(L); + + // Each file is run as a chunk taking one argument and returning one value + const load = (file, chunkName) => { + const code = fs.readFileSync(file); + const status = lauxlib.luaL_loadbuffer(L, code, code.length, to_luastring(`@${chunkName}`)); + 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))); + } + }; + + // The environment stubs factorio and loads module/control.lua from disk + load(path.join(luaRoot, "env.lua"), "test/lua/env.lua"); + lua.lua_pushstring(L, to_luastring(moduleRoot)); + call(); + + // The environment stays on the stack and is passed to the test chunk + load(path.join(luaRoot, name), `test/lua/${name}`); + lua.lua_insert(L, -2); + call(); + + const json = to_jsstring(lua.lua_tostring(L, -1)); + return JSON.parse(json); +} + +/** Report lua results through a tap test object. */ +function reportLuaTests(t, name) { + const results = runLuaTests(name); + t.ok(results.length > 0, `${name} produced results`); + for (const result of results) { + t.ok(result.ok, result.name, result.detail ? { detail: result.detail } : {}); + } + t.end(); +} + +module.exports = { runLuaTests, reportLuaTests }; diff --git a/exp_roles/test/lua/assignment.lua b/exp_roles/test/lua/assignment.lua new file mode 100644 index 00000000..cba6b7cc --- /dev/null +++ b/exp_roles/test/lua/assignment.lua @@ -0,0 +1,93 @@ +--- Assignment through role methods, confirmation, and rejection +local Env = ... +local env = Env.new() +local check, eq, R = env.check, env.eq, env.R +local Roles = env.Roles +local sd = Roles._script_data() + +local alice = env.add_player("alice", 1) +local bob = env.add_player("bob", 2) +env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("bob", { 6 }), +} +Roles.set_emit_events(true) +env.reset_log() + +-- Assign with sync +R("Moderator"):assign(bob, { by_player_name = "alice" }) +check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "assignment is sent to the controller") +check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "assignment payload") +check(R("Moderator"):has_player(bob), "the role applies before confirmation") +check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "event carries the assigned role id") +check(env.events[1].data.by_player_index == 1, "event carries who made the change") +check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced") +check(#env.sounds == 1 and env.sounds[1] == "bob:utility/achievement_unlocked", "assign sound") +check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "held locally and pending") + +env.reset_log() +R("Moderator"):assign(bob) +check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op") + +-- Controller confirms +env.reset_log() +Roles.receive_assignment_updates{ Env.assignment("bob", { 6, 5 }) } +check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing") +check(sd.local_players.bob == nil and sd.pending.bob == nil, "a confirmed role is no longer held locally") +check(R("Moderator"):has_player(bob), "the role is still held after confirmation") + +-- Unassign a synced role +env.reset_log() +R("Moderator"):unassign(bob, { by_player_name = "alice", silent = true }) +check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 5 }), "unassignment is sent to the controller") +check(not R("Moderator"):has_player(bob), "the role is removed locally") +check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "event carries the unassigned role id") +check(#env.printed == 0, "silent suppresses the announcement") +check(#env.sounds == 1 and env.sounds[1] == "bob:utility/game_lost", "unassign sound") +Roles.receive_assignment_updates{ Env.assignment("bob", { 6 }) } + +-- Rejection rolls back +env.reset_log() +R("Jail"):assign(alice) +check(R("Jail"):has_player(alice), "the role applies before rejection") +env.reset_log() +Roles.reject_assignment{ name = "alice", role_ids = { 7 } } +check(not R("Jail"):has_player(alice), "a rejected role is rolled back") +check(#env.sent == 0, "the rollback is not sent to the controller") +check(#env.events == 1 and eq(env.events[1].data.unassigned, { 7 }), "the rollback raises the event") +check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") + +-- Local only roles +env.reset_log() +R("Regular"):assign(alice, { silent = true, local_only = true }) +check(#env.sent == 0, "a local only role is not sent") +check(R("Regular"):has_player(alice), "a local only role applies") +check(sd.pending.alice == nil and eq(sd.local_players.alice, { 6 }), "a local only role is not pending") +Roles.receive_assignment_updates{ Env.assignment("alice", { 5 }) } +check(R("Regular"):has_player(alice), "a local only role survives controller updates") +env.reset_log() +R("Regular"):unassign(alice, { local_only = true }) +check(not R("Regular"):has_player(alice) and #env.sent == 0, "a local only role is removed without sync") + +-- Jail priority +env.reset_log() +R("Jail"):assign(alice, { by_player_name = "", silent = true }) +check(eq(env.names(Roles.get_player_roles(alice)), { "Jail" }), "jail suppresses every other role") +check(R("Moderator"):has_player(alice), "a suppressed role is still held") +check(not Roles.player_has_permission(alice, "exp_scenario.command.kill"), "a jailed player loses permissions") +check(not Roles.player_has_permission(alice, "exp_scenario.gui.readme"), "a jailed player loses the default role") +check(not Roles.player_outranks(alice, bob), "a jailed player no longer outranks") +check(eq(env.events[1].data.assigned, { 7 }) and #env.events[1].data.unassigned == 0, + "the jail event lists only the jail role") +env.reset_log() +R("Jail"):unassign(alice, { by_player_name = "", silent = true }) +check(eq(env.sorted(env.names(Roles.get_player_roles(alice))), { "Moderator", "Player" }), "unjail restores the roles") +check(#env.events[1].data.assigned == 0 and eq(env.events[1].data.unassigned, { 7 }), + "the unjail event lists only the jail role") + +-- The server can not be assigned roles +env.reset_log() +R("Moderator"):assign(env.server) +check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op") + +return Env.results_json(env) diff --git a/exp_roles/test/lua/env.lua b/exp_roles/test/lua/env.lua new file mode 100644 index 00000000..33bf7b40 --- /dev/null +++ b/exp_roles/test/lua/env.lua @@ -0,0 +1,193 @@ +--[[-- Test environment for module/control.lua +Stubs the factorio globals and the clusterio modules, then loads a fresh copy +of the roles module. Each call to Env.new() is independent. + +Run as a chunk by test/helpers/lua.js, receiving the module directory and +returning this table, which is in turn passed to each test chunk. +]] + +local module_root = ... --- @type string + +local Env = {} + +--- Standard fixture: permission names sent once and referenced by zero based index +Env.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 +function Env.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 + +function Env.assignment(name, role_ids) + return { name = name, role_ids = role_ids } +end + +--- Create an independent environment with a fresh copy of the roles module +function Env.new() + local env = { + events = {}, -- events raised through script.raise_event + printed = {}, -- game.print and player.print messages + sent = {}, -- payloads sent to the controller + sounds = {}, -- sounds played to players + results = {}, -- test results collected by env.check + } + + local next_event_id = 100 + script = { + generate_event_name = function() + next_event_id = next_event_id + 1 + return next_event_id + end, + raise_event = function(id, data) + env.events[#env.events + 1] = { id = id, data = data } + end, + register_metatable = function() end, + } + defines = { events = { on_player_joined_game = 1, on_multiplayer_init = 2 } } + + local players_by_name, players_by_index, connected = {}, {}, {} + game = { + tick = 1, + player = nil, + players = players_by_name, + connected_players = connected, + print = function(message) env.printed[#env.printed + 1] = message end, + get_player = function(key) return players_by_name[key] or players_by_index[key] end, + } + + local stubs = { + ["modules/clusterio/api"] = { + send_json = function(channel, data) + env.sent[#env.sent + 1] = { channel = channel, data = data } + end, + }, + ["modules/clusterio/compat"] = { script_data = {} }, + ["modules/exp_util/async"] = { + register = function(callback) + return function(...) return callback(...) end + end, + }, + } + require = function(name) + return assert(stubs[name], "Unexpected require: " .. name) + end + + --- Add a player to the stubbed game + function env.add_player(name, index, is_connected) + local player = { + name = name, + index = index, + connected = is_connected ~= false, + valid = true, + play_sound = function(opts) + env.sounds[#env.sounds + 1] = name .. ":" .. opts.path + end, + print = function(message) + env.printed[#env.printed + 1] = { to = name, message } + end, + } + players_by_name[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 + env.server = setmetatable({ index = 0, name = "" }, { + __index = function(_, key) error("Server player field accessed: " .. tostring(key)) end, + }) + + env.Roles = assert(loadfile(module_root .. "/control.lua"))() + env.Roles.on_server_startup() + + --- Get a role by name, failing the test file when it does not exist + function env.R(name) + return (assert(env.Roles.get_role_by_name(name), "No role named " .. name)) + end + + --- Load the standard fixture and the given assignments + function env.initialise(assignments) + env.Roles.initialise{ + permission_names = Env.permission_names, + roles = Env.role_records(), + assignments = assignments or {}, + } + end + + --- Forget everything recorded so far + function env.reset_log() + env.events, env.printed, env.sent, env.sounds = {}, {}, {}, {} + end + + --- Record one test result + function env.check(ok, name, detail) + env.results[#env.results + 1] = { name = name, ok = not not ok, detail = detail } + end + + --- Shallow array equality + function env.eq(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return a == b end + if #a ~= #b then return false end + for i = 1, #a do + if a[i] ~= b[i] then return false end + end + return true + end + + --- Names of an array of roles + function env.names(roles) + local rtn = {} + for index, role in ipairs(roles) do + rtn[index] = role.name + end + return rtn + end + + --- Sorted copy of an array of strings + function env.sorted(list) + local rtn = {} + for index, value in ipairs(list) do rtn[index] = value end + table.sort(rtn) + return rtn + end + + return env +end + +--- Encode the results of an environment as JSON for the javascript side +function Env.results_json(env) + 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(env.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 Env diff --git a/exp_roles/test/lua/holders.lua b/exp_roles/test/lua/holders.lua new file mode 100644 index 00000000..bb345ddf --- /dev/null +++ b/exp_roles/test/lua/holders.lua @@ -0,0 +1,47 @@ +--- Listing and printing to the players who hold a role +local Env = ... +local env = Env.new() +local check, eq, R = env.check, env.eq, env.R +local Roles = env.Roles + +local alice = env.add_player("alice", 1) +env.add_player("bob", 2) +env.add_player("dave", 4, false) -- offline +env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("bob", { 6 }), + Env.assignment("dave", { 5 }), + Env.assignment("zed", { 5 }), -- has never joined this map +} +Roles.set_emit_events(true) + +local mod = R("Moderator") +check(eq(env.sorted(mod:get_player_names()), { "alice", "dave", "zed" }), + "player names include players not on this map") +check(eq(env.sorted(env.names(mod:get_players())), { "alice", "dave" }), + "players are limited to this map") +check(eq(env.names(mod:get_players(true)), { "alice" }), "players can be filtered by connected state") +check(eq(env.names(mod:get_players(false)), { "dave" }), "players can be filtered to offline") + +-- A player holding the role in both the synced and the local list counts once +R("Regular"):assign(alice, { silent = true, local_only = true }) +Roles.receive_assignment_updates{ Env.assignment("alice", { 5, 6 }) } +local sd = Roles._script_data() +check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists") +check(eq(env.sorted(R("Regular"):get_player_names()), { "alice", "bob" }), + "a player in both lists is counted once") + +env.reset_log() +check(mod:print("hello") == 1, "print returns the number of players reached") +check(#env.printed == 1 and env.printed[1].to == "alice", "print reaches only online holders") + +env.reset_log() +for _, role in ipairs(Roles.get_higher_roles(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 +check(eq(env.sorted(got), { "alice", "alice", "bob" }), "printing to the higher roles reaches their online holders") + +return Env.results_json(env) diff --git a/exp_roles/test/lua/lookup.lua b/exp_roles/test/lua/lookup.lua new file mode 100644 index 00000000..b32bf3b0 --- /dev/null +++ b/exp_roles/test/lua/lookup.lua @@ -0,0 +1,36 @@ +--- Role lookup, decoding, and comparisons +local Env = ... +local env = Env.new() +local check, eq, names, R = env.check, env.eq, env.names, env.R +local Roles = env.Roles + +env.initialise{} + +check(eq(names(Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }), + "ordered roles are most privileged first with deleted roles skipped") +check(#Roles.get_roles() == 5, "get_roles returns every role") +check(Roles.get_role(6).name == "Regular", "get_role looks up by clusterio id") +check(Roles.get_role_by_name("Moderator").id == 5, "get_role_by_name searches the roles") +check(Roles.get_role(R("Jail").id) == R("Jail"), "lookups return the same object") +check(Roles.get_role(9) == nil and Roles.get_role_by_name("Gone") == nil, "deleted roles are not known") +check(Roles.get_default_role() == R("Player"), "the default role comes from is_default") + +check(R("Moderator").short_hand == "Mod" or R("Moderator").short_hand == "Moderator", + "short hand falls back to the name") +check(R("Cluster Admin").short_hand == "SYS", "short hand decoded") +check(R("Moderator").color.g == 170, "color decoded") +check(R("Jail").priority == 1 and R("Jail").block_auto_assign, "priority and block auto assign decoded") + +check(R("Moderator"):is_higher_than(R("Player")), "roles compare on their order") +check(R("Player"):is_lower_than(R("Moderator")), "is_lower_than is the reverse") +check(not R("Moderator"):is_higher_than(R("Moderator")), "a role is not higher than itself") + +check(eq(env.sorted(names(Roles.get_higher_roles(R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }), + "higher roles include the role itself and exclude the default role") +check(eq(env.sorted(names(Roles.get_lower_roles(R("Regular")))), { "Jail", "Regular" }), + "lower roles include the role itself and exclude the default role") + +local sorted = Roles.sort_roles{ R("Player"), R("Cluster Admin"), R("Regular") } +check(eq(names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first") + +return Env.results_json(env) diff --git a/exp_roles/test/lua/players.lua b/exp_roles/test/lua/players.lua new file mode 100644 index 00000000..c77bb349 --- /dev/null +++ b/exp_roles/test/lua/players.lua @@ -0,0 +1,56 @@ +--- Player role and permission checks +local Env = ... +local env = Env.new() +local check, eq, names, R = env.check, env.eq, env.names, env.R +local Roles = env.Roles + +local alice = env.add_player("alice", 1) -- moderator +local bob = env.add_player("bob", 2) -- regular +local carol = env.add_player("carol", 3) -- only the default role +env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("bob", { 6 }), +} + +check(eq(env.sorted(names(Roles.get_player_roles(alice))), { "Moderator", "Player" }), + "player roles include the default role") +check(eq(names(Roles.get_player_roles(carol)), { "Player" }), "a player with no roles has the default role") +check(eq(names(Roles.get_player_roles(nil)), { "" }), "nil is the server") +check(eq(names(Roles.get_player_roles(env.server)), { "" }), "a player with index 0 is the server") +check(Roles.get_player_highest_role(alice) == R("Moderator"), "highest role") +check(Roles.get_player_highest_role(carol) == R("Player"), "highest role is the default role when none are held") + +check(Roles.player_has_permission(alice, "exp_scenario.command.kill"), "permission through a held role") +check(Roles.player_has_permission(alice, "exp_scenario.gui.readme"), "permission through the default role") +check(not Roles.player_has_permission(bob, "exp_scenario.command.jail"), "permission not granted") +check(Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission") +check(Roles.player_has_permission(env.server, "anything.at.all"), "an index 0 player has every permission") + +check(Roles.player_has_any_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.kill"), + "has any passes when one permission is granted") +check(not Roles.player_has_any_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.assign_role"), + "has any fails when none are granted") +check(not Roles.player_has_any_permission(bob), "has any with no permissions is false") +check(Roles.player_has_all_permission(alice, "exp_scenario.command.jail", "exp_scenario.command.kill"), + "has all passes when every permission is granted") +check(not Roles.player_has_all_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.kill"), + "has all fails when one is missing") +check(Roles.player_has_all_permission(bob), "has all with no permissions is true") +check(Roles.player_has_any_permission(nil, "anything.at.all"), "the server passes has any") + +check(R("Moderator"):has_permission("exp_scenario.command.kill"), "role has_permission") +check(not R("Regular"):has_permission("exp_scenario.command.jail"), "role has_permission not granted") +check(R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything") + +check(R("Moderator"):has_player(alice), "has_player for a held role") +check(not R("Moderator"):has_player(bob), "has_player for a role not held") +check(R("Player"):has_player(carol), "everyone has the default role") +check(not R("Player"):has_player(nil), "the server does not have the default role") + +check(Roles.player_outranks(alice, bob), "a higher role outranks a lower one") +check(not Roles.player_outranks(bob, alice), "a lower role does not outrank a higher one") +check(not Roles.player_outranks(alice, alice), "nobody outranks themselves") +check(Roles.player_outranks(nil, alice), "the server outranks everyone") +check(not Roles.player_outranks(alice, nil), "nobody outranks the server") + +return Env.results_json(env) diff --git a/exp_roles/test/lua/sync.lua b/exp_roles/test/lua/sync.lua new file mode 100644 index 00000000..dc84edf8 --- /dev/null +++ b/exp_roles/test/lua/sync.lua @@ -0,0 +1,91 @@ +--- Initialise semantics and updates from the controller +local Env = ... +local env = Env.new() +local check, eq, R = env.check, env.eq, env.R +local Roles = env.Roles +local sd = Roles._script_data() + +local alice = env.add_player("alice", 1) +local carol = env.add_player("carol", 3) + +local admin_state = {} +Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) + admin_state[player.name] = state +end) + +env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("zed", { 5, 6 }), -- has never joined this map +} +Roles.set_emit_events(true) + +check(admin_state.alice == true and admin_state.carol == false, "triggers run for connected players on initialise") +check(#env.events == 2, "the roles changed event is raised once per connected player on initialise") +check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "initialise events are empty") +check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing") + +-- Changes to the roles a player holds, made on the controller +env.reset_log() +Roles.receive_assignment_updates{ Env.assignment("carol", { 5 }) } +check(R("Moderator"):has_player(carol), "a controller assignment applies") +check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "a controller assignment raises the event") +check(env.events[1].data.by_player_index == 0, "controller changes have no by player") +check(#env.printed == 1 and env.printed[1][4] == "", "a controller assignment is announced by the server") +check(admin_state.carol == true, "triggers follow controller assignments") + +env.reset_log() +Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } } +check(not R("Moderator"):has_player(carol), "a controller removal applies") +check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "a controller removal raises the event") +check(admin_state.carol == false, "triggers follow controller removals") + +env.reset_log() +Roles.receive_assignment_updates{ Env.assignment("zed", { 5 }) } +check(#env.events == 0 and #env.printed == 0, "changes for players not on this map raise nothing") + +-- Changes to the roles themselves +env.reset_log() +Roles.receive_role_updates{ + { id = 6, name = "Regular", permissions = { "exp_scenario.command.jail" }, meta = { id = 0, order = 3 } }, +} +check(R("Regular"):has_permission("exp_scenario.command.jail"), "a role permission change applies") +check(#env.events == 2, "a role change raises for every connected player") +check(#env.events[1].data.assigned == 0, "role change events are empty") + +env.reset_log() +Roles.receive_role_updates{ { id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true } } +check(Roles.get_role(6) == nil, "a deleted role is removed") + +env.reset_log() +Roles.receive_role_updates{ + { id = 1, name = "Player", permissions = {}, meta = { id = 0, order = 5 } }, + { id = 8, name = "Guest", permissions = {}, meta = { id = 0, order = 7 }, is_default = true }, +} +check(Roles.get_default_role() == R("Guest"), "the default role follows is_default") +check(eq(env.names(Roles.get_player_roles(carol)), { "Guest" }), "players pick up the new default role") + +-- Initialise is authoritative for pending roles +R("Moderator"):assign(carol) +R("Jail"):assign(carol, { silent = true, local_only = true }) +check(eq(sd.pending.carol, { 5 }), "the role is pending before initialise") +env.reset_log() +env.initialise{ Env.assignment("alice", { 5 }) } +check(sd.pending.carol == nil, "initialise clears pending roles") +check(not R("Moderator"):has_player(carol), "an unconfirmed role is given up") +check(eq(sd.local_players.carol, { 7 }), "local only roles are kept") +check(#env.sent == 0, "initialise sends nothing") + +-- Emit updates gate and load +Roles.set_emit_events(false) +env.reset_log() +R("Regular"):assign(alice) +check(#env.sent == 0, "nothing is sent while emit is disabled") +Roles.on_load() +check(R("Moderator"):is_higher_than(R("Regular")), "roles are usable after load") + +-- Triggers on join +admin_state.alice = nil +Roles.on_player_joined_game{ player_index = 1 } +check(admin_state.alice ~= nil, "triggers run when a player joins") + +return Env.results_json(env) diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js new file mode 100644 index 00000000..ff1c774a --- /dev/null +++ b/exp_roles/test/messages.test.js @@ -0,0 +1,43 @@ +"use strict"; +const t = require("tap"); +const { Value } = require("@sinclair/typebox/value"); +const messages = require("../dist/node/messages"); + +function roundTrip(subtest, Record, record) { + const json = record.toJSON(); + subtest.ok(Value.Check(Record.jsonSchema, json), "json matches the schema"); + subtest.strictSame(Record.fromJSON(json), record, "the record round trips"); +} + +t.test("RoleMetaRecord", subtest => { + roundTrip(subtest, messages.RoleMetaRecord, new messages.RoleMetaRecord(7, 3)); + roundTrip(subtest, messages.RoleMetaRecord, new messages.RoleMetaRecord( + 7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false, + )); + subtest.end(); +}); + +t.test("RoleRecord", subtest => { + roundTrip(subtest, messages.RoleRecord, new messages.RoleRecord( + 7, "Moderator", ["exp_scenario.command.kill"], new messages.RoleMetaRecord(7, 3), true, 12345, + )); + subtest.end(); +}); + +t.test("AssignmentRecord", subtest => { + roundTrip(subtest, messages.AssignmentRecord, new messages.AssignmentRecord("alice", new Set([5, 6]), 12345)); + subtest.end(); +}); + +t.test("encodeRolesForLua sends each permission name once", subtest => { + 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)), + ]); + + subtest.strictSame(encoded.permission_names, ["p.one", "p.two", "p.three"], "names are deduplicated"); + subtest.strictSame(encoded.roles[0].permissions, [0, 1], "indexes are zero based"); + subtest.strictSame(encoded.roles[1].permissions, [1, 2], "shared names reuse their index"); + subtest.end(); +}); diff --git a/exp_roles/test/module.test.js b/exp_roles/test/module.test.js new file mode 100644 index 00000000..9bca4e1a --- /dev/null +++ b/exp_roles/test/module.test.js @@ -0,0 +1,8 @@ +"use strict"; +const t = require("tap"); +const { reportLuaTests } = require("./helpers/lua"); + +// Each file runs in its own lua state with a fresh copy of the module +for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) { + t.test(file, subtest => reportLuaTests(subtest, file)); +} diff --git a/exp_roles/test/seed.test.js b/exp_roles/test/seed.test.js new file mode 100644 index 00000000..60f4c2f2 --- /dev/null +++ b/exp_roles/test/seed.test.js @@ -0,0 +1,40 @@ +"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("every seed permission is defined", subtest => { + for (const role of seedRoles) { + for (const permission of flattenSeedPermissions(role)) { + subtest.ok(lib.permissions.has(permission), `${role.name} grants defined permission ${permission}`); + } + } + subtest.end(); +}); + +t.test("parents exist and their permissions are inherited", subtest => { + 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); + subtest.ok(parent, `${role.name} has parent ${role.parent}`); + const flattened = flattenSeedPermissions(role); + for (const permission of flattenSeedPermissions(parent)) { + subtest.ok(flattened.has(permission), `${role.name} inherits ${permission}`); + } + } + subtest.end(); +}); + +t.test("seed role names are unique with one default and one admin", subtest => { + const names = seedRoles.map(role => role.name); + subtest.strictSame(names.length, new Set(names).size, "names are unique"); + subtest.strictSame(seedRoles.filter(role => role.isDefault).length, 1, "one default role"); + subtest.strictSame(seedRoles.filter(role => role.isAdmin).length, 1, "one admin role"); + subtest.end(); +}); From 59a0e0be13c7be902ab2f40d8228fd0bcba45d63 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:45:51 +0000 Subject: [PATCH 09/16] Build the effective roles in a single pass The list restarts whenever a higher priority is found, rather than collecting every role and filtering afterwards. Co-Authored-By: Claude Fable 5 --- exp_roles/module/control.lua | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/exp_roles/module/control.lua b/exp_roles/module/control.lua index f6a8aff1..5db7b55a 100644 --- a/exp_roles/module/control.lua +++ b/exp_roles/module/control.lua @@ -168,23 +168,20 @@ local function get_effective_roles(player_name) role_ids[#role_ids + 1] = script_data.default_role_id end - local roles, highest_priority = {}, nil + -- Only the roles with the highest priority apply, so the list restarts + -- whenever a higher priority is found + local rtn, highest_priority = {}, nil for _, role_id in pairs(role_ids) do local role = script_data.roles[role_id] if role then - roles[#roles + 1] = role if highest_priority == nil or role.priority > highest_priority then highest_priority = role.priority + rtn = { role } + elseif role.priority == highest_priority then + rtn[#rtn + 1] = role end end end - - local rtn = {} - for _, role in pairs(roles) do - if role.priority == highest_priority then - rtn[#rtn + 1] = role - end - end return rtn end From 24d36db601ae5dc52eb60ba6137e744b8d49bc0a Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:45:51 +0000 Subject: [PATCH 10/16] Address review on the test harness - The generic parts move to test/ in the repository root so other plugins can reuse them: the factorio and clusterio stubs, the test framework, the fengari runner, and clusterio's testMatrix and round trip helpers. A plugin composes them from its own env.lua, which adds its stubs and fixtures. - Tests are declared with Test.test(name, fn) and every test function receives a fresh environment, so nothing carries over between them. A test which errors is reported as a failure rather than aborting the file. - Every stub raises on properties it does not implement, which mirrors the game api. game.player is the one property allowed to read as nil. - Test.deep_eq compares tables recursively with keys checked from both sides. - The message records round trip through the same testMatrix and testRoundTripJsonSerialisable helpers the clusterio tests use, covering every optional field combination of the records, events and requests. - controller.test.js and instance.test.js cover the node side of the plugin against faked controller and instance internals: property creation and sweeping, record building, broadcasts, subscription replay, assignment validation, auto assignment and its blocking role, seeding, the initialise payload, sync mode gating, and the rejection rollback. Co-Authored-By: Claude Fable 5 --- exp_roles/test/controller.test.js | 195 ++++++++++++++++++ exp_roles/test/instance.test.js | 143 +++++++++++++ exp_roles/test/lua/assignment.lua | 188 +++++++++-------- exp_roles/test/lua/env.lua | 151 ++------------ exp_roles/test/lua/holders.lua | 113 ++++++---- exp_roles/test/lua/lookup.lua | 66 +++--- exp_roles/test/lua/players.lua | 117 ++++++----- exp_roles/test/lua/sync.lua | 175 +++++++++------- exp_roles/test/messages.test.js | 76 +++++-- exp_roles/test/module.test.js | 9 +- test/common.js | 41 ++++ test/lua/framework.lua | 109 ++++++++++ .../test/helpers/lua.js => test/lua/runner.js | 39 ++-- test/lua/stubs.lua | 118 +++++++++++ 14 files changed, 1106 insertions(+), 434 deletions(-) create mode 100644 exp_roles/test/controller.test.js create mode 100644 exp_roles/test/instance.test.js create mode 100644 test/common.js create mode 100644 test/lua/framework.lua rename exp_roles/test/helpers/lua.js => test/lua/runner.js (53%) create mode 100644 test/lua/stubs.lua diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js new file mode 100644 index 00000000..2536ef44 --- /dev/null +++ b/exp_roles/test/controller.test.js @@ -0,0 +1,195 @@ +"use strict"; +const t = require("tap"); +const lib = require("@clusterio/lib"); +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"); + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +class FakeUser { + constructor(name, roleIds = []) { + this.name = name; + this.roleIds = new Set(roleIds); + this.playerStats = { onlineTimeMs: 0 }; + this.updatedAtMs = 0; + this.isDeleted = false; + } + + set(field, value) { + this[field] = value; + this.updatedAtMs += 1; + } +} + +/** Build a plugin around fake controller internals, started on first use by each test. */ +async function startPlugin(t2, { roles = [], users = [], config = {} } = {}) { + const state = { broadcasts: [], permissionUpdates: [] }; + const configValues = { + "controller.database_directory": t2.testdir(), + "controller.default_role_id": lib.Role.DefaultPlayerRoleId, + ...config, + }; + + const roleStore = new lib.SubscribableDatastore( + ...await new lib.MemoryDatastoreProvider(new Map(roles.map(role => [role.id, role]))).bootstrap() + ); + const userStore = new Map(users.map(user => [user.name, user])); + + const controller = { + config: { get: key => configValues[key] }, + roles: roleStore, + users: { + records: { on: () => {}, values: () => userStore.values() }, + getByNameMutable: name => userStore.get(name), + valuesMutable: () => userStore.values(), + getOrCreateUser: name => { + if (!userStore.has(name)) { + userStore.set(name, new FakeUser(name)); + } + return userStore.get(name); + }, + }, + subscriptions: { handle: () => {}, broadcast: event => state.broadcasts.push(event) }, + handle: () => {}, + userPermissionsUpdated: user => state.permissionUpdates.push(user.name), + }; + + const plugin = new ControllerPlugin({ name: "exp_roles" }, controller, undefined, logger); + await plugin.init(); + return { plugin, controller, state, users: userStore }; +} + +const role = (id, name, permissions = []) => new lib.Role(id, name, "", new Set(permissions)); + +t.test("init creates properties for existing roles and sweeps orphans", async t2 => { + const { plugin } = await startPlugin(t2, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); + + t2.strictSame(plugin.roleMeta.get(0).order, 1, "the first role is ordered first"); + t2.strictSame(plugin.roleMeta.get(5).order, 2, "the next role is ordered after it"); + + plugin.roleMeta.set(new messages.RoleMetaRecord(99, 3)); + plugin.sweepRoleMeta(); + t2.notOk(plugin.roleMeta.get(99), "properties without a role are removed"); + t2.ok(plugin.roleMeta.get(5), "properties with a role are kept"); +}); + +t.test("role records combine the role with its properties", async t2 => { + const { plugin } = await startPlugin(t2, { + 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(plugin.controller.roles.get(5)); + t2.strictSame(record.name, "Moderator", "the name comes from the role"); + t2.strictSame(record.permissions, ["exp_scenario.command.kill"], "the permissions come from the role"); + t2.strictSame(record.meta.shortHand, "Mod", "the properties come from the datastore"); + t2.notOk(record.isDefault, "not the default role"); + t2.ok(plugin.buildRoleRecord(plugin.controller.roles.get(1)).isDefault, "the default role is marked"); +}); + +t.test("role changes broadcast to subscribers", async t2 => { + const { plugin, controller, state } = await startPlugin(t2, { roles: [role(1, "Player")] }); + + state.broadcasts.length = 0; + controller.roles.set(role(5, "Moderator")); + const updated = state.broadcasts.flatMap(event => event.updates.map(update => update.name)); + t2.ok(updated.includes("Moderator"), "a new role is broadcast"); + + state.broadcasts.length = 0; + plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 0, "Mod")); + t2.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"); + t2.ok(state.broadcasts.some(event => event instanceof messages.RoleUpdatedEvent), "a default role change rebroadcasts"); +}); + +t.test("subscriptions replay only newer records", async t2 => { + const { plugin, controller } = await startPlugin(t2, { 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 }); + t2.strictSame(all.updates.length, 2, "everything is replayed from the start"); + const none = await plugin.handleRoleSubscription({ lastRequestTimeMs: updatedAtMs }); + t2.strictSame(none, null, "nothing is replayed when up to date"); +}); + +t.test("assignments mirror users without the default role", async t2 => { + const alice = new FakeUser("alice", [lib.Role.DefaultPlayerRoleId, 5]); + alice.updatedAtMs = 10; + const bob = new FakeUser("bob", [lib.Role.DefaultPlayerRoleId]); + const { plugin } = await startPlugin(t2, { roles: [role(1, "Player"), role(5, "Moderator")], users: [alice, bob] }); + + const records = plugin.listAssignmentRecords(); + t2.strictSame(records.length, 1, "users with only the default role are left out"); + t2.strictSame(records[0].name, "alice"); + t2.strictSame([...records[0].roleIds], [5], "the default role is not listed"); +}); + +t.test("assignment updates validate the user and roles", async t2 => { + const alice = new FakeUser("alice", [5]); + const { plugin, state } = await startPlugin(t2, { + roles: [role(1, "Player"), role(5, "Moderator"), role(6, "Regular")], users: [alice], + }); + + await t2.rejects( + plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("nobody", [], [])), + { message: /does not exist/ }, "an unknown user is refused", + ); + await t2.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])); + t2.strictSame([...alice.roleIds], [6], "roles are added and removed"); + t2.ok(state.permissionUpdates.includes("alice"), "permission changes are pushed to the user"); +}); + +t.test("roles are granted from online time", async t2 => { + const newcomer = new FakeUser("newcomer"); + newcomer.playerStats.onlineTimeMs = 3600000; + const veteran = new FakeUser("veteran"); + veteran.playerStats.onlineTimeMs = 7200000; + const jailed = new FakeUser("jailed", [7]); + jailed.playerStats.onlineTimeMs = 7200000; + + const { plugin } = await startPlugin(t2, { + roles: [role(1, "Player"), role(6, "Regular"), role(7, "Jail")], + users: [newcomer, veteran, jailed], + }); + // 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)); + t2.ok(veteran.roleIds.has(6), "enough online time grants the role"); + t2.notOk(newcomer.roleIds.has(6), "not enough online time does not"); + t2.notOk(jailed.roleIds.has(6), "a blocking role prevents the grant"); + + veteran.roleIds.delete(6); + await plugin.onPlayerEvent(undefined, { type: "leave", name: "veteran" }); + t2.ok(veteran.roleIds.has(6), "granted again when the player leaves"); +}); + +t.test("seeding creates the roles and reuses them by name", async t2 => { + const { plugin, controller } = await startPlugin(t2, { + roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")], + }); + + await plugin.handleSeedRolesRequest(); + t2.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); + + const moderator = [...controller.roles.values()].find(other => other.name === "Moderator"); + t2.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in"); + t2.ok(plugin.roleMeta.get(moderator.id), "the role properties are created"); + t2.strictSame(plugin.roleMeta.get(moderator.id).shortHand, "Mod", "the properties match the seed"); + + await plugin.handleSeedRolesRequest(); + t2.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles"); +}); diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js new file mode 100644 index 00000000..161b4f28 --- /dev/null +++ b/exp_roles/test/instance.test.js @@ -0,0 +1,143 @@ +"use strict"; +const t = require("tap"); +const lib = require("@clusterio/lib"); +const { InstancePlugin } = require("../dist/node/instance"); +const messages = require("../dist/node/messages"); + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +// Subscribing validates event names against the link registry +lib.Link.register(messages.RoleUpdatedEvent); +lib.Link.register(messages.AssignmentUpdatedEvent); + +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 fake instance, started on first use by each test. */ +async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles() } = {}) { + const state = { sent: [], rcons: [], warnings: [], failNext: null }; + const instance = { + logger, + config: { get: field => (field === "exp_roles.sync_mode" ? syncMode : undefined) }, + handle: () => {}, + server: { handle: () => {} }, + 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; + }, + sendRcon: async command => state.rcons.push(command), + }; + + 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("start subscribes and initialises the lua module", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); + await plugin.onStart(); + + const subscribed = state.sent.filter(request => request instanceof lib.SubscriptionRequest); + t2.strictSame(subscribed.length, 2, "roles and assignments are subscribed to"); + t2.ok(state.sent.some(request => request instanceof messages.RoleListRequest), "the roles are requested"); + t2.ok(state.sent.some(request => request instanceof messages.AssignmentListRequest), "the assignments are requested"); + + const initialise = decodeRcon(state.rcons[0]); + t2.strictSame(initialise.receiver, "initialise", "the lua module is initialised"); + t2.strictSame(initialise.payload.permission_names, ["exp_scenario.gui.readme", "core.admin"], + "each permission name is sent once"); + t2.strictSame(initialise.payload.roles[1].permissions, [0, 1], "roles reference permissions by index"); + t2.strictSame(initialise.payload.assignments, [{ name: "alice", role_ids: [5] }], "the assignments are sent"); + + const emit = decodeRcon(state.rcons[1]); + t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "enabled does not emit changes back"); + t2.strictSame(state.warnings.length, 0, "no warnings with a default role"); +}); + +t.test("bidirectional emits changes back", async t2 => { + const { plugin, state } = await startPlugin(t2); + await plugin.onStart(); + const emit = decodeRcon(state.rcons[state.rcons.length - 1]); + t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]); +}); + +t.test("disabled does nothing on start", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); + await plugin.onStart(); + t2.strictSame(state.sent.length, 0, "nothing is sent"); + t2.strictSame(state.rcons.length, 0, "no commands are run"); +}); + +t.test("a missing default role is warned about", async t2 => { + const roles = sampleRoles().map(record => { record.isDefault = false; return record; }); + const { plugin, state } = await startPlugin(t2, { roles }); + await plugin.onStart(); + t2.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role"); +}); + +t.test("updates from the controller are forwarded to lua", async t2 => { + const { plugin, state } = await startPlugin(t2); + await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); + const roles = decodeRcon(state.rcons[0]); + t2.strictSame(roles.receiver, "receive_role_updates", "role updates are forwarded"); + t2.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]); + t2.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); +}); + +t.test("updates are not forwarded while disabled", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); + await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); + await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); + t2.strictSame(state.rcons.length, 0, "no commands are run"); +}); + +t.test("in game changes are sent up and rolled back when refused", async t2 => { + const { plugin, state } = await startPlugin(t2); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + const request = state.sent[0]; + t2.ok(request instanceof messages.AssignmentUpdateRequest, "the change is sent as a request"); + t2.strictSame([request.name, request.assign, request.unassign], ["alice", [5], []], "the request names the change"); + + state.failNext = new Error("User 'alice' does not exist"); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + const rollback = decodeRcon(state.rcons[0]); + t2.strictSame(rollback.receiver, "reject_assignment", "the refusal is rolled back in lua"); + t2.strictSame(rollback.payload, { name: "alice", role_ids: [5] }, "the rollback names the refused roles"); +}); + +t.test("in game changes are dropped unless bidirectional", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + t2.strictSame(state.sent.length, 0, "nothing is sent"); +}); + +t.test("changing the sync mode toggles emit", async t2 => { + const { plugin, state } = await startPlugin(t2); + await plugin.onInstanceConfigFieldChanged("exp_roles.sync_mode", "enabled", "bidirectional"); + const emit = decodeRcon(state.rcons[0]); + t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting"); +}); diff --git a/exp_roles/test/lua/assignment.lua b/exp_roles/test/lua/assignment.lua index cba6b7cc..b77ba40b 100644 --- a/exp_roles/test/lua/assignment.lua +++ b/exp_roles/test/lua/assignment.lua @@ -1,93 +1,119 @@ --- Assignment through role methods, confirmation, and rejection local Env = ... -local env = Env.new() -local check, eq, R = env.check, env.eq, env.R -local Roles = env.Roles -local sd = Roles._script_data() +local Test = Env.Test +local test, check, eq = Test.test, Test.check, Test.eq -local alice = env.add_player("alice", 1) -local bob = env.add_player("bob", 2) -env.initialise{ - Env.assignment("alice", { 5 }), - Env.assignment("bob", { 6 }), -} -Roles.set_emit_events(true) -env.reset_log() +--- 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", 1), + bob = env.add_player("bob", 2), + } + env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("bob", { 6 }), + } + env.Roles.set_emit_events(true) + env.reset_log() + return players +end --- Assign with sync -R("Moderator"):assign(bob, { by_player_name = "alice" }) -check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "assignment is sent to the controller") -check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "assignment payload") -check(R("Moderator"):has_player(bob), "the role applies before confirmation") -check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "event carries the assigned role id") -check(env.events[1].data.by_player_index == 1, "event carries who made the change") -check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced") -check(#env.sounds == 1 and env.sounds[1] == "bob:utility/achievement_unlocked", "assign sound") -check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "held locally and pending") +test("assign applies locally and is sent to the controller", function(env) + local players = setup(env) + env.R("Moderator"):assign(players.bob, { by_player_name = "alice" }) + check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "the assignment is sent") + check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "the payload names the role and player") + check(env.R("Moderator"):has_player(players.bob), "the role applies before confirmation") + check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event carries the assigned role id") + check(env.events[1].data.by_player_index == 1, "the event carries who made the change") + check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced") + check(#env.sounds == 1 and env.sounds[1] == "bob:utility/achievement_unlocked", "the assign sound plays") -env.reset_log() -R("Moderator"):assign(bob) -check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op") + local sd = env.Roles._script_data() + check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "the role is held locally and pending") --- Controller confirms -env.reset_log() -Roles.receive_assignment_updates{ Env.assignment("bob", { 6, 5 }) } -check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing") -check(sd.local_players.bob == nil and sd.pending.bob == nil, "a confirmed role is no longer held locally") -check(R("Moderator"):has_player(bob), "the role is still held after confirmation") + env.reset_log() + env.R("Moderator"):assign(players.bob) + check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op") +end) --- Unassign a synced role -env.reset_log() -R("Moderator"):unassign(bob, { by_player_name = "alice", silent = true }) -check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 5 }), "unassignment is sent to the controller") -check(not R("Moderator"):has_player(bob), "the role is removed locally") -check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "event carries the unassigned role id") -check(#env.printed == 0, "silent suppresses the announcement") -check(#env.sounds == 1 and env.sounds[1] == "bob:utility/game_lost", "unassign sound") -Roles.receive_assignment_updates{ Env.assignment("bob", { 6 }) } +test("a confirmation releases the local hold", function(env) + local players = setup(env) + env.R("Moderator"):assign(players.bob) + env.reset_log() + env.Roles.receive_assignment_updates{ Env.assignment("bob", { 6, 5 }) } + check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing") --- Rejection rolls back -env.reset_log() -R("Jail"):assign(alice) -check(R("Jail"):has_player(alice), "the role applies before rejection") -env.reset_log() -Roles.reject_assignment{ name = "alice", role_ids = { 7 } } -check(not R("Jail"):has_player(alice), "a rejected role is rolled back") -check(#env.sent == 0, "the rollback is not sent to the controller") -check(#env.events == 1 and eq(env.events[1].data.unassigned, { 7 }), "the rollback raises the event") -check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") + local sd = env.Roles._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) --- Local only roles -env.reset_log() -R("Regular"):assign(alice, { silent = true, local_only = true }) -check(#env.sent == 0, "a local only role is not sent") -check(R("Regular"):has_player(alice), "a local only role applies") -check(sd.pending.alice == nil and eq(sd.local_players.alice, { 6 }), "a local only role is not pending") -Roles.receive_assignment_updates{ Env.assignment("alice", { 5 }) } -check(R("Regular"):has_player(alice), "a local only role survives controller updates") -env.reset_log() -R("Regular"):unassign(alice, { local_only = true }) -check(not R("Regular"):has_player(alice) and #env.sent == 0, "a local only role is removed without sync") +test("unassign a synced role", function(env) + local players = setup(env) + env.R("Regular"):unassign(players.bob, { by_player_name = "alice", silent = true }) + check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 6 }), "the unassignment is sent") + check(not env.R("Regular"):has_player(players.bob), "the role is removed locally") + check(#env.events == 1 and eq(env.events[1].data.unassigned, { 6 }), "the event carries the unassigned role id") + check(#env.printed == 0, "silent suppresses the announcement") + check(#env.sounds == 1 and env.sounds[1] == "bob:utility/game_lost", "the unassign sound plays") +end) --- Jail priority -env.reset_log() -R("Jail"):assign(alice, { by_player_name = "", silent = true }) -check(eq(env.names(Roles.get_player_roles(alice)), { "Jail" }), "jail suppresses every other role") -check(R("Moderator"):has_player(alice), "a suppressed role is still held") -check(not Roles.player_has_permission(alice, "exp_scenario.command.kill"), "a jailed player loses permissions") -check(not Roles.player_has_permission(alice, "exp_scenario.gui.readme"), "a jailed player loses the default role") -check(not Roles.player_outranks(alice, bob), "a jailed player no longer outranks") -check(eq(env.events[1].data.assigned, { 7 }) and #env.events[1].data.unassigned == 0, - "the jail event lists only the jail role") -env.reset_log() -R("Jail"):unassign(alice, { by_player_name = "", silent = true }) -check(eq(env.sorted(env.names(Roles.get_player_roles(alice))), { "Moderator", "Player" }), "unjail restores the roles") -check(#env.events[1].data.assigned == 0 and eq(env.events[1].data.unassigned, { 7 }), - "the unjail event lists only the jail role") +test("a rejection rolls the assignment back", function(env) + local players = setup(env) + env.R("Jail"):assign(players.alice) + check(env.R("Jail"):has_player(players.alice), "the role applies before rejection") --- The server can not be assigned roles -env.reset_log() -R("Moderator"):assign(env.server) -check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op") + env.reset_log() + env.Roles.reject_assignment{ name = "alice", role_ids = { 7 } } + check(not env.R("Jail"):has_player(players.alice), "the rejected role is rolled back") + check(#env.sent == 0, "the rollback is not sent to the controller") + check(#env.events == 1 and eq(env.events[1].data.unassigned, { 7 }), "the rollback raises the event") -return Env.results_json(env) + local sd = env.Roles._script_data() + check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") +end) + +test("local only roles never reach the controller", function(env) + local players = setup(env) + env.R("Regular"):assign(players.alice, { silent = true, local_only = true }) + check(#env.sent == 0, "a local only role is not sent") + check(env.R("Regular"):has_player(players.alice), "a local only role applies") + + local sd = env.Roles._script_data() + check(sd.pending.alice == nil and eq(sd.local_players.alice, { 6 }), "a local only role is not pending") + + env.Roles.receive_assignment_updates{ Env.assignment("alice", { 5 }) } + check(env.R("Regular"):has_player(players.alice), "a local only role survives controller updates") + + env.reset_log() + env.R("Regular"):unassign(players.alice, { local_only = true }) + check(not env.R("Regular"):has_player(players.alice) and #env.sent == 0, "removed without sync") +end) + +test("jail suppresses every other role", function(env) + local players = setup(env) + env.R("Jail"):assign(players.alice, { by_player_name = "", silent = true }) + check(eq(Test.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") + check(eq(env.events[1].data.assigned, { 7 }) and #env.events[1].data.unassigned == 0, + "the event lists only the jail role") + + env.reset_log() + env.R("Jail"):unassign(players.alice, { by_player_name = "", silent = true }) + check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }), + "unjail restores the roles") + check(#env.events[1].data.assigned == 0 and eq(env.events[1].data.unassigned, { 7 }), + "the unjail event lists only the jail role") +end) + +test("the server can not be assigned roles", function(env) + setup(env) + env.R("Moderator"):assign(env.server) + check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op") +end) + +return Env.finish() diff --git a/exp_roles/test/lua/env.lua b/exp_roles/test/lua/env.lua index 33bf7b40..ddec6edc 100644 --- a/exp_roles/test/lua/env.lua +++ b/exp_roles/test/lua/env.lua @@ -1,14 +1,23 @@ --[[-- Test environment for module/control.lua -Stubs the factorio globals and the clusterio modules, then loads a fresh copy -of the roles module. Each call to Env.new() is independent. +Composes the shared stubs and framework from the repository test folder with +the fixtures for this plugin. Each call to Env.new() is an independent +environment with a fresh copy of the roles module. -Run as a chunk by test/helpers/lua.js, receiving the module directory and +Run as a chunk by test/lua/runner.js, receiving the shared folder and returning this table, which is in turn passed to each test chunk. ]] -local module_root = ... --- @type string +local shared_root = ... --- @type string +local source = assert(debug.getinfo(1, "S")).source +local plugin_root = assert(source:match("^@(.*)/test/lua/env%.lua$")) -local Env = {} +local Stubs = assert(loadfile(shared_root .. "/stubs.lua"))() +local Framework = assert(loadfile(shared_root .. "/framework.lua"))() + +local Env = { + --- Test registry for the file being run, see framework.lua + Test = Framework.new(), +} --- Standard fixture: permission names sent once and referenced by zero based index Env.permission_names = { @@ -44,83 +53,12 @@ end --- Create an independent environment with a fresh copy of the roles module function Env.new() - local env = { - events = {}, -- events raised through script.raise_event - printed = {}, -- game.print and player.print messages - sent = {}, -- payloads sent to the controller - sounds = {}, -- sounds played to players - results = {}, -- test results collected by env.check - } + local env = Stubs.new() - local next_event_id = 100 - script = { - generate_event_name = function() - next_event_id = next_event_id + 1 - return next_event_id - end, - raise_event = function(id, data) - env.events[#env.events + 1] = { id = id, data = data } - end, - register_metatable = function() end, - } - defines = { events = { on_player_joined_game = 1, on_multiplayer_init = 2 } } - - local players_by_name, players_by_index, connected = {}, {}, {} - game = { - tick = 1, - player = nil, - players = players_by_name, - connected_players = connected, - print = function(message) env.printed[#env.printed + 1] = message end, - get_player = function(key) return players_by_name[key] or players_by_index[key] end, - } - - local stubs = { - ["modules/clusterio/api"] = { - send_json = function(channel, data) - env.sent[#env.sent + 1] = { channel = channel, data = data } - end, - }, - ["modules/clusterio/compat"] = { script_data = {} }, - ["modules/exp_util/async"] = { - register = function(callback) - return function(...) return callback(...) end - end, - }, - } - require = function(name) - return assert(stubs[name], "Unexpected require: " .. name) - end - - --- Add a player to the stubbed game - function env.add_player(name, index, is_connected) - local player = { - name = name, - index = index, - connected = is_connected ~= false, - valid = true, - play_sound = function(opts) - env.sounds[#env.sounds + 1] = name .. ":" .. opts.path - end, - print = function(message) - env.printed[#env.printed + 1] = { to = name, message } - end, - } - players_by_name[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 - env.server = setmetatable({ index = 0, name = "" }, { - __index = function(_, key) error("Server player field accessed: " .. tostring(key)) end, - }) - - env.Roles = assert(loadfile(module_root .. "/control.lua"))() + env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))() env.Roles.on_server_startup() - --- Get a role by name, failing the test file when it does not exist + --- Get a role by name, failing the test when it does not exist function env.R(name) return (assert(env.Roles.get_role_by_name(name), "No role named " .. name)) end @@ -134,60 +72,13 @@ function Env.new() } end - --- Forget everything recorded so far - function env.reset_log() - env.events, env.printed, env.sent, env.sounds = {}, {}, {}, {} - end - - --- Record one test result - function env.check(ok, name, detail) - env.results[#env.results + 1] = { name = name, ok = not not ok, detail = detail } - end - - --- Shallow array equality - function env.eq(a, b) - if type(a) ~= "table" or type(b) ~= "table" then return a == b end - if #a ~= #b then return false end - for i = 1, #a do - if a[i] ~= b[i] then return false end - end - return true - end - - --- Names of an array of roles - function env.names(roles) - local rtn = {} - for index, role in ipairs(roles) do - rtn[index] = role.name - end - return rtn - end - - --- Sorted copy of an array of strings - function env.sorted(list) - local rtn = {} - for index, value in ipairs(list) do rtn[index] = value end - table.sort(rtn) - return rtn - end - return env end ---- Encode the results of an environment as JSON for the javascript side -function Env.results_json(env) - 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(env.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, ",") .. "]" +--- Run the declared tests, each against a fresh environment +function Env.finish() + Env.Test.run(Env.new) + return Env.Test.results_json() end return Env diff --git a/exp_roles/test/lua/holders.lua b/exp_roles/test/lua/holders.lua index bb345ddf..398377ec 100644 --- a/exp_roles/test/lua/holders.lua +++ b/exp_roles/test/lua/holders.lua @@ -1,47 +1,76 @@ --- Listing and printing to the players who hold a role local Env = ... -local env = Env.new() -local check, eq, R = env.check, env.eq, env.R -local Roles = env.Roles +local Test = Env.Test +local test, check, eq = Test.test, Test.check, Test.eq -local alice = env.add_player("alice", 1) -env.add_player("bob", 2) -env.add_player("dave", 4, false) -- offline -env.initialise{ - Env.assignment("alice", { 5 }), - Env.assignment("bob", { 6 }), - Env.assignment("dave", { 5 }), - Env.assignment("zed", { 5 }), -- has never joined this map -} -Roles.set_emit_events(true) - -local mod = R("Moderator") -check(eq(env.sorted(mod:get_player_names()), { "alice", "dave", "zed" }), - "player names include players not on this map") -check(eq(env.sorted(env.names(mod:get_players())), { "alice", "dave" }), - "players are limited to this map") -check(eq(env.names(mod:get_players(true)), { "alice" }), "players can be filtered by connected state") -check(eq(env.names(mod:get_players(false)), { "dave" }), "players can be filtered to offline") - --- A player holding the role in both the synced and the local list counts once -R("Regular"):assign(alice, { silent = true, local_only = true }) -Roles.receive_assignment_updates{ Env.assignment("alice", { 5, 6 }) } -local sd = Roles._script_data() -check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists") -check(eq(env.sorted(R("Regular"):get_player_names()), { "alice", "bob" }), - "a player in both lists is counted once") - -env.reset_log() -check(mod:print("hello") == 1, "print returns the number of players reached") -check(#env.printed == 1 and env.printed[1].to == "alice", "print reaches only online holders") - -env.reset_log() -for _, role in ipairs(Roles.get_higher_roles(R("Regular"))) do - role:print("hello") +--- alice and dave are moderators with dave offline, zed has never joined +local function setup(env) + local players = { + alice = env.add_player("alice", 1), + bob = env.add_player("bob", 2), + dave = env.add_player("dave", 4, false), + } + env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("bob", { 6 }), + Env.assignment("dave", { 5 }), + Env.assignment("zed", { 5 }), + } + env.Roles.set_emit_events(true) + return players 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 -check(eq(env.sorted(got), { "alice", "alice", "bob" }), "printing to the higher roles reaches their online holders") -return Env.results_json(env) +test("player names include players not on this map", function(env) + setup(env) + check(eq(Test.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" }), + "every player given the role is listed") +end) + +test("players are limited to this map and can be filtered", function(env) + setup(env) + local mod = env.R("Moderator") + check(eq(Test.sorted(Test.names(mod:get_players())), { "alice", "dave" }), "players are limited to this map") + check(eq(Test.names(mod:get_players(true)), { "alice" }), "filtered to connected players") + check(eq(Test.names(mod:get_players(false)), { "dave" }), "filtered to offline players") +end) + +test("a player holding the role in both lists is counted 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", { 5, 6 }) } + + local sd = env.Roles._script_data() + check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists") + check(eq(Test.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }), "the player is counted once") +end) + +test("print reaches online holders", function(env) + setup(env) + env.reset_log() + check(env.R("Moderator"):print("hello") == 1, "print returns the number of players reached") + check(#env.printed == 1 and env.printed[1].to == "alice", "only online holders are reached") +end) + +test("printing to the higher roles", 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 + check(eq(Test.sorted(got), { "alice", "alice", "bob" }), "the online holders of each role are reached") +end) + +test("deep equality helper", function(env) + check(Test.deep_eq({ a = { 1, 2 }, b = "x" }, { a = { 1, 2 }, b = "x" }), "equal nested tables") + check(not Test.deep_eq({ a = { 1, 2 } }, { a = { 1, 3 } }), "differing nested values") + check(not Test.deep_eq({ a = 1 }, { a = 1, b = 2 }), "extra keys on the right") + check(not Test.deep_eq({ a = 1, b = 2 }, { a = 1 }), "extra keys on the left") + check(Test.deep_eq(env.Roles._script_data().pending, {}), "works against module state") +end) + +return Env.finish() diff --git a/exp_roles/test/lua/lookup.lua b/exp_roles/test/lua/lookup.lua index b32bf3b0..f188bf46 100644 --- a/exp_roles/test/lua/lookup.lua +++ b/exp_roles/test/lua/lookup.lua @@ -1,36 +1,48 @@ --- Role lookup, decoding, and comparisons local Env = ... -local env = Env.new() -local check, eq, names, R = env.check, env.eq, env.names, env.R -local Roles = env.Roles +local Test = Env.Test +local test, check, eq = Test.test, Test.check, Test.eq -env.initialise{} +test("roles are ordered most privileged first", function(env) + env.initialise{} + check(eq(Test.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }), + "ordered roles follow their order with deleted roles skipped") + check(#env.Roles.get_roles() == 5, "get_roles returns every role") -check(eq(names(Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }), - "ordered roles are most privileged first with deleted roles skipped") -check(#Roles.get_roles() == 5, "get_roles returns every role") -check(Roles.get_role(6).name == "Regular", "get_role looks up by clusterio id") -check(Roles.get_role_by_name("Moderator").id == 5, "get_role_by_name searches the roles") -check(Roles.get_role(R("Jail").id) == R("Jail"), "lookups return the same object") -check(Roles.get_role(9) == nil and Roles.get_role_by_name("Gone") == nil, "deleted roles are not known") -check(Roles.get_default_role() == R("Player"), "the default role comes from is_default") + local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") } + check(eq(Test.names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first") +end) -check(R("Moderator").short_hand == "Mod" or R("Moderator").short_hand == "Moderator", - "short hand falls back to the name") -check(R("Cluster Admin").short_hand == "SYS", "short hand decoded") -check(R("Moderator").color.g == 170, "color decoded") -check(R("Jail").priority == 1 and R("Jail").block_auto_assign, "priority and block auto assign decoded") +test("roles are looked up by id", function(env) + env.initialise{} + check(env.Roles.get_role(6).name == "Regular", "get_role looks up by clusterio id") + check(env.Roles.get_role_by_name("Moderator").id == 5, "get_role_by_name searches the roles") + check(env.Roles.get_role(env.R("Jail").id) == env.R("Jail"), "lookups return the same object") + check(env.Roles.get_role(9) == nil and env.Roles.get_role_by_name("Gone") == nil, "deleted roles are not known") + check(env.Roles.get_default_role() == env.R("Player"), "the default role comes from is_default") +end) -check(R("Moderator"):is_higher_than(R("Player")), "roles compare on their order") -check(R("Player"):is_lower_than(R("Moderator")), "is_lower_than is the reverse") -check(not R("Moderator"):is_higher_than(R("Moderator")), "a role is not higher than itself") +test("role records are decoded", function(env) + env.initialise{} + check(env.R("Cluster Admin").short_hand == "SYS", "short hand decoded") + check(env.R("Moderator").short_hand == "Moderator", "short hand falls back to the name") + check(env.R("Moderator").color.g == 170, "color decoded") + check(env.R("Jail").priority == 1 and env.R("Jail").block_auto_assign, "priority and block auto assign decoded") +end) -check(eq(env.sorted(names(Roles.get_higher_roles(R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }), - "higher roles include the role itself and exclude the default role") -check(eq(env.sorted(names(Roles.get_lower_roles(R("Regular")))), { "Jail", "Regular" }), - "lower roles include the role itself and exclude the default role") +test("roles compare on their 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) -local sorted = Roles.sort_roles{ R("Player"), R("Cluster Admin"), R("Regular") } -check(eq(names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first") +test("higher and lower roles", function(env) + env.initialise{} + check(eq(Test.sorted(Test.names(env.Roles.get_higher_roles(env.R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }), + "higher roles include the role itself and exclude the default role") + check(eq(Test.sorted(Test.names(env.Roles.get_lower_roles(env.R("Regular")))), { "Jail", "Regular" }), + "lower roles include the role itself and exclude the default role") +end) -return Env.results_json(env) +return Env.finish() diff --git a/exp_roles/test/lua/players.lua b/exp_roles/test/lua/players.lua index c77bb349..4a49ae6c 100644 --- a/exp_roles/test/lua/players.lua +++ b/exp_roles/test/lua/players.lua @@ -1,56 +1,81 @@ --- Player role and permission checks local Env = ... -local env = Env.new() -local check, eq, names, R = env.check, env.eq, env.names, env.R -local Roles = env.Roles +local Test = Env.Test +local test, check, eq = Test.test, Test.check, Test.eq -local alice = env.add_player("alice", 1) -- moderator -local bob = env.add_player("bob", 2) -- regular -local carol = env.add_player("carol", 3) -- only the default role -env.initialise{ - Env.assignment("alice", { 5 }), - Env.assignment("bob", { 6 }), -} +--- 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", 1), + bob = env.add_player("bob", 2), + carol = env.add_player("carol", 3), + } + env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("bob", { 6 }), + } + return players +end -check(eq(env.sorted(names(Roles.get_player_roles(alice))), { "Moderator", "Player" }), - "player roles include the default role") -check(eq(names(Roles.get_player_roles(carol)), { "Player" }), "a player with no roles has the default role") -check(eq(names(Roles.get_player_roles(nil)), { "" }), "nil is the server") -check(eq(names(Roles.get_player_roles(env.server)), { "" }), "a player with index 0 is the server") -check(Roles.get_player_highest_role(alice) == R("Moderator"), "highest role") -check(Roles.get_player_highest_role(carol) == R("Player"), "highest role is the default role when none are held") +test("player roles include the default role", function(env) + local players = setup(env) + check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }), + "held roles and the default role") + check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Player" }), + "a player with no roles has the default role") +end) -check(Roles.player_has_permission(alice, "exp_scenario.command.kill"), "permission through a held role") -check(Roles.player_has_permission(alice, "exp_scenario.gui.readme"), "permission through the default role") -check(not Roles.player_has_permission(bob, "exp_scenario.command.jail"), "permission not granted") -check(Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission") -check(Roles.player_has_permission(env.server, "anything.at.all"), "an index 0 player has every permission") +test("the server is nil or a player with index 0", function(env) + setup(env) + check(eq(Test.names(env.Roles.get_player_roles(nil)), { "" }), "nil is the server") + check(eq(Test.names(env.Roles.get_player_roles(env.server)), { "" }), "index 0 is the server") + check(env.Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission") + check(not env.R("Player"):has_player(nil), "the server does not have the default role") +end) -check(Roles.player_has_any_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.kill"), - "has any passes when one permission is granted") -check(not Roles.player_has_any_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.assign_role"), - "has any fails when none are granted") -check(not Roles.player_has_any_permission(bob), "has any with no permissions is false") -check(Roles.player_has_all_permission(alice, "exp_scenario.command.jail", "exp_scenario.command.kill"), - "has all passes when every permission is granted") -check(not Roles.player_has_all_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.kill"), - "has all fails when one is missing") -check(Roles.player_has_all_permission(bob), "has all with no permissions is true") -check(Roles.player_has_any_permission(nil, "anything.at.all"), "the server passes has any") +test("the highest role", function(env) + local players = setup(env) + check(env.Roles.get_player_highest_role(players.alice) == env.R("Moderator"), "highest held role") + check(env.Roles.get_player_highest_role(players.carol) == env.R("Player"), "the default role when none are held") +end) -check(R("Moderator"):has_permission("exp_scenario.command.kill"), "role has_permission") -check(not R("Regular"):has_permission("exp_scenario.command.jail"), "role has_permission not granted") -check(R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything") +test("permission checks", 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) -check(R("Moderator"):has_player(alice), "has_player for a held role") -check(not R("Moderator"):has_player(bob), "has_player for a role not held") -check(R("Player"):has_player(carol), "everyone has the default role") -check(not R("Player"):has_player(nil), "the server does not have the default role") +test("has any and has all", 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") +end) -check(Roles.player_outranks(alice, bob), "a higher role outranks a lower one") -check(not Roles.player_outranks(bob, alice), "a lower role does not outrank a higher one") -check(not Roles.player_outranks(alice, alice), "nobody outranks themselves") -check(Roles.player_outranks(nil, alice), "the server outranks everyone") -check(not Roles.player_outranks(alice, nil), "nobody outranks the server") +test("role permission and player methods", function(env) + local players = setup(env) + check(env.R("Moderator"):has_permission("exp_scenario.command.kill"), "has_permission granted") + check(not env.R("Regular"):has_permission("exp_scenario.command.jail"), "has_permission not granted") + check(env.R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything") + check(env.R("Moderator"):has_player(players.alice), "has_player for a held role") + check(not env.R("Moderator"):has_player(players.bob), "has_player for a role not held") + check(env.R("Player"):has_player(players.carol), "everyone has the default role") +end) -return Env.results_json(env) +test("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 Env.finish() diff --git a/exp_roles/test/lua/sync.lua b/exp_roles/test/lua/sync.lua index dc84edf8..ea710692 100644 --- a/exp_roles/test/lua/sync.lua +++ b/exp_roles/test/lua/sync.lua @@ -1,91 +1,118 @@ --- Initialise semantics and updates from the controller local Env = ... -local env = Env.new() -local check, eq, R = env.check, env.eq, env.R -local Roles = env.Roles -local sd = Roles._script_data() +local Test = Env.Test +local test, check, eq = Test.test, Test.check, Test.eq -local alice = env.add_player("alice", 1) -local carol = env.add_player("carol", 3) +--- alice is a moderator and carol has only the default role, both connected +local function setup(env) + local players = { + alice = env.add_player("alice", 1), + carol = env.add_player("carol", 3), + } + env.admin_state = {} + env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) + env.admin_state[player.name] = state + end) + env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("zed", { 5, 6 }), -- has never joined this map + } + env.Roles.set_emit_events(true) + return players +end -local admin_state = {} -Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) - admin_state[player.name] = state +test("initialise applies triggers and raises empty events", function(env) + setup(env) + check(env.admin_state.alice == true and env.admin_state.carol == false, + "triggers run for connected players") + check(#env.events == 2, "the event is raised once per connected player") + check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "the events are empty") + check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing") end) -env.initialise{ - Env.assignment("alice", { 5 }), - Env.assignment("zed", { 5, 6 }), -- has never joined this map -} -Roles.set_emit_events(true) +test("controller assignments apply and are announced", function(env) + local players = setup(env) + env.reset_log() + env.Roles.receive_assignment_updates{ Env.assignment("carol", { 5 }) } + check(env.R("Moderator"):has_player(players.carol), "the assignment applies") + check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event is raised") + check(env.events[1].data.by_player_index == 0, "controller changes have no by player") + check(#env.printed == 1 and env.printed[1][4] == "", "the change is announced by the server") + check(env.admin_state.carol == true, "triggers follow the assignment") -check(admin_state.alice == true and admin_state.carol == false, "triggers run for connected players on initialise") -check(#env.events == 2, "the roles changed event is raised once per connected player on initialise") -check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "initialise events are empty") -check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing") + 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") + check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "the removal raises the event") + check(env.admin_state.carol == false, "triggers follow the removal") +end) --- Changes to the roles a player holds, made on the controller -env.reset_log() -Roles.receive_assignment_updates{ Env.assignment("carol", { 5 }) } -check(R("Moderator"):has_player(carol), "a controller assignment applies") -check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "a controller assignment raises the event") -check(env.events[1].data.by_player_index == 0, "controller changes have no by player") -check(#env.printed == 1 and env.printed[1][4] == "", "a controller assignment is announced by the server") -check(admin_state.carol == true, "triggers follow controller assignments") +test("changes for players not on this map raise nothing", function(env) + setup(env) + env.reset_log() + env.Roles.receive_assignment_updates{ Env.assignment("zed", { 5 }) } + check(#env.events == 0 and #env.printed == 0, "no event and no announcement") +end) -env.reset_log() -Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } } -check(not R("Moderator"):has_player(carol), "a controller removal applies") -check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "a controller removal raises the event") -check(admin_state.carol == false, "triggers follow controller removals") +test("role updates apply to their holders", function(env) + setup(env) + env.reset_log() + env.Roles.receive_role_updates{ + { id = 6, 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") + check(#env.events == 2, "a role change raises for every connected player") + check(#env.events[1].data.assigned == 0, "role change events are empty") -env.reset_log() -Roles.receive_assignment_updates{ Env.assignment("zed", { 5 }) } -check(#env.events == 0 and #env.printed == 0, "changes for players not on this map raise nothing") + env.Roles.receive_role_updates{ + { id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true }, + } + check(env.Roles.get_role(6) == nil, "a deleted role is removed") +end) --- Changes to the roles themselves -env.reset_log() -Roles.receive_role_updates{ - { id = 6, name = "Regular", permissions = { "exp_scenario.command.jail" }, meta = { id = 0, order = 3 } }, -} -check(R("Regular"):has_permission("exp_scenario.command.jail"), "a role permission change applies") -check(#env.events == 2, "a role change raises for every connected player") -check(#env.events[1].data.assigned == 0, "role change events are empty") +test("the default role follows is_default", function(env) + local players = setup(env) + env.Roles.receive_role_updates{ + { id = 1, 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") + check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Guest" }), "players pick up the new default") +end) -env.reset_log() -Roles.receive_role_updates{ { id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true } } -check(Roles.get_role(6) == nil, "a deleted role is removed") +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 }) -env.reset_log() -Roles.receive_role_updates{ - { id = 1, name = "Player", permissions = {}, meta = { id = 0, order = 5 } }, - { id = 8, name = "Guest", permissions = {}, meta = { id = 0, order = 7 }, is_default = true }, -} -check(Roles.get_default_role() == R("Guest"), "the default role follows is_default") -check(eq(env.names(Roles.get_player_roles(carol)), { "Guest" }), "players pick up the new default role") + local sd = env.Roles._script_data() + check(eq(sd.pending.carol, { 5 }), "the role is pending before initialise") --- Initialise is authoritative for pending roles -R("Moderator"):assign(carol) -R("Jail"):assign(carol, { silent = true, local_only = true }) -check(eq(sd.pending.carol, { 5 }), "the role is pending before initialise") -env.reset_log() -env.initialise{ Env.assignment("alice", { 5 }) } -check(sd.pending.carol == nil, "initialise clears pending roles") -check(not R("Moderator"):has_player(carol), "an unconfirmed role is given up") -check(eq(sd.local_players.carol, { 7 }), "local only roles are kept") -check(#env.sent == 0, "initialise sends nothing") + env.reset_log() + env.initialise{ Env.assignment("alice", { 5 }) } + check(sd.pending.carol == nil, "pending roles are cleared") + check(not env.R("Moderator"):has_player(players.carol), "an unconfirmed role is given up") + check(eq(sd.local_players.carol, { 7 }), "local only roles are kept") + check(#env.sent == 0, "initialise sends nothing") +end) --- Emit updates gate and load -Roles.set_emit_events(false) -env.reset_log() -R("Regular"):assign(alice) -check(#env.sent == 0, "nothing is sent while emit is disabled") -Roles.on_load() -check(R("Moderator"):is_higher_than(R("Regular")), "roles are usable after load") +test("nothing is sent while emit is disabled", function(env) + local players = setup(env) + env.Roles.set_emit_events(false) + env.reset_log() + env.R("Regular"):assign(players.alice) + check(#env.sent == 0, "the assignment is not sent") + check(env.R("Regular"):has_player(players.alice), "the assignment still applies locally") +end) --- Triggers on join -admin_state.alice = nil -Roles.on_player_joined_game{ player_index = 1 } -check(admin_state.alice ~= nil, "triggers run when a player joins") +test("roles are usable after load and triggers run on join", function(env) + setup(env) + env.Roles.on_load() + check(env.R("Moderator"):is_higher_than(env.R("Regular")), "roles are usable after load") -return Env.results_json(env) + env.admin_state.alice = nil + env.Roles.on_player_joined_game{ player_index = 1 } + check(env.admin_state.alice ~= nil, "triggers run when a player joins") +end) + +return Env.finish() diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js index ff1c774a..1d865f5a 100644 --- a/exp_roles/test/messages.test.js +++ b/exp_roles/test/messages.test.js @@ -1,31 +1,83 @@ "use strict"; const t = require("tap"); -const { Value } = require("@sinclair/typebox/value"); const messages = require("../dist/node/messages"); +const { testMatrix, testRoundTripJsonSerialisable } = require("../../test/common"); -function roundTrip(subtest, Record, record) { - const json = record.toJSON(); - subtest.ok(Value.Check(Record.jsonSchema, json), "json matches the schema"); - subtest.strictSame(Record.fromJSON(json), record, "the record round trips"); -} +const fullMeta = new messages.RoleMetaRecord( + 7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false, +); + +t.test("RoleColor", subtest => { + testRoundTripJsonSerialisable(messages.RoleColor, testMatrix( + [0, 255], // r + [0, 128], // g + [0, 1], // b + )); + subtest.pass("round trips"); + subtest.end(); +}); t.test("RoleMetaRecord", subtest => { - roundTrip(subtest, messages.RoleMetaRecord, new messages.RoleMetaRecord(7, 3)); - roundTrip(subtest, messages.RoleMetaRecord, new messages.RoleMetaRecord( - 7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false, + testRoundTripJsonSerialisable(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 )); + subtest.pass("round trips"); subtest.end(); }); t.test("RoleRecord", subtest => { - roundTrip(subtest, messages.RoleRecord, new messages.RoleRecord( - 7, "Moderator", ["exp_scenario.command.kill"], new messages.RoleMetaRecord(7, 3), true, 12345, + testRoundTripJsonSerialisable(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 )); + subtest.pass("round trips"); subtest.end(); }); t.test("AssignmentRecord", subtest => { - roundTrip(subtest, messages.AssignmentRecord, new messages.AssignmentRecord("alice", new Set([5, 6]), 12345)); + testRoundTripJsonSerialisable(messages.AssignmentRecord, testMatrix( + ["alice"], // name + [new Set(), new Set([5, 6])], // roleIds + [0, 12345], // updatedAtMs + [false, true], // isDeleted + )); + subtest.pass("round trips"); + subtest.end(); +}); + +t.test("update events and requests", subtest => { + const record = new messages.RoleRecord(7, "Moderator", ["a.b"], fullMeta, true, 12345); + const assignment = new messages.AssignmentRecord("alice", new Set([5]), 12345); + + testRoundTripJsonSerialisable(messages.RoleUpdatedEvent, testMatrix( + [[], [record]], // updates + )); + testRoundTripJsonSerialisable(messages.AssignmentUpdatedEvent, testMatrix( + [[], [assignment]], // updates + )); + testRoundTripJsonSerialisable(messages.RoleMetaUpdateRequest, testMatrix( + [new messages.RoleMetaRecord(7, 3), fullMeta], // meta + )); + testRoundTripJsonSerialisable(messages.AssignmentUpdateRequest, testMatrix( + ["alice"], // name + [[], [5]], // assign + [[], [6]], // unassign + )); + subtest.pass("round trips"); subtest.end(); }); diff --git a/exp_roles/test/module.test.js b/exp_roles/test/module.test.js index 9bca4e1a..8f11dc5c 100644 --- a/exp_roles/test/module.test.js +++ b/exp_roles/test/module.test.js @@ -1,8 +1,11 @@ "use strict"; +const path = require("node:path"); const t = require("tap"); -const { reportLuaTests } = require("./helpers/lua"); +const { reportLuaTests } = require("../../test/lua/runner"); -// Each file runs in its own lua state with a fresh copy of the module +const envFile = path.join(__dirname, "lua", "env.lua"); + +// Each file runs in its own lua state, and each test in a fresh environment for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) { - t.test(file, subtest => reportLuaTests(subtest, file)); + t.test(file, subtest => reportLuaTests(subtest, envFile, path.join(__dirname, "lua", file))); } diff --git a/test/common.js b/test/common.js new file mode 100644 index 00000000..eb75a3f2 --- /dev/null +++ b/test/common.js @@ -0,0 +1,41 @@ +"use strict"; +const assert = require("assert").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 {{new(...args: T): object}} Class - The class which has toJSON and fromJSON methods. + * @param {T[]} tests - The tests inputs to pass to the class constructor. + */ +function testRoundTripJsonSerialisable(Class, tests) { + const validate = compile(Class.jsonSchema); + for (const test of tests) { + const original = new Class(...test); + const serialised = JSON.stringify(original); + const jsonObject = JSON.parse(serialised); + const reconstructed = Class.fromJSON(jsonObject); + assert.deepEqual(reconstructed, original, JSON.stringify(test)); + if (!validate(jsonObject)) { + throw validate.errors; + } + } +} + +module.exports = { + testMatrix, + testRoundTripJsonSerialisable, +}; diff --git a/test/lua/framework.lua b/test/lua/framework.lua new file mode 100644 index 00000000..f5193b50 --- /dev/null +++ b/test/lua/framework.lua @@ -0,0 +1,109 @@ +--[[-- Test framework for plugin module tests +Test files declare named tests with `Test.test(name, function(env) ... end)`. +The runner creates a fresh environment for every test, so tests are +independent and can not leak state into each other. +]] + +local Framework = {} + +--- Create a test registry, one is made per test file +function Framework.new() + --- @class Test + local Test = { + tests = {}, -- { name, fn } in declaration order + results = {}, -- { name, ok, detail? } 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: table) + function Test.test(name, fn) + Test.tests[#Test.tests + 1] = { name = name, fn = fn } + end + + --- Record one check within the current test + --- @param ok any Truthy when the check passed + --- @param name string + --- @param detail string? + function Test.check(ok, name, detail) + Test.results[#Test.results + 1] = { + name = current_test and (current_test .. ": " .. name) or name, + ok = not not ok, + detail = detail, + } + end + + --- Shallow array equality + function Test.eq(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return a == b end + if #a ~= #b then return false end + for i = 1, #a do + if a[i] ~= b[i] then return false end + end + return true + end + + --- Recursive table equality, keys checked from both sides + function Test.deep_eq(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return a == b end + for key, value in pairs(a) do + if not Test.deep_eq(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 + + --- Names of an array of roles + function Test.names(roles) + local rtn = {} + for index, role in ipairs(roles) do + rtn[index] = role.name + end + return rtn + end + + --- Sorted copy of an array of strings + function Test.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 against a fresh environment + --- @param make_env fun(): table + function Test.run(make_env) + for _, test in ipairs(Test.tests) do + current_test = test.name + local ok, err = pcall(test.fn, make_env()) + if not ok then + Test.check(false, "did not error", tostring(err)) + end + end + current_test = nil + end + + --- Encode the results as JSON for the javascript side + function Test.results_json() + 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(Test.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 Test +end + +return Framework diff --git a/exp_roles/test/helpers/lua.js b/test/lua/runner.js similarity index 53% rename from exp_roles/test/helpers/lua.js rename to test/lua/runner.js index 8f8dddde..51019609 100644 --- a/exp_roles/test/helpers/lua.js +++ b/test/lua/runner.js @@ -3,28 +3,30 @@ const path = require("node:path"); const fs = require("node:fs"); const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari"); -const moduleRoot = path.join(__dirname, "..", "..", "module"); -const luaRoot = path.join(__dirname, "..", "lua"); - /** * 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 environment. The file receives the environment helpers - * from env.lua and must return an array of { name, ok, detail? } results, - * encoded as JSON. + * gets an independent set of stubs. Three chunks run in order, each taking one + * argument and returning one value: * - * @param {string} name - File in test/lua to run, such as "lookup.lua". + * 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(name) { +function runLuaTests(envFile, testFile) { const L = lauxlib.luaL_newstate(); lualib.luaL_openlibs(L); - // Each file is run as a chunk taking one argument and returning one value - const load = (file, chunkName) => { + const load = (file) => { const code = fs.readFileSync(file); - const status = lauxlib.luaL_loadbuffer(L, code, code.length, to_luastring(`@${chunkName}`)); + 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))); } @@ -35,13 +37,12 @@ function runLuaTests(name) { } }; - // The environment stubs factorio and loads module/control.lua from disk - load(path.join(luaRoot, "env.lua"), "test/lua/env.lua"); - lua.lua_pushstring(L, to_luastring(moduleRoot)); + load(envFile); + lua.lua_pushstring(L, to_luastring(__dirname)); call(); // The environment stays on the stack and is passed to the test chunk - load(path.join(luaRoot, name), `test/lua/${name}`); + load(testFile); lua.lua_insert(L, -2); call(); @@ -49,10 +50,10 @@ function runLuaTests(name) { return JSON.parse(json); } -/** Report lua results through a tap test object. */ -function reportLuaTests(t, name) { - const results = runLuaTests(name); - t.ok(results.length > 0, `${name} produced results`); +/** 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 } : {}); } diff --git a/test/lua/stubs.lua b/test/lua/stubs.lua new file mode 100644 index 00000000..5ed3fa48 --- /dev/null +++ b/test/lua/stubs.lua @@ -0,0 +1,118 @@ +--[[-- 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 stubs of their +own through `stubs.requires` and `Stubs.strict`. +]] + +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 + +--- Create an independent set of stubs, installed as the lua globals +function Stubs.new() + local stubs = { + events = {}, -- events raised through script.raise_event + printed = {}, -- game.print and player.print messages + sent = {}, -- payloads sent through the clusterio api + sounds = {}, -- sounds played to players + } + + local next_event_id = 100 + script = Stubs.strict("script", { + generate_event_name = function() + next_event_id = next_event_id + 1 + return next_event_id + end, + raise_event = function(id, data) + stubs.events[#stubs.events + 1] = { id = id, data = data } + end, + register_metatable = function() end, + }) + + defines = Stubs.strict("defines", { + events = Stubs.strict("defines.events", { + on_player_joined_game = 1, + on_multiplayer_init = 2, + }), + }) + + local players_by_name, players_by_index, connected = {}, {}, {} + game = Stubs.strict("game", { + tick = 1, + players = players_by_name, + connected_players = connected, + print = function(message) stubs.printed[#stubs.printed + 1] = message end, + get_player = function(key) return players_by_name[key] or players_by_index[key] end, + }, { "player" }) + + --- Add a player to the stubbed game + function stubs.add_player(name, index, is_connected) + 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, + }) + players_by_name[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 + stubs.server = Stubs.strict("server player", { index = 0, name = "" }) + + --- Modules resolved by the stubbed require, extend before loading the module + stubs.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(stubs.requires[name], "Unexpected require: " .. name) + end + + --- Forget everything recorded so far + function stubs.reset_log() + stubs.events, stubs.printed, stubs.sent, stubs.sounds = {}, {}, {}, {} + end + + return stubs +end + +return Stubs From 7adcdde9dccc108cde59fef68f9fcdde0ccbca17 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:21:39 +0000 Subject: [PATCH 11/16] Address review on the test framework - The registry is a Suite, created around the environment factory it runs its tests with, so the plugin's env.lua reads as: build environments, hand the test files a suite of them. The suite returned there becomes `...` in each test file, which is now said where it happens. - pass and fail are the primitives every other check goes through. eq and deep_eq assert rather than compare, failing with both values in the detail. - Stubs are extended through extend_requires, extend_script, extend_game and extend_defines, a recursive merge which raises when a value already exists, rather than by mutating the tables. The strict labels carry the factorio class names. - The stubs record registered metatables and can save and load the script data the way factorio does: functions are refused and only registered metatables survive. env.save_load() uses it, and a new on_load test shows role methods and held roles surviving the round trip, which only passes because the module registers its metatable. - The names helper moved out of the shared framework, it is role specific. - Tests are named after the function or method they cover, on both sides: the lua tests read as "role:assign applies locally and is sent", and the javascript files wrap their tests in the class they exercise with subtests per method. module.test.js is control.test.js, after the file it covers. Co-Authored-By: Claude Fable 5 --- .../test/{module.test.js => control.test.js} | 9 +- exp_roles/test/controller.test.js | 239 +++++++++--------- exp_roles/test/instance.test.js | 149 +++++------ exp_roles/test/lua/assignment.lua | 74 +++--- exp_roles/test/lua/env.lua | 59 +++-- exp_roles/test/lua/holders.lua | 57 ++--- exp_roles/test/lua/lookup.lua | 87 ++++--- exp_roles/test/lua/players.lua | 51 ++-- exp_roles/test/lua/sync.lua | 85 ++++--- exp_roles/test/messages.test.js | 33 ++- exp_roles/test/seed.test.js | 6 +- test/lua/framework.lua | 114 ++++++--- test/lua/stubs.lua | 77 +++++- 13 files changed, 592 insertions(+), 448 deletions(-) rename exp_roles/test/{module.test.js => control.test.js} (51%) diff --git a/exp_roles/test/module.test.js b/exp_roles/test/control.test.js similarity index 51% rename from exp_roles/test/module.test.js rename to exp_roles/test/control.test.js index 8f11dc5c..70272979 100644 --- a/exp_roles/test/module.test.js +++ b/exp_roles/test/control.test.js @@ -6,6 +6,9 @@ const { reportLuaTests } = require("../../test/lua/runner"); const envFile = path.join(__dirname, "lua", "env.lua"); // Each file runs in its own lua state, and each test in a fresh environment -for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) { - t.test(file, subtest => reportLuaTests(subtest, envFile, path.join(__dirname, "lua", file))); -} +t.test("module/control.lua", t2 => { + for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) { + t2.test(`lua/${file}`, subtest => reportLuaTests(subtest, envFile, path.join(__dirname, "lua", file))); + } + t2.end(); +}); diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index 2536ef44..3ea4caa4 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -65,131 +65,134 @@ async function startPlugin(t2, { roles = [], users = [], config = {} } = {}) { const role = (id, name, permissions = []) => new lib.Role(id, name, "", new Set(permissions)); -t.test("init creates properties for existing roles and sweeps orphans", async t2 => { - const { plugin } = await startPlugin(t2, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); +t.test("class ControllerPlugin", t2 => { + t2.test("init and sweepRoleMeta manage the role properties", async t2 => { + const { plugin } = await startPlugin(t2, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); - t2.strictSame(plugin.roleMeta.get(0).order, 1, "the first role is ordered first"); - t2.strictSame(plugin.roleMeta.get(5).order, 2, "the next role is ordered after it"); + t2.strictSame(plugin.roleMeta.get(0).order, 1, "the first role is ordered first"); + t2.strictSame(plugin.roleMeta.get(5).order, 2, "the next role is ordered after it"); - plugin.roleMeta.set(new messages.RoleMetaRecord(99, 3)); - plugin.sweepRoleMeta(); - t2.notOk(plugin.roleMeta.get(99), "properties without a role are removed"); - t2.ok(plugin.roleMeta.get(5), "properties with a role are kept"); -}); - -t.test("role records combine the role with its properties", async t2 => { - const { plugin } = await startPlugin(t2, { - 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(plugin.controller.roles.get(5)); - t2.strictSame(record.name, "Moderator", "the name comes from the role"); - t2.strictSame(record.permissions, ["exp_scenario.command.kill"], "the permissions come from the role"); - t2.strictSame(record.meta.shortHand, "Mod", "the properties come from the datastore"); - t2.notOk(record.isDefault, "not the default role"); - t2.ok(plugin.buildRoleRecord(plugin.controller.roles.get(1)).isDefault, "the default role is marked"); -}); - -t.test("role changes broadcast to subscribers", async t2 => { - const { plugin, controller, state } = await startPlugin(t2, { roles: [role(1, "Player")] }); - - state.broadcasts.length = 0; - controller.roles.set(role(5, "Moderator")); - const updated = state.broadcasts.flatMap(event => event.updates.map(update => update.name)); - t2.ok(updated.includes("Moderator"), "a new role is broadcast"); - - state.broadcasts.length = 0; - plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 0, "Mod")); - t2.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"); - t2.ok(state.broadcasts.some(event => event instanceof messages.RoleUpdatedEvent), "a default role change rebroadcasts"); -}); - -t.test("subscriptions replay only newer records", async t2 => { - const { plugin, controller } = await startPlugin(t2, { 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 }); - t2.strictSame(all.updates.length, 2, "everything is replayed from the start"); - const none = await plugin.handleRoleSubscription({ lastRequestTimeMs: updatedAtMs }); - t2.strictSame(none, null, "nothing is replayed when up to date"); -}); - -t.test("assignments mirror users without the default role", async t2 => { - const alice = new FakeUser("alice", [lib.Role.DefaultPlayerRoleId, 5]); - alice.updatedAtMs = 10; - const bob = new FakeUser("bob", [lib.Role.DefaultPlayerRoleId]); - const { plugin } = await startPlugin(t2, { roles: [role(1, "Player"), role(5, "Moderator")], users: [alice, bob] }); - - const records = plugin.listAssignmentRecords(); - t2.strictSame(records.length, 1, "users with only the default role are left out"); - t2.strictSame(records[0].name, "alice"); - t2.strictSame([...records[0].roleIds], [5], "the default role is not listed"); -}); - -t.test("assignment updates validate the user and roles", async t2 => { - const alice = new FakeUser("alice", [5]); - const { plugin, state } = await startPlugin(t2, { - roles: [role(1, "Player"), role(5, "Moderator"), role(6, "Regular")], users: [alice], + plugin.roleMeta.set(new messages.RoleMetaRecord(99, 3)); + plugin.sweepRoleMeta(); + t2.notOk(plugin.roleMeta.get(99), "properties without a role are removed"); + t2.ok(plugin.roleMeta.get(5), "properties with a role are kept"); }); - await t2.rejects( - plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("nobody", [], [])), - { message: /does not exist/ }, "an unknown user is refused", - ); - await t2.rejects( - plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("alice", [99], [])), - { message: /does not exist/ }, "an unknown role is refused", - ); + t2.test("buildRoleRecord combines the role with its properties", async t2 => { + const { plugin } = await startPlugin(t2, { + roles: [role(1, "Player"), role(5, "Moderator", ["exp_scenario.command.kill"])], + }); + plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 1, "Mod")); - await plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("alice", [6], [5])); - t2.strictSame([...alice.roleIds], [6], "roles are added and removed"); - t2.ok(state.permissionUpdates.includes("alice"), "permission changes are pushed to the user"); -}); - -t.test("roles are granted from online time", async t2 => { - const newcomer = new FakeUser("newcomer"); - newcomer.playerStats.onlineTimeMs = 3600000; - const veteran = new FakeUser("veteran"); - veteran.playerStats.onlineTimeMs = 7200000; - const jailed = new FakeUser("jailed", [7]); - jailed.playerStats.onlineTimeMs = 7200000; - - const { plugin } = await startPlugin(t2, { - roles: [role(1, "Player"), role(6, "Regular"), role(7, "Jail")], - users: [newcomer, veteran, jailed], - }); - // 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)); - t2.ok(veteran.roleIds.has(6), "enough online time grants the role"); - t2.notOk(newcomer.roleIds.has(6), "not enough online time does not"); - t2.notOk(jailed.roleIds.has(6), "a blocking role prevents the grant"); - - veteran.roleIds.delete(6); - await plugin.onPlayerEvent(undefined, { type: "leave", name: "veteran" }); - t2.ok(veteran.roleIds.has(6), "granted again when the player leaves"); -}); - -t.test("seeding creates the roles and reuses them by name", async t2 => { - const { plugin, controller } = await startPlugin(t2, { - roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")], + const record = plugin.buildRoleRecord(plugin.controller.roles.get(5)); + t2.strictSame(record.name, "Moderator", "the name comes from the role"); + t2.strictSame(record.permissions, ["exp_scenario.command.kill"], "the permissions come from the role"); + t2.strictSame(record.meta.shortHand, "Mod", "the properties come from the datastore"); + t2.notOk(record.isDefault, "not the default role"); + t2.ok(plugin.buildRoleRecord(plugin.controller.roles.get(1)).isDefault, "the default role is marked"); }); - await plugin.handleSeedRolesRequest(); - t2.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); + t2.test("rolesUpdated and roleMetaUpdated broadcast to subscribers", async t2 => { + const { plugin, controller, state } = await startPlugin(t2, { roles: [role(1, "Player")] }); - const moderator = [...controller.roles.values()].find(other => other.name === "Moderator"); - t2.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in"); - t2.ok(plugin.roleMeta.get(moderator.id), "the role properties are created"); - t2.strictSame(plugin.roleMeta.get(moderator.id).shortHand, "Mod", "the properties match the seed"); + state.broadcasts.length = 0; + controller.roles.set(role(5, "Moderator")); + const updated = state.broadcasts.flatMap(event => event.updates.map(update => update.name)); + t2.ok(updated.includes("Moderator"), "a new role is broadcast"); - await plugin.handleSeedRolesRequest(); - t2.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles"); + state.broadcasts.length = 0; + plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 0, "Mod")); + t2.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"); + t2.ok(state.broadcasts.some(event => event instanceof messages.RoleUpdatedEvent), "a default role change rebroadcasts"); + }); + + t2.test("handleRoleSubscription replays only newer records", async t2 => { + const { plugin, controller } = await startPlugin(t2, { 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 }); + t2.strictSame(all.updates.length, 2, "everything is replayed from the start"); + const none = await plugin.handleRoleSubscription({ lastRequestTimeMs: updatedAtMs }); + t2.strictSame(none, null, "nothing is replayed when up to date"); + }); + + t2.test("listAssignmentRecords mirrors users without the default role", async t2 => { + const alice = new FakeUser("alice", [lib.Role.DefaultPlayerRoleId, 5]); + alice.updatedAtMs = 10; + const bob = new FakeUser("bob", [lib.Role.DefaultPlayerRoleId]); + const { plugin } = await startPlugin(t2, { roles: [role(1, "Player"), role(5, "Moderator")], users: [alice, bob] }); + + const records = plugin.listAssignmentRecords(); + t2.strictSame(records.length, 1, "users with only the default role are left out"); + t2.strictSame(records[0].name, "alice"); + t2.strictSame([...records[0].roleIds], [5], "the default role is not listed"); + }); + + t2.test("handleAssignmentUpdateRequest validates the user and roles", async t2 => { + const alice = new FakeUser("alice", [5]); + const { plugin, state } = await startPlugin(t2, { + roles: [role(1, "Player"), role(5, "Moderator"), role(6, "Regular")], users: [alice], + }); + + await t2.rejects( + plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("nobody", [], [])), + { message: /does not exist/ }, "an unknown user is refused", + ); + await t2.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])); + t2.strictSame([...alice.roleIds], [6], "roles are added and removed"); + t2.ok(state.permissionUpdates.includes("alice"), "permission changes are pushed to the user"); + }); + + t2.test("applyAutoAssign and onPlayerEvent grant roles from online time", async t2 => { + const newcomer = new FakeUser("newcomer"); + newcomer.playerStats.onlineTimeMs = 3600000; + const veteran = new FakeUser("veteran"); + veteran.playerStats.onlineTimeMs = 7200000; + const jailed = new FakeUser("jailed", [7]); + jailed.playerStats.onlineTimeMs = 7200000; + + const { plugin } = await startPlugin(t2, { + roles: [role(1, "Player"), role(6, "Regular"), role(7, "Jail")], + users: [newcomer, veteran, jailed], + }); + // 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)); + t2.ok(veteran.roleIds.has(6), "enough online time grants the role"); + t2.notOk(newcomer.roleIds.has(6), "not enough online time does not"); + t2.notOk(jailed.roleIds.has(6), "a blocking role prevents the grant"); + + veteran.roleIds.delete(6); + await plugin.onPlayerEvent(undefined, { type: "leave", name: "veteran" }); + t2.ok(veteran.roleIds.has(6), "granted again when the player leaves"); + }); + + t2.test("handleSeedRolesRequest creates the roles and reuses them by name", async t2 => { + const { plugin, controller } = await startPlugin(t2, { + roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")], + }); + + await plugin.handleSeedRolesRequest(); + t2.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); + + const moderator = [...controller.roles.values()].find(other => other.name === "Moderator"); + t2.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in"); + t2.ok(plugin.roleMeta.get(moderator.id), "the role properties are created"); + t2.strictSame(plugin.roleMeta.get(moderator.id).shortHand, "Mod", "the properties match the seed"); + + await plugin.handleSeedRolesRequest(); + t2.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles"); + }); + t2.end(); }); diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js index 161b4f28..1b1e2d9b 100644 --- a/exp_roles/test/instance.test.js +++ b/exp_roles/test/instance.test.js @@ -54,90 +54,93 @@ function decodeRcon(command) { return { receiver: match[1], payload: JSON.parse(match[2]) }; } -t.test("start subscribes and initialises the lua module", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); - await plugin.onStart(); +t.test("class InstancePlugin", t2 => { + t2.test("onStart subscribes and initialises the lua module", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); + await plugin.onStart(); - const subscribed = state.sent.filter(request => request instanceof lib.SubscriptionRequest); - t2.strictSame(subscribed.length, 2, "roles and assignments are subscribed to"); - t2.ok(state.sent.some(request => request instanceof messages.RoleListRequest), "the roles are requested"); - t2.ok(state.sent.some(request => request instanceof messages.AssignmentListRequest), "the assignments are requested"); + const subscribed = state.sent.filter(request => request instanceof lib.SubscriptionRequest); + t2.strictSame(subscribed.length, 2, "roles and assignments are subscribed to"); + t2.ok(state.sent.some(request => request instanceof messages.RoleListRequest), "the roles are requested"); + t2.ok(state.sent.some(request => request instanceof messages.AssignmentListRequest), "the assignments are requested"); - const initialise = decodeRcon(state.rcons[0]); - t2.strictSame(initialise.receiver, "initialise", "the lua module is initialised"); - t2.strictSame(initialise.payload.permission_names, ["exp_scenario.gui.readme", "core.admin"], - "each permission name is sent once"); - t2.strictSame(initialise.payload.roles[1].permissions, [0, 1], "roles reference permissions by index"); - t2.strictSame(initialise.payload.assignments, [{ name: "alice", role_ids: [5] }], "the assignments are sent"); + const initialise = decodeRcon(state.rcons[0]); + t2.strictSame(initialise.receiver, "initialise", "the lua module is initialised"); + t2.strictSame(initialise.payload.permission_names, ["exp_scenario.gui.readme", "core.admin"], + "each permission name is sent once"); + t2.strictSame(initialise.payload.roles[1].permissions, [0, 1], "roles reference permissions by index"); + t2.strictSame(initialise.payload.assignments, [{ name: "alice", role_ids: [5] }], "the assignments are sent"); - const emit = decodeRcon(state.rcons[1]); - t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "enabled does not emit changes back"); - t2.strictSame(state.warnings.length, 0, "no warnings with a default role"); -}); + const emit = decodeRcon(state.rcons[1]); + t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "enabled does not emit changes back"); + t2.strictSame(state.warnings.length, 0, "no warnings with a default role"); + }); -t.test("bidirectional emits changes back", async t2 => { - const { plugin, state } = await startPlugin(t2); - await plugin.onStart(); - const emit = decodeRcon(state.rcons[state.rcons.length - 1]); - t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]); -}); + t2.test("onStart with bidirectional emits changes back", async t2 => { + const { plugin, state } = await startPlugin(t2); + await plugin.onStart(); + const emit = decodeRcon(state.rcons[state.rcons.length - 1]); + t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]); + }); -t.test("disabled does nothing on start", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); - await plugin.onStart(); - t2.strictSame(state.sent.length, 0, "nothing is sent"); - t2.strictSame(state.rcons.length, 0, "no commands are run"); -}); + t2.test("onStart with disabled does nothing", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); + await plugin.onStart(); + t2.strictSame(state.sent.length, 0, "nothing is sent"); + t2.strictSame(state.rcons.length, 0, "no commands are run"); + }); -t.test("a missing default role is warned about", async t2 => { - const roles = sampleRoles().map(record => { record.isDefault = false; return record; }); - const { plugin, state } = await startPlugin(t2, { roles }); - await plugin.onStart(); - t2.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role"); -}); + t2.test("onStart warns when no default role is set", async t2 => { + const roles = sampleRoles().map(record => { record.isDefault = false; return record; }); + const { plugin, state } = await startPlugin(t2, { roles }); + await plugin.onStart(); + t2.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role"); + }); -t.test("updates from the controller are forwarded to lua", async t2 => { - const { plugin, state } = await startPlugin(t2); - await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); - const roles = decodeRcon(state.rcons[0]); - t2.strictSame(roles.receiver, "receive_role_updates", "role updates are forwarded"); - t2.strictSame(roles.payload[0].name, "Player", "the records are sent in plain form"); + t2.test("handleRoleUpdatedEvent and handleAssignmentUpdatedEvent forward to lua", async t2 => { + const { plugin, state } = await startPlugin(t2); + await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); + const roles = decodeRcon(state.rcons[0]); + t2.strictSame(roles.receiver, "receive_role_updates", "role updates are forwarded"); + t2.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]); - t2.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); -}); + await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); + const assignments = decodeRcon(state.rcons[1]); + t2.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); + }); -t.test("updates are not forwarded while disabled", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); - await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); - await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); - t2.strictSame(state.rcons.length, 0, "no commands are run"); -}); + t2.test("handleRoleUpdatedEvent and handleAssignmentUpdatedEvent are gated while disabled", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); + await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); + await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); + t2.strictSame(state.rcons.length, 0, "no commands are run"); + }); -t.test("in game changes are sent up and rolled back when refused", async t2 => { - const { plugin, state } = await startPlugin(t2); - await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); - const request = state.sent[0]; - t2.ok(request instanceof messages.AssignmentUpdateRequest, "the change is sent as a request"); - t2.strictSame([request.name, request.assign, request.unassign], ["alice", [5], []], "the request names the change"); + t2.test("handleAssignmentUpdateIPC sends the change and rolls back a refusal", async t2 => { + const { plugin, state } = await startPlugin(t2); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + const request = state.sent[0]; + t2.ok(request instanceof messages.AssignmentUpdateRequest, "the change is sent as a request"); + t2.strictSame([request.name, request.assign, request.unassign], ["alice", [5], []], "the request names the change"); - state.failNext = new Error("User 'alice' does not exist"); - await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); - const rollback = decodeRcon(state.rcons[0]); - t2.strictSame(rollback.receiver, "reject_assignment", "the refusal is rolled back in lua"); - t2.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: [5], unassign: undefined }); + const rollback = decodeRcon(state.rcons[0]); + t2.strictSame(rollback.receiver, "reject_assignment", "the refusal is rolled back in lua"); + t2.strictSame(rollback.payload, { name: "alice", role_ids: [5] }, "the rollback names the refused roles"); + }); -t.test("in game changes are dropped unless bidirectional", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); - await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); - t2.strictSame(state.sent.length, 0, "nothing is sent"); -}); + t2.test("handleAssignmentUpdateIPC is gated unless bidirectional", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + t2.strictSame(state.sent.length, 0, "nothing is sent"); + }); -t.test("changing the sync mode toggles emit", async t2 => { - const { plugin, state } = await startPlugin(t2); - await plugin.onInstanceConfigFieldChanged("exp_roles.sync_mode", "enabled", "bidirectional"); - const emit = decodeRcon(state.rcons[0]); - t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting"); + t2.test("onInstanceConfigFieldChanged toggles emit with the sync mode", async t2 => { + const { plugin, state } = await startPlugin(t2); + await plugin.onInstanceConfigFieldChanged("exp_roles.sync_mode", "enabled", "bidirectional"); + const emit = decodeRcon(state.rcons[0]); + t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting"); + }); + t2.end(); }); diff --git a/exp_roles/test/lua/assignment.lua b/exp_roles/test/lua/assignment.lua index b77ba40b..5d75eabb 100644 --- a/exp_roles/test/lua/assignment.lua +++ b/exp_roles/test/lua/assignment.lua @@ -1,7 +1,6 @@ ---- Assignment through role methods, confirmation, and rejection -local Env = ... -local Test = Env.Test -local test, check, eq = Test.test, Test.check, Test.eq +--- Tests for the assignment methods of module/control.lua +local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua +local test, check, eq = Suite.test, Suite.check, Suite.eq --- alice is a moderator and bob a regular, with changes sent to the controller local function setup(env) @@ -10,38 +9,41 @@ local function setup(env) bob = env.add_player("bob", 2), } env.initialise{ - Env.assignment("alice", { 5 }), - Env.assignment("bob", { 6 }), + env.assignment("alice", { 5 }), + env.assignment("bob", { 6 }), } env.Roles.set_emit_events(true) env.reset_log() return players end -test("assign applies locally and is sent to the controller", function(env) +test("role:assign applies locally and is sent to the controller", function(env) local players = setup(env) env.R("Moderator"):assign(players.bob, { by_player_name = "alice" }) check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "the assignment is sent") - check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "the payload names the role and player") + check(env.sent[1].data.name == "bob", "the payload names the player") + eq(env.sent[1].data.assign, { 5 }, "the payload names the role") check(env.R("Moderator"):has_player(players.bob), "the role applies before confirmation") - check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event carries the assigned role id") + check(#env.events == 1, "one event is raised") + eq(env.events[1].data.assigned, { 5 }, "the event carries the assigned role id") check(env.events[1].data.by_player_index == 1, "the event carries who made the change") check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced") - check(#env.sounds == 1 and env.sounds[1] == "bob:utility/achievement_unlocked", "the assign sound plays") + eq(env.sounds, { "bob:utility/achievement_unlocked" }, "the assign sound plays") local sd = env.Roles._script_data() - check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "the role is held locally and pending") + eq(sd.local_players.bob, { 5 }, "the role is held locally") + eq(sd.pending.bob, { 5 }, "the role is pending") env.reset_log() env.R("Moderator"):assign(players.bob) check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op") end) -test("a confirmation releases the local hold", function(env) +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", { 6, 5 }) } + env.Roles.receive_assignment_updates{ env.assignment("bob", { 6, 5 }) } check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing") local sd = env.Roles._script_data() @@ -49,17 +51,19 @@ test("a confirmation releases the local hold", function(env) check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation") end) -test("unassign a synced role", function(env) +test("role:unassign removes a synced role", function(env) local players = setup(env) env.R("Regular"):unassign(players.bob, { by_player_name = "alice", silent = true }) - check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 6 }), "the unassignment is sent") + check(#env.sent == 1, "the unassignment is sent") + eq(env.sent[1].data.unassign, { 6 }, "the payload names the role") check(not env.R("Regular"):has_player(players.bob), "the role is removed locally") - check(#env.events == 1 and eq(env.events[1].data.unassigned, { 6 }), "the event carries the unassigned role id") + check(#env.events == 1, "one event is raised") + eq(env.events[1].data.unassigned, { 6 }, "the event carries the unassigned role id") check(#env.printed == 0, "silent suppresses the announcement") - check(#env.sounds == 1 and env.sounds[1] == "bob:utility/game_lost", "the unassign sound plays") + eq(env.sounds, { "bob:utility/game_lost" }, "the unassign sound plays") end) -test("a rejection rolls the assignment back", function(env) +test("reject_assignment rolls the assignment back", function(env) local players = setup(env) env.R("Jail"):assign(players.alice) check(env.R("Jail"):has_player(players.alice), "the role applies before rejection") @@ -68,52 +72,54 @@ test("a rejection rolls the assignment back", function(env) env.Roles.reject_assignment{ name = "alice", role_ids = { 7 } } check(not env.R("Jail"):has_player(players.alice), "the rejected role is rolled back") check(#env.sent == 0, "the rollback is not sent to the controller") - check(#env.events == 1 and eq(env.events[1].data.unassigned, { 7 }), "the rollback raises the event") + check(#env.events == 1, "the rollback raises the event") + eq(env.events[1].data.unassigned, { 7 }, "the event carries the refused role id") local sd = env.Roles._script_data() check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") end) -test("local only roles never reach the controller", function(env) +test("role:assign with local_only never reaches the controller", function(env) local players = setup(env) env.R("Regular"):assign(players.alice, { silent = true, local_only = true }) - check(#env.sent == 0, "a local only role is not sent") - check(env.R("Regular"):has_player(players.alice), "a local only role applies") + check(#env.sent == 0, "the assignment is not sent") + check(env.R("Regular"):has_player(players.alice), "the role applies") local sd = env.Roles._script_data() - check(sd.pending.alice == nil and eq(sd.local_players.alice, { 6 }), "a local only role is not pending") + check(sd.pending.alice == nil, "the role is not pending") + eq(sd.local_players.alice, { 6 }, "the role is held locally") - env.Roles.receive_assignment_updates{ Env.assignment("alice", { 5 }) } - check(env.R("Regular"):has_player(players.alice), "a local only role survives controller updates") + env.Roles.receive_assignment_updates{ env.assignment("alice", { 5 }) } + check(env.R("Regular"):has_player(players.alice), "the role survives controller updates") env.reset_log() env.R("Regular"):unassign(players.alice, { local_only = true }) check(not env.R("Regular"):has_player(players.alice) and #env.sent == 0, "removed without sync") end) -test("jail suppresses every other role", function(env) +test("role:assign of a higher priority role suppresses the rest", function(env) local players = setup(env) env.R("Jail"):assign(players.alice, { by_player_name = "", silent = true }) - check(eq(Test.names(env.Roles.get_player_roles(players.alice)), { "Jail" }), "only the jail role applies") + eq(env.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") - check(eq(env.events[1].data.assigned, { 7 }) and #env.events[1].data.unassigned == 0, - "the event lists only the jail role") + eq(env.events[1].data.assigned, { 7 }, "the event lists the jail role") + check(#env.events[1].data.unassigned == 0, "the suppressed roles are not listed as unassigned") env.reset_log() env.R("Jail"):unassign(players.alice, { by_player_name = "", silent = true }) - check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }), + eq(Suite.sorted(env.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, "unjail restores the roles") - check(#env.events[1].data.assigned == 0 and eq(env.events[1].data.unassigned, { 7 }), - "the unjail event lists only the jail role") + check(#env.events[1].data.assigned == 0, "the restored roles are not listed as assigned") + eq(env.events[1].data.unassigned, { 7 }, "the unjail event lists the jail role") end) -test("the server can not be assigned roles", function(env) +test("role:assign ignores the server", function(env) setup(env) env.R("Moderator"):assign(env.server) check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op") end) -return Env.finish() +return Suite.run() diff --git a/exp_roles/test/lua/env.lua b/exp_roles/test/lua/env.lua index ddec6edc..7c6fdd51 100644 --- a/exp_roles/test/lua/env.lua +++ b/exp_roles/test/lua/env.lua @@ -1,10 +1,11 @@ --[[-- Test environment for module/control.lua -Composes the shared stubs and framework from the repository test folder with -the fixtures for this plugin. Each call to Env.new() is an independent -environment with a fresh copy of the roles module. +The module specific extension between the shared stubs and the test files: it +creates environments which combine the stubs with a fresh copy of the roles +module and the fixtures below, and hands each test file a suite built around +that factory. -Run as a chunk by test/lua/runner.js, receiving the shared folder and -returning this table, which is in turn passed to each test chunk. +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 @@ -14,13 +15,8 @@ local plugin_root = assert(source:match("^@(.*)/test/lua/env%.lua$")) local Stubs = assert(loadfile(shared_root .. "/stubs.lua"))() local Framework = assert(loadfile(shared_root .. "/framework.lua"))() -local Env = { - --- Test registry for the file being run, see framework.lua - Test = Framework.new(), -} - --- Standard fixture: permission names sent once and referenced by zero based index -Env.permission_names = { +local permission_names = { "core.admin", -- 0 "exp_scenario.command.kill", -- 1 "exp_scenario.command.jail", -- 2 @@ -36,7 +32,7 @@ local function meta(order, extra) end --- Standard fixture: the roles as the controller would send them on initialise -function Env.role_records() +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 } }) }, @@ -47,12 +43,8 @@ function Env.role_records() } end -function Env.assignment(name, role_ids) - return { name = name, role_ids = role_ids } -end - --- Create an independent environment with a fresh copy of the roles module -function Env.new() +local function make_env() local env = Stubs.new() env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))() @@ -63,22 +55,37 @@ function Env.new() return (assert(env.Roles.get_role_by_name(name), "No role named " .. name)) end + --- Names of an array of roles + function env.names(roles) + local rtn = {} + for index, role in ipairs(roles) do + rtn[index] = role.name + end + return rtn + end + + --- An assignment record as the controller would send it + function env.assignment(name, role_ids) + return { name = name, role_ids = role_ids } + end + --- Load the standard fixture and the given assignments function env.initialise(assignments) env.Roles.initialise{ - permission_names = Env.permission_names, - roles = Env.role_records(), + permission_names = permission_names, + roles = role_records(), assignments = assignments or {}, } end + --- Save and load the map, as far as the roles module can tell + local stub_save_load = env.save_load + function env.save_load() + stub_save_load() + env.Roles.on_load() + end + return env end ---- Run the declared tests, each against a fresh environment -function Env.finish() - Env.Test.run(Env.new) - return Env.Test.results_json() -end - -return Env +return Framework.new_suite(make_env) diff --git a/exp_roles/test/lua/holders.lua b/exp_roles/test/lua/holders.lua index 398377ec..4e0e16ea 100644 --- a/exp_roles/test/lua/holders.lua +++ b/exp_roles/test/lua/holders.lua @@ -1,7 +1,6 @@ ---- Listing and printing to the players who hold a role -local Env = ... -local Test = Env.Test -local test, check, eq = Test.test, Test.check, Test.eq +--- Tests for listing and printing to the players who hold a role +local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua +local test, check, eq = Suite.test, Suite.check, Suite.eq --- alice and dave are moderators with dave offline, zed has never joined local function setup(env) @@ -11,47 +10,47 @@ local function setup(env) dave = env.add_player("dave", 4, false), } env.initialise{ - Env.assignment("alice", { 5 }), - Env.assignment("bob", { 6 }), - Env.assignment("dave", { 5 }), - Env.assignment("zed", { 5 }), + env.assignment("alice", { 5 }), + env.assignment("bob", { 6 }), + env.assignment("dave", { 5 }), + env.assignment("zed", { 5 }), } env.Roles.set_emit_events(true) return players end -test("player names include players not on this map", function(env) +test("role:get_player_names includes players not on this map", function(env) setup(env) - check(eq(Test.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" }), + eq(Suite.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" }, "every player given the role is listed") end) -test("players are limited to this map and can be filtered", function(env) - setup(env) - local mod = env.R("Moderator") - check(eq(Test.sorted(Test.names(mod:get_players())), { "alice", "dave" }), "players are limited to this map") - check(eq(Test.names(mod:get_players(true)), { "alice" }), "filtered to connected players") - check(eq(Test.names(mod:get_players(false)), { "dave" }), "filtered to offline players") -end) - -test("a player holding the role in both lists is counted once", function(env) +test("role: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", { 5, 6 }) } + env.Roles.receive_assignment_updates{ env.assignment("alice", { 5, 6 }) } local sd = env.Roles._script_data() check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists") - check(eq(Test.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }), "the player is counted once") + eq(Suite.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }, "the player is counted once") end) -test("print reaches online holders", function(env) +test("role:get_players is limited to this map and can be filtered", function(env) + setup(env) + local mod = env.R("Moderator") + eq(Suite.sorted(env.names(mod:get_players())), { "alice", "dave" }, "players are limited to this map") + eq(env.names(mod:get_players(true)), { "alice" }, "filtered to connected players") + eq(env.names(mod:get_players(false)), { "dave" }, "filtered to offline players") +end) + +test("role:print reaches online holders", function(env) setup(env) env.reset_log() check(env.R("Moderator"):print("hello") == 1, "print returns the number of players reached") check(#env.printed == 1 and env.printed[1].to == "alice", "only online holders are reached") end) -test("printing to the higher roles", function(env) +test("role: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() @@ -62,15 +61,7 @@ test("printing to the higher roles", function(env) 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 - check(eq(Test.sorted(got), { "alice", "alice", "bob" }), "the online holders of each role are reached") + eq(Suite.sorted(got), { "alice", "alice", "bob" }, "the online holders of each role are reached") end) -test("deep equality helper", function(env) - check(Test.deep_eq({ a = { 1, 2 }, b = "x" }, { a = { 1, 2 }, b = "x" }), "equal nested tables") - check(not Test.deep_eq({ a = { 1, 2 } }, { a = { 1, 3 } }), "differing nested values") - check(not Test.deep_eq({ a = 1 }, { a = 1, b = 2 }), "extra keys on the right") - check(not Test.deep_eq({ a = 1, b = 2 }, { a = 1 }), "extra keys on the left") - check(Test.deep_eq(env.Roles._script_data().pending, {}), "works against module state") -end) - -return Env.finish() +return Suite.run() diff --git a/exp_roles/test/lua/lookup.lua b/exp_roles/test/lua/lookup.lua index f188bf46..6843557a 100644 --- a/exp_roles/test/lua/lookup.lua +++ b/exp_roles/test/lua/lookup.lua @@ -1,28 +1,58 @@ ---- Role lookup, decoding, and comparisons -local Env = ... -local Test = Env.Test -local test, check, eq = Test.test, Test.check, Test.eq +--- Tests for the role lookup functions of module/control.lua +local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua +local test, check, eq = Suite.test, Suite.check, Suite.eq -test("roles are ordered most privileged first", function(env) +test("get_role returns the role with the given clusterio id", function(env) env.initialise{} - check(eq(Test.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }), - "ordered roles follow their order with deleted roles skipped") - check(#env.Roles.get_roles() == 5, "get_roles returns every role") - - local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") } - check(eq(Test.names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first") -end) - -test("roles are looked up by id", function(env) - env.initialise{} - check(env.Roles.get_role(6).name == "Regular", "get_role looks up by clusterio id") - check(env.Roles.get_role_by_name("Moderator").id == 5, "get_role_by_name searches the roles") + check(env.Roles.get_role(6).name == "Regular", "the role is found by id") check(env.Roles.get_role(env.R("Jail").id) == env.R("Jail"), "lookups return the same object") - check(env.Roles.get_role(9) == nil and env.Roles.get_role_by_name("Gone") == nil, "deleted roles are not known") - check(env.Roles.get_default_role() == env.R("Player"), "the default role comes from is_default") + check(env.Roles.get_role(9) == nil, "deleted roles are not known") end) -test("role records are decoded", function(env) +test("get_role_by_name searches the roles", function(env) + env.initialise{} + check(env.Roles.get_role_by_name("Moderator").id == 5, "the role is found by name") + check(env.Roles.get_role_by_name("Gone") == nil, "deleted roles are not known") +end) + +test("get_roles returns every role", function(env) + env.initialise{} + check(#env.Roles.get_roles() == 5, "every role is returned with deleted roles skipped") +end) + +test("get_ordered_roles returns the most privileged first", function(env) + env.initialise{} + eq(env.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }, + "roles follow their order") +end) + +test("sort_roles orders most privileged first", function(env) + env.initialise{} + local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") } + eq(env.names(sorted), { "Cluster Admin", "Regular", "Player" }, "the list is sorted in place") +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(env.names(env.Roles.get_higher_roles(env.R("Regular")))), + { "Cluster Admin", "Moderator", "Regular" }, "higher roles") + eq(Suite.sorted(env.names(env.Roles.get_lower_roles(env.R("Regular")))), + { "Jail", "Regular" }, "lower roles") +end) + +test("role:is_higher_than and role: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{} check(env.R("Cluster Admin").short_hand == "SYS", "short hand decoded") check(env.R("Moderator").short_hand == "Moderator", "short hand falls back to the name") @@ -30,19 +60,4 @@ test("role records are decoded", function(env) check(env.R("Jail").priority == 1 and env.R("Jail").block_auto_assign, "priority and block auto assign decoded") end) -test("roles compare on their 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("higher and lower roles", function(env) - env.initialise{} - check(eq(Test.sorted(Test.names(env.Roles.get_higher_roles(env.R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }), - "higher roles include the role itself and exclude the default role") - check(eq(Test.sorted(Test.names(env.Roles.get_lower_roles(env.R("Regular")))), { "Jail", "Regular" }), - "lower roles include the role itself and exclude the default role") -end) - -return Env.finish() +return Suite.run() diff --git a/exp_roles/test/lua/players.lua b/exp_roles/test/lua/players.lua index 4a49ae6c..8f41a89b 100644 --- a/exp_roles/test/lua/players.lua +++ b/exp_roles/test/lua/players.lua @@ -1,7 +1,6 @@ ---- Player role and permission checks -local Env = ... -local Test = Env.Test -local test, check, eq = Test.test, Test.check, Test.eq +--- Tests for the player checks of module/control.lua +local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua +local test, check, eq = Suite.test, Suite.check, Suite.eq --- alice is a moderator, bob is a regular, and carol has only the default role local function setup(env) @@ -11,42 +10,42 @@ local function setup(env) carol = env.add_player("carol", 3), } env.initialise{ - Env.assignment("alice", { 5 }), - Env.assignment("bob", { 6 }), + env.assignment("alice", { 5 }), + env.assignment("bob", { 6 }), } return players end -test("player roles include the default role", function(env) +test("get_player_roles includes the default role", function(env) local players = setup(env) - check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }), + eq(Suite.sorted(env.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, "held roles and the default role") - check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Player" }), + eq(env.names(env.Roles.get_player_roles(players.carol)), { "Player" }, "a player with no roles has the default role") end) -test("the server is nil or a player with index 0", function(env) +test("get_player_roles treats nil and index 0 as the server", function(env) setup(env) - check(eq(Test.names(env.Roles.get_player_roles(nil)), { "" }), "nil is the server") - check(eq(Test.names(env.Roles.get_player_roles(env.server)), { "" }), "index 0 is the server") + eq(env.names(env.Roles.get_player_roles(nil)), { "" }, "nil is the server") + eq(env.names(env.Roles.get_player_roles(env.server)), { "" }, "index 0 is the server") check(env.Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission") check(not env.R("Player"):has_player(nil), "the server does not have the default role") end) -test("the highest role", function(env) +test("get_player_highest_role", function(env) local players = setup(env) - check(env.Roles.get_player_highest_role(players.alice) == env.R("Moderator"), "highest held role") + 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("permission checks", function(env) +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("has any and has all", function(env) +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") @@ -59,17 +58,21 @@ test("has any and has all", function(env) check(env.Roles.player_has_any_permission(nil, "anything.at.all"), "the server passes any") end) -test("role permission and player methods", function(env) - local players = setup(env) - check(env.R("Moderator"):has_permission("exp_scenario.command.kill"), "has_permission granted") - check(not env.R("Regular"):has_permission("exp_scenario.command.jail"), "has_permission not granted") +test("role: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") - check(env.R("Moderator"):has_player(players.alice), "has_player for a held role") - check(not env.R("Moderator"):has_player(players.bob), "has_player for a role not held") +end) + +test("role: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("outranks", function(env) +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") @@ -78,4 +81,4 @@ test("outranks", function(env) check(not env.Roles.player_outranks(players.alice, nil), "nobody outranks the server") end) -return Env.finish() +return Suite.run() diff --git a/exp_roles/test/lua/sync.lua b/exp_roles/test/lua/sync.lua index ea710692..415fcc4b 100644 --- a/exp_roles/test/lua/sync.lua +++ b/exp_roles/test/lua/sync.lua @@ -1,7 +1,6 @@ ---- Initialise semantics and updates from the controller -local Env = ... -local Test = Env.Test -local test, check, eq = Test.test, Test.check, Test.eq +--- Tests for the sync entry points of module/control.lua +local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua +local test, check, eq, deep_eq = Suite.test, Suite.check, Suite.eq, Suite.deep_eq --- alice is a moderator and carol has only the default role, both connected local function setup(env) @@ -14,8 +13,8 @@ local function setup(env) env.admin_state[player.name] = state end) env.initialise{ - Env.assignment("alice", { 5 }), - Env.assignment("zed", { 5, 6 }), -- has never joined this map + env.assignment("alice", { 5 }), + env.assignment("zed", { 5, 6 }), -- has never joined this map } env.Roles.set_emit_events(true) return players @@ -23,19 +22,35 @@ end test("initialise applies triggers and raises empty events", function(env) setup(env) - check(env.admin_state.alice == true and env.admin_state.carol == false, - "triggers run for connected players") + deep_eq(env.admin_state, { alice = true, carol = false }, "triggers run for connected players") check(#env.events == 2, "the event is raised once per connected player") check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "the events are empty") check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing") end) -test("controller assignments apply and are announced", function(env) +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.Roles._script_data() + eq(sd.pending.carol, { 5 }, "the role is pending before initialise") + + env.reset_log() + env.initialise{ env.assignment("alice", { 5 }) } + check(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, { 7 }, "local only roles are kept") + check(#env.sent == 0, "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", { 5 }) } + env.Roles.receive_assignment_updates{ env.assignment("carol", { 5 }) } check(env.R("Moderator"):has_player(players.carol), "the assignment applies") - check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event is raised") + check(#env.events == 1, "the event is raised") + eq(env.events[1].data.assigned, { 5 }, "the event carries the role id") check(env.events[1].data.by_player_index == 0, "controller changes have no by player") check(#env.printed == 1 and env.printed[1][4] == "", "the change is announced by the server") check(env.admin_state.carol == true, "triggers follow the assignment") @@ -43,18 +58,18 @@ test("controller assignments apply and are announced", function(env) 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") - check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "the removal raises the event") + eq(env.events[1].data.unassigned, { 5 }, "the removal raises the event") check(env.admin_state.carol == false, "triggers follow the removal") end) -test("changes for players not on this map raise nothing", function(env) +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", { 5 }) } + env.Roles.receive_assignment_updates{ env.assignment("zed", { 5 }) } check(#env.events == 0 and #env.printed == 0, "no event and no announcement") end) -test("role updates apply to their holders", function(env) +test("receive_role_updates applies to the holders", function(env) setup(env) env.reset_log() env.Roles.receive_role_updates{ @@ -70,33 +85,17 @@ test("role updates apply to their holders", function(env) check(env.Roles.get_role(6) == nil, "a deleted role is removed") end) -test("the default role follows is_default", function(env) +test("receive_role_updates moves the default role", function(env) local players = setup(env) env.Roles.receive_role_updates{ { id = 1, 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") - check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Guest" }), "players pick up the new default") + eq(env.names(env.Roles.get_player_roles(players.carol)), { "Guest" }, "players pick up the new default") 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.Roles._script_data() - check(eq(sd.pending.carol, { 5 }), "the role is pending before initialise") - - env.reset_log() - env.initialise{ Env.assignment("alice", { 5 }) } - check(sd.pending.carol == nil, "pending roles are cleared") - check(not env.R("Moderator"):has_player(players.carol), "an unconfirmed role is given up") - check(eq(sd.local_players.carol, { 7 }), "local only roles are kept") - check(#env.sent == 0, "initialise sends nothing") -end) - -test("nothing is sent while emit is disabled", function(env) +test("set_emit_events gates what is sent", function(env) local players = setup(env) env.Roles.set_emit_events(false) env.reset_log() @@ -105,14 +104,22 @@ test("nothing is sent while emit is disabled", function(env) check(env.R("Regular"):has_player(players.alice), "the assignment still applies locally") end) -test("roles are usable after load and triggers run on join", function(env) - setup(env) - env.Roles.on_load() - check(env.R("Moderator"):is_higher_than(env.R("Regular")), "roles are usable after load") +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() + 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) + setup(env) env.admin_state.alice = nil env.Roles.on_player_joined_game{ player_index = 1 } check(env.admin_state.alice ~= nil, "triggers run when a player joins") end) -return Env.finish() +return Suite.run() diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js index 1d865f5a..b2105072 100644 --- a/exp_roles/test/messages.test.js +++ b/exp_roles/test/messages.test.js @@ -7,7 +7,7 @@ const fullMeta = new messages.RoleMetaRecord( 7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false, ); -t.test("RoleColor", subtest => { +t.test("class RoleColor", subtest => { testRoundTripJsonSerialisable(messages.RoleColor, testMatrix( [0, 255], // r [0, 128], // g @@ -17,7 +17,7 @@ t.test("RoleColor", subtest => { subtest.end(); }); -t.test("RoleMetaRecord", subtest => { +t.test("class RoleMetaRecord", subtest => { testRoundTripJsonSerialisable(messages.RoleMetaRecord, testMatrix( [7], // id [3], // order @@ -34,7 +34,7 @@ t.test("RoleMetaRecord", subtest => { subtest.end(); }); -t.test("RoleRecord", subtest => { +t.test("class RoleRecord", subtest => { testRoundTripJsonSerialisable(messages.RoleRecord, testMatrix( [7], // id ["Moderator"], // name @@ -48,7 +48,7 @@ t.test("RoleRecord", subtest => { subtest.end(); }); -t.test("AssignmentRecord", subtest => { +t.test("class AssignmentRecord", subtest => { testRoundTripJsonSerialisable(messages.AssignmentRecord, testMatrix( ["alice"], // name [new Set(), new Set([5, 6])], // roleIds @@ -59,19 +59,34 @@ t.test("AssignmentRecord", subtest => { subtest.end(); }); -t.test("update events and requests", subtest => { - const record = new messages.RoleRecord(7, "Moderator", ["a.b"], fullMeta, true, 12345); - const assignment = new messages.AssignmentRecord("alice", new Set([5]), 12345); +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", subtest => { testRoundTripJsonSerialisable(messages.RoleUpdatedEvent, testMatrix( - [[], [record]], // updates + [[], [sampleRecord]], // updates )); + subtest.pass("round trips"); + subtest.end(); +}); + +t.test("class AssignmentUpdatedEvent", subtest => { testRoundTripJsonSerialisable(messages.AssignmentUpdatedEvent, testMatrix( - [[], [assignment]], // updates + [[], [sampleAssignment]], // updates )); + subtest.pass("round trips"); + subtest.end(); +}); + +t.test("class RoleMetaUpdateRequest", subtest => { testRoundTripJsonSerialisable(messages.RoleMetaUpdateRequest, testMatrix( [new messages.RoleMetaRecord(7, 3), fullMeta], // meta )); + subtest.pass("round trips"); + subtest.end(); +}); + +t.test("class AssignmentUpdateRequest", subtest => { testRoundTripJsonSerialisable(messages.AssignmentUpdateRequest, testMatrix( ["alice"], // name [[], [5]], // assign diff --git a/exp_roles/test/seed.test.js b/exp_roles/test/seed.test.js index 60f4c2f2..755ddc03 100644 --- a/exp_roles/test/seed.test.js +++ b/exp_roles/test/seed.test.js @@ -6,7 +6,7 @@ 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("every seed permission is defined", subtest => { +t.test("seedRoles grant only defined permissions", subtest => { for (const role of seedRoles) { for (const permission of flattenSeedPermissions(role)) { subtest.ok(lib.permissions.has(permission), `${role.name} grants defined permission ${permission}`); @@ -15,7 +15,7 @@ t.test("every seed permission is defined", subtest => { subtest.end(); }); -t.test("parents exist and their permissions are inherited", subtest => { +t.test("flattenSeedPermissions carries parents into their children", subtest => { const byName = new Map(seedRoles.map(role => [role.name, role])); for (const role of seedRoles) { if (role.parent === undefined) { @@ -31,7 +31,7 @@ t.test("parents exist and their permissions are inherited", subtest => { subtest.end(); }); -t.test("seed role names are unique with one default and one admin", subtest => { +t.test("seedRoles are unique with one default and one admin", subtest => { const names = seedRoles.map(role => role.name); subtest.strictSame(names.length, new Set(names).size, "names are unique"); subtest.strictSame(seedRoles.filter(role => role.isDefault).length, 1, "one default role"); diff --git a/test/lua/framework.lua b/test/lua/framework.lua index f5193b50..b158b518 100644 --- a/test/lua/framework.lua +++ b/test/lua/framework.lua @@ -1,15 +1,30 @@ --[[-- Test framework for plugin module tests -Test files declare named tests with `Test.test(name, function(env) ... end)`. -The runner creates a fresh environment for every test, so tests are -independent and can not leak state into each other. +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 from the +factory the suite was created with, so tests are independent and can not leak +state into each other. ]] local Framework = {} ---- Create a test registry, one is made per test file -function Framework.new() - --- @class Test - local Test = { +--- 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 +--- @param make_env fun(): table Creates the environment given to each test +function Framework.new_suite(make_env) + --- @class Suite + local Suite = { tests = {}, -- { name, fn } in declaration order results = {}, -- { name, ok, detail? } accumulated across tests } @@ -19,37 +34,61 @@ function Framework.new() --- Declare a named test, its function receives a fresh environment --- @param name string --- @param fn fun(env: table) - function Test.test(name, fn) - Test.tests[#Test.tests + 1] = { name = name, fn = fn } + function Suite.test(name, fn) + Suite.tests[#Suite.tests + 1] = { name = name, fn = fn } end - --- Record one check within the current test - --- @param ok any Truthy when the check passed + --- 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 Test.check(ok, name, detail) - Test.results[#Test.results + 1] = { + function Suite.fail(name, detail) + Suite.results[#Suite.results + 1] = { name = current_test and (current_test .. ": " .. name) or name, - ok = not not ok, + ok = false, detail = detail, } end - --- Shallow array equality - function Test.eq(a, b) - if type(a) ~= "table" or type(b) ~= "table" then return a == b end - if #a ~= #b then return false end - for i = 1, #a do - if a[i] ~= b[i] then return false 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 - return true + end + + --- Check two values or arrays are shallowly equal + function Suite.eq(a, b, name) + local equal + if type(a) ~= "table" or type(b) ~= "table" then + equal = a == b + else + equal = #a == #b + for i = 1, #a do + if a[i] ~= b[i] then equal = false break end + end + end + Suite.check(equal, name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1))) end --- Recursive table equality, keys checked from both sides - function Test.deep_eq(a, b) + 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 Test.deep_eq(value, b[key]) then return false end + 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 @@ -57,38 +96,31 @@ function Framework.new() return true end - --- Names of an array of roles - function Test.names(roles) - local rtn = {} - for index, role in ipairs(roles) do - rtn[index] = role.name - end - return rtn + --- Check two values are equal, comparing tables recursively + function Suite.deep_eq(a, b, name) + Suite.check(deep_equal(a, b), name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1))) end --- Sorted copy of an array of strings - function Test.sorted(list) + 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 against a fresh environment - --- @param make_env fun(): table - function Test.run(make_env) - for _, test in ipairs(Test.tests) do + --- 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(test.fn, make_env()) if not ok then - Test.check(false, "did not error", tostring(err)) + Suite.fail("did not error", tostring(err)) end end current_test = nil - end - --- Encode the results as JSON for the javascript side - function Test.results_json() local function escape(value) return (value:gsub('[%c"\\]', function(c) return string.format("\\u%04x", c:byte()) @@ -96,14 +128,14 @@ function Framework.new() end local parts = {} - for index, result in ipairs(Test.results) do + 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 Test + return Suite end return Framework diff --git a/test/lua/stubs.lua b/test/lua/stubs.lua index 5ed3fa48..6245cbfc 100644 --- a/test/lua/stubs.lua +++ b/test/lua/stubs.lua @@ -3,8 +3,8 @@ 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 stubs of their -own through `stubs.requires` and `Stubs.strict`. +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 = {} @@ -29,6 +29,25 @@ function Stubs.strict(name, tbl, allowed_nil) }) 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() local stubs = { @@ -39,7 +58,8 @@ function Stubs.new() } local next_event_id = 100 - script = Stubs.strict("script", { + local registered_metatables = {} --- @type table + script = Stubs.strict("LuaBootstrap", { generate_event_name = function() next_event_id = next_event_id + 1 return next_event_id @@ -47,7 +67,9 @@ function Stubs.new() raise_event = function(id, data) stubs.events[#stubs.events + 1] = { id = id, data = data } end, - register_metatable = function() end, + register_metatable = function(_, metatable) + registered_metatables[metatable] = true + end, }) defines = Stubs.strict("defines", { @@ -58,7 +80,7 @@ function Stubs.new() }) local players_by_name, players_by_index, connected = {}, {}, {} - game = Stubs.strict("game", { + game = Stubs.strict("LuaGameScript", { tick = 1, players = players_by_name, connected_players = connected, @@ -87,10 +109,10 @@ function Stubs.new() end --- A player object which represents the server - stubs.server = Stubs.strict("server player", { index = 0, name = "" }) + stubs.server = Stubs.strict("LuaPlayer ", { index = 0, name = "" }) - --- Modules resolved by the stubbed require, extend before loading the module - stubs.requires = { + --- 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 } @@ -104,7 +126,44 @@ function Stubs.new() }), } require = function(name) - return assert(stubs.requires[name], "Unexpected require: " .. 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 From e6552a39be5ded7e362902167adf14c6cc1cd1fa Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:18:09 +0000 Subject: [PATCH 12/16] Address review on the stubs and controller tests - The controller tests run against a real Controller, which is side effect free while not started, with real users through its UserManager. The only fakes left are two spies which record broadcasts and permission pushes on their way through. Since any user update also applies auto assignment, the leave event is observed directly rather than through a role change. - raise_event fills in the name and tick of the event the way factorio does, rather than wrapping the payload, and game.players finds players by index as well as by name through a metatable. - names is generic over anything with a name property, so it lives on the suite; the save and load helper no longer calls on_load, the test calls the handler itself as it does for the other handlers. - Sent messages and events are compared whole with deep_eq, which pins the event shape, the tick, and that the unassign key is absent rather than empty. Co-Authored-By: Claude Fable 5 --- exp_roles/test/controller.test.js | 208 +++++++++++++++--------------- exp_roles/test/lua/assignment.lua | 62 ++++++--- exp_roles/test/lua/env.lua | 16 --- exp_roles/test/lua/holders.lua | 6 +- exp_roles/test/lua/lookup.lua | 8 +- exp_roles/test/lua/players.lua | 8 +- exp_roles/test/lua/sync.lua | 22 +++- test/lua/framework.lua | 12 ++ test/lua/stubs.lua | 17 ++- 9 files changed, 195 insertions(+), 164 deletions(-) diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index 3ea4caa4..661ba735 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -1,6 +1,7 @@ "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"); @@ -8,191 +9,186 @@ 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: () => {} }; -class FakeUser { - constructor(name, roleIds = []) { - this.name = name; - this.roleIds = new Set(roleIds); - this.playerStats = { onlineTimeMs: 0 }; - this.updatedAtMs = 0; - this.isDeleted = false; - } - - set(field, value) { - this[field] = value; - this.updatedAtMs += 1; - } -} - -/** Build a plugin around fake controller internals, started on first use by each test. */ -async function startPlugin(t2, { roles = [], users = [], config = {} } = {}) { - const state = { broadcasts: [], permissionUpdates: [] }; - const configValues = { +/** 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, - ...config, + }); + 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 roleStore = new lib.SubscribableDatastore( - ...await new lib.MemoryDatastoreProvider(new Map(roles.map(role => [role.id, role]))).bootstrap() - ); - const userStore = new Map(users.map(user => [user.name, user])); - - const controller = { - config: { get: key => configValues[key] }, - roles: roleStore, - users: { - records: { on: () => {}, values: () => userStore.values() }, - getByNameMutable: name => userStore.get(name), - valuesMutable: () => userStore.values(), - getOrCreateUser: name => { - if (!userStore.has(name)) { - userStore.set(name, new FakeUser(name)); - } - return userStore.get(name); - }, - }, - subscriptions: { handle: () => {}, broadcast: event => state.broadcasts.push(event) }, - handle: () => {}, - userPermissionsUpdated: user => state.permissionUpdates.push(user.name), + 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, users: userStore }; + 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 t2 => { - const { plugin } = await startPlugin(t2, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); + t2.test("init and sweepRoleMeta manage the role properties", async t3 => { + const { plugin } = await startPlugin(t3, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); - t2.strictSame(plugin.roleMeta.get(0).order, 1, "the first role is ordered first"); - t2.strictSame(plugin.roleMeta.get(5).order, 2, "the next role is ordered after it"); + 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(); - t2.notOk(plugin.roleMeta.get(99), "properties without a role are removed"); - t2.ok(plugin.roleMeta.get(5), "properties with a role are kept"); + 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 t2 => { - const { plugin } = await startPlugin(t2, { + 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(plugin.controller.roles.get(5)); - t2.strictSame(record.name, "Moderator", "the name comes from the role"); - t2.strictSame(record.permissions, ["exp_scenario.command.kill"], "the permissions come from the role"); - t2.strictSame(record.meta.shortHand, "Mod", "the properties come from the datastore"); - t2.notOk(record.isDefault, "not the default role"); - t2.ok(plugin.buildRoleRecord(plugin.controller.roles.get(1)).isDefault, "the default role is marked"); + 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 t2 => { - const { plugin, controller, state } = await startPlugin(t2, { roles: [role(1, "Player")] }); + 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.flatMap(event => event.updates.map(update => update.name)); - t2.ok(updated.includes("Moderator"), "a new role is broadcast"); + 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")); - t2.ok(state.broadcasts.some( + 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"); - t2.ok(state.broadcasts.some(event => event instanceof messages.RoleUpdatedEvent), "a default role change rebroadcasts"); + t3.ok( + state.broadcasts.some(event => event instanceof messages.RoleUpdatedEvent), + "a default role change rebroadcasts", + ); }); - t2.test("handleRoleSubscription replays only newer records", async t2 => { - const { plugin, controller } = await startPlugin(t2, { roles: [role(1, "Player")] }); + 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 }); - t2.strictSame(all.updates.length, 2, "everything is replayed from the start"); + t3.strictSame(all.updates.length, 2, "everything is replayed from the start"); const none = await plugin.handleRoleSubscription({ lastRequestTimeMs: updatedAtMs }); - t2.strictSame(none, null, "nothing is replayed when up to date"); + t3.strictSame(none, null, "nothing is replayed when up to date"); }); - t2.test("listAssignmentRecords mirrors users without the default role", async t2 => { - const alice = new FakeUser("alice", [lib.Role.DefaultPlayerRoleId, 5]); - alice.updatedAtMs = 10; - const bob = new FakeUser("bob", [lib.Role.DefaultPlayerRoleId]); - const { plugin } = await startPlugin(t2, { roles: [role(1, "Player"), role(5, "Moderator")], users: [alice, bob] }); + 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(); - t2.strictSame(records.length, 1, "users with only the default role are left out"); - t2.strictSame(records[0].name, "alice"); - t2.strictSame([...records[0].roleIds], [5], "the default role is not listed"); + 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 t2 => { - const alice = new FakeUser("alice", [5]); - const { plugin, state } = await startPlugin(t2, { - roles: [role(1, "Player"), role(5, "Moderator"), role(6, "Regular")], users: [alice], + 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 t2.rejects( + await t3.rejects( plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("nobody", [], [])), { message: /does not exist/ }, "an unknown user is refused", ); - await t2.rejects( + 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])); - t2.strictSame([...alice.roleIds], [6], "roles are added and removed"); - t2.ok(state.permissionUpdates.includes("alice"), "permission changes are pushed to the user"); + 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 t2 => { - const newcomer = new FakeUser("newcomer"); - newcomer.playerStats.onlineTimeMs = 3600000; - const veteran = new FakeUser("veteran"); - veteran.playerStats.onlineTimeMs = 7200000; - const jailed = new FakeUser("jailed", [7]); - jailed.playerStats.onlineTimeMs = 7200000; - - const { plugin } = await startPlugin(t2, { + 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")], - users: [newcomer, veteran, jailed], }); + 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)); - t2.ok(veteran.roleIds.has(6), "enough online time grants the role"); - t2.notOk(newcomer.roleIds.has(6), "not enough online time does not"); - t2.notOk(jailed.roleIds.has(6), "a blocking role prevents the grant"); + 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"); - veteran.roleIds.delete(6); + // 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" }); - t2.ok(veteran.roleIds.has(6), "granted again when the player leaves"); + 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("handleSeedRolesRequest creates the roles and reuses them by name", async t2 => { - const { plugin, controller } = await startPlugin(t2, { + 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(); - t2.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); + t3.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); const moderator = [...controller.roles.values()].find(other => other.name === "Moderator"); - t2.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in"); - t2.ok(plugin.roleMeta.get(moderator.id), "the role properties are created"); - t2.strictSame(plugin.roleMeta.get(moderator.id).shortHand, "Mod", "the properties match the seed"); + 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(); - t2.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles"); + t3.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles"); }); + t2.end(); }); diff --git a/exp_roles/test/lua/assignment.lua b/exp_roles/test/lua/assignment.lua index 5d75eabb..5396e1fe 100644 --- a/exp_roles/test/lua/assignment.lua +++ b/exp_roles/test/lua/assignment.lua @@ -1,6 +1,6 @@ --- Tests for the assignment methods of module/control.lua local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua -local test, check, eq = Suite.test, Suite.check, Suite.eq +local test, check, eq, deep_eq = Suite.test, Suite.check, Suite.eq, Suite.deep_eq --- alice is a moderator and bob a regular, with changes sent to the controller local function setup(env) @@ -20,13 +20,20 @@ end test("role:assign applies locally and is sent to the controller", function(env) local players = setup(env) env.R("Moderator"):assign(players.bob, { by_player_name = "alice" }) - check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "the assignment is sent") - check(env.sent[1].data.name == "bob", "the payload names the player") - eq(env.sent[1].data.assign, { 5 }, "the payload names the role") + deep_eq(env.sent, { + { channel = "exp_roles:assignment_update", data = { name = "bob", assign = { 5 } } }, + }, "the assignment is sent to the controller") check(env.R("Moderator"):has_player(players.bob), "the role applies before confirmation") - check(#env.events == 1, "one event is raised") - eq(env.events[1].data.assigned, { 5 }, "the event carries the assigned role id") - check(env.events[1].data.by_player_index == 1, "the event carries who made the change") + deep_eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = 2, + by_player_index = 1, + assigned = { 5 }, + unassigned = {}, + }, + }, "the event carries the change") check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced") eq(env.sounds, { "bob:utility/achievement_unlocked" }, "the assign sound plays") @@ -54,11 +61,20 @@ end) test("role:unassign removes a synced role", function(env) local players = setup(env) env.R("Regular"):unassign(players.bob, { by_player_name = "alice", silent = true }) - check(#env.sent == 1, "the unassignment is sent") - eq(env.sent[1].data.unassign, { 6 }, "the payload names the role") + deep_eq(env.sent, { + { channel = "exp_roles:assignment_update", data = { name = "bob", unassign = { 6 } } }, + }, "the unassignment is sent to the controller") check(not env.R("Regular"):has_player(players.bob), "the role is removed locally") - check(#env.events == 1, "one event is raised") - eq(env.events[1].data.unassigned, { 6 }, "the event carries the unassigned role id") + deep_eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = 2, + by_player_index = 1, + assigned = {}, + unassigned = { 6 }, + }, + }, "the event carries the change") check(#env.printed == 0, "silent suppresses the announcement") eq(env.sounds, { "bob:utility/game_lost" }, "the unassign sound plays") end) @@ -72,8 +88,16 @@ test("reject_assignment rolls the assignment back", function(env) env.Roles.reject_assignment{ name = "alice", role_ids = { 7 } } check(not env.R("Jail"):has_player(players.alice), "the rejected role is rolled back") check(#env.sent == 0, "the rollback is not sent to the controller") - check(#env.events == 1, "the rollback raises the event") - eq(env.events[1].data.unassigned, { 7 }, "the event carries the refused role id") + deep_eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = 1, + by_player_index = 0, + assigned = {}, + unassigned = { 7 }, + }, + }, "the rollback raises the event") local sd = env.Roles._script_data() check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") @@ -100,20 +124,20 @@ end) test("role:assign of a higher priority role suppresses the rest", function(env) local players = setup(env) env.R("Jail"):assign(players.alice, { by_player_name = "", silent = true }) - eq(env.names(env.Roles.get_player_roles(players.alice)), { "Jail" }, "only the jail role applies") + 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[1].data.assigned, { 7 }, "the event lists the jail role") - check(#env.events[1].data.unassigned == 0, "the suppressed roles are not listed as unassigned") + eq(env.events[1].assigned, { 7 }, "the event lists the jail role") + check(#env.events[1].unassigned == 0, "the suppressed roles are not listed as unassigned") env.reset_log() env.R("Jail"):unassign(players.alice, { by_player_name = "", silent = true }) - eq(Suite.sorted(env.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, + eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, "unjail restores the roles") - check(#env.events[1].data.assigned == 0, "the restored roles are not listed as assigned") - eq(env.events[1].data.unassigned, { 7 }, "the unjail event lists the jail role") + check(#env.events[1].assigned == 0, "the restored roles are not listed as assigned") + eq(env.events[1].unassigned, { 7 }, "the unjail event lists the jail role") end) test("role:assign ignores the server", function(env) diff --git a/exp_roles/test/lua/env.lua b/exp_roles/test/lua/env.lua index 7c6fdd51..dc8e246d 100644 --- a/exp_roles/test/lua/env.lua +++ b/exp_roles/test/lua/env.lua @@ -55,15 +55,6 @@ local function make_env() return (assert(env.Roles.get_role_by_name(name), "No role named " .. name)) end - --- Names of an array of roles - function env.names(roles) - local rtn = {} - for index, role in ipairs(roles) do - rtn[index] = role.name - end - return rtn - end - --- An assignment record as the controller would send it function env.assignment(name, role_ids) return { name = name, role_ids = role_ids } @@ -78,13 +69,6 @@ local function make_env() } end - --- Save and load the map, as far as the roles module can tell - local stub_save_load = env.save_load - function env.save_load() - stub_save_load() - env.Roles.on_load() - end - return env end diff --git a/exp_roles/test/lua/holders.lua b/exp_roles/test/lua/holders.lua index 4e0e16ea..cf458666 100644 --- a/exp_roles/test/lua/holders.lua +++ b/exp_roles/test/lua/holders.lua @@ -38,9 +38,9 @@ end) test("role:get_players is limited to this map and can be filtered", function(env) setup(env) local mod = env.R("Moderator") - eq(Suite.sorted(env.names(mod:get_players())), { "alice", "dave" }, "players are limited to this map") - eq(env.names(mod:get_players(true)), { "alice" }, "filtered to connected players") - eq(env.names(mod:get_players(false)), { "dave" }, "filtered to offline players") + eq(Suite.sorted(Suite.names(mod:get_players())), { "alice", "dave" }, "players are limited to this map") + eq(Suite.names(mod:get_players(true)), { "alice" }, "filtered to connected players") + eq(Suite.names(mod:get_players(false)), { "dave" }, "filtered to offline players") end) test("role:print reaches online holders", function(env) diff --git a/exp_roles/test/lua/lookup.lua b/exp_roles/test/lua/lookup.lua index 6843557a..e946fc80 100644 --- a/exp_roles/test/lua/lookup.lua +++ b/exp_roles/test/lua/lookup.lua @@ -22,14 +22,14 @@ end) test("get_ordered_roles returns the most privileged first", function(env) env.initialise{} - eq(env.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }, + 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", function(env) env.initialise{} local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") } - eq(env.names(sorted), { "Cluster Admin", "Regular", "Player" }, "the list is sorted in place") + eq(Suite.names(sorted), { "Cluster Admin", "Regular", "Player" }, "the list is sorted in place") end) test("get_default_role follows is_default", function(env) @@ -39,9 +39,9 @@ end) test("get_higher_roles and get_lower_roles include the role and exclude the default", function(env) env.initialise{} - eq(Suite.sorted(env.names(env.Roles.get_higher_roles(env.R("Regular")))), + eq(Suite.sorted(Suite.names(env.Roles.get_higher_roles(env.R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }, "higher roles") - eq(Suite.sorted(env.names(env.Roles.get_lower_roles(env.R("Regular")))), + eq(Suite.sorted(Suite.names(env.Roles.get_lower_roles(env.R("Regular")))), { "Jail", "Regular" }, "lower roles") end) diff --git a/exp_roles/test/lua/players.lua b/exp_roles/test/lua/players.lua index 8f41a89b..7ec01258 100644 --- a/exp_roles/test/lua/players.lua +++ b/exp_roles/test/lua/players.lua @@ -18,16 +18,16 @@ end test("get_player_roles includes the default role", function(env) local players = setup(env) - eq(Suite.sorted(env.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, + eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, "held roles and the default role") - eq(env.names(env.Roles.get_player_roles(players.carol)), { "Player" }, + 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(env.names(env.Roles.get_player_roles(nil)), { "" }, "nil is the server") - eq(env.names(env.Roles.get_player_roles(env.server)), { "" }, "index 0 is the server") + eq(Suite.names(env.Roles.get_player_roles(nil)), { "" }, "nil is the server") + eq(Suite.names(env.Roles.get_player_roles(env.server)), { "" }, "index 0 is the server") check(env.Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission") check(not env.R("Player"):has_player(nil), "the server does not have the default role") end) diff --git a/exp_roles/test/lua/sync.lua b/exp_roles/test/lua/sync.lua index 415fcc4b..f02fb3f4 100644 --- a/exp_roles/test/lua/sync.lua +++ b/exp_roles/test/lua/sync.lua @@ -24,7 +24,7 @@ test("initialise applies triggers and raises empty events", function(env) setup(env) deep_eq(env.admin_state, { alice = true, carol = false }, "triggers run for connected players") check(#env.events == 2, "the event is raised once per connected player") - check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "the events are empty") + check(#env.events[1].assigned == 0 and #env.events[1].unassigned == 0, "the events are empty") check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing") end) @@ -49,16 +49,23 @@ test("receive_assignment_updates applies controller changes", function(env) env.reset_log() env.Roles.receive_assignment_updates{ env.assignment("carol", { 5 }) } check(env.R("Moderator"):has_player(players.carol), "the assignment applies") - check(#env.events == 1, "the event is raised") - eq(env.events[1].data.assigned, { 5 }, "the event carries the role id") - check(env.events[1].data.by_player_index == 0, "controller changes have no by player") + deep_eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = 3, + by_player_index = 0, + assigned = { 5 }, + unassigned = {}, + }, + }, "the event carries the change with no by player") check(#env.printed == 1 and env.printed[1][4] == "", "the change is announced by the server") check(env.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[1].data.unassigned, { 5 }, "the removal raises the event") + eq(env.events[1].unassigned, { 5 }, "the removal raises the event") check(env.admin_state.carol == false, "triggers follow the removal") end) @@ -77,7 +84,7 @@ test("receive_role_updates applies to the holders", function(env) } check(env.R("Regular"):has_permission("exp_scenario.command.jail"), "the permission change applies") check(#env.events == 2, "a role change raises for every connected player") - check(#env.events[1].data.assigned == 0, "role change events are empty") + check(#env.events[1].assigned == 0, "role change events are empty") env.Roles.receive_role_updates{ { id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true }, @@ -92,7 +99,7 @@ test("receive_role_updates moves the default role", function(env) { 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(env.names(env.Roles.get_player_roles(players.carol)), { "Guest" }, "players pick up the new default") + 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", function(env) @@ -108,6 +115,7 @@ 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") diff --git a/test/lua/framework.lua b/test/lua/framework.lua index b158b518..f8fbe1ef 100644 --- a/test/lua/framework.lua +++ b/test/lua/framework.lua @@ -101,6 +101,18 @@ function Framework.new_suite(make_env) Suite.check(deep_equal(a, b), name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1))) 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 = {} diff --git a/test/lua/stubs.lua b/test/lua/stubs.lua index 6245cbfc..4a4a91bd 100644 --- a/test/lua/stubs.lua +++ b/test/lua/stubs.lua @@ -65,7 +65,10 @@ function Stubs.new() return next_event_id end, raise_event = function(id, data) - stubs.events[#stubs.events + 1] = { id = id, data = 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 @@ -79,13 +82,17 @@ function Stubs.new() }), }) - local players_by_name, players_by_index, connected = {}, {}, {} + -- 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_by_name, + players = players, connected_players = connected, print = function(message) stubs.printed[#stubs.printed + 1] = message end, - get_player = function(key) return players_by_name[key] or players_by_index[key] end, + get_player = function(key) return players[key] end, }, { "player" }) --- Add a player to the stubbed game @@ -102,7 +109,7 @@ function Stubs.new() stubs.printed[#stubs.printed + 1] = { to = name, message } end, }) - players_by_name[name] = player + rawset(players, name, player) players_by_index[index] = player if player.connected then connected[#connected + 1] = player end return player From 3218351db6a96b57042f8ab1152adec0c7a4f4a5 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:11:58 +0000 Subject: [PATCH 13/16] Address review on the suite and instance tests - The instance tests run against a real Instance with a real InstanceConfig, matching the controller tests: the spies are sendTo and the server, which record what leaves the instance, so sendRcon passes through the real script command gate. The plugin's config fields are registered so the sync mode is a real field. - Shallow eq is gone and eq compares recursively; the suite is created with Framework.suite(extend_env), which hands the extension a fresh set of stubs per test, so the plugin's env.lua is only the extension. - add_player assigns continuous indexes itself. - The lua fixtures are referenced by role name: assignments take names, and ids in expectations come from the role object. The name lookup in env.R uses a fixture index rather than searching the roles. - Test names format functions as name() and methods as .name(). - Branch coverage filled in: sort tie breaking, the highest role assertion when a player has no roles, has all for the server, the by player default from game.player, deleted assignment records on initialise, set_emit_events with no argument, printing to a role with no holders, role property update validation, the assignment subscription replay, unassignment over the ipc, and a refused removal having nothing to roll back. Co-Authored-By: Claude Fable 5 --- exp_roles/test/controller.test.js | 41 ++++++-- exp_roles/test/instance.test.js | 167 ++++++++++++++++++------------ exp_roles/test/lua/assignment.lua | 112 +++++++++++--------- exp_roles/test/lua/env.lua | 36 ++++--- exp_roles/test/lua/holders.lua | 35 ++++--- exp_roles/test/lua/lookup.lua | 30 +++--- exp_roles/test/lua/players.lua | 35 ++++--- exp_roles/test/lua/sync.lua | 75 ++++++++------ exp_roles/test/messages.test.js | 2 +- exp_roles/test/seed.test.js | 2 +- test/lua/framework.lua | 34 +++--- test/lua/stubs.lua | 5 +- 12 files changed, 340 insertions(+), 234 deletions(-) diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index 661ba735..8b47e5cb 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -52,7 +52,7 @@ async function startPlugin(t2, { roles = [] } = {}) { 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 => { + 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"); @@ -64,7 +64,7 @@ t.test("class ControllerPlugin", t2 => { t3.ok(plugin.roleMeta.get(5), "properties with a role are kept"); }); - t2.test("buildRoleRecord combines the role with its properties", async t3 => { + 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"])], }); @@ -78,7 +78,7 @@ t.test("class ControllerPlugin", t2 => { t3.ok(plugin.buildRoleRecord(controller.roles.get(1)).isDefault, "the default role is marked"); }); - t2.test("rolesUpdated and roleMetaUpdated broadcast to subscribers", async t3 => { + 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; @@ -102,7 +102,7 @@ t.test("class ControllerPlugin", t2 => { ); }); - t2.test("handleRoleSubscription replays only newer records", async t3 => { + 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; @@ -113,7 +113,7 @@ t.test("class ControllerPlugin", t2 => { t3.strictSame(none, null, "nothing is replayed when up to date"); }); - t2.test("listAssignmentRecords mirrors users without the default role", async t3 => { + 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"); @@ -124,7 +124,7 @@ t.test("class ControllerPlugin", t2 => { t3.strictSame([...records[0].roleIds], [5], "the default role is not listed"); }); - t2.test("handleAssignmentUpdateRequest validates the user and roles", async t3 => { + 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")], }); @@ -144,7 +144,7 @@ t.test("class ControllerPlugin", t2 => { 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 => { + 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")], }); @@ -173,7 +173,32 @@ t.test("class ControllerPlugin", t2 => { t3.strictSame(applied.length, 1, "other player events do not"); }); - t2.test("handleSeedRolesRequest creates the roles and reuses them by name", async t3 => { + 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(".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")], }); diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js index 1b1e2d9b..cb3db308 100644 --- a/exp_roles/test/instance.test.js +++ b/exp_roles/test/instance.test.js @@ -1,14 +1,32 @@ "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: () => {} }; -// Subscribing validates event names against the link registry -lib.Link.register(messages.RoleUpdatedEvent); -lib.Link.register(messages.AssignmentUpdatedEvent); +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), @@ -16,30 +34,40 @@ const sampleRoles = () => [ ]; const sampleAssignments = () => [new messages.AssignmentRecord("alice", new Set([5]))]; -/** Build a plugin around a fake instance, started on first use by each test. */ +/** 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 }; - const instance = { - logger, - config: { get: field => (field === "exp_roles.sync_mode" ? syncMode : undefined) }, + instance.server = { handle: () => {}, - server: { handle: () => {} }, - 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; + sendRcon: async command => { + state.rcons.push(command); + return ""; }, - sendRcon: async command => state.rcons.push(command), + }; + 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, {}); @@ -55,92 +83,103 @@ function decodeRcon(command) { } t.test("class InstancePlugin", t2 => { - t2.test("onStart subscribes and initialises the lua module", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); + 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); - t2.strictSame(subscribed.length, 2, "roles and assignments are subscribed to"); - t2.ok(state.sent.some(request => request instanceof messages.RoleListRequest), "the roles are requested"); - t2.ok(state.sent.some(request => request instanceof messages.AssignmentListRequest), "the assignments are requested"); + 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]); - t2.strictSame(initialise.receiver, "initialise", "the lua module is initialised"); - t2.strictSame(initialise.payload.permission_names, ["exp_scenario.gui.readme", "core.admin"], + 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"); - t2.strictSame(initialise.payload.roles[1].permissions, [0, 1], "roles reference permissions by index"); - t2.strictSame(initialise.payload.assignments, [{ name: "alice", role_ids: [5] }], "the assignments are sent"); + 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]); - t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "enabled does not emit changes back"); - t2.strictSame(state.warnings.length, 0, "no warnings with a default role"); + 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 t2 => { - const { plugin, state } = await startPlugin(t2); + 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]); - t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]); + t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]); }); - t2.test("onStart with disabled does nothing", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); + t2.test(".onStart() with disabled does nothing", async t3 => { + const { plugin, state } = await startPlugin(t3, { syncMode: "disabled" }); await plugin.onStart(); - t2.strictSame(state.sent.length, 0, "nothing is sent"); - t2.strictSame(state.rcons.length, 0, "no commands are run"); + 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 t2 => { + 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(t2, { roles }); + const { plugin, state } = await startPlugin(t3, { roles }); await plugin.onStart(); - t2.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role"); + 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 t2 => { - const { plugin, state } = await startPlugin(t2); + 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]); - t2.strictSame(roles.receiver, "receive_role_updates", "role updates are forwarded"); - t2.strictSame(roles.payload[0].name, "Player", "the records are sent in plain form"); + 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]); - t2.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); + t3.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); }); - t2.test("handleRoleUpdatedEvent and handleAssignmentUpdatedEvent are gated while disabled", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); + 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())); - t2.strictSame(state.rcons.length, 0, "no commands are run"); + t3.strictSame(state.rcons.length, 0, "no commands are run"); }); - t2.test("handleAssignmentUpdateIPC sends the change and rolls back a refusal", async t2 => { - const { plugin, state } = await startPlugin(t2); + 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]; - t2.ok(request instanceof messages.AssignmentUpdateRequest, "the change is sent as a request"); - t2.strictSame([request.name, request.assign, request.unassign], ["alice", [5], []], "the request names the change"); + 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]); - t2.strictSame(rollback.receiver, "reject_assignment", "the refusal is rolled back in lua"); - t2.strictSame(rollback.payload, { name: "alice", role_ids: [5] }, "the rollback names the refused roles"); + 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 t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); + 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 }); - t2.strictSame(state.sent.length, 0, "nothing is sent"); + t3.strictSame(state.sent.length, 0, "nothing is sent"); }); - t2.test("onInstanceConfigFieldChanged toggles emit with the sync mode", async t2 => { - const { plugin, state } = await startPlugin(t2); + 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]); - t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting"); + t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting"); }); + t2.end(); }); diff --git a/exp_roles/test/lua/assignment.lua b/exp_roles/test/lua/assignment.lua index 5396e1fe..406fa6fd 100644 --- a/exp_roles/test/lua/assignment.lua +++ b/exp_roles/test/lua/assignment.lua @@ -1,36 +1,37 @@ --- Tests for the assignment methods of module/control.lua local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua -local test, check, eq, deep_eq = Suite.test, Suite.check, Suite.eq, Suite.deep_eq +local test, check, eq = Suite.test, Suite.check, Suite.eq --- 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", 1), - bob = env.add_player("bob", 2), + alice = env.add_player("alice"), + bob = env.add_player("bob"), } env.initialise{ - env.assignment("alice", { 5 }), - env.assignment("bob", { 6 }), + env.assignment("alice", { "Moderator" }), + env.assignment("bob", { "Regular" }), } env.Roles.set_emit_events(true) env.reset_log() return players end -test("role:assign applies locally and is sent to the controller", function(env) +test(".assign() applies locally and is sent to the controller", function(env) local players = setup(env) - env.R("Moderator"):assign(players.bob, { by_player_name = "alice" }) - deep_eq(env.sent, { - { channel = "exp_roles:assignment_update", data = { name = "bob", assign = { 5 } } }, + 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(env.R("Moderator"):has_player(players.bob), "the role applies before confirmation") - deep_eq(env.events, { + 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 = 2, - by_player_index = 1, - assigned = { 5 }, + player_index = players.bob.index, + by_player_index = players.alice.index, + assigned = { moderator.id }, unassigned = {}, }, }, "the event carries the change") @@ -38,19 +39,26 @@ test("role:assign applies locally and is sent to the controller", function(env) eq(env.sounds, { "bob:utility/achievement_unlocked" }, "the assign sound plays") local sd = env.Roles._script_data() - eq(sd.local_players.bob, { 5 }, "the role is held locally") - eq(sd.pending.bob, { 5 }, "the role is pending") + 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() - env.R("Moderator"):assign(players.bob) + moderator:assign(players.bob) check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op") end) -test("receive_assignment_updates releases the local hold once confirmed", function(env) +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 }) + check(env.events[1].by_player_index == players.alice.index, "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", { 6, 5 }) } + env.Roles.receive_assignment_updates{ env.assignment("bob", { "Regular", "Moderator" }) } check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing") local sd = env.Roles._script_data() @@ -58,44 +66,46 @@ test("receive_assignment_updates releases the local hold once confirmed", functi check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation") end) -test("role:unassign removes a synced role", function(env) +test(".unassign() removes a synced role", function(env) local players = setup(env) - env.R("Regular"):unassign(players.bob, { by_player_name = "alice", silent = true }) - deep_eq(env.sent, { - { channel = "exp_roles:assignment_update", data = { name = "bob", unassign = { 6 } } }, + 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 env.R("Regular"):has_player(players.bob), "the role is removed locally") - deep_eq(env.events, { + 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 = 2, - by_player_index = 1, + player_index = players.bob.index, + by_player_index = players.alice.index, assigned = {}, - unassigned = { 6 }, + unassigned = { regular.id }, }, }, "the event carries the change") check(#env.printed == 0, "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) +test("reject_assignment() rolls the assignment back", function(env) local players = setup(env) - env.R("Jail"):assign(players.alice) - check(env.R("Jail"):has_player(players.alice), "the role applies before rejection") + 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 = { 7 } } - check(not env.R("Jail"):has_player(players.alice), "the rejected role is rolled back") + env.Roles.reject_assignment{ name = "alice", role_ids = { jail.id } } + check(not jail:has_player(players.alice), "the rejected role is rolled back") check(#env.sent == 0, "the rollback is not sent to the controller") - deep_eq(env.events, { + eq(env.events, { { name = env.Roles.events.on_player_roles_changed, tick = 1, - player_index = 1, + player_index = players.alice.index, by_player_index = 0, assigned = {}, - unassigned = { 7 }, + unassigned = { jail.id }, }, }, "the rollback raises the event") @@ -103,44 +113,46 @@ test("reject_assignment rolls the assignment back", function(env) check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") end) -test("role:assign with local_only never reaches the controller", function(env) +test(".assign() with local_only never reaches the controller", function(env) local players = setup(env) - env.R("Regular"):assign(players.alice, { silent = true, local_only = true }) + local regular = env.R("Regular") + regular:assign(players.alice, { silent = true, local_only = true }) check(#env.sent == 0, "the assignment is not sent") - check(env.R("Regular"):has_player(players.alice), "the role applies") + check(regular:has_player(players.alice), "the role applies") local sd = env.Roles._script_data() check(sd.pending.alice == nil, "the role is not pending") - eq(sd.local_players.alice, { 6 }, "the role is held locally") + eq(sd.local_players.alice, { regular.id }, "the role is held locally") - env.Roles.receive_assignment_updates{ env.assignment("alice", { 5 }) } - check(env.R("Regular"):has_player(players.alice), "the role survives controller updates") + env.Roles.receive_assignment_updates{ env.assignment("alice", { "Moderator" }) } + check(regular:has_player(players.alice), "the role survives controller updates") env.reset_log() - env.R("Regular"):unassign(players.alice, { local_only = true }) - check(not env.R("Regular"):has_player(players.alice) and #env.sent == 0, "removed without sync") + regular:unassign(players.alice, { local_only = true }) + check(not regular:has_player(players.alice) and #env.sent == 0, "removed without sync") end) -test("role:assign of a higher priority role suppresses the rest", function(env) +test(".assign() of a higher priority role suppresses the rest", function(env) local players = setup(env) - env.R("Jail"):assign(players.alice, { by_player_name = "", silent = true }) + local jail = env.R("Jail") + jail:assign(players.alice, { by_player_name = "", silent = true }) eq(Suite.names(env.Roles.get_player_roles(players.alice)), { "Jail" }, "only the jail role applies") check(env.R("Moderator"):has_player(players.alice), "a suppressed role is still held") check(not env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "permissions are lost") check(not env.Roles.player_has_permission(players.alice, "exp_scenario.gui.readme"), "the default role is lost") check(not env.Roles.player_outranks(players.alice, players.bob), "a jailed player no longer outranks") - eq(env.events[1].assigned, { 7 }, "the event lists the jail role") + eq(env.events[1].assigned, { jail.id }, "the event lists the jail role") check(#env.events[1].unassigned == 0, "the suppressed roles are not listed as unassigned") env.reset_log() - env.R("Jail"):unassign(players.alice, { by_player_name = "", silent = true }) + jail:unassign(players.alice, { by_player_name = "", silent = true }) eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, "unjail restores the roles") check(#env.events[1].assigned == 0, "the restored roles are not listed as assigned") - eq(env.events[1].unassigned, { 7 }, "the unjail event lists the jail role") + eq(env.events[1].unassigned, { jail.id }, "the unjail event lists the jail role") end) -test("role:assign ignores the server", function(env) +test(".assign() ignores the server", function(env) setup(env) env.R("Moderator"):assign(env.server) check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op") diff --git a/exp_roles/test/lua/env.lua b/exp_roles/test/lua/env.lua index dc8e246d..b1c7f908 100644 --- a/exp_roles/test/lua/env.lua +++ b/exp_roles/test/lua/env.lua @@ -1,8 +1,7 @@ --[[-- Test environment for module/control.lua The module specific extension between the shared stubs and the test files: it -creates environments which combine the stubs with a fresh copy of the roles -module and the fixtures below, and hands each test file a suite built around -that factory. +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. @@ -12,7 +11,6 @@ local shared_root = ... --- @type string local source = assert(debug.getinfo(1, "S")).source local plugin_root = assert(source:match("^@(.*)/test/lua/env%.lua$")) -local Stubs = assert(loadfile(shared_root .. "/stubs.lua"))() local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- Standard fixture: permission names sent once and referenced by zero based index @@ -43,21 +41,33 @@ local function role_records() } end ---- Create an independent environment with a fresh copy of the roles module -local function make_env() - local env = Stubs.new() +--- 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 +return Framework.suite(function(env) env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))() env.Roles.on_server_startup() - --- Get a role by name, failing the test when it does not exist + --- 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 - function env.assignment(name, role_ids) - return { name = name, role_ids = role_ids } + --- 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 --- Load the standard fixture and the given assignments @@ -70,6 +80,4 @@ local function make_env() end return env -end - -return Framework.new_suite(make_env) +end) diff --git a/exp_roles/test/lua/holders.lua b/exp_roles/test/lua/holders.lua index cf458666..486767c8 100644 --- a/exp_roles/test/lua/holders.lua +++ b/exp_roles/test/lua/holders.lua @@ -5,52 +5,53 @@ 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", 1), - bob = env.add_player("bob", 2), - dave = env.add_player("dave", 4, false), + alice = env.add_player("alice"), + bob = env.add_player("bob"), + dave = env.add_player("dave", false), } env.initialise{ - env.assignment("alice", { 5 }), - env.assignment("bob", { 6 }), - env.assignment("dave", { 5 }), - env.assignment("zed", { 5 }), + 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("role:get_player_names includes players not on this map", function(env) +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("role:get_player_names counts a player in both lists once", function(env) +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", { 5, 6 }) } + env.Roles.receive_assignment_updates{ env.assignment("alice", { "Moderator", "Regular" }) } local sd = env.Roles._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("role:get_players is limited to this map and can be filtered", function(env) +test(".get_players() is limited to this map and can be filtered", function(env) setup(env) - local mod = env.R("Moderator") - eq(Suite.sorted(Suite.names(mod:get_players())), { "alice", "dave" }, "players are limited to this map") - eq(Suite.names(mod:get_players(true)), { "alice" }, "filtered to connected players") - eq(Suite.names(mod:get_players(false)), { "dave" }, "filtered to offline players") + 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("role:print reaches online holders", function(env) +test(".print() reaches online holders", function(env) setup(env) env.reset_log() check(env.R("Moderator"):print("hello") == 1, "print returns the number of players reached") check(#env.printed == 1 and env.printed[1].to == "alice", "only online holders are reached") + check(env.R("Jail"):print("hello") == 0, "a role with no online holders reaches nobody") end) -test("role:print over get_higher_roles can repeat", function(env) +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() diff --git a/exp_roles/test/lua/lookup.lua b/exp_roles/test/lua/lookup.lua index e946fc80..56db829d 100644 --- a/exp_roles/test/lua/lookup.lua +++ b/exp_roles/test/lua/lookup.lua @@ -2,42 +2,48 @@ local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua local test, check, eq = Suite.test, Suite.check, Suite.eq -test("get_role returns the role with the given clusterio id", function(env) +test("get_role() returns the role with the given clusterio id", function(env) env.initialise{} - check(env.Roles.get_role(6).name == "Regular", "the role is found by id") - check(env.Roles.get_role(env.R("Jail").id) == env.R("Jail"), "lookups return the same object") + check(env.Roles.get_role(env.R("Regular").id) == env.R("Regular"), "the role is found by id") check(env.Roles.get_role(9) == nil, "deleted roles are not known") end) -test("get_role_by_name searches the roles", function(env) +test("get_role_by_name() searches the roles", function(env) env.initialise{} - check(env.Roles.get_role_by_name("Moderator").id == 5, "the role is found by name") + check(env.Roles.get_role_by_name("Moderator") == env.R("Moderator"), "the role is found by name") check(env.Roles.get_role_by_name("Gone") == nil, "deleted roles are not known") end) -test("get_roles returns every role", function(env) +test("get_roles() returns every role", function(env) env.initialise{} check(#env.Roles.get_roles() == 5, "every role is returned with deleted roles skipped") end) -test("get_ordered_roles returns the most privileged first", function(env) +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", function(env) +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) +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) +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") @@ -45,14 +51,14 @@ test("get_higher_roles and get_lower_roles include the role and exclude the defa { "Jail", "Regular" }, "lower roles") end) -test("role:is_higher_than and role:is_lower_than compare on order", function(env) +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) +test("initialise() decodes the role records", function(env) env.initialise{} check(env.R("Cluster Admin").short_hand == "SYS", "short hand decoded") check(env.R("Moderator").short_hand == "Moderator", "short hand falls back to the name") diff --git a/exp_roles/test/lua/players.lua b/exp_roles/test/lua/players.lua index 7ec01258..57f85ded 100644 --- a/exp_roles/test/lua/players.lua +++ b/exp_roles/test/lua/players.lua @@ -5,18 +5,18 @@ 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", 1), - bob = env.add_player("bob", 2), - carol = env.add_player("carol", 3), + alice = env.add_player("alice"), + bob = env.add_player("bob"), + carol = env.add_player("carol"), } env.initialise{ - env.assignment("alice", { 5 }), - env.assignment("bob", { 6 }), + env.assignment("alice", { "Moderator" }), + env.assignment("bob", { "Regular" }), } return players end -test("get_player_roles includes the default role", function(env) +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") @@ -24,7 +24,7 @@ test("get_player_roles includes the default role", function(env) "a player with no roles has the default role") end) -test("get_player_roles treats nil and index 0 as the server", function(env) +test("get_player_roles() treats nil and index 0 as the server", function(env) setup(env) eq(Suite.names(env.Roles.get_player_roles(nil)), { "" }, "nil is the server") eq(Suite.names(env.Roles.get_player_roles(env.server)), { "" }, "index 0 is the server") @@ -32,20 +32,28 @@ test("get_player_roles treats nil and index 0 as the server", function(env) check(not env.R("Player"):has_player(nil), "the server does not have the default role") end) -test("get_player_highest_role", function(env) +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("player_has_permission", function(env) +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 }, + } + check(not pcall(env.Roles.get_player_highest_role, players.carol), "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) +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") @@ -56,23 +64,24 @@ test("player_has_any_permission and player_has_all_permission", function(env) 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("role:has_permission", function(env) +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("role:has_player", function(env) +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) +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") diff --git a/exp_roles/test/lua/sync.lua b/exp_roles/test/lua/sync.lua index f02fb3f4..4bdc043a 100644 --- a/exp_roles/test/lua/sync.lua +++ b/exp_roles/test/lua/sync.lua @@ -1,61 +1,69 @@ --- Tests for the sync entry points of module/control.lua local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua -local test, check, eq, deep_eq = Suite.test, Suite.check, Suite.eq, Suite.deep_eq +local test, check, eq = Suite.test, Suite.check, Suite.eq --- alice is a moderator and carol has only the default role, both connected local function setup(env) local players = { - alice = env.add_player("alice", 1), - carol = env.add_player("carol", 3), + alice = env.add_player("alice"), + carol = env.add_player("carol"), } env.admin_state = {} env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) env.admin_state[player.name] = state end) env.initialise{ - env.assignment("alice", { 5 }), - env.assignment("zed", { 5, 6 }), -- has never joined this map + 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) +test("initialise() applies triggers and raises empty events", function(env) setup(env) - deep_eq(env.admin_state, { alice = true, carol = false }, "triggers run for connected players") + eq(env.admin_state, { alice = true, carol = false }, "triggers run for connected players") check(#env.events == 2, "the event is raised once per connected player") check(#env.events[1].assigned == 0 and #env.events[1].unassigned == 0, "the events are empty") check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing") end) -test("initialise is authoritative for pending roles", function(env) +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.Roles._script_data() - eq(sd.pending.carol, { 5 }, "the role is pending before initialise") + eq(sd.pending.carol, { env.R("Moderator").id }, "the role is pending before initialise") env.reset_log() - env.initialise{ env.assignment("alice", { 5 }) } + env.initialise{ env.assignment("alice", { "Moderator" }) } check(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, { 7 }, "local only roles are kept") + eq(sd.local_players.carol, { env.R("Jail").id }, "local only roles are kept") check(#env.sent == 0, "initialise sends nothing") end) -test("receive_assignment_updates applies controller changes", function(env) +test("receive_assignment_updates() applies controller changes", function(env) local players = setup(env) env.reset_log() - env.Roles.receive_assignment_updates{ env.assignment("carol", { 5 }) } + env.Roles.receive_assignment_updates{ env.assignment("carol", { "Moderator" }) } check(env.R("Moderator"):has_player(players.carol), "the assignment applies") - deep_eq(env.events, { + eq(env.events, { { name = env.Roles.events.on_player_roles_changed, tick = 1, - player_index = 3, + player_index = players.carol.index, by_player_index = 0, - assigned = { 5 }, + assigned = { env.R("Moderator").id }, unassigned = {}, }, }, "the event carries the change with no by player") @@ -65,53 +73,58 @@ test("receive_assignment_updates applies controller changes", function(env) 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[1].unassigned, { 5 }, "the removal raises the event") + eq(env.events[1].unassigned, { env.R("Moderator").id }, "the removal raises the event") check(env.admin_state.carol == false, "triggers follow the removal") end) -test("receive_assignment_updates for players not on this map raises nothing", function(env) +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", { 5 }) } + env.Roles.receive_assignment_updates{ env.assignment("zed", { "Moderator" }) } check(#env.events == 0 and #env.printed == 0, "no event and no announcement") end) -test("receive_role_updates applies to the holders", function(env) +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 = 6, name = "Regular", permissions = { "exp_scenario.command.jail" }, meta = { id = 0, order = 3 } }, + { 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") check(#env.events == 2, "a role change raises for every connected player") check(#env.events[1].assigned == 0, "role change events are empty") env.Roles.receive_role_updates{ - { id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true }, + { id = regular_id, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true }, } - check(env.Roles.get_role(6) == nil, "a deleted role is removed") + check(env.Roles.get_role(regular_id) == nil, "a deleted role is removed") end) -test("receive_role_updates moves the default role", function(env) +test("receive_role_updates() moves the default role", function(env) local players = setup(env) env.Roles.receive_role_updates{ - { id = 1, name = "Player", permissions = {}, meta = { id = 0, order = 5 } }, + { 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", function(env) +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) - check(#env.sent == 0, "the assignment is not sent") + check(#env.sent == 0, "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 }) + check(#env.sent == 1, "no argument enables sending") end) -test("on_load restores the state after a save and load", function(env) +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() @@ -123,10 +136,10 @@ test("on_load restores the state after a save and load", function(env) 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) - setup(env) +test("on_player_joined_game() runs the triggers", function(env) + local players = setup(env) env.admin_state.alice = nil - env.Roles.on_player_joined_game{ player_index = 1 } + env.Roles.on_player_joined_game{ player_index = players.alice.index } check(env.admin_state.alice ~= nil, "triggers run when a player joins") end) diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js index b2105072..ba89b550 100644 --- a/exp_roles/test/messages.test.js +++ b/exp_roles/test/messages.test.js @@ -96,7 +96,7 @@ t.test("class AssignmentUpdateRequest", subtest => { subtest.end(); }); -t.test("encodeRolesForLua sends each permission name once", subtest => { +t.test("encodeRolesForLua() sends each permission name once", subtest => { const meta = id => new messages.RoleMetaRecord(id, id); const encoded = messages.encodeRolesForLua([ new messages.RoleRecord(1, "A", ["p.one", "p.two"], meta(1)), diff --git a/exp_roles/test/seed.test.js b/exp_roles/test/seed.test.js index 755ddc03..010dbd5d 100644 --- a/exp_roles/test/seed.test.js +++ b/exp_roles/test/seed.test.js @@ -15,7 +15,7 @@ t.test("seedRoles grant only defined permissions", subtest => { subtest.end(); }); -t.test("flattenSeedPermissions carries parents into their children", subtest => { +t.test("flattenSeedPermissions() carries parents into their children", subtest => { const byName = new Map(seedRoles.map(role => [role.name, role])); for (const role of seedRoles) { if (role.parent === undefined) { diff --git a/test/lua/framework.lua b/test/lua/framework.lua index f8fbe1ef..dfda4fc9 100644 --- a/test/lua/framework.lua +++ b/test/lua/framework.lua @@ -1,11 +1,15 @@ --[[-- 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 from the -factory the suite was created with, so tests are independent and can not leak -state into each other. +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"))() + local Framework = {} --- Turn a value into a string for failure messages @@ -21,8 +25,8 @@ local function repr(value, depth) end --- Create a suite of tests, one is made per test file ---- @param make_env fun(): table Creates the environment given to each test -function Framework.new_suite(make_env) +--- @param extend_env fun(env: table): table Extends the stubs given to each test +function Framework.suite(extend_env) --- @class Suite local Suite = { tests = {}, -- { name, fn } in declaration order @@ -70,20 +74,6 @@ function Framework.new_suite(make_env) end end - --- Check two values or arrays are shallowly equal - function Suite.eq(a, b, name) - local equal - if type(a) ~= "table" or type(b) ~= "table" then - equal = a == b - else - equal = #a == #b - for i = 1, #a do - if a[i] ~= b[i] then equal = false break end - end - end - Suite.check(equal, name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1))) - 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 @@ -97,7 +87,7 @@ function Framework.new_suite(make_env) end --- Check two values are equal, comparing tables recursively - function Suite.deep_eq(a, b, name) + function Suite.eq(a, b, name) Suite.check(deep_equal(a, b), name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1))) end @@ -126,7 +116,9 @@ function Framework.new_suite(make_env) function Suite.run() for _, test in ipairs(Suite.tests) do current_test = test.name - local ok, err = pcall(test.fn, make_env()) + local ok, err = pcall(function() + test.fn(extend_env(Stubs.new())) + end) if not ok then Suite.fail("did not error", tostring(err)) end diff --git a/test/lua/stubs.lua b/test/lua/stubs.lua index 4a4a91bd..e587728e 100644 --- a/test/lua/stubs.lua +++ b/test/lua/stubs.lua @@ -95,8 +95,9 @@ function Stubs.new() get_player = function(key) return players[key] end, }, { "player" }) - --- Add a player to the stubbed game - function stubs.add_player(name, index, is_connected) + --- Add a player to the stubbed game, indexes are assigned in join order + function stubs.add_player(name, is_connected) + local index = #players_by_index + 1 local player = Stubs.strict("LuaPlayer " .. name, { name = name, index = index, From 574a0a4b529420a1a4da4199993eef1b6d75387d Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:48:43 +0000 Subject: [PATCH 14/16] Address final review nits on the assertions - Suite.empty(value, name) checks a table has no entries and reports what it held, and Suite.throws(fn, message, name) asserts a function errors with the given message, so an unrelated error no longer passes as the expected one. - Scalar comparisons and zero length checks go through eq and empty, and the announced messages and print output are compared by content, so a failure shows the actual data. - get_roles() is asserted through sorted names rather than a count. Co-Authored-By: Claude Fable 5 --- exp_roles/test/lua/assignment.lua | 30 ++++++++++++++++----------- exp_roles/test/lua/holders.lua | 6 +++--- exp_roles/test/lua/lookup.lua | 14 +++++++------ exp_roles/test/lua/players.lua | 4 +++- exp_roles/test/lua/sync.lua | 34 +++++++++++++++++++------------ test/lua/framework.lua | 20 ++++++++++++++++++ 6 files changed, 73 insertions(+), 35 deletions(-) diff --git a/exp_roles/test/lua/assignment.lua b/exp_roles/test/lua/assignment.lua index 406fa6fd..9a99dd58 100644 --- a/exp_roles/test/lua/assignment.lua +++ b/exp_roles/test/lua/assignment.lua @@ -1,6 +1,6 @@ --- Tests for the assignment methods of module/control.lua local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua -local test, check, eq = Suite.test, Suite.check, Suite.eq +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) @@ -35,7 +35,9 @@ test(".assign() applies locally and is sent to the controller", function(env) unassigned = {}, }, }, "the event carries the change") - check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced") + 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.Roles._script_data() @@ -44,14 +46,15 @@ test(".assign() applies locally and is sent to the controller", function(env) env.reset_log() moderator:assign(players.bob) - check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op") + 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 }) - check(env.events[1].by_player_index == players.alice.index, "the acting player is game.player") + eq(env.events[1].by_player_index, players.alice.index, "the acting player is game.player") end) test("receive_assignment_updates() releases the local hold once confirmed", function(env) @@ -59,7 +62,8 @@ test("receive_assignment_updates() releases the local hold once confirmed", func env.R("Moderator"):assign(players.bob) env.reset_log() env.Roles.receive_assignment_updates{ env.assignment("bob", { "Regular", "Moderator" }) } - check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing") + empty(env.events, "a confirmation raises no events") + empty(env.printed, "a confirmation announces nothing") local sd = env.Roles._script_data() check(sd.local_players.bob == nil and sd.pending.bob == nil, "the role is no longer held locally") @@ -84,7 +88,7 @@ test(".unassign() removes a synced role", function(env) unassigned = { regular.id }, }, }, "the event carries the change") - check(#env.printed == 0, "silent suppresses the announcement") + empty(env.printed, "silent suppresses the announcement") eq(env.sounds, { "bob:utility/game_lost" }, "the unassign sound plays") end) @@ -97,7 +101,7 @@ test("reject_assignment() rolls the assignment back", function(env) 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") - check(#env.sent == 0, "the rollback is not sent to the controller") + empty(env.sent, "the rollback is not sent to the controller") eq(env.events, { { name = env.Roles.events.on_player_roles_changed, @@ -117,7 +121,7 @@ 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 }) - check(#env.sent == 0, "the assignment is not sent") + empty(env.sent, "the assignment is not sent") check(regular:has_player(players.alice), "the role applies") local sd = env.Roles._script_data() @@ -129,7 +133,8 @@ test(".assign() with local_only never reaches the controller", function(env) env.reset_log() regular:unassign(players.alice, { local_only = true }) - check(not regular:has_player(players.alice) and #env.sent == 0, "removed without sync") + 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) @@ -142,20 +147,21 @@ test(".assign() of a higher priority role suppresses the rest", function(env) 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[1].assigned, { jail.id }, "the event lists the jail role") - check(#env.events[1].unassigned == 0, "the suppressed roles are not listed as unassigned") + empty(env.events[1].unassigned, "the suppressed roles are not listed as unassigned") env.reset_log() jail:unassign(players.alice, { by_player_name = "", silent = true }) eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, "unjail restores the roles") - check(#env.events[1].assigned == 0, "the restored roles are not listed as assigned") + empty(env.events[1].assigned, "the restored roles are not listed as assigned") eq(env.events[1].unassigned, { jail.id }, "the unjail event lists the jail role") end) test(".assign() ignores the server", function(env) setup(env) env.R("Moderator"):assign(env.server) - check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op") + empty(env.sent, "assigning to the server sends nothing") + empty(env.events, "assigning to the server raises nothing") end) return Suite.run() diff --git a/exp_roles/test/lua/holders.lua b/exp_roles/test/lua/holders.lua index 486767c8..8ee4c91f 100644 --- a/exp_roles/test/lua/holders.lua +++ b/exp_roles/test/lua/holders.lua @@ -46,9 +46,9 @@ end) test(".print() reaches online holders", function(env) setup(env) env.reset_log() - check(env.R("Moderator"):print("hello") == 1, "print returns the number of players reached") - check(#env.printed == 1 and env.printed[1].to == "alice", "only online holders are reached") - check(env.R("Jail"):print("hello") == 0, "a role with no online holders reaches nobody") + 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) diff --git a/exp_roles/test/lua/lookup.lua b/exp_roles/test/lua/lookup.lua index 56db829d..2479e6c8 100644 --- a/exp_roles/test/lua/lookup.lua +++ b/exp_roles/test/lua/lookup.lua @@ -5,18 +5,20 @@ 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") - check(env.Roles.get_role(9) == nil, "deleted roles are not known") + 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") - check(env.Roles.get_role_by_name("Gone") == nil, "deleted roles are not known") + 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{} - check(#env.Roles.get_roles() == 5, "every role is returned with deleted roles skipped") + 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) @@ -60,9 +62,9 @@ end) test("initialise() decodes the role records", function(env) env.initialise{} - check(env.R("Cluster Admin").short_hand == "SYS", "short hand decoded") - check(env.R("Moderator").short_hand == "Moderator", "short hand falls back to the name") - check(env.R("Moderator").color.g == 170, "color decoded") + 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) diff --git a/exp_roles/test/lua/players.lua b/exp_roles/test/lua/players.lua index 57f85ded..a13dce6c 100644 --- a/exp_roles/test/lua/players.lua +++ b/exp_roles/test/lua/players.lua @@ -43,7 +43,9 @@ test("get_player_highest_role() errors when a player has no roles", function(env env.Roles.receive_role_updates{ { id = env.R("Player").id, name = "Player", permissions = {}, meta = { id = 0, order = 5 }, is_deleted = true }, } - check(not pcall(env.Roles.get_player_highest_role, players.carol), "no default role and no held roles errors") + 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) diff --git a/exp_roles/test/lua/sync.lua b/exp_roles/test/lua/sync.lua index 4bdc043a..2a94875d 100644 --- a/exp_roles/test/lua/sync.lua +++ b/exp_roles/test/lua/sync.lua @@ -1,6 +1,6 @@ --- Tests for the sync entry points of module/control.lua local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua -local test, check, eq = Suite.test, Suite.check, Suite.eq +local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty --- alice is a moderator and carol has only the default role, both connected local function setup(env) @@ -23,9 +23,12 @@ end test("initialise() applies triggers and raises empty events", function(env) setup(env) eq(env.admin_state, { alice = true, carol = false }, "triggers run for connected players") - check(#env.events == 2, "the event is raised once per connected player") - check(#env.events[1].assigned == 0 and #env.events[1].unassigned == 0, "the events are empty") - check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing") + eq(#env.events, 2, "the event is raised once per connected player") + empty(env.events[1].assigned, "the events assign nothing") + empty(env.events[1].unassigned, "the events unassign nothing") + 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) @@ -46,10 +49,10 @@ test("initialise() is authoritative for pending roles", function(env) env.reset_log() env.initialise{ env.assignment("alice", { "Moderator" }) } - check(sd.pending.carol == nil, "pending roles are cleared") + 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") - check(#env.sent == 0, "initialise sends nothing") + empty(env.sent, "initialise sends nothing") end) test("receive_assignment_updates() applies controller changes", function(env) @@ -67,7 +70,9 @@ test("receive_assignment_updates() applies controller changes", function(env) unassigned = {}, }, }, "the event carries the change with no by player") - check(#env.printed == 1 and env.printed[1][4] == "", "the change is announced by the server") + eq(env.printed, { + { "exp-roles.game-message-assign", "carol", "Moderator", "" }, + }, "the change is announced by the server") check(env.admin_state.carol == true, "triggers follow the assignment") env.reset_log() @@ -81,7 +86,8 @@ test("receive_assignment_updates() for players not on this map raises nothing", setup(env) env.reset_log() env.Roles.receive_assignment_updates{ env.assignment("zed", { "Moderator" }) } - check(#env.events == 0 and #env.printed == 0, "no event and no announcement") + empty(env.events, "no event is raised") + empty(env.printed, "nothing is announced") end) test("receive_role_updates() applies to the holders", function(env) @@ -92,13 +98,13 @@ test("receive_role_updates() applies to the holders", function(env) { 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") - check(#env.events == 2, "a role change raises for every connected player") - check(#env.events[1].assigned == 0, "role change events are empty") + eq(#env.events, 2, "a role change raises for every connected player") + empty(env.events[1].assigned, "role change events are empty") env.Roles.receive_role_updates{ { id = regular_id, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true }, } - check(env.Roles.get_role(regular_id) == nil, "a deleted role is removed") + eq(env.Roles.get_role(regular_id), nil, "a deleted role is removed") end) test("receive_role_updates() moves the default role", function(env) @@ -116,12 +122,14 @@ test("set_emit_events() gates what is sent and defaults to enabled", function(en env.Roles.set_emit_events(false) env.reset_log() env.R("Regular"):assign(players.alice) - check(#env.sent == 0, "nothing is sent while disabled") + 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 }) - check(#env.sent == 1, "no argument enables sending") + 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) diff --git a/test/lua/framework.lua b/test/lua/framework.lua index dfda4fc9..eadca54c 100644 --- a/test/lua/framework.lua +++ b/test/lua/framework.lua @@ -91,6 +91,26 @@ function Framework.suite(extend_env) 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 }[] From 59df8bdff3600625e3abed447af6c934e735c668 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:04:09 +0100 Subject: [PATCH 15/16] Manual changes following smoke test --- exp_roles/package.json | 3 +- exp_roles/test/controller.test.js | 31 +++++++ exp_roles/test/messages.test.js | 80 ++++++++++--------- .../test/{control.test.js => module.test.js} | 6 +- exp_roles/test/{lua => module}/assignment.lua | 2 +- exp_roles/test/{lua => module}/env.lua | 8 +- exp_roles/test/{lua => module}/holders.lua | 2 +- exp_roles/test/{lua => module}/lookup.lua | 2 +- exp_roles/test/{lua => module}/players.lua | 2 +- exp_roles/test/{lua => module}/sync.lua | 2 +- exp_roles/test/seed.test.js | 52 ++++++++---- .../module/control/nuke_protection.lua | 2 +- exp_scenario/module/control/spawn_area.lua | 53 +++++++----- exp_scenario/module/gui/module_inserter.lua | 2 +- .../module/gui/research_milestones.lua | 4 +- test/common.js | 36 ++++++--- test/lua/framework.lua | 1 + test/lua/stubs.lua | 1 + 18 files changed, 191 insertions(+), 98 deletions(-) rename exp_roles/test/{control.test.js => module.test.js} (62%) rename exp_roles/test/{lua => module}/assignment.lua (98%) rename exp_roles/test/{lua => module}/env.lua (94%) rename exp_roles/test/{lua => module}/holders.lua (96%) rename exp_roles/test/{lua => module}/lookup.lua (97%) rename exp_roles/test/{lua => module}/players.lua (97%) rename exp_roles/test/{lua => module}/sync.lua (98%) diff --git a/exp_roles/package.json b/exp_roles/package.json index 1e462135..05f53e71 100644 --- a/exp_roles/package.json +++ b/exp_roles/package.json @@ -8,7 +8,8 @@ "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --env production", - "test": "tap --disable-coverage --allow-empty-coverage test/*.test.js" + "test": "tap --disable-coverage --allow-empty-coverage test/*.test.js", + "coverage": "tap --coverage-report=text test/*.test.js" }, "engines": { "node": ">=18" diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index 8b47e5cb..bf2f843c 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -20,6 +20,12 @@ 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", { @@ -173,6 +179,18 @@ t.test("class ControllerPlugin", t2 => { 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")] }); @@ -187,6 +205,19 @@ t.test("class ControllerPlugin", t2 => { 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])); diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js index ba89b550..8ec26550 100644 --- a/exp_roles/test/messages.test.js +++ b/exp_roles/test/messages.test.js @@ -7,18 +7,18 @@ const fullMeta = new messages.RoleMetaRecord( 7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false, ); -t.test("class RoleColor", subtest => { - testRoundTripJsonSerialisable(messages.RoleColor, testMatrix( +t.test("class RoleColor", t2 => { + testRoundTripJsonSerialisable(t2, messages.RoleColor, testMatrix( [0, 255], // r [0, 128], // g [0, 1], // b )); - subtest.pass("round trips"); - subtest.end(); + + t2.end(); }); -t.test("class RoleMetaRecord", subtest => { - testRoundTripJsonSerialisable(messages.RoleMetaRecord, testMatrix( +t.test("class RoleMetaRecord", t2 => { + testRoundTripJsonSerialisable(t2, messages.RoleMetaRecord, testMatrix( [7], // id [3], // order [0, 1], // priority @@ -30,12 +30,12 @@ t.test("class RoleMetaRecord", subtest => { [0, 12345], // updatedAtMs [false, true], // isDeleted )); - subtest.pass("round trips"); - subtest.end(); + + t2.end(); }); -t.test("class RoleRecord", subtest => { - testRoundTripJsonSerialisable(messages.RoleRecord, testMatrix( +t.test("class RoleRecord", t2 => { + testRoundTripJsonSerialisable(t2, messages.RoleRecord, testMatrix( [7], // id ["Moderator"], // name [[], ["a.b", "c.d"]], // permissions @@ -44,67 +44,73 @@ t.test("class RoleRecord", subtest => { [0, 12345], // updatedAtMs [false, true], // isDeleted )); - subtest.pass("round trips"); - subtest.end(); + + t2.end(); }); -t.test("class AssignmentRecord", subtest => { - testRoundTripJsonSerialisable(messages.AssignmentRecord, testMatrix( +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 )); - subtest.pass("round trips"); - subtest.end(); + + 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", subtest => { - testRoundTripJsonSerialisable(messages.RoleUpdatedEvent, testMatrix( +t.test("class RoleUpdatedEvent", t2 => { + testRoundTripJsonSerialisable(t2, messages.RoleUpdatedEvent, testMatrix( [[], [sampleRecord]], // updates )); - subtest.pass("round trips"); - subtest.end(); + + t2.end(); }); -t.test("class AssignmentUpdatedEvent", subtest => { - testRoundTripJsonSerialisable(messages.AssignmentUpdatedEvent, testMatrix( +t.test("class AssignmentUpdatedEvent", t2 => { + testRoundTripJsonSerialisable(t2, messages.AssignmentUpdatedEvent, testMatrix( [[], [sampleAssignment]], // updates )); - subtest.pass("round trips"); - subtest.end(); + + t2.end(); }); -t.test("class RoleMetaUpdateRequest", subtest => { - testRoundTripJsonSerialisable(messages.RoleMetaUpdateRequest, testMatrix( +t.test("class RoleMetaUpdateRequest", t2 => { + testRoundTripJsonSerialisable(t2, messages.RoleMetaUpdateRequest, testMatrix( [new messages.RoleMetaRecord(7, 3), fullMeta], // meta )); - subtest.pass("round trips"); - subtest.end(); + + t2.end(); }); -t.test("class AssignmentUpdateRequest", subtest => { - testRoundTripJsonSerialisable(messages.AssignmentUpdateRequest, testMatrix( +t.test("class AssignmentUpdateRequest", t2 => { + testRoundTripJsonSerialisable(t2, messages.AssignmentUpdateRequest, testMatrix( ["alice"], // name [[], [5]], // assign [[], [6]], // unassign )); - subtest.pass("round trips"); - subtest.end(); + + t2.end(); }); -t.test("encodeRolesForLua() sends each permission name once", subtest => { +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)), ]); - subtest.strictSame(encoded.permission_names, ["p.one", "p.two", "p.three"], "names are deduplicated"); - subtest.strictSame(encoded.roles[0].permissions, [0, 1], "indexes are zero based"); - subtest.strictSame(encoded.roles[1].permissions, [1, 2], "shared names reuse their index"); - subtest.end(); + t2.strictSame(encoded.permission_names, ["p.one", "p.two", "p.three"], "names are deduplicated"); + t2.strictSame(encoded.roles[0].permissions, [0, 1], "indexes are zero based"); + t2.strictSame(encoded.roles[1].permissions, [1, 2], "shared names reuse their index"); + t2.end(); }); diff --git a/exp_roles/test/control.test.js b/exp_roles/test/module.test.js similarity index 62% rename from exp_roles/test/control.test.js rename to exp_roles/test/module.test.js index 70272979..2e5d4aa5 100644 --- a/exp_roles/test/control.test.js +++ b/exp_roles/test/module.test.js @@ -3,12 +3,12 @@ const path = require("node:path"); const t = require("tap"); const { reportLuaTests } = require("../../test/lua/runner"); -const envFile = path.join(__dirname, "lua", "env.lua"); +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("module/control.lua", t2 => { +t.test("control.lua", t2 => { for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) { - t2.test(`lua/${file}`, subtest => reportLuaTests(subtest, envFile, path.join(__dirname, "lua", file))); + t2.test(file, t3 => reportLuaTests(t3, envFile, path.join(__dirname, "module", file))); } t2.end(); }); diff --git a/exp_roles/test/lua/assignment.lua b/exp_roles/test/module/assignment.lua similarity index 98% rename from exp_roles/test/lua/assignment.lua rename to exp_roles/test/module/assignment.lua index 9a99dd58..a774af5f 100644 --- a/exp_roles/test/lua/assignment.lua +++ b/exp_roles/test/module/assignment.lua @@ -1,5 +1,5 @@ --- Tests for the assignment methods of module/control.lua -local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see env.lua local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty --- alice is a moderator and bob a regular, with changes sent to the controller diff --git a/exp_roles/test/lua/env.lua b/exp_roles/test/module/env.lua similarity index 94% rename from exp_roles/test/lua/env.lua rename to exp_roles/test/module/env.lua index b1c7f908..5b612329 100644 --- a/exp_roles/test/lua/env.lua +++ b/exp_roles/test/module/env.lua @@ -8,10 +8,10 @@ returned here becomes `...` in each test file. ]] local shared_root = ... --- @type string -local source = assert(debug.getinfo(1, "S")).source -local plugin_root = assert(source:match("^@(.*)/test/lua/env%.lua$")) +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"))() +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 = { @@ -48,7 +48,7 @@ for _, record in ipairs(role_records()) do end return Framework.suite(function(env) - env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))() + 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 diff --git a/exp_roles/test/lua/holders.lua b/exp_roles/test/module/holders.lua similarity index 96% rename from exp_roles/test/lua/holders.lua rename to exp_roles/test/module/holders.lua index 8ee4c91f..6d5cdb16 100644 --- a/exp_roles/test/lua/holders.lua +++ b/exp_roles/test/module/holders.lua @@ -1,5 +1,5 @@ --- Tests for listing and printing to the players who hold a role -local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local test, check, eq = Suite.test, Suite.check, Suite.eq --- alice and dave are moderators with dave offline, zed has never joined diff --git a/exp_roles/test/lua/lookup.lua b/exp_roles/test/module/lookup.lua similarity index 97% rename from exp_roles/test/lua/lookup.lua rename to exp_roles/test/module/lookup.lua index 2479e6c8..e203fbc6 100644 --- a/exp_roles/test/lua/lookup.lua +++ b/exp_roles/test/module/lookup.lua @@ -1,5 +1,5 @@ --- Tests for the role lookup functions of module/control.lua -local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local test, check, eq = Suite.test, Suite.check, Suite.eq test("get_role() returns the role with the given clusterio id", function(env) diff --git a/exp_roles/test/lua/players.lua b/exp_roles/test/module/players.lua similarity index 97% rename from exp_roles/test/lua/players.lua rename to exp_roles/test/module/players.lua index a13dce6c..50513a30 100644 --- a/exp_roles/test/lua/players.lua +++ b/exp_roles/test/module/players.lua @@ -1,5 +1,5 @@ --- Tests for the player checks of module/control.lua -local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local test, check, eq = Suite.test, Suite.check, Suite.eq --- alice is a moderator, bob is a regular, and carol has only the default role diff --git a/exp_roles/test/lua/sync.lua b/exp_roles/test/module/sync.lua similarity index 98% rename from exp_roles/test/lua/sync.lua rename to exp_roles/test/module/sync.lua index 2a94875d..64daf581 100644 --- a/exp_roles/test/lua/sync.lua +++ b/exp_roles/test/module/sync.lua @@ -1,5 +1,5 @@ --- Tests for the sync entry points of module/control.lua -local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty --- alice is a moderator and carol has only the default role, both connected diff --git a/exp_roles/test/seed.test.js b/exp_roles/test/seed.test.js index 010dbd5d..adcb0987 100644 --- a/exp_roles/test/seed.test.js +++ b/exp_roles/test/seed.test.js @@ -6,35 +6,57 @@ 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", subtest => { +t.test("seedRoles[] grant only defined permissions", t2 => { for (const role of seedRoles) { - for (const permission of flattenSeedPermissions(role)) { - subtest.ok(lib.permissions.has(permission), `${role.name} grants defined permission ${permission}`); + for (const permission of role.permissions) { + t2.ok(lib.permissions.has(permission), `${role.name} grants defined permission ${permission}`); } } - subtest.end(); + t2.end(); }); -t.test("flattenSeedPermissions() carries parents into their children", subtest => { +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); - subtest.ok(parent, `${role.name} has parent ${role.parent}`); + t2.ok(parent, `${role.name} has parent ${role.parent}`); + const flattened = flattenSeedPermissions(role); for (const permission of flattenSeedPermissions(parent)) { - subtest.ok(flattened.has(permission), `${role.name} inherits ${permission}`); + t2.ok(flattened.has(permission), `${role.name} inherits ${permission}`); } } - subtest.end(); + t2.end(); }); -t.test("seedRoles are unique with one default and one admin", subtest => { - const names = seedRoles.map(role => role.name); - subtest.strictSame(names.length, new Set(names).size, "names are unique"); - subtest.strictSame(seedRoles.filter(role => role.isDefault).length, 1, "one default role"); - subtest.strictSame(seedRoles.filter(role => role.isAdmin).length, 1, "one admin role"); - subtest.end(); -}); +t.test("flattenSeedPermissions() does not inherit permissions when parent is undefined", t2 => { + for (const role of seedRoles) { + if (role.parent !== undefined) { + continue; + } + + const flattened = flattenSeedPermissions(role); + for (const permission of role.permissions) { + t2.ok(flattened.has(permission), `${role.name} contains ${permission}`); + } + + t2.equal( + flattened.size, + role.permissions.length, + `${role.name} has no inherited permissions`, + ); + } + t2.end(); +}); \ No newline at end of file diff --git a/exp_scenario/module/control/nuke_protection.lua b/exp_scenario/module/control/nuke_protection.lua index c8798388..ba8f4320 100644 --- a/exp_scenario/module/control/nuke_protection.lua +++ b/exp_scenario/module/control/nuke_protection.lua @@ -19,7 +19,7 @@ local function check_items(player, type, banned_items) local inventory = assert(player.get_inventory(type)) -- Check what items the player has for i = 1, #inventory do - local item = inventory[i] + local item = inventory[i --[[@as uint]]] if item.valid_for_read and banned_items[item.name] then player.print{ "exp_nuke-protection.chat-found", item.prototype.localised_name } items[#items + 1] = item diff --git a/exp_scenario/module/control/spawn_area.lua b/exp_scenario/module/control/spawn_area.lua index aab08d17..d4c23275 100644 --- a/exp_scenario/module/control/spawn_area.lua +++ b/exp_scenario/module/control/spawn_area.lua @@ -5,8 +5,8 @@ Adds a custom spawn area with chests and afk turrets local config = require("modules.exp_legacy.config.spawn_area") --- Apply an offset to a LuaPosition ---- @param position MapPosition ---- @param offset MapPosition +--- @param position MapPosition.struct +--- @param offset MapPosition.struct --- @return MapPosition.struct local function apply_offset(position, offset) return { @@ -17,7 +17,7 @@ end --- Apply offset to an array of positions --- @param positions table ---- @param offset MapPosition +--- @param offset MapPosition.struct --- @param x_index number --- @param y_index number local function apply_offset_to_array(positions, offset, x_index, y_index) @@ -97,14 +97,19 @@ local belt_details = { --- Makes a 2x2 afk belt at the locations in the config --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_belts(surface, offset) local belt_type = config.afk_belts.belt_type + local entity_pos = { x = 0, y = 0 } + local group_offset = { x = 0, y = 0 } for _, position in pairs(config.afk_belts.locations) do - position = apply_offset(position, offset) + group_offset.x = position[1] + offset.x + group_offset.y = position[2] + offset.y for _, belt in pairs(belt_details) do - local pos = apply_offset(position, belt) + entity_pos.x = belt[1] + entity_pos.y = belt[2] + local pos = apply_offset(entity_pos, group_offset) local entity = surface.create_entity{ name = belt_type, position = pos, force = "neutral", direction = belt[3] } if entity and config.afk_belts.protected then protect_entity(entity) @@ -115,13 +120,16 @@ end -- Generates extra tiles in a set pattern as defined in the config --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_pattern_tiles(surface, offset) local tiles_to_make = {} local pattern_tile = config.pattern.pattern_tile + local tile_pos = { x = 0, y = 0 } for index, position in pairs(config.pattern.locations) do - tiles_to_make[index] = { name = pattern_tile, position = apply_offset(position, offset) } + tile_pos.x = position[1] + tile_pos.y = position[2] + tiles_to_make[index] = { name = pattern_tile, position = apply_offset(tile_pos, offset) } end surface.set_tiles(tiles_to_make) @@ -129,13 +137,16 @@ end -- Generates extra water as defined in the config --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_water_tiles(surface, offset) local tiles_to_make = {} local water_tile = config.water.water_tile + local tile_pos = { x = 0, y = 0 } for _, position in pairs(config.water.locations) do - table.insert(tiles_to_make, { name = water_tile, position = apply_offset(position, offset) }) + tile_pos.x = position[1] + tile_pos.y = position[2] + table.insert(tiles_to_make, { name = water_tile, position = apply_offset(tile_pos, offset) }) end surface.set_tiles(tiles_to_make) @@ -143,11 +154,17 @@ end --- Generates the entities that are in the config --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_entities(surface, offset) + local entity_pos = { x = 0, y = 0 } for _, entity_details in pairs(config.entities.locations) do - local pos = apply_offset({ entity_details[2], entity_details[3] }, offset) - local entity = surface.create_entity{ name = entity_details[1], position = pos, force = "neutral" } + entity_pos.x = entity_details[2] + entity_pos.y = entity_details[3] + local entity = surface.create_entity{ + name = entity_details[1], + position = apply_offset(entity_pos, offset), + force = "neutral" + } if entity then if config.entities.protected then @@ -161,7 +178,7 @@ end --- Generates an area with no water or entities, no water area is larger --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function clear_spawn_area(surface, offset) local get_tile = surface.get_tile @@ -182,7 +199,7 @@ local function clear_spawn_area(surface, offset) for y = -fill_radius, fill_radius do -- loop over y local y_sqr = (y + 0.5) ^ 2 local dst = x_sqr + y_sqr - local pos = apply_offset({ x, y }, offset) + local pos = apply_offset({ x = x, y = y }, offset) if dst < tile_radius_sqr then -- If it is inside the decon radius always set the tile tiles_to_make[#tiles_to_make + 1] = { name = decon_tile, position = pos } @@ -204,14 +221,14 @@ end --- Spawn the resource tiles --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_resources_tiles(surface, offset) for _, resource in ipairs(config.resource_tiles.resources) do if resource.enabled then local pos = apply_offset(resource.offset, offset) for x = pos.x, pos.x + resource.size[1] do for y = pos.y, pos.y + resource.size[2] do - surface.create_entity{ name = resource.name, amount = resource.amount, position = { x, y } } + surface.create_entity{ name = resource.name, amount = resource.amount, position = { x = x, y = y } } end end end @@ -220,7 +237,7 @@ end --- Spawn the resource entities --- @param surface LuaSurface ---- @param offset MapPosition +--- @param offset MapPosition.struct local function create_resource_patches(surface, offset) for _, resource in ipairs(config.resource_patches.resources) do if resource.enabled then diff --git a/exp_scenario/module/gui/module_inserter.lua b/exp_scenario/module/gui/module_inserter.lua index ed320137..f930bd34 100644 --- a/exp_scenario/module/gui/module_inserter.lua +++ b/exp_scenario/module/gui/module_inserter.lua @@ -459,7 +459,7 @@ local function on_entity_settings_pasted(event) -- Get the modules and add them to the planner local all_modules = {} for i = 1, #module_inventory do - local slot = module_inventory[i] + local slot = module_inventory[i --[[@as uint]]] if slot.valid_for_read and slot.count > 0 then all_modules[i] = { name = slot.name, quality = slot.quality.name } else diff --git a/exp_scenario/module/gui/research_milestones.lua b/exp_scenario/module/gui/research_milestones.lua index 390867c8..a7e06846 100644 --- a/exp_scenario/module/gui/research_milestones.lua +++ b/exp_scenario/module/gui/research_milestones.lua @@ -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 diff --git a/test/common.js b/test/common.js index eb75a3f2..bf63b030 100644 --- a/test/common.js +++ b/test/common.js @@ -1,5 +1,4 @@ "use strict"; -const assert = require("assert").strict; const { compile } = require("@clusterio/lib"); /** @@ -15,22 +14,37 @@ function testMatrix(...arrays) { } /** - * Test that a class is round trip json serialisable across multiple test cases. + * 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 tests inputs to pass to the class constructor. + * @param {T[]} tests - The test inputs to pass to the class constructor. */ -function testRoundTripJsonSerialisable(Class, tests) { +function testRoundTripJsonSerialisable(t, Class, tests) { const validate = compile(Class.jsonSchema); + for (const test of tests) { - const original = new Class(...test); - const serialised = JSON.stringify(original); - const jsonObject = JSON.parse(serialised); - const reconstructed = Class.fromJSON(jsonObject); - assert.deepEqual(reconstructed, original, JSON.stringify(test)); - if (!validate(jsonObject)) { - throw validate.errors; + 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 }); } } } diff --git a/test/lua/framework.lua b/test/lua/framework.lua index eadca54c..9fc97207 100644 --- a/test/lua/framework.lua +++ b/test/lua/framework.lua @@ -10,6 +10,7 @@ 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 diff --git a/test/lua/stubs.lua b/test/lua/stubs.lua index e587728e..3cc1b3a8 100644 --- a/test/lua/stubs.lua +++ b/test/lua/stubs.lua @@ -50,6 +50,7 @@ end --- Create an independent set of stubs, installed as the lua globals function Stubs.new() + --- @class Stubs local stubs = { events = {}, -- events raised through script.raise_event printed = {}, -- game.print and player.print messages From 08fe6b688e04f17928b68a2c062276afee875956 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:09:40 +0000 Subject: [PATCH 16/16] Type the test environments for autocomplete Building on the class annotations from the smoke test changes: - The suite is generic over its environment: Framework.suite is @generic T : Stubs with the extension returning T, and Suite passes T to every test function. A test file declares `local Suite = ... --- @type Suite` and env autocompletes from there. - The stubs declare their recorded fields, add_player returns Stubs.Player, and the server is one. - ExpRoles.TestEnv : Stubs declares the extension fields, so env.R returns ExpRoles.Role and env.Roles is the module. The typing immediately paid for itself by flagging real drift in the tests, now fixed: _script_data is package private so the env exposes script_data() instead, the join event carries its name and tick, the assignment record type admits is_deleted, admin_state is a file local rather than an undeclared field, and the event assertions which indexed [1] without a nil check compare the whole event list. Co-Authored-By: Claude Fable 5 --- exp_roles/test/module/assignment.lua | 45 ++++++++++++++++++++------ exp_roles/test/module/env.lua | 16 ++++++++++ exp_roles/test/module/holders.lua | 4 +-- exp_roles/test/module/lookup.lua | 2 +- exp_roles/test/module/players.lua | 2 +- exp_roles/test/module/sync.lua | 48 ++++++++++++++++++++-------- test/lua/framework.lua | 12 ++++--- test/lua/stubs.lua | 17 +++++++--- 8 files changed, 109 insertions(+), 37 deletions(-) diff --git a/exp_roles/test/module/assignment.lua b/exp_roles/test/module/assignment.lua index a774af5f..69cfb7b9 100644 --- a/exp_roles/test/module/assignment.lua +++ b/exp_roles/test/module/assignment.lua @@ -1,5 +1,5 @@ --- Tests for the assignment methods of module/control.lua -local Suite = ... --- @type Suite The suite this file adds its tests to, see env.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see env.lua local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty --- alice is a moderator and bob a regular, with changes sent to the controller @@ -40,7 +40,7 @@ test(".assign() applies locally and is sent to the controller", function(env) }, "the change is announced") eq(env.sounds, { "bob:utility/achievement_unlocked" }, "the assign sound plays") - local sd = env.Roles._script_data() + 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") @@ -54,7 +54,16 @@ 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[1].by_player_index, players.alice.index, "the acting player is game.player") + 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) @@ -65,7 +74,7 @@ test("receive_assignment_updates() releases the local hold once confirmed", func empty(env.events, "a confirmation raises no events") empty(env.printed, "a confirmation announces nothing") - local sd = env.Roles._script_data() + 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) @@ -113,7 +122,7 @@ test("reject_assignment() rolls the assignment back", function(env) }, }, "the rollback raises the event") - local sd = env.Roles._script_data() + local sd = env.script_data() check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") end) @@ -124,7 +133,7 @@ test(".assign() with local_only never reaches the controller", function(env) empty(env.sent, "the assignment is not sent") check(regular:has_player(players.alice), "the role applies") - local sd = env.Roles._script_data() + 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") @@ -146,15 +155,31 @@ test(".assign() of a higher priority role suppresses the rest", function(env) 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[1].assigned, { jail.id }, "the event lists the jail role") - empty(env.events[1].unassigned, "the suppressed roles are not listed as unassigned") + eq(env.events, { + { + name = env.Roles.events.on_player_roles_changed, + tick = 1, + player_index = players.alice.index, + by_player_index = 0, + assigned = { jail.id }, + unassigned = {}, + }, + }, "the event lists only the jail role") env.reset_log() jail:unassign(players.alice, { by_player_name = "", silent = true }) eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, "unjail restores the roles") - empty(env.events[1].assigned, "the restored roles are not listed as assigned") - eq(env.events[1].unassigned, { jail.id }, "the unjail event lists the jail role") + 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) diff --git a/exp_roles/test/module/env.lua b/exp_roles/test/module/env.lua index 5b612329..d4e3953a 100644 --- a/exp_roles/test/module/env.lua +++ b/exp_roles/test/module/env.lua @@ -47,7 +47,17 @@ 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() @@ -70,6 +80,12 @@ return Framework.suite(function(env) 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{ diff --git a/exp_roles/test/module/holders.lua b/exp_roles/test/module/holders.lua index 6d5cdb16..a57c8cac 100644 --- a/exp_roles/test/module/holders.lua +++ b/exp_roles/test/module/holders.lua @@ -1,5 +1,5 @@ --- Tests for listing and printing to the players who hold a role -local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local test, check, eq = Suite.test, Suite.check, Suite.eq --- alice and dave are moderators with dave offline, zed has never joined @@ -30,7 +30,7 @@ test(".get_player_names() counts a player in both lists once", function(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.Roles._script_data() + 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) diff --git a/exp_roles/test/module/lookup.lua b/exp_roles/test/module/lookup.lua index e203fbc6..0efcf1b8 100644 --- a/exp_roles/test/module/lookup.lua +++ b/exp_roles/test/module/lookup.lua @@ -1,5 +1,5 @@ --- Tests for the role lookup functions of module/control.lua -local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local test, check, eq = Suite.test, Suite.check, Suite.eq test("get_role() returns the role with the given clusterio id", function(env) diff --git a/exp_roles/test/module/players.lua b/exp_roles/test/module/players.lua index 50513a30..c7e00257 100644 --- a/exp_roles/test/module/players.lua +++ b/exp_roles/test/module/players.lua @@ -1,5 +1,5 @@ --- Tests for the player checks of module/control.lua -local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local test, check, eq = Suite.test, Suite.check, Suite.eq --- alice is a moderator, bob is a regular, and carol has only the default role diff --git a/exp_roles/test/module/sync.lua b/exp_roles/test/module/sync.lua index 64daf581..dc04fcbd 100644 --- a/exp_roles/test/module/sync.lua +++ b/exp_roles/test/module/sync.lua @@ -1,16 +1,19 @@ --- Tests for the sync entry points of module/control.lua -local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua +local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty +--- The state given by the admin trigger, reset by setup for every test +local admin_state = {} --- @type table + --- alice is a moderator and carol has only the default role, both connected local function setup(env) local players = { alice = env.add_player("alice"), carol = env.add_player("carol"), } - env.admin_state = {} + admin_state = {} env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) - env.admin_state[player.name] = state + admin_state[player.name] = state end) env.initialise{ env.assignment("alice", { "Moderator" }), @@ -22,10 +25,12 @@ end test("initialise() applies triggers and raises empty events", function(env) setup(env) - eq(env.admin_state, { alice = true, carol = false }, "triggers run for connected players") + eq(admin_state, { alice = true, carol = false }, "triggers run for connected players") eq(#env.events, 2, "the event is raised once per connected player") - empty(env.events[1].assigned, "the events assign nothing") - empty(env.events[1].unassigned, "the events unassign nothing") + 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") @@ -44,7 +49,7 @@ test("initialise() is authoritative for pending roles", function(env) env.R("Moderator"):assign(players.carol) env.R("Jail"):assign(players.carol, { silent = true, local_only = true }) - local sd = env.Roles._script_data() + local sd = env.script_data() eq(sd.pending.carol, { env.R("Moderator").id }, "the role is pending before initialise") env.reset_log() @@ -73,13 +78,22 @@ test("receive_assignment_updates() applies controller changes", function(env) eq(env.printed, { { "exp-roles.game-message-assign", "carol", "Moderator", "" }, }, "the change is announced by the server") - check(env.admin_state.carol == true, "triggers follow the assignment") + 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[1].unassigned, { env.R("Moderator").id }, "the removal raises the event") - check(env.admin_state.carol == false, "triggers follow the removal") + 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) @@ -99,7 +113,9 @@ test("receive_role_updates() applies to the holders", function(env) } 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") - empty(env.events[1].assigned, "role change events are empty") + 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 }, @@ -146,9 +162,13 @@ end) test("on_player_joined_game() runs the triggers", function(env) local players = setup(env) - env.admin_state.alice = nil - env.Roles.on_player_joined_game{ player_index = players.alice.index } - check(env.admin_state.alice ~= nil, "triggers run when a player joins") + admin_state.alice = nil + env.Roles.on_player_joined_game{ + name = defines.events.on_player_joined_game, + tick = game.tick, + player_index = players.alice.index, + } + check(admin_state.alice ~= nil, "triggers run when a player joins") end) return Suite.run() diff --git a/test/lua/framework.lua b/test/lua/framework.lua index 9fc97207..c9918cd0 100644 --- a/test/lua/framework.lua +++ b/test/lua/framework.lua @@ -26,19 +26,21 @@ local function repr(value, depth) end --- Create a suite of tests, one is made per test file ---- @param extend_env fun(env: table): table Extends the stubs given to each test +--- @generic T : Stubs +--- @param extend_env fun(env: Stubs): T Extends the stubs given to each test +--- @return Suite function Framework.suite(extend_env) - --- @class Suite + --- @class Suite local Suite = { - tests = {}, -- { name, fn } in declaration order - results = {}, -- { name, ok, detail? } accumulated across tests + 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: table) + --- @param fn fun(env: T) function Suite.test(name, fn) Suite.tests[#Suite.tests + 1] = { name = name, fn = fn } end diff --git a/test/lua/stubs.lua b/test/lua/stubs.lua index 3cc1b3a8..b18d13ef 100644 --- a/test/lua/stubs.lua +++ b/test/lua/stubs.lua @@ -51,11 +51,15 @@ 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 = {}, -- events raised through script.raise_event - printed = {}, -- game.print and player.print messages - sent = {}, -- payloads sent through the clusterio api - sounds = {}, -- sounds played to players + events = {}, + printed = {}, + sent = {}, + sounds = {}, } local next_event_id = 100 @@ -97,8 +101,12 @@ function Stubs.new() }, { "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, @@ -118,6 +126,7 @@ function Stubs.new() end --- A player object which represents the server + --- @type Stubs.Player stubs.server = Stubs.strict("LuaPlayer ", { index = 0, name = "" }) --- Modules resolved by the stubbed require, add to them with extend_requires