From 88cd64e422f3e19b81d42a5784ca261fd82f2480 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 19 Oct 2024 05:20:28 +0100 Subject: [PATCH] Update Command Lib --- .luarc.json | 8 +- ...ission_authorities.lua => authorities.lua} | 24 +- exp_commands/module/commands/data_types.lua | 179 ------- exp_commands/module/commands/help.lua | 36 +- exp_commands/module/commands/ipc.lua | 10 +- exp_commands/module/commands/rcon.lua | 17 +- exp_commands/module/commands/sudo.lua | 13 +- exp_commands/module/commands/types.lua | 206 ++++++++ exp_commands/module/locale/en.cfg | 27 +- exp_commands/module/module.json | 7 +- exp_commands/module/module_exports.lua | 474 +++++++++--------- exp_commands/module/search.lua | 112 +++++ exp_commands/webpack.config.js | 32 -- exp_util/module/common.lua | 70 ++- exp_util/module/flying_text.lua | 4 +- exp_util/module/storage.lua | 4 + 16 files changed, 710 insertions(+), 513 deletions(-) rename exp_commands/module/commands/{permission_authorities.lua => authorities.lua} (81%) delete mode 100644 exp_commands/module/commands/data_types.lua create mode 100644 exp_commands/module/commands/types.lua create mode 100644 exp_commands/module/search.lua delete mode 100644 exp_commands/webpack.config.js diff --git a/.luarc.json b/.luarc.json index 5771c303..2e32d2b1 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,12 +1,12 @@ { "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", "completion.requireSeparator": "/", - "doc.privateName": [ "__(\\w+)" ], - "doc.protectedName": [ "_(\\w+)" ], + "doc.privateName": [ "__([_%w]+)" ], + "doc.packageName": [ "_([_%w]+)" ], "runtime.pluginArgs": [ "--clusterio-modules" ], - "diagnostics.unusedLocalExclude": [ "_", "i", "j", "k", "v" ], + "diagnostics.unusedLocalExclude": [ "_([_%w]*)", "i", "j", "k", "v" ], "diagnostics.groupFileStatus": { "ambiguity": "Any", "await": "None", @@ -32,7 +32,7 @@ "nameStyle.config": { "local_name_style": [{ "type" : "pattern", - "param": "_?_?(\\w+)?", + "param": "_?_?(%w+)?", "$1": "snake_case" }], "module_local_name_style": [{ diff --git a/exp_commands/module/commands/permission_authorities.lua b/exp_commands/module/commands/authorities.lua similarity index 81% rename from exp_commands/module/commands/permission_authorities.lua rename to exp_commands/module/commands/authorities.lua index 55088d6e..1403507b 100644 --- a/exp_commands/module/commands/permission_authorities.lua +++ b/exp_commands/module/commands/authorities.lua @@ -8,15 +8,15 @@ The default permission authorities controlled by the flags: admin_only, system_o /c require("modules/exp-commands").disable("my-command") ]] -local Global = require("modules/exp_util/global") +local Storage = require("modules/exp_util/storage") local Commands = require("modules/exp_commands") local add, allow, deny = Commands.add_permission_authority, Commands.status.success, Commands.status.unauthorised -local permission_authorities = {} +local authorities = {} local system_players = {} local disabled_commands = {} -Global.register({ +Storage.register({ system_players, disabled_commands, }, function(tbl) @@ -25,13 +25,13 @@ Global.register({ end) --- Allow a player access to system commands, use for debug purposes only --- @tparam[opt] string player_name The name of the player to give access to, default is the current player +--- @param player_name string? The name of the player to give access to, default is the current player function Commands.unlock_system_commands(player_name) system_players[player_name or game.player.name] = true end --- Remove access from system commands for a player, use for debug purposes only --- @tparam[opt] string player_name The name of the player to give access to, default is the current player +--- @param player_name string? The name of the player to give access to, default is the current player function Commands.lock_system_commands(player_name) system_players[player_name or game.player.name] = nil end @@ -42,13 +42,13 @@ function Commands.get_system_command_players() end --- Stops a command from be used by any one --- @tparam string command_name The name of the command to disable +--- @param command_name string The name of the command to disable function Commands.disable(command_name) disabled_commands[command_name] = true end --- Allows a command to be used again after disable was used --- @tparam string command_name The name of the command to enable +--- @param command_name string The name of the command to enable function Commands.enable(command_name) disabled_commands[command_name] = nil end @@ -59,7 +59,7 @@ function Commands.get_disabled_commands() end --- If a command has the flag "admin_only" then only admins can use the command# -permission_authorities.admin_only = +authorities.admin_only = add(function(player, command) if command.flags.admin_only and not player.admin then return deny{ "exp-commands-permissions.admin-only" } @@ -69,7 +69,7 @@ permission_authorities.admin_only = end) --- If a command has the flag "system_only" then only rcon connections can use the command -permission_authorities.system_only = +authorities.system_only = add(function(player, command) if command.flags.system_only and not system_players[player.name] then return deny{ "exp-commands-permissions.system-only" } @@ -79,8 +79,8 @@ permission_authorities.system_only = end) --- If Commands.disable was called then no one can use the command -permission_authorities.disabled = - add(function(_, command) +authorities.disabled = + add(function(_player, command) if disabled_commands[command.name] then return deny{ "exp-commands-permissions.disabled" } else @@ -88,4 +88,4 @@ permission_authorities.disabled = end end) -return permission_authorities +return authorities diff --git a/exp_commands/module/commands/data_types.lua b/exp_commands/module/commands/data_types.lua deleted file mode 100644 index 5e29fc68..00000000 --- a/exp_commands/module/commands/data_types.lua +++ /dev/null @@ -1,179 +0,0 @@ ---[[-- Command Module - Default data types -The default data types that are available to all commands - -@usage Adds parsers for: - boolean - string-options - options: array of strings - string-key - map: table of string keys and any values - string-max-length - maximum: number - number - integer - number-range - minimum: number, maximum: number - integer-range - minimum: number, maximum: number - player - player-online - player-alive - force - surface - color -]] - -local ExpUtil = require("modules/exp_util") -local Commands = require("modules/exp_commands") -local add, parse = Commands.add_data_type, Commands.parse_data_type -local valid, invalid = Commands.status.success, Commands.status.invalid_input - ---- A boolean value where true is one of: yes, y, true, 1 -add("boolean", function(input) - input = input:lower() - if input == "yes" - or input == "y" - or input == "true" - or input == "1" then - return valid(true) - else - return valid(false) - end -end) - ---- A string, validation does nothing but it is a requirement -add("string", function(input) - return valid(input) -end) - ---- A string from a set of options, takes one argument which is an array of options -add("string-options", function(input, _, options) - local option = ExpUtil.auto_complete(options, input) - if option == nil then - return invalid{ "exp-commands-parse.string-options", table.concat(options, ", ") } - else - return valid(option) - end -end) - ---- A string which is the key of a table, takes one argument which is an map of string keys to values -add("string-key", function(input, _, map) - local option = ExpUtil.auto_complete(map, input, true) - if option == nil then - return invalid{ "exp-commands-parse.string-options", table.concat(table.get_keys(map), ", ") } - else - return valid(option) - end -end) - ---- A string with a maximum length, takes one argument which is the maximum length of a string -add("string-max-length", function(input, _, maximum) - if input:len() > maximum then - return invalid{ "exp-commands-parse.string-max-length", maximum } - else - return valid(input) - end -end) - ---- A number -add("number", function(input) - local number = tonumber(input) - if number == nil then - return invalid{ "exp-commands-parse.number" } - else - return valid(number) - end -end) - ---- An integer, number which has been floored -add("integer", function(input) - local number = tonumber(input) - if number == nil then - return invalid{ "exp-commands-parse.number" } - else - return valid(math.floor(number)) - end -end) - ---- A number in a given inclusive range -add("number-range", function(input, _, minimum, maximum) - local success, status, number = parse("number", input) - if not success then - return status, number - elseif number < minimum or number > maximum then - return invalid{ "exp-commands-parse.number-range", minimum, maximum } - else - return valid(number) - end -end) - ---- An integer in a given inclusive range -add("integer-range", function(input, _, minimum, maximum) - local success, status, number = parse("integer", input) - if not success then - return status, number - elseif number < minimum or number > maximum then - return invalid{ "exp-commands-parse.number-range", minimum, maximum } - else - return valid(number) - end -end) - ---- A player who has joined the game at least once -add("player", function(input) - local player = game.get_player(input) - if player == nil then - return invalid{ "exp-commands-parse.player", input } - else - return valid(player) - end -end) - ---- A player who is online -add("player-online", function(input) - local success, status, player = parse("player", input) - if not success then - return status, player - elseif player.connected == false then - return invalid{ "exp-commands-parse.player-online" } - else - return valid(player) - end -end) - ---- A player who is online and alive -add("player-alive", function(input) - local success, status, player = parse("player-online", input) - if not success then - return status, player - elseif player.character == nil or player.character.health <= 0 then - return invalid{ "exp-commands-parse.player-alive" } - else - return valid(player) - end -end) - ---- A force within the game -add("force", function(input) - local force = game.forces[input] - if force == nil then - return invalid{ "exp-commands-parse.force" } - else - return valid(force) - end -end) - ---- A surface within the game -add("surface", function(input) - local surface = game.surfaces[input] - if surface == nil then - return invalid{ "exp-commands-parse.surface" } - else - return valid(surface) - end -end) - ---- A name of a color from the predefined list, too many colours to use string-key -add("color", function(input) - local color = ExpUtil.auto_complete(Commands.color, input, true) - if color == nil then - return invalid{ "exp-commands-parse.color" } - else - return valid(color) - end -end) diff --git a/exp_commands/module/commands/help.lua b/exp_commands/module/commands/help.lua index c2e55a42..62cb1f57 100644 --- a/exp_commands/module/commands/help.lua +++ b/exp_commands/module/commands/help.lua @@ -2,23 +2,26 @@ Game command to list and search all registered commands in a nice format @commands _system-ipc -@usage-- Get all messages related to banning a player +--- Get all messages related to banning a player /commands ban -- Get the second page of results /commands ban 2 ]] -local Global = require("modules/exp_util/global") +local Storage = require("modules/exp_util/storage") local Commands = require("modules/exp_commands") local PAGE_SIZE = 5 local search_cache = {} -Global.register(search_cache, function(tbl) +Storage.register(search_cache, function(tbl) search_cache = tbl end) --- Format commands into a strings across multiple pages +--- @param commands { [string]: Commands.Command } The commands to split into pages +--- @param page_size number The number of requests to show per page +--- @return LocalisedString[][], number local function format_as_pages(commands, page_size) local pages = { {} } local page_length = 0 @@ -34,22 +37,37 @@ local function format_as_pages(commands, page_size) page_length = 1 end + local description + if command.defined_at then + --- @cast command Commands.ExpCommand + description = { "", command.help_text[2], "- ", command.description } + else + description = command.description + end + local aliases = #command.aliases > 0 and { "exp-commands-help.aliases", table.concat(command.aliases, ", ") } or "" - pages[current_page][page_length] = { "exp-commands-help.format", command.name, command.description, command.help, aliases } + pages[current_page][page_length] = { "exp-commands-help.format", command.name, description, aliases } end return pages, total end -Commands.new("commands", "List and search all commands for a keyword") +Commands.new("commands", { "exp-commands-help.description" }) :add_aliases{ "chelp", "helpp" } - :argument("keyword", "string") - :optional("page", "integer") - :defaults{ page = 1 } + :optional("keyword", { "exp-commands-help.arg-keyword" }, Commands.types.string) + :optional("page", { "exp-commands-help.arg-page" }, Commands.types.integer) + :defaults{ keyword = "", page = 1 } :register(function(player, keyword, page) + -- Allow listing of all commands + local as_number = tonumber(keyword) + local cache = search_cache[player.index] + if as_number and page == 1 then + keyword = cache and cache.keyword or "" + page = as_number + end + keyword = keyword:lower() local pages, found - local cache = search_cache[player.index] if cache and cache.keyword == keyword then -- Cached value found, no search is needed pages = cache.pages diff --git a/exp_commands/module/commands/ipc.lua b/exp_commands/module/commands/ipc.lua index e7ed2f49..8179683a 100644 --- a/exp_commands/module/commands/ipc.lua +++ b/exp_commands/module/commands/ipc.lua @@ -2,7 +2,7 @@ System command which sends an object to the clustorio api, should be used for debugging / echo commands @commands _system-ipc -@usage-- Send a message on your custom channel, message is a json string +--- Send a message on your custom channel, message is a json string /_ipc myChannel { "myProperty": "foo", "playerName": "Cooldude2606" } ]] @@ -11,12 +11,12 @@ local Clustorio = require("modules/clusterio/api") local json_to_table = helpers.json_to_table -Commands.new("_ipc", "Send an IPC message on the selected channel") +Commands.new("_ipc", { "exp-commands-ipc.description" }) :add_flags{ "system_only" } :enable_auto_concatenation() - :argument("channel", "string") - :argument("message", "string") - :register(function(_, channel, message) + :argument("channel", { "exp-commands-ipc.arg-channel" }, Commands.types.string) + :argument("message", { "exp-commands-ipc.arg-message" }, Commands.types.string) + :register(function(_player, channel, message) local tbl = json_to_table(message) if tbl == nil then return Commands.status.invalid_input("Invalid json string") diff --git a/exp_commands/module/commands/rcon.lua b/exp_commands/module/commands/rcon.lua index 555bdd4e..fdf73808 100644 --- a/exp_commands/module/commands/rcon.lua +++ b/exp_commands/module/commands/rcon.lua @@ -2,16 +2,16 @@ System command which runs arbitrary code within a custom (not sandboxed) environment @commands _system-rcon -@usage-- Get the names of all online players, using rcon +--- Get the names of all online players, using rcon /_system-rcon local names = {}; for index, player in pairs(game.connected_player) do names[index] = player.name end; return names; -@usage-- Get the names of all online players, using clustorio ipcs +--- Get the names of all online players, using clustorio ipcs /_system-rcon local names = {}; for index, player in pairs(game.connected_player) do names[index] = player.name end; ipc("online-players", names); ]] local ExpUtil = require("modules/exp_util") local Async = require("modules/exp_util/async") -local Global = require("modules/exp_util/global") +local Storage = require("modules/exp_util/storage") local Commands = require("modules/exp_commands") local Clustorio = require("modules/clusterio/api") @@ -27,7 +27,7 @@ rcon_statics.Async = Async rcon_statics.ExpUtil = ExpUtil rcon_statics.Commands = Commands rcon_statics.Clustorio = Clustorio -rcon_statics.output = Commands.print +rcon_statics.print = Commands.print rcon_statics.ipc = Clustorio.send_json --- @diagnostic enable: name-style-check @@ -45,7 +45,7 @@ function rcon_callbacks.entity(player) return player and player.selected end function rcon_callbacks.tile(player) return player and player.surface.get_tile(player.position) end --- The rcon env is saved between command runs to prevent desyncs -Global.register(rcon_env, function(tbl) +Storage.register(rcon_env, function(tbl) rcon_env = setmetatable(tbl, { __index = rcon_statics }) end) @@ -61,10 +61,10 @@ function Commands.add_rcon_callback(name, callback) rcon_callbacks[name] = callback end -Commands.new("_rcon", "Execute arbitrary code within a custom environment") +Commands.new("_rcon", { "exp-commands-rcon.description" }) :add_flags{ "system_only" } :enable_auto_concatenation() - :argument("invocation", "string") + :argument("invocation", { "exp-commands-rcon.arg-invocation" }, Commands.types.string) :register(function(player, invocation_string) -- Construct the environment the command will run within local env = setmetatable({}, { __index = rcon_env, __newindex = rcon_env }) @@ -80,8 +80,7 @@ Commands.new("_rcon", "Execute arbitrary code within a custom environment") else local success, rtn = xpcall(invocation, debug.traceback) if success == false then - local err = rtn:gsub("%.%.%..-/temp/currently%-playing/", "") - return Commands.status.error(err) + return Commands.status.error(rtn) else return Commands.status.success(rtn) end diff --git a/exp_commands/module/commands/sudo.lua b/exp_commands/module/commands/sudo.lua index 917e7f6a..7d2ac064 100644 --- a/exp_commands/module/commands/sudo.lua +++ b/exp_commands/module/commands/sudo.lua @@ -2,20 +2,21 @@ System command to execute a command as another player using their permissions (except for permissions group actions) @commands _system-sudo -@usage-- Run the example command as another player +--- Run the example command as another player -- As Cooldude2606: /repeat 5 /_system-sudo Cooldude2606 repeat 5 ]] local Commands = require("modules/exp_commands") -Commands.new("_sudo", "Run a command as another player") +Commands.new("_sudo", { "exp-commands-sudo.description" }) :add_flags{ "system_only" } :enable_auto_concatenation() - :argument("player", "player") - :argument("command", "string-key", Commands.registered_commands) - :argument("arguments", "string") - :register(function(_, player, command, parameter) + :argument("player", { "exp-commands-sudo.arg-player" }, Commands.types.player) + :argument("command", { "exp-commands-sudo.arg-command" }, Commands.types.string_key(Commands.registered_commands)) + :argument("arguments", { "exp-commands-sudo.arg-arguments" }, Commands.types.string) + :register(function(_player, player, command, parameter) + --- @diagnostic disable-next-line: invisible return Commands._event_handler{ name = command.name, tick = game.tick, diff --git a/exp_commands/module/commands/types.lua b/exp_commands/module/commands/types.lua new file mode 100644 index 00000000..91567074 --- /dev/null +++ b/exp_commands/module/commands/types.lua @@ -0,0 +1,206 @@ +--[[-- Command Module - Default data types +The default data types that are available to all commands + +Adds parsers for: + boolean + string_options - options: array of strings + string_key - map: table of string keys and any values + string_max_length - maximum: number + number + integer + number_range - minimum: number, maximum: number + integer_range - minimum: number, maximum: number + player + player_online + player_alive + force + surface + color +]] + +local ExpUtil = require("modules/exp_util") +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 + +--- A boolean value where true is one of: yes, y, true, 1 +add("boolean", function(input) + input = input:lower() + if input == "yes" + or input == "y" + or input == "true" + or input == "1" then + return valid(true) + else + return valid(false) + end +end) + +--- A string, validation does nothing but it is a requirement +--- @type Commands.InputParser +add("string", function(input) + return valid(input) +end) + +--- A string from a set of options, takes one argument which is an array of options +--- @param options string[] The options which can be selected +--- @return Commands.InputParser +add("string_array", function(options) + return function(input) + local option = ExpUtil.auto_complete(options, input) + if option == nil then + return invalid{ "exp-commands-parse.string-options", table.concat(options, ", ") } + else + return valid(option) + end + end +end) + +--- A string which is the key of a table, takes one argument which is an map of string keys to values +--- @param map { [string]: any } The options which can be selected +--- @return Commands.InputParser +add("string_key", function(map) + return function(input) + local option = ExpUtil.auto_complete(map, input, true) + if option == nil then + return invalid{ "exp-commands-parse.string-options", table.concat(table.get_keys(map), ", ") } + else + return valid(option) + end + end +end) + +--- A string with a maximum length, takes one argument which is the maximum length of a string +--- @param maximum number The maximum length of the input +--- @return Commands.InputParser +add("string_max_length", function(maximum) + return function(input) + if input:len() > maximum then + return invalid{ "exp-commands-parse.string-max-length", maximum } + else + return valid(input) + end + end +end) + +--- A number +add("number", function(input) + local number = tonumber(input) + if number == nil then + return invalid{ "exp-commands-parse.number" } + else + return valid(number) + end +end) + +--- An integer, number which has been floored +add("integer", function(input) + local number = tonumber(input) + if number == nil then + return invalid{ "exp-commands-parse.number" } + else + return valid(math.floor(number)) + end +end) + +--- A number in a given inclusive range +--- @param minimum number The minimum of the allowed range, inclusive +--- @param maximum number The maximum of the allowed range, inclusive +--- @return Commands.InputParser +add("number_range", function(minimum, maximum) + local parser_number = Commands.types.number + return function(input, player) + local success, status, result = parse(input, player, parser_number) + if not success then + return status, result + elseif result < minimum or result > maximum then + return invalid{ "exp-commands-parse.number-range", minimum, maximum } + else + return valid(result) + end + end +end) + +--- An integer in a given inclusive range +--- @param minimum number The minimum of the allowed range, inclusive +--- @param maximum number The maximum of the allowed range, inclusive +--- @return Commands.InputParser +add("integer_range", function(minimum, maximum) + local parser_integer = Commands.types.integer + return function(input, player) + local success, status, result = parse(input, player, parser_integer) + if not success then + return status, result + elseif result < minimum or result > maximum then + return invalid{ "exp-commands-parse.number-range", minimum, maximum } + else + return valid(result) + end + end +end) + +--- A player who has joined the game at least once +add("player", function(input) + local player = game.get_player(input) + if player == nil then + return invalid{ "exp-commands-parse.player", input } + else + return valid(player) + end +end) + +--- A player who is online +add("player_online", function(input, player) + local success, status, result = parse(input, player, Commands.types.player) + --- @cast result LuaPlayer + if not success then + return status, result + elseif result.connected == false then + return invalid{ "exp-commands-parse.player-online" } + else + return valid(result) + end +end) + +--- A player who is online and alive +add("player_alive", function(input, player) + local success, status, result = parse(input, player, Commands.types.player_online) + --- @cast result LuaPlayer + if not success then + return status, result + elseif result.character == nil or result.character.health <= 0 then + return invalid{ "exp-commands-parse.player-alive" } + else + return valid(result) + end +end) + +--- A force within the game +add("force", function(input) + local force = game.forces[input] + if force == nil then + return invalid{ "exp-commands-parse.force" } + else + return valid(force) + end +end) + +--- A surface within the game +add("surface", function(input) + local surface = game.surfaces[input] + if surface == nil then + return invalid{ "exp-commands-parse.surface" } + else + return valid(surface) + end +end) + +--- A name of a color from the predefined list, too many colours to use string-key +add("color", function(input) + local color = ExpUtil.auto_complete(Commands.color, input, true) + if color == nil then + return invalid{ "exp-commands-parse.color" } + else + return valid(color) + end +end) diff --git a/exp_commands/module/locale/en.cfg b/exp_commands/module/locale/en.cfg index d7f81f9b..13e9c736 100644 --- a/exp_commands/module/locale/en.cfg +++ b/exp_commands/module/locale/en.cfg @@ -1,7 +1,12 @@ color-tag=[color=__1__]__2__[/color] [exp-commands] -command-help=__1__ - __2__ +help=__1__- __2____3__ +aliases=\n Aliaies: __1__ +argument=<__1__> +optional=[__1__] +argument-verbose=\n <__1__> - __2__ +optional-verbose=\n [__1__] - __2__ success=Command Complete. error=Command failed to run: __1__ error-default=Please check you gave the correct arguments. @@ -30,9 +35,27 @@ system-only=This command can not be ran by players. disabled=This command is currently disabled. [exp-commands-help] +description=List and search all commands for a keyword +arg-keyword=The keyword to search for +arg-page=The results page to display header=Help results for "__1__": footer=[__1__ results found: page __2__ of __3__] -format=/__1__ __2__ - __3__ __4__ +format=/__1__ __2__ __3__ aliases=Aliaies: __1__ out-of-range=__1__ is an invalid page number. Last page: __2__ no-results=No commands were found + +[exp-commands-ipc] +description=Send an IPC message on the selected channel +arg-channel=The channel to send the IPC message on +arg-message=The message to send on the IPC channel + +[exp-commands-rcon] +description=Execute arbitrary code within a custom environment +arg-invocation=The code to run + +[exp-commands-sudo] +description=Run a command as another player +arg-player=The player to run the command as +arg-command=The command to run +arg-arguments=The arguments to pass to the command diff --git a/exp_commands/module/module.json b/exp_commands/module/module.json index 642fddcf..516e6715 100644 --- a/exp_commands/module/module.json +++ b/exp_commands/module/module.json @@ -1,8 +1,11 @@ { "name": "exp_commands", + "load": [ + "module_exports.lua" + ], "require": [ - "commands/data_types.lua", - "commands/permission_authorities.lua", + "commands/types.lua", + "commands/authorities.lua", "commands/help.lua", "commands/rcon.lua", "commands/sudo.lua", diff --git a/exp_commands/module/module_exports.lua b/exp_commands/module/module_exports.lua index bc9f10df..54c5d503 100644 --- a/exp_commands/module/module_exports.lua +++ b/exp_commands/module/module_exports.lua @@ -1,9 +1,7 @@ --[[-- Core Module - Commands - Factorio command making module that makes commands with better parse and more modularity -@core Commands -@alias Commands -@usage-- Adding a permission authority +--- Adding a permission authority -- You are only required to return a boolean, but by using the unauthorised status you can provide better feedback to the user Commands.add_permission_authority(function(player, command) if command.flags.admin_only and not player.admin then @@ -12,7 +10,7 @@ Commands.add_permission_authority(function(player, command) return Commands.status.success() end) -@usage-- Adding a data type +--- Adding a data type -- You can not return nil from this function, doing so will raise an error, you must return a status Commands.add_data_type("integer", function(input, player) local number = tonumber(input) @@ -35,7 +33,7 @@ Commands.add_data_type("integer-range", function(input, player, minimum, maximum end end) -@usage-- Adding a command +--- Adding a command Commands.new("repeat", "This is my new command, it will repeat a message a number of times") :add_flags{ "admin_only" } -- Using the permission authority above, this makes the command admin only :add_aliases{ "repeat-message" } -- You can add as many aliases as you want @@ -57,27 +55,76 @@ end) ]] local ExpUtil = require("modules/exp_util") -local Color = require("modules/exp_util/include/color") +local Search = require("modules/exp_commands/search") local Commands = { - color = Color, -- A useful reference to the color utils to be used with command outputs - _prototype = {}, -- Contains the methods for the command object - registered_commands = {}, -- Stores a reference to all registered commands - permission_authorities = {}, -- Stores a reference to all active permission authorities - data_types = {}, -- Stores all input parsers and validators for different data types + color = ExpUtil.color, + format_rich_text_color = ExpUtil.format_rich_text_color, + format_rich_text_color_locale = ExpUtil.format_rich_text_color_locale, + format_player_name = ExpUtil.format_player_name, + format_player_name_locale = ExpUtil.format_player_name_locale, + + types = {}, --- @type { [string]: Commands.InputParser | Commands.InputParserFactory } Stores all input parsers and validators for different data types + registered_commands = {}, --- @type { [string]: Commands.ExpCommand } Stores a reference to all registered commands + permission_authorities = {}, --- @type Commands.PermissionAuthority[] Stores a reference to all active permission authorities status = {}, -- Contains the different status values a command can return + + --- @package Stores the event handlers + events = { + [defines.events.on_player_locale_changed] = Search.on_player_locale_changed, + [defines.events.on_player_joined_game] = Search.on_player_locale_changed, + [defines.events.on_string_translated] = Search.on_string_translated, + }, } +--- @package +function Commands.on_init() Search.prepare(Commands.registered_commands) end + +--- @package +function Commands.on_load() Search.prepare(Commands.registered_commands) end + +--- @alias Commands.Callback fun(player: LuaPlayer, ...: any): Commands.Status?, LocalisedString? +--- This is a default callback that should never be called +local function default_command_callback() + return Commands.status.internal_error("No callback registered") +end + +--- @class Commands.Argument +--- @field name string The name of the argument +--- @field description LocalisedString The description of the argument +--- @field input_parser Commands.InputParser The input parser for the argument +--- @field optional boolean True when the argument is optional +--- @field default any? The default value of the argument + +--- @class Commands.Command +--- @field name string The name of the command +--- @field description LocalisedString The description of the command +--- @field help_text LocalisedString The full help text for the command +--- @field aliases string[] Aliases that the command will also be registered under +--- @field defined_at? string If present then this is an ExpCommand + +--- @class Commands.ExpCommand: Commands.Command +--- @field callback Commands.Callback The callback which is ran for the command +--- @field defined_at string The file location that the command is defined at +--- @field auto_concat boolean True if the command auto concatenates tailing parameters into a single string +--- @field min_arg_count number The minimum number of expected arguments +--- @field max_arg_count number The maximum number of expected arguments +--- @field flags table Stores flags which can be used by permission authorities +--- @field arguments Commands.Argument[] The arguments for this command +Commands._prototype = {} + Commands._metatable = { __index = Commands._prototype, __class = "ExpCommand", } -Commands.player_server = setmetatable({ +--- @type LuaPlayer +Commands.server = setmetatable({ index = 0, - color = Color.white, - chat_color = Color.white, + color = ExpUtil.color.white, + chat_color = ExpUtil.color.white, name = "", + locale = "en", tag = "", connected = true, admin = true, @@ -102,41 +149,54 @@ Commands.player_server = setmetatable({ --- Status Returns. -- Return values used by command callbacks --- @section command-status + +--- @alias Commands.Status fun(msg: LocalisedString?): Commands.Status, LocalisedString --- Used to signal success from a command, data type parser, or permission authority --- @tparam[opt] LocaleString|string msg An optional message to be included when a command completes (only has an effect in command callbacks) +--- @param msg LocalisedString? An optional message to be included when a command completes (only has an effect in command callbacks) +--- @return Commands.Status, LocalisedString # Should be returned directly without modification +--- @type Commands.Status function Commands.status.success(msg) return Commands.status.success, msg or { "exp-commands.success" } end --- Used to signal an error has occurred in a command, data type parser, or permission authority --- For data type parsers and permission authority, an error return will prevent the command from being executed --- @tparam[opt] LocaleString|string msg An optional error message to be included in the output, a generic message is used if not provided +--- For data type parsers and permission authority, an error return will prevent the command from being executed +--- @param msg LocalisedString? An optional error message to be included in the output, a generic message is used if not provided +--- @return Commands.Status, LocalisedString # Should be returned directly without modification +--- @type Commands.Status function Commands.status.error(msg) return Commands.status.error, { "exp-commands.error", msg or { "exp-commands.error-default" } } end --- Used to signal the player is unauthorised to use a command, primarily used by permission authorities but can be used in a command callback --- For permission authorities, an error return will prevent the command from being executed --- @tparam[opt] LocaleString|string msg An optional error message to be included in the output, a generic message is used if not provided +--- For permission authorities, an unauthorised return will prevent the command from being executed +--- @param msg LocalisedString? An optional error message to be included in the output, a generic message is used if not provided +--- @return Commands.Status, LocalisedString # Should be returned directly without modification +--- @type Commands.Status function Commands.status.unauthorised(msg) return Commands.status.unauthorised, msg or { "exp-commands.unauthorized", msg or { "exp-commands.unauthorized-default" } } end --- Used to signal the player provided invalid input to an command, primarily used by data type parsers but can be used in a command callback --- For data type parsers, an error return will prevent the command from being executed --- @tparam[opt] LocaleString|string msg An optional error message to be included in the output, a generic message is used if not provided +--- For data type parsers, an invalid_input return will prevent the command from being executed +--- @param msg LocalisedString? An optional error message to be included in the output, a generic message is used if not provided +--- @return Commands.Status, LocalisedString # Should be returned directly without modification +--- @type Commands.Status function Commands.status.invalid_input(msg) return Commands.status.invalid_input, msg or { "exp-commands.invalid-input" } end ---- Used to signal an internal error has occurred, this is reserved for internal use --- @tparam LocaleString|string msg A message detailing the error which has occurred, will be logged and outputted +--- Used to signal an internal error has occurred, this is reserved for internal use only +--- @param msg LocalisedString A message detailing the error which has occurred, will be logged and outputted +--- @return Commands.Status, LocalisedString # Should be returned directly without modification +--- @package +--- @type Commands.Status function Commands.status.internal_error(msg) return Commands.status.internal_error, { "exp-commands.internal-error", msg } end +--- @type { [Commands.Status]: string } local valid_command_status = {} -- Hashmap lookup for testing if a status is valid for name, status in pairs(Commands.status) do valid_command_status[status] = name @@ -144,39 +204,46 @@ end --- Permission Authority. -- Functions that control who can use commands --- @section permission-authority + +--- @alias Commands.PermissionAuthority fun(player: LuaPlayer, command: Commands.ExpCommand): boolean | Commands.Status, LocalisedString? --- Add a permission authority, a permission authority is a function which provides access control for commands, multiple can be active at once --- When multiple are active, all authorities must give permission for the command to execute, if any deny access then the command is not ran --- @tparam function permission_authority The function to provide access control to commands, see module usage. --- @treturn function The function which was provided as the first argument +--- When multiple are active, all authorities must give permission for the command to execute, if any deny access then the command is not ran +--- @param permission_authority Commands.PermissionAuthority The function to provide access control to commands, see module usage. +--- @return Commands.PermissionAuthority # The function which was provided as the first argument function Commands.add_permission_authority(permission_authority) + for _, value in ipairs(Commands.permission_authorities) do + if value == permission_authority then + return permission_authority + end + end + local next_index = #Commands.permission_authorities + 1 Commands.permission_authorities[next_index] = permission_authority return permission_authority end --- Remove a permission authority, must be the same function reference which was passed to add_permission_authority --- @tparam function permission_authority The access control function to remove as a permission authority +--- @param permission_authority Commands.PermissionAuthority The access control function to remove as a permission authority function Commands.remove_permission_authority(permission_authority) - local pms = Commands.permission_authorities - for index, value in pairs(pms) do + local pas = Commands.permission_authorities + for index, value in ipairs(pas) do if value == permission_authority then - local last = #pms - pms[index] = pms[last] - pms[last] = nil + local last = #pas + pas[index] = pas[last] + pas[last] = nil return end end end --- Check if a player has permission to use a command, calling all permission authorities --- @tparam LuaPlayer player The player to test the permission of, nil represents the server and always returns true --- @tparam Command command The command the player is attempting to use --- @treturn boolean true if the player has permission to use the command --- @treturn LocaleString|string when permission is denied, this is the reason permission was denied +--- @param player LuaPlayer? The player to test the permission of, nil represents the server and always returns true +--- @param command Commands.ExpCommand The command the player is attempting to use +--- @return boolean # True if the player has permission to use the command +--- @return LocalisedString # When permission is denied, this is the reason permission was denied function Commands.player_has_permission(player, command) - if player == nil or player == Commands.player_server then return true end + if player == nil or player == Commands.server then return true end for _, permission_authority in ipairs(Commands.permission_authorities) do local status, msg = permission_authority(player, command) @@ -185,12 +252,14 @@ function Commands.player_has_permission(player, command) local _, rtn_msg = Commands.status.unauthorised(msg) return false, rtn_msg end - elseif valid_command_status[status] then + elseif status and valid_command_status[status] then if status ~= Commands.status.success then return false, msg end else - return false, "Permission authority returned unexpected value" + local class_name = ExpUtil.get_class_name(status) + local _, rtn_msg = Commands.status.internal_error("Permission authority returned unexpected value: " .. class_name) + return false, rtn_msg end end @@ -199,67 +268,68 @@ end --- Data Type Parsing. -- Functions that parse and validate player input --- @section input-parse-and-validation ---- Add a new input parser to the command library, this allows use of a data type without needing to pass the function directly --- @tparam string data_type The name of the data type the input parser reads in and validates --- @tparam function parser The function used to parse and validate the data type --- @treturn string The data type passed as the first argument -function Commands.add_data_type(data_type, parser) - if Commands.data_types[data_type] then - error("Data type \"" .. tostring(data_type) .. "\" already has a parser registered", 2) +--- @generic T +--- @alias Commands.InputParser (fun(input: string, player: LuaPlayer): T) | (fun(input: string, player: LuaPlayer): Commands.Status, LocalisedString | T) + +--- @generic T +--- @alias Commands.InputParserFactory fun(...: any): Commands.InputParser + +--- Add a new input parser to the command library, this method validates that it does not already exist +--- @param data_type string The name of the data type the input parser reads in and validates, becomes a key of Commands.types +--- @param input_parser Commands.InputParser | Commands.InputParserFactory The function used to parse and validate the data type +--- @return string # The data type passed as the first argument +function Commands.add_data_type(data_type, input_parser) + if Commands.types[data_type] then + local defined_at = ExpUtil.get_function_name(Commands.types[data_type], true) + error("Data type \"" .. tostring(data_type) .. "\" already has a parser registered: " .. defined_at, 2) end - Commands.data_types[data_type] = parser + Commands.types[data_type] = input_parser return data_type end --- Remove an input parser for a data type, must be the same string that was passed to add_input_parser --- @tparam string data_type The data type for which you want to remove the input parser of +--- @param data_type string The name of data type you want to remove the input parser for function Commands.remove_data_type(data_type) - Commands.data_types[data_type] = nil + Commands.types[data_type] = nil end --- Parse and validate an input string as a given data type --- @tparam string|function data_type The name of the data type parser to use to read and validate the input text --- @tparam string input The input string that will be read by the parser --- @param ... Any other arguments that the parser is expecting --- @treturn boolean true when the input was successfully parsed and validated to be the correct type --- @return When The error status for why parsing failed, otherwise it is the parsed value --- @return When first is false, this is the error message, otherwise this is the parsed value -function Commands.parse_data_type(data_type, input, ...) - local parser = Commands.data_types[data_type] - if type(data_type) == "function" then - parser = data_type - elseif parser == nil then - return false, Commands.status.internal_error, { "exp-commands.internal-error", "Data type \"" .. tostring(data_type) .. "\" does not have a registered parser" } - end - - local status, parsed = parser(input, ...) +--- @generic T +--- @param input string The input string +--- @param player LuaPlayer The player who gave the input +--- @param input_parser Commands.InputParser The parser to apply to the input string +--- @return boolean success True when the input was successfully parsed and validated to be the correct type +--- @return Commands.Status status, T | LocalisedString result # If success is false then Remaining values should be returned directly without modification +function Commands.parse_input(input, player, input_parser) + local status, status_msg = input_parser(input, player) if status == nil then - return Commands.status.internal_error, { "exp-commands.internal-error", "Parser for data type \"" .. tostring(data_type) .. "\" returned a nil value" } + local data_type = table.get_key(Commands.types, input_parser) or ExpUtil.get_function_name(input_parser, true) + local rtn_status, rtn_msg = Commands.status.internal_error("Parser for data type \"" .. data_type .. "\" returned a nil value") + return false, rtn_status, rtn_msg elseif valid_command_status[status] then if status ~= Commands.status.success then - return false, status, parsed -- error_type, error_msg + return false, status, status_msg else - return true, status, parsed -- success, parsed_data + return true, status, status_msg -- status_msg is the parsed data end else - return true, Commands.status.success, status -- success, parsed_data + return true, Commands.status.success, status -- status is the parsed data end end --- List and Search -- Functions used to list and search for commands --- @section list-and-search --- Returns a list of all registered custom commands --- @treturn table An array of registered commands +--- @return { [string]: Commands.ExpCommand } # A dictionary of commands function Commands.list_all() return Commands.registered_commands end --- Returns a list of all registered custom commands which the given player has permission to use --- @treturn table An array of registered commands +--- @param player LuaPlayer? The player to get the command of, nil represents the server but list_all should be used +--- @return { [string]: Commands.ExpCommand } # A dictionary of commands function Commands.list_for_player(player) local rtn = {} @@ -272,160 +342,87 @@ function Commands.list_for_player(player) return rtn end ---- Searches all game commands and the provided custom commands for the given keyword -local function search_commands(keyword, custom_commands) - keyword = keyword:lower() - local rtn = {} - - -- Search all custom commands - for name, command in pairs(custom_commands) do - local search = string.format("%s %s %s", name, command.help, table.concat(command.aliases, " ")) - if search:lower():match(keyword) then - rtn[name] = command - end - end - - -- Search all game commands - for name, description in pairs(commands.game_commands) do - local search = string.format("%s %s", name, description) - if search:lower():match(keyword) then - rtn[name] = { - name = name, - help = description, - description = "", - aliases = {}, - } - end - end - - return rtn -end - --- Searches all custom commands and game commands for the given keyword --- @treturn table An array of registered commands +--- @param keyword string The keyword to search for +--- @return { [string]: Commands.Command } # A dictionary of commands function Commands.search_all(keyword) - return search_commands(keyword, Commands.list_all()) + return Search.search_commands(keyword, Commands.list_all(), "en") end --- Searches custom commands allowed for this player and all game commands for the given keyword --- @treturn table An array of registered commands +--- @param keyword string The keyword to search for +--- @param player LuaPlayer? The player to search the commands of, nil represents server but search_all should be used +--- @return { [string]: Commands.Command } # A dictionary of commands function Commands.search_for_player(keyword, player) - return search_commands(keyword, Commands.list_for_player(player)) + return Search.search_commands(keyword, Commands.list_for_player(player), player and player.locale) end --- Command Output -- Prints output to the player or rcon connection --- @section player-print ---- Set the color of a message using rich text chat --- @tparam string message The message to set the color of --- @tparam Color color The color that the message should be --- @treturn string The string which can be printed to game chat -function Commands.set_chat_message_color(message, color) - local color_tag = math.round(color.r, 3) .. ", " .. math.round(color.g, 3) .. ", " .. math.round(color.b, 3) - return string.format("[color=%s]%s[/color]", color_tag, message) -end - ---- Set the color of a locale message using rich text chat --- @tparam LocaleString message The message to set the color of --- @tparam Color color The color that the message should be --- @treturn LocaleString The locale string which can be printed to game chat -function Commands.set_locale_chat_message_color(message, color) - local color_tag = math.round(color.r, 3) .. ", " .. math.round(color.g, 3) .. ", " .. math.round(color.b, 3) - return { "color-tag", color_tag, message } -end - ---- Get a string representing the name of the given player in their chat colour --- @tparam LuaPlayer player The player to use the name and color of, nil represents the server --- @treturn string The players name formatted as a string in their chat color -function Commands.format_player_name(player) - local player_name = player and player.name or "" - local player_color = player and player.chat_color or Color.white - local color_tag = math.round(player_color.r, 3) .. ", " .. math.round(player_color.g, 3) .. ", " .. math.round(player_color.b, 3) - return string.format("[color=%s]%s[/color]", color_tag, player_name) -end - ---- Get a locale string representing the name of the given player in their chat colour --- @tparam LuaPlayer player The player to use the name and color of, nil represents the server --- @treturn LocaleString The players name formatted as a locale string in their chat color -function Commands.format_locale_player_name(player) - local player_name = player and player.name or "" - local player_color = player and player.chat_color or Color.white - local color_tag = math.round(player_color.r, 3) .. ", " .. math.round(player_color.g, 3) .. ", " .. math.round(player_color.b, 3) - return { "color-tag", color_tag, player_name } -end +local print_format_options = { max_line_count = 20 } +local print_default_settings = { sound_path = "utility/scenario_message" } --- Print a message to the user of a command, accepts any value and will print in a readable and safe format --- @tparam any message The message / value to be printed --- @tparam[opt] Color color The color the message should be printed in --- @tparam[opt] string sound The sound path to be played when the message is printed -function Commands.print(message, color, sound) +--- @param message any The message / value to be printed +--- @param settings PrintSettings? The settings to print with +function Commands.print(message, settings) local player = game.player if not player then rcon.print(ExpUtil.format_any(message)) else - local formatted = ExpUtil.format_any(message, { max_line_count = 20 }) - player.print(formatted, { - color = color or Color.white, - sound_path = sound or "utility/scenario_message", - }) + if not settings then + settings = print_default_settings + elseif not settings.sound_path then + settings.sound_path = print_default_settings.sound_path + end + player.print(ExpUtil.format_any(message, print_format_options), settings) end end --- Print an error message to the user of a command, accepts any value and will print in a readable and safe format --- @tparam any message The message / value to be printed +--- @param message any The message / value to be printed function Commands.error(message) - return Commands.print(message, Color.orange_red, "utility/wire_pickup") + Commands.print(message, { + color = ExpUtil.color.orange_red, + sound_path = "utility/wire_pickup", + }) end --- Command Prototype --- The prototype defination for command objects --- @section command-prototype +-- The prototype definition for command objects ---- This is a default callback that should never be called -local function default_command_callback() - return Commands.status.internal_error("No callback registered") -end - ---- Returns a new command object, this will not register the command to the game --- @tparam string name The name of the command as it will be registered later --- @tparam string help The help message / description of the command --- @treturn Command A new command object which can be registered -function Commands.new(name, help) +--- Returns a new command object, this will not register the command but act as a way to start construction +--- @param name string The name of the command as it will be registered later +--- @param description LocalisedString The description of the command displayed in the help message +--- @return Commands.ExpCommand +function Commands.new(name, description) ExpUtil.assert_argument_type(name, "string", 1, "name") - ExpUtil.assert_argument_type(help, "string", 2, "help") if Commands.registered_commands[name] then error("Command is already defined at: " .. Commands.registered_commands[name].defined_at, 2) end return setmetatable({ name = name, - help = help, - callback = default_command_callback, + description = description, + help_text = description, -- Will be replaced in command:register + callback = default_command_callback, -- Will be replaced in command:register defined_at = ExpUtil.safe_file_path(2), auto_concat = false, min_arg_count = 0, max_arg_count = 0, - flags = {}, -- stores flags that can be used by auth - aliases = {}, -- stores aliases to this command - arguments = {}, -- [{name: string, optional: boolean, default: any, data_type: function, parse_args: table}] + flags = {}, + aliases = {}, + arguments = {}, }, Commands._metatable) end ---- Get the data type parser from a name, will raise an error if it doesnt exist -local function get_parser(data_type) - local rtn = Commands.data_types[data_type] - if rtn == nil then - error("Unknown data type: " .. tostring(data_type), 3) - end - return data_type, rtn -end - --- Add a new required argument to the command of the given data type --- @tparam string name The name of the argument being added --- @tparam string data_type The data type of this argument, must have previously been registered with add_data_type --- @treturn Command The command object to allow chaining method calls -function Commands._prototype:argument(name, data_type, ...) +--- @param name string The name of the argument being added +--- @param description LocalisedString The description of the argument being added +--- @param input_parser Commands.InputParser The input parser to be used for the argument +--- @return Commands.ExpCommand +function Commands._prototype:argument(name, description, input_parser) if self.min_arg_count ~= self.max_arg_count then error("Can not have required arguments after optional arguments", 2) end @@ -433,39 +430,38 @@ function Commands._prototype:argument(name, data_type, ...) self.max_arg_count = self.max_arg_count + 1 self.arguments[#self.arguments + 1] = { name = name, + description = description, + input_parser = input_parser, optional = false, - data_type = data_type, - data_type_parser = get_parser(data_type), - parse_args = { ... }, } return self end --- Add a new optional argument to the command of the given data type --- @tparam string name The name of the argument being added --- @tparam string data_type The data type of this argument, must have previously been registered with add_data_type --- @treturn Command The command object to allow chaining method calls -function Commands._prototype:optional(name, data_type, ...) +--- @param name string The name of the argument being added +--- @param description LocalisedString The description of the argument being added +--- @param input_parser Commands.InputParser The input parser to be used for the argument +--- @return Commands.ExpCommand +function Commands._prototype:optional(name, description, input_parser) self.max_arg_count = self.max_arg_count + 1 self.arguments[#self.arguments + 1] = { name = name, + description = description, + input_parser = input_parser, optional = true, - data_type = data_type, - data_type_parser = get_parser(data_type), - parse_args = { ... }, } return self end --- Set the defaults for optional arguments, any not provided will have their value as nil --- @tparam table defaults A table who's keys are the argument names and values are the defaults or function which returns a default --- @treturn Command The command object to allow chaining method calls +--- @param defaults table The default values for the optional arguments, the key is the name of the argument +--- @return Commands.ExpCommand function Commands._prototype:defaults(defaults) local matched = {} for _, argument in ipairs(self.arguments) do if defaults[argument.name] then if not argument.optional then - error("Attempting to set default value for required argument: " .. argument.name) + error("Attempting to set default value for required argument: " .. argument.name, 2) end argument.default = defaults[argument.name] matched[argument.name] = true @@ -475,16 +471,16 @@ function Commands._prototype:defaults(defaults) -- Check that there are no extra values in the table for name in pairs(defaults) do if not matched[name] then - error("No argument with name: " .. name) + error("No argument with name: " .. name, 2) end end return self end ---- Set the flags for the command, these can be accessed by permission authorities to check who should use a command --- @tparam table flags An array of string flags, or a table who's keys are the flag names and values are the flag values --- @treturn Command The command object to allow chaining method calls +--- Set the flags for the command, these can be accessed by permission authorities to check who can use a command +--- @param flags table An array of strings or a dictionary of flag names and values, when an array is used the flags values are set to true +--- @return Commands.ExpCommand function Commands._prototype:add_flags(flags) for name, value in pairs(flags) do if type(name) == "number" then @@ -498,8 +494,8 @@ function Commands._prototype:add_flags(flags) end --- Set the aliases for the command, these are alternative names that the command can be ran under --- @tparam table aliases An array of string names to use as aliases to this command --- @treturn Command The command object to allow chaining method calls +--- @param aliases string[] An array of string names to use as aliases to this command +--- @return Commands.ExpCommand function Commands._prototype:add_aliases(aliases) local start_index = #self.aliases for index, alias in ipairs(aliases) do @@ -510,69 +506,84 @@ function Commands._prototype:add_aliases(aliases) end --- Enable concatenation of all arguments after the last, this should be used for user provided reason text --- @treturn Command The command object to allow chaining method calls +--- @return Commands.ExpCommand function Commands._prototype:enable_auto_concatenation() self.auto_concat = true return self end --- Register the command to the game with the given callback, this must be the final step as the object becomes immutable afterwards --- @tparam function callback The function which is called to perform the command action +--- @param callback Commands.Callback The function which is called to perform the command action function Commands._prototype:register(callback) Commands.registered_commands[self.name] = self self.callback = callback -- Generates a description to be used - local description = {} + local argument_names = { "" } --- @type LocalisedString + local argument_help_text = { "", "" } --- @type LocalisedString + local help_text = { "exp-commands.help", argument_names, self.description, argument_help_text } --- @type LocalisedString + self.help_text = help_text + if next(self.aliases) then + argument_help_text[2] = { "exp-commands.aliases", table.concat(self.aliases, ", ") } + end + for index, argument in pairs(self.arguments) do if argument.optional then - description[index] = "[" .. argument.name .. "]" + argument_names[index + 2] = { "exp-commands.optional", argument.name } + argument_help_text[index + 2] = { "exp-commands.optional-verbose", argument.name, argument.description } else - description[index] = "<" .. argument.name .. ">" + argument_names[index + 2] = { "exp-commands.argument", argument.name } + argument_help_text[index + 2] = { "exp-commands.argument-verbose", argument.name, argument.description } end end - self.description = table.concat(description, " ") - -- Callback which is called by the game engine + ---@param event CustomCommandData local function command_callback(event) event.name = self.name local success, traceback = xpcall(Commands._event_handler, debug.traceback, event) --- @cast traceback string if not success and not traceback:find("Command does not support rcon usage") then - local _, msg = Commands.status.internal_error(event.tick) + local key = "<" .. table.concat({ event.name, event.player_index, event.tick }, ":") .. ">" + local _, msg = Commands.status.internal_error(key) Commands.error(msg) - log("Internal Command Error " .. event.tick .. "\n" .. traceback) + log("Internal Command Error " .. key .. "\n" .. traceback) end end -- Registers the command under its own name - local help = { "exp-commands.command-help", self.description, self.help } - commands.add_command(self.name, help, command_callback) + commands.add_command(self.name, self.help_text, command_callback) -- Registers the command under its aliases for _, alias in ipairs(self.aliases) do - commands.add_command(alias, help, command_callback) + commands.add_command(alias, self.help_text, command_callback) end end --- Command Runner -- Used internally to run commands --- @section command-runner --- Log that a command was attempted and its outcome (error / success) -local function log_command(comment, command, player, args, detail) - local player_name = player and player.name or "" +--- @param comment string The main comment to include in the log +--- @param command Commands.ExpCommand The command that is being executed +--- @param player LuaPlayer The player who is running the command +--- @param parameter string The raw command parameter that was used +--- @param detail any +local function log_command(comment, command, player, parameter, detail) ExpUtil.write_json("log/commands.log", { comment = comment, - detail = detail, - player_name = player_name, command_name = command.name, - args = args, + player_name = player.name, + parameter = parameter, + detail = detail, }) end --- Extract the arguments from a string input string +--- @param raw_input string? The raw input from the player +--- @param max_args number The maximum number of allowed arguments +--- @param auto_concat boolean True when remaining arguments should be concatenated +--- @return table? # Nil if there are too many arguments local function extract_arguments(raw_input, max_args, auto_concat) -- nil check when no input given if raw_input == nil then return {} end @@ -613,13 +624,16 @@ local function extract_arguments(raw_input, max_args, auto_concat) end --- Internal event handler for the command event +--- @param event CustomCommandData +--- @return nil +--- @package function Commands._event_handler(event) local command = Commands.registered_commands[event.name] if command == nil then error("Command not recognised: " .. event.name) end - local player = nil -- nil represents the server until the command is called + local player = Commands.server if event.player_index then player = game.players[event.player_index] end @@ -644,7 +658,7 @@ function Commands._event_handler(event) return Commands.error{ "exp-commands.invalid-usage", command.name, command.description } end - -- Check the minimum number of arguments is fullfiled + -- Check the minimum number of arguments is fulfilled if #raw_arguments < command.min_arg_count then log_command("Too few arguments", command, player, event.parameter, { minimum = command.min_arg_count, maximum = command.max_arg_count }) return Commands.error{ "exp-commands.invalid-usage", command.name, command.description } @@ -655,7 +669,7 @@ function Commands._event_handler(event) for index, argument in ipairs(command.arguments) do local input = raw_arguments[index] if input == nil then - -- We know this is an optional argument because the mimimum count is satisfied + -- We know this is an optional argument because the minimum count is satisfied assert(argument.optional == true, "Argument was required") if type(argument.default) == "function" then arguments[index] = argument.default(player) @@ -664,7 +678,7 @@ function Commands._event_handler(event) end else -- Parse the raw argument to get the correct data type - local success, status, parsed = Commands.parse_data_type(argument.data_type_parser, input, player, table.unpack(argument.parse_args)) + local success, status, parsed = Commands.parse_input(input, player, argument.input_parser) if success == false then log_command("Input parse failed", command, player, event.parameter, { status = valid_command_status[status], index = index, argument = argument, reason = parsed }) return Commands.error{ "exp-commands.invalid-argument", argument.name, parsed } @@ -675,8 +689,8 @@ function Commands._event_handler(event) end -- Run the command, dont need xpcall here because errors are caught in command_callback - local status, status_msg = command.callback(player or Commands.player_server, table.unpack(arguments)) - if valid_command_status[status] then + local status, status_msg = command.callback(player, table.unpack(arguments)) + if status and valid_command_status[status] then if status ~= Commands.status.success then log_command("Custom Error", command, player, event.parameter, status_msg) return Commands.error(status_msg) diff --git a/exp_commands/module/search.lua b/exp_commands/module/search.lua new file mode 100644 index 00000000..2fdb07fa --- /dev/null +++ b/exp_commands/module/search.lua @@ -0,0 +1,112 @@ + +local Search = {} +local Storage = require("modules/exp_util/storage") + +--- Setup the storage to contain the pending translations and the completed ones +local pending = {} --- @type { [uint]: { [1]: string, [2]: string }? } +local translations = {} --- @type { [string]: { [string]: string } } +Storage.register({ + pending, + translations, +}, function(tbl) + pending = tbl[1] + translations = tbl[2] +end) + +local command_names = {} --- @type string[] +local command_objects = {} --- @type { [string]: Commands.Command } +local required_translations = {} --- @type LocalisedString[] + +--- Gets the descriptions of all commands, not including their aliases +--- @param custom_commands { [string]: Commands.ExpCommand } The complete list of registered custom commands +function Search.prepare(custom_commands) + local known_aliases = {} --- @type { [string]: string } + for name, command in pairs(custom_commands) do + for _, alias in ipairs(command.aliases) do + known_aliases[alias] = name + end + end + + local index = 0 + for name, locale_desc in pairs(commands.commands) do + if not known_aliases[name] then + index = index + 1 + command_names[index] = name + required_translations[index] = locale_desc + command_objects[name] = custom_commands[name] or { + name = name, + description = locale_desc, + help_text = locale_desc, + aliases = {}, + } + end + end + + for name, locale_desc in pairs(commands.game_commands) do + index = index + 1 + command_names[index] = name + required_translations[index] = locale_desc + command_objects[name] = { + name = name, + description = locale_desc, + help_text = locale_desc, + aliases = {}, + } + end +end + +--- Called when a locale changes so the new translations can be requested +--- @param event EventData.on_player_locale_changed | EventData.on_player_joined_game +function Search.on_player_locale_changed(event) + local player = game.players[event.player_index] + local locale = player.locale + if not translations[locale] then + translations[locale] = {} + local ids = player.request_translations(required_translations) + assert(ids, "Translation ids was nil") + for i, command_name in ipairs(command_names) do + pending[ids[i]] = { locale, command_name } + end + end +end + +--- Called when a translation request is completed +--- @param event EventData.on_string_translated +--- @return nil +function Search.on_string_translated(event) + local info = pending[event.id] + if not info then return end + pending[event.id] = nil + if not event.translated then + return log("Failed translation for " .. info[1] .. " " .. info[2]) + end + translations[info[1]][info[2]] = event.result:lower() +end + +--- Searches all game commands and the provided custom commands for the given keyword +--- @param keyword string The keyword to search for +--- @param custom_commands { [string]: Commands.ExpCommand } A dictionary of commands to search +--- @param locale string? The local to search, default is english ("en") +--- @return { [string]: Commands.Command } # A dictionary of commands +function Search.search_commands(keyword, custom_commands, locale) + local rtn = {} --- @type { [string]: Commands.Command } + keyword = keyword:lower() + locale = locale or "en" + + local searchable_commands = translations[locale] + if not searchable_commands then return {} end + + -- Search all custom commands + for name, search_text in pairs(searchable_commands) do + if search_text:match(keyword) or name:match(keyword) then + local obj = command_objects[name] + if not obj.defined_at or custom_commands[name] then + rtn[name] = obj + end + end + end + + return rtn +end + +return Search diff --git a/exp_commands/webpack.config.js b/exp_commands/webpack.config.js deleted file mode 100644 index dfe36ef4..00000000 --- a/exp_commands/webpack.config.js +++ /dev/null @@ -1,32 +0,0 @@ -"use strict"; -const path = require("path"); -const webpack = require("webpack"); -const { merge } = require("webpack-merge"); - -const common = require("@clusterio/web_ui/webpack.common"); - -module.exports = (env = {}) => merge(common(env), { - context: __dirname, - entry: "./web/index.jsx", - output: { - path: path.resolve(__dirname, "dist", "web"), - }, - plugins: [ - new webpack.container.ModuleFederationPlugin({ - name: "exp_commands", - library: { type: "var", name: "plugin_exp_commands" }, - exposes: { - "./info": "./dist/plugin/info.js", - "./package.json": "./package.json", - "./web": "./web/index.jsx", - }, - shared: { - "@clusterio/lib": { import: false }, - "@clusterio/web_ui": { import: false }, - "antd": { import: false }, - "react": { import: false }, - "react-dom": { import: false }, - }, - }), - ], -}); diff --git a/exp_util/module/common.lua b/exp_util/module/common.lua index ef17fa1c..6aeca732 100644 --- a/exp_util/module/common.lua +++ b/exp_util/module/common.lua @@ -2,12 +2,16 @@ Adds some commonly used functions used in many modules ]] +local type = type local assert = assert +local getmetatable = getmetatable local getinfo = debug.getinfo local traceback = debug.traceback local floor = math.floor +local round = math.round local concat = table.concat - +local inspect = table.inspect +local format_string = string.format local table_to_json = helpers.table_to_json local write_file = helpers.write_file @@ -59,6 +63,22 @@ local function check_type(value, type_name) return value == nil or value_type ~= type_name, value_type end +--- Get the name of a class or object, better than just using type +--- @param value any The value to get the class of +--- @return string # One of type, object_name, __class +function Common.get_class_name(value) + local value_type = type(value) --[[@as string]] + if value_type == "userdata" then + return value.object_name + elseif value_type == "table" then + local mt = getmetatable(value) + if mt and mt.__class then + return mt.__class + end + end + return value_type +end + local assert_type_fmt = "%s expected to be of type %s but got %s" --- Raise an error if the type of a value is not as expected --- @param value any The value to assert the type of @@ -126,7 +146,7 @@ end --- @return string # The relative filepath of the given stack frame function Common.safe_file_path(level) local debug_info = getinfo((level or 1) + 1, "Sn") - local safe_source = debug_info.source:find("__level__") + local safe_source = debug_info.source:find("@__level__") return safe_source == 1 and debug_info.short_src:sub(10, -5) or debug_info.source end @@ -149,7 +169,7 @@ end --- @return string # The name of the function at the given stack frame or provided as an argument function Common.get_function_name(func, raw) local debug_info = getinfo(func, "Sn") - local safe_source = debug_info.source:find("__level__") + local safe_source = debug_info.source:find("@__level__") local file_name = safe_source == 1 and debug_info.short_src:sub(10, -5) or debug_info.source local func_name = debug_info.name or debug_info.linedefined if raw then return file_name .. ":" .. func_name end @@ -179,9 +199,9 @@ function Common.auto_complete(options, input, use_key, rtn_key) end end ---- Formats any value into a safe representation, useful with table.inspect +--- Formats any value into a safe representation, useful with inspect --- @param value any The value to be formatted ---- @return string | LocalisedString # The formatted version of the value +--- @return LocalisedString # The formatted version of the value --- @return boolean # True if value is a locale string, nil otherwise function Common.safe_value(value) if type(value) == "table" then @@ -211,7 +231,7 @@ end --- Formats any value to be presented in a safe and human readable format --- @param value any The value to be formatted --- @param options Common.format_any_param? Options for the formatter ---- @return string | LocalisedString # The formatted version of the value +--- @return LocalisedString # The formatted version of the value function Common.format_any(value, options) options = options or {} local formatted, is_locale_string = Common.safe_value(value) @@ -221,10 +241,10 @@ function Common.format_any(value, options) if success then return rtn end end if options.max_line_count ~= 0 then - local rtn = table.inspect(value, { depth = options.depth or 5, indent = " ", newline = "\n", process = Common.safe_value }) + local rtn = inspect(value, { depth = options.depth or 5, indent = " ", newline = "\n", process = Common.safe_value }) if options.max_line_count == nil or select(2, rtn:gsub("\n", "")) < options.max_line_count then return rtn end end - return table.inspect(value, { depth = options.depth or 5, indent = "", newline = "", process = Common.safe_value }) + return inspect(value, { depth = options.depth or 5, indent = "", newline = "", process = Common.safe_value }) end return formatted end @@ -309,7 +329,7 @@ function Common.format_time_locale(ticks, format, units) end local rtn = {} - local join = ", " --- @type string | LocalisedString + local join = ", " --- @type LocalisedString if format == "clock" then -- Example 12:34:56 or --:--:-- if units.days then rtn[#rtn + 1] = rtn_days end @@ -516,39 +536,47 @@ end --- Returns a message formatted for game chat using rich text colour tags --- @param message string ---- @param color Color | string +--- @param color Color --- @return string function Common.format_rich_text_color(message, color) - color = color or Common.color.white - local color_tag = "[color=" .. math.round(color.r, 3) .. ", " .. math.round(color.g, 3) .. ", " .. math.round(color.b, 3) .. "]" - return string.format("%s%s[/color]", color_tag, message) + return format_string( + "[color=%s,%s,%s]%s[/color]", + round(color.r or color[1] or 0, 3), + round(color.g or color[2] or 0, 3), + round(color.b or color[3] or 0, 3), + message + ) end --- Returns a message formatted for game chat using rich text colour tags --- @param message string ---- @param color Color | string +--- @param color Color --- @return LocalisedString function Common.format_rich_text_color_locale(message, color) - color = color or Common.color.white - color = math.round(color.r, 3) .. ", " .. math.round(color.g, 3) .. ", " .. math.round(color.b, 3) - return { "color-tag", color, message } + return { + "color-tag", + round(color.r or color[1] or 0, 3), + round(color.g or color[2] or 0, 3), + round(color.b or color[3] or 0, 3), + message + } end --- Formats a players name using rich text color ---- @param player LuaPlayer +--- @param player PlayerIdentification? --- @return string function Common.format_player_name(player) - local valid_player = type(player) == "userdata" and player or game.get_player(player) + local valid_player = type(player) == "userdata" and player or game.get_player(player --[[@as string|number]]) --[[@as LuaPlayer?]] local player_name = valid_player and valid_player.name or "" local player_chat_colour = valid_player and valid_player.chat_color or Common.color.white return Common.format_rich_text_color(player_name, player_chat_colour) end --- Formats a players name using rich text color ---- @param player LuaPlayer +--- @param player PlayerIdentification? --- @return LocalisedString function Common.format_player_name_locale(player) - local valid_player = type(player) == "userdata" and player or game.get_player(player) + local valid_player = type(player) == "userdata" and player or game.get_player(player --[[@as string|number]]) --[[@as LuaPlayer?]] local player_name = valid_player and valid_player.name or "" local player_chat_colour = valid_player and valid_player.chat_color or Common.color.white return Common.format_rich_text_color_locale(player_name, player_chat_colour) diff --git a/exp_util/module/flying_text.lua b/exp_util/module/flying_text.lua index 1dd59989..204e459c 100644 --- a/exp_util/module/flying_text.lua +++ b/exp_util/module/flying_text.lua @@ -55,7 +55,7 @@ end --- Create flying above a player, overrides the position option of FlyingText.create --- @param options FlyingText.create_above_player_param function FlyingText.create_above_player(options) - local player = assert(options.target_player, "A target entity is required") + local player = assert(options.target_player, "A target player is required") local entity = player.character; if not entity then return end local size_y = entity.bounding_box.left_top.y - entity.bounding_box.right_bottom.y @@ -73,7 +73,7 @@ end --- Create flying above a player, overrides the position and color option of FlyingText.create --- @param options FlyingText.create_as_player_param function FlyingText.create_as_player(options) - local player = assert(options.target_player, "A target entity is required") + local player = assert(options.target_player, "A target player is required") local entity = player.character; if not entity then return end local size_y = entity.bounding_box.left_top.y - entity.bounding_box.right_bottom.y diff --git a/exp_util/module/storage.lua b/exp_util/module/storage.lua index cd2bedd4..b71bab26 100644 --- a/exp_util/module/storage.lua +++ b/exp_util/module/storage.lua @@ -37,6 +37,7 @@ local Clustorio = require("modules/clusterio/api") local ExpUtil = require("modules/exp_util/common") local Storage = { + --- @package registered = {}, -- Map of all registered values and their initial values } @@ -67,6 +68,7 @@ function Storage.register_metatable(name, tbl) end --- Restore aliases on load, we do not need to initialise data during this event +--- @package function Storage.on_load() local exp_storage = storage.exp_storage if exp_storage == nil then return end @@ -78,6 +80,7 @@ function Storage.on_load() end --- Event Handler, sets initial values if needed and calls all callbacks +--- @package function Storage.on_init() local exp_storage = storage.exp_storage if exp_storage == nil then @@ -93,6 +96,7 @@ function Storage.on_init() end end +--- @package Storage.events = { [Clustorio.events.on_server_startup] = Storage.on_init, }