Add Clusterio Plugins

This commit is contained in:
Cooldude2606
2024-10-20 17:11:02 +01:00
parent 16813dca40
commit 446e87b610
53 changed files with 4938 additions and 823 deletions

8
exp_util/index.ts Normal file
View File

@@ -0,0 +1,8 @@
import * as lib from "@clusterio/lib";
export const plugin: lib.PluginDeclaration = {
name: "exp_util",
title: "ExpGaming Module Utilities",
description: "Provides extensions and overrides of base Lua library functions, and provides utility modules for improved module compatibly",
instanceEntrypoint: "./dist/node/instance",
};

7
exp_util/instance.ts Normal file
View File

@@ -0,0 +1,7 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
export class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}

326
exp_util/module/async.lua Normal file
View File

@@ -0,0 +1,326 @@
--[[-- Util Module - Async
- Provides a method of spreading work across multiple ticks and running functions at a later time
@core Async
@alias Async
@usage-- Bypass permission groups
-- This is a simple example, you should have some kind of validation to prevent security flaws
local function setAdmin(player, state)
player.admin = state
end
local setAdminAsync = Async.register(setAdmin)
setAdminAsync(game.players[1], true)
@usage-- Functions stored in global table
-- This can be used to create run time configurable callbacks, although this is not recommended
global.myCallback = Async.register(function()
game.print("I got called!")
end)
-- The function can be called just like any other function
global.myCallback()
@usage-- Creating singleton tasks (best used with global data)
-- This allows you to split large tasks across multiple ticks to prevent lag
local myTask = Async.register(function(remainingWork)
game.print("Working... " .. remainingWork)
if remainingWork > 0 then
return Async.status.continue(remainingWork - 1)
end
end)
myTask:start_task(10) -- Queues the task
myTask:start_task(10) -- Does nothing, task is already running
myTask:start_now(10) -- Ignores the already running instance and starts a second one
@usage-- Actions with variable delays
-- on_nth_tick is great for consistent delays, but tasks allow for variable delays
local linearBackoff = Async.register(function(startingDelay, remainingWork)
game.print("Working... " .. remainingWork)
if remainingWork > 0 then
local newDelay = startingDelay + 1
return Async.status.delay(newDelay, newDelay, remainingWork - 1)
end
end)
linearBackoff(1, 10)
@usage-- Getting return values
-- you can capture the return values of an async function using the event
local fillTableAsync = Async.register(function(tbl, val, remainingWork)
table.insert(tbl, val)
if remainingWork > 0 then
return Async.status.continue(tbl, val, remainingWork - 1)
else
return Async.status.complete(tbl)
end
end)
local function on_function_complete(event)
if event.async_id ~= fillTableAsync.id then return end
local filledTable = table.unpack(event.return_values)
game.print("Table has length of " .. #filledTable)
end
fillTableAsync({}, "foo", 10) -- Puts 10 lots of foo into the table
]]
local Clustorio = require("modules/clusterio/api")
local ExpUtil = require("modules.exp_util.common") --- @dep exp_util.common
local Async = {
status = {}, -- Stores the allowed return types from a async function
events = {}, -- Stores all event handlers for this module
_prototype = {}, -- Prototype of the async function type
_queue_pressure = {}, -- Stores the count of each function in the queue to avoid queue iteration during start_task
_functions = {}, -- Stores a reference to all registered functions
--- Raised when any async function has finished execution
-- @event on_function_complete
-- @tparam AsyncFunction async_id The function which finished execution, comparable to the return of register
-- @tparam table return_values An array representing the values returned by the completed function
on_function_complete = script.generate_event_name()
}
Async._metatable = {
__call = function(self, ...) Async._prototype.start_soon(self, ...) end,
__index = Async._prototype,
__class = "AsyncFunction"
}
script.register_metatable("AsyncFunction", Async._metatable)
--- Globals
local async_next -- Stores a queue of async functions to be executed on the next tick
local async_queue -- Stores a queue of async functions to be executed on a later tick
local on_tick_mutex = false -- It is not safe to modify the globals while this value is true
--- Insert an item into the priority queue
local function add_to_queue(pending)
local tick = pending.tick
for index = #async_queue, 1, -1 do
if async_queue[index].tick >= tick then
async_queue[index + 1] = pending
return
else
async_queue[index + 1] = async_queue[index]
end
end
async_queue[1] = pending
end
--- Static Methods.
-- Static methods of the class
-- @section async-static
--- Register a new async function
-- @tparam function func The function which becomes the async function
-- @treturn AsyncFunction The newly registered async function
function Async.register(func)
ExpUtil.assert_not_runtime()
ExpUtil.assert_argument_type(func, "function", 1, "func")
local id = ExpUtil.get_function_name(func)
Async._functions[id] = func
Async._queue_pressure[id] = 0
return setmetatable({ id = id }, Async._metatable)
end
--- Prototype Methods.
-- Prototype methods of the class instances
-- @section async-prototype
--- Run an async function on the next tick, this is the default and can be used to bypass permission groups
-- @param ... The arguments to call the function with
function Async._prototype:start_soon(...)
assert(not on_tick_mutex, "Cannot queue new async call during execution of another")
assert(Async._functions[self.id], "Async function is not registered")
Async._queue_pressure[self.id] = Async._queue_pressure[self.id] + 1
async_next[#async_next + 1] = {
id = self.id,
args = {...}
}
end
--- Run an async function after the given number of ticks
-- @tparam number ticks The number of ticks to call the function after
-- @param ... The arguments to call the function with
function Async._prototype:start_after(ticks, ...)
ExpUtil.assert_argument_type(ticks, "number", 1, "ticks")
assert(not on_tick_mutex, "Cannot queue new async call during execution of another")
assert(Async._functions[self.id], "Async function is not registered")
Async._queue_pressure[self.id] = Async._queue_pressure[self.id] + 1
add_to_queue({
id = self.id,
args = {...},
tick = game.tick + ticks
})
end
--- Run an async function on the next tick if the function is not already queued, allows singleton task/thread behaviour
-- @param ... The arguments to call the function with
function Async._prototype:start_task(...)
assert(not on_tick_mutex, "Cannot queue new async call during execution of another")
assert(Async._functions[self.id], "Async function is not registered")
if Async._queue_pressure[self.id] > 0 then return end
self:start_soon(...)
end
--- Run an async function on this tick, then queue it based on its return value
-- @param ... The arguments to call the function with
function Async._prototype:start_now(...)
assert(not on_tick_mutex, "Cannot queue new async call during execution of another")
assert(Async._functions[self.id], "Async function is not registered")
local status, rtn1, rtn2 = Async._functions[self.id](...)
if status == Async.status.continue then
self:start_soon(table.unpack(rtn1))
elseif status == Async.status.delay then
self:start_after(rtn1, table.unpack(rtn2))
elseif status == Async.status.complete or status == nil then
-- The function has finished execution, raise the custom event
script.raise_event(Async.on_function_complete, {
event = Async.on_function_complete,
tick = game.tick,
async_id = self.id,
returned = rtn1
})
else
error("Async function " .. self.id .. " returned an invalid status: " .. table.inspect(status))
end
end
--- Status Returns.
-- Return values used by async functions
-- @section async-status
local empty_table = setmetatable({}, {
__index = function() error("Field 'Returned' is Immutable") end
}) -- File scope to allow for reuse
--- Default status, will raise on_function_complete
-- @param ... The return value of the async call
function Async.status.complete(...)
if ... == nil then
return Async.status.complete, empty_table
end
return Async.status.complete, {...}
end
--- Will queue the function to be called again on the next tick using the new arguments
-- @param ... The arguments to call the function with
function Async.status.continue(...)
if ... == nil then
return Async.status.continue, empty_table
end
return Async.status.continue, {...}
end
--- Will queue the function to be called again on a later tick using the new arguments
-- @param ... The arguments to call the function with
function Async.status.delay(ticks, ...)
ExpUtil.assert_argument_type(ticks, "number", 1, "ticks")
if ... == nil then
return Async.status.continue, ticks, empty_table
end
return Async.status.delay, ticks, {...}
end
--- Executes an async function and processes the return value
local function exec(pending, tick, new_next, new_queue)
local status, rtn1, rtn2 = Async._functions[pending.id](table.unpack(pending.args))
if status == Async.status.continue then
new_next[#new_next + 1] = pending
pending.tick = nil
pending.args = rtn1
elseif status == Async.status.delay then
new_queue[#new_queue + 1] = pending
pending.tick = tick + rtn1
pending.args = rtn2
elseif status == Async.status.complete or status == nil then
-- The function has finished execution, raise the custom event
Async._queue_pressure[pending.id] = Async._queue_pressure[pending.id] - 1
script.raise_event(Async.on_function_complete, {
event = Async.on_function_complete,
tick = tick,
async_id = pending.id,
returned = rtn1
})
else
error("Async function " .. pending.id .. " returned an invalid status: " .. table.inspect(status))
end
end
local new_next, new_queue = {}, {} -- File scope to allow for reuse
--- Each tick, run all next tick functions, then check if any in the queue need to be executed
local function on_tick()
if async_next == nil then return end
local tick = game.tick
-- Execute all pending functions
for index = 1, #async_next, 1 do
exec(async_next[index], tick, new_next, new_queue)
async_next[index] = nil
end
for index = #async_queue, 1, -1 do
local pending = async_queue[index]
if pending.tick > tick then
break;
end
exec(pending, tick, new_next, new_queue)
async_queue[index] = nil
end
-- Queue any functions that did not complete
for index = 1, #new_next, 1 do
async_next[index] = new_next[index]
new_next[index] = nil
end
for index = 1, #new_queue, 1 do
add_to_queue(new_next[index])
new_next[index] = nil
end
end
--- On load, check the queue status and update the pressure values
local function on_load()
if global.exp_async_next == nil then return end
async_next = global.exp_async_next
async_queue = global.exp_async_queue
for _, pending in ipairs(async_next) do
local count = Async._queue_pressure[pending.id]
if count == nil then
log("Warning: Pending async function missing after load: " .. pending.id)
Async._functions[pending.id] = function() end -- NOP
count = 0
end
Async._queue_pressure[pending.id] = count + 1
end
for _, pending in ipairs(async_queue) do
local count = Async._queue_pressure[pending.id]
if count == nil then
log("Warning: Pending async function missing after load: " .. pending.id)
Async._functions[pending.id] = function() end -- NOP
count = 0
end
Async._queue_pressure[pending.id] = count + 1
end
end
--- On server startup initialise the global data
local function on_server_startup()
if global.exp_async_next == nil then
global.exp_async_next = {}
global.exp_async_queue = {}
end
on_load()
end
Async.on_load = on_load
Async.on_init = on_server_startup
Async.events[defines.events.on_tick] = on_tick
Async.events[Clustorio.events.on_server_startup] = on_server_startup
return Async

384
exp_util/module/common.lua Normal file
View File

@@ -0,0 +1,384 @@
--[[-- Util Module - Common
- Adds some commonly used functions used in many modules
@core Common
@alias Common
]]
local assert = assert
--local getlocal = debug.getlocal
--local getupvalue = debug.getupvalue
local getinfo = debug.getinfo
local traceback = debug.traceback
local floor = math.floor
local concat = table.concat
local Common = {
--- A large mapping of colour rgb values by their common name
color = require 'modules.exp_util.include.color'
}
--- Raise an error if we are not in runtime
function Common.assert_not_runtime()
assert(package.lifecycle ~= package.lifecycle_stage.runtime, "Can not be called during runtime")
end
--[[local assert_not_closure_fmt = "Can not be called with the closure %s at runtime"
--- Raise an error if a function is a closure and we are in runtime
-- @tparam function func The function to assert is not a closure if we are in runtime
function Common.assert_not_closure(func)
assert(package.lifecycle ~= package.lifecycle_stage.runtime, "Can not be called during runtime")
local info = getinfo(2, "nu")
for i = 1, info.nups do
if getupvalue(func, i) ~= "_ENV" then
error(assert_not_closure_fmt:format(info.name or "<anonymous>"))
end
end
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 The value to assert the type of
-- @tparam string type_name The name of the type that value is expected to be
-- @tparam[opt=Value] string value_name The name of the value being tested, this is included in the error message
function Common.assert_type(value, type_name, value_name)
if value == nil or type(value) ~= type_name then
error(assert_type_fmt:format(value_name or "Value", type_name, type(value)), 2)
end
end
local assert_argument_fmt = "Bad argument #%d to %s; %s expected to be of type %s but got %s"
--[[--- Raise an error if the type of any argument is not as expected, can be costly, for frequent callers see assert_argument_type
-- @tparam string ... The type for each argument of the calling function
function Common.assert_argument_types(...)
local arg_types = {...}
local info = getinfo(2, "nu")
for arg_index = 1, info.nparams do
local arg_name, arg_value = getlocal(2, arg_index)
if arg_types[arg_index] and (arg_value == nil or type(arg_value) ~= arg_types[arg_index]) then
error(assert_argument_fmt:format(arg_index, info.name or "<anonymous>", arg_name, arg_types[arg_index]), 2)
end
end
end]]
--- Raise an error if the type of any argument is not as expected, more performant than assert_argument_types, but requires more manual input
-- @param arg_value The argument to assert the type of
-- @tparam string type_name The name of the type that value is expected to be
-- @tparam number arg_index The index of the argument being tested, this is included in the error message
-- @tparam[opt=Argument] string arg_name The name of the argument being tested, this is included in the error message
function Common.assert_argument_type(arg_value, type_name, arg_index, arg_name)
if arg_value == nil or type(arg_value) ~= type_name then
local func_name = getinfo(2, "n").name or "<anonymous>"
error(assert_argument_fmt:format(arg_index, func_name, arg_name or "Argument", type_name), 2)
end
end
--- Write a luu table to a file as a json string, note the defaults are different to game.write_file
-- @tparam string path The path to write the json to
-- @tparam table value The table to write to file
-- @tparam[opt=false] boolean overwrite When true the json replaces the full contents of the file
-- @tparam[opt=0] number player_index The player's machine to write on, -1 means all, 0 means host only
function Common.write_json(path, tbl, overwrite, player_index)
if player_index == -1 then
return game.write_file(path, game.table_to_json(tbl).."\n", not overwrite)
end
return game.write_file(path, game.table_to_json(tbl).."\n", not overwrite, player_index or 0)
end
--- Clear a file by replacing its contents with an empty string
-- @tparam string path The path to clear the contents of
-- @tparam[opt=0] number player_index The player's machine to write on, -1 means all, 0 means host only
function Common.clear_file(path, player_index)
if player_index == -1 then
return game.write_file(path, "", false)
end
return game.write_file(path, "", false, player_index or 0)
end
--- Same as require but will return nil if the module does not exist, all other errors will propagate to the caller
-- @tparam string module_path The path to the module to require, same syntax as normal require
-- @return The contents of the module, or nil if the module does not exist or did not return a value
function Common.optional_require(module_path)
local success, rtn = xpcall(require, traceback, module_path)
if success then return rtn end
if not rtn:find("not found; no such file", 0, true) then
error(rtn, 2)
end
end
--- Returns a desync sale filepath for a given stack frame, default is the current file
-- @tparam number level The level of the stack to get the file of, a value of 1 is the caller of this function
-- @treturn string The relative filepath of the given stack frame
function Common.safe_file_path(level)
level = level or 1
return getinfo(level+1, 'S').source:match('^.+/currently%-playing/(.+)$'):sub(1, -5)
end
--- Returns the name of your module, this assumes your module is stored within /modules (which it is for clustorio)
-- @tparam[opt=1] number level The level of the stack to get the module of, a value of 1 is the caller of this function
-- @treturn string The name of the module at the given stack frame
function Common.get_module_name(level)
local file_within_module = getinfo((level or 1)+1, 'S').source:match('^.+/currently%-playing/modules/(.+)$'):sub(1, -5)
local next_slash = file_within_module:find("/")
if next_slash then
return file_within_module:sub(1, next_slash-1)
else
return file_within_module
end
end
--- Returns the name of a function in a safe and consistent format
-- @tparam number|function func The level of the stack to get the name of, a value of 1 is the caller of this function
-- @tparam boolean raw When true there will not be any < > around the name
-- @treturn 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:match('^.+/currently%-playing/(.+)$')
local file_name = safe_source and safe_source:sub(1, -5) or debug_info.source
local func_name = debug_info.name or debug_info.linedefined
if raw then return file_name .. ":" .. func_name end
return "<" .. file_name .. ":" .. func_name .. ">"
end
--- Attempt a simple autocomplete search from a set of options
-- @tparam table options The table representing the possible options which can be selected
-- @tparam string input The user input string which should be matched to an option
-- @tparam[opt=false] boolean use_key When true the keys will be searched, when false the values will be searched
-- @tparam[opt=false] boolean rtn_key When true the selected key will be returned, when false the selected value will be returned
-- @return The selected key or value which first matches the input text
function Common.auto_complete(options, input, use_key, rtn_key)
input = input:lower()
if use_key then
for k, v in pairs(options) do
if k:lower():find(input) then
if rtn_key then return k else return v end
end
end
else
for k, v in pairs(options) do
if v:lower():find(input) then
if rtn_key then return k else return v end
end
end
end
end
--- Formats any value into a safe representation, useful with table.insert
-- @param value The value to be formated
-- @return The formated version of the value
-- @return True if value is a locale string, nil otherwise
function Common.safe_value(value)
if type(value) == "table" or type(value) == "userdata" then
if type(value.__self) == "userdata" or type(value) == "userdata" then
local success, rtn = pcall(function() -- some userdata doesnt contain "valid"
if value.valid then -- userdata
return "<userdata:"..value.object_name..">"
else -- invalid userdata
return "<userdata:"..value.object_name..":invalid>"
end
end)
return success and rtn or "<userdata:"..value.object_name..">"
elseif type(value[1]) == "string" and string.find(value[1], ".+[.].+") and not string.find(value[1], "%s") then
return value, true -- locale string
elseif tostring(value) ~= "table" then
return tostring(value) -- has __tostring metamethod
else -- plain table
return value
end
elseif type(value) == "function" then -- function
return "<function:"..Common.get_function_name(value, true)..">"
else -- not: table, userdata, or function
return tostring(value)
end
end
--- Formats any value to be presented in a safe and human readable format
-- @param value The value to be formated
-- @param[opt] tableAsJson If table values should be returned as json
-- @param[opt] maxLineCount If table newline count exceeds provided then it will be inlined
-- @return The formated version of the value
function Common.format_any(value, tableAsJson, maxLineCount)
local formatted, is_locale_string = Common.safe_value(value)
if type(formatted) == "table" and not is_locale_string then
if tableAsJson then
local success, rtn = pcall(game.table_to_json, value)
if success then return rtn end
end
local rtn = table.inspect(value, {depth=5, indent=' ', newline='\n', process=Common.safe_value})
if maxLineCount == nil or select(2, rtn:gsub("\n", "")) < maxLineCount then return rtn end
return table.inspect(value, {depth=5, indent='', newline='', process=Common.safe_value})
end
return formatted
end
--- Format a tick value into one of a selection of pre-defined formats (short, long, clock)
-- @tparam number ticks The number of ticks which will be represented, can be any duration or time value
-- @tparam string format The format to display, must be one of: short, long, clock
-- @tparam[opt] table units A table selecting which units should be displayed, options are: days, hours, minutes, seconds
-- @treturn string The ticks formatted into a string of the desired format
function Common.format_time(ticks, format, units)
units = units or { days = false, hours = true, minutes = true, seconds = false }
local rtn_days, rtn_hours, rtn_minutes, rtn_seconds = "--", "--", "--", "--"
if ticks ~= nil then
-- Calculate the values to be determine the display values
local max_days, max_hours, max_minutes, max_seconds = ticks/5184000, ticks/216000, ticks/3600, ticks/60
local days, hours = max_days, max_hours-floor(max_days)*24
local minutes, seconds = max_minutes-floor(max_hours)*60, max_seconds-floor(max_minutes)*60
-- Calculate rhw units to be displayed
rtn_days, rtn_hours, rtn_minutes, rtn_seconds = floor(days), floor(hours), floor(minutes), floor(seconds)
if not units.days then rtn_hours = rtn_hours + rtn_days*24 end
if not units.hours then rtn_minutes = rtn_minutes + rtn_hours*60 end
if not units.minutes then rtn_seconds = rtn_seconds + rtn_minutes*60 end
end
local rtn = {}
if format == "clock" then
-- Example 12:34:56 or --:--:--
if units.days then rtn[#rtn+1] = rtn_days end
if units.hours then rtn[#rtn+1] = rtn_hours end
if units.minutes then rtn[#rtn+1] = rtn_minutes end
if units.seconds then rtn[#rtn+1] = rtn_seconds end
return concat(rtn, ":")
elseif format == "short" then
-- Example 12d 34h 56m or --d --h --m
if units.days then rtn[#rtn+1] = rtn_days.."d" end
if units.hours then rtn[#rtn+1] = rtn_hours.."h" end
if units.minutes then rtn[#rtn+1] = rtn_minutes.."m" end
if units.seconds then rtn[#rtn+1] = rtn_seconds.."s" end
return concat(rtn, " ")
else
-- Example 12 days, 34 hours, and 56 minutes or -- days, -- hours, and -- minutes
if units.days then rtn[#rtn+1] = rtn_days.." days" end
if units.hours then rtn[#rtn+1] = rtn_hours.." hours" end
if units.minutes then rtn[#rtn+1] = rtn_minutes.." minutes" end
if units.seconds then rtn[#rtn+1] = rtn_seconds.." seconds" end
rtn[#rtn] = "and "..rtn[#rtn]
return concat(rtn, ", ")
end
end
--- Format a tick value into one of a selection of pre-defined formats (short, long, clock)
-- @tparam number ticks The number of ticks which will be represented, can be any duration or time value
-- @tparam string format The format to display, must be one of: short, long, clock
-- @tparam[opt] table units A table selecting which units should be displayed, options are: days, hours, minutes, seconds
-- @treturn LocaleString The ticks formatted into a LocaleString of the desired format
function Common.format_locale_time(ticks, format, units)
units = units or { days = false, hours = true, minutes = true, seconds = false }
local rtn_days, rtn_hours, rtn_minutes, rtn_seconds = "--", "--", "--", "--"
if ticks ~= nil then
-- Calculate the values to be determine the display values
local max_days, max_hours, max_minutes, max_seconds = ticks/5184000, ticks/216000, ticks/3600, ticks/60
local days, hours = max_days, max_hours-floor(max_days)*24
local minutes, seconds = max_minutes-floor(max_hours)*60, max_seconds-floor(max_minutes)*60
-- Calculate rhw units to be displayed
rtn_days, rtn_hours, rtn_minutes, rtn_seconds = floor(days), floor(hours), floor(minutes), floor(seconds)
if not units.days then rtn_hours = rtn_hours + rtn_days*24 end
if not units.hours then rtn_minutes = rtn_minutes + rtn_hours*60 end
if not units.minutes then rtn_seconds = rtn_seconds + rtn_minutes*60 end
end
local rtn = {}
local join = ", "
if format == "clock" then
-- Example 12:34:56 or --:--:--
if units.days then rtn[#rtn+1] = rtn_days end
if units.hours then rtn[#rtn+1] = rtn_hours end
if units.minutes then rtn[#rtn+1] = rtn_minutes end
if units.seconds then rtn[#rtn+1] = rtn_seconds end
join = { "colon" }
elseif format == "short" then
-- Example 12d 34h 56m or --d --h --m
if units.days then rtn[#rtn+1] = {"?", {"time-symbol-days-short", rtn_days}, rtn_days.."d"} end
if units.hours then rtn[#rtn+1] = {"time-symbol-hours-short", rtn_hours} end
if units.minutes then rtn[#rtn+1] = {"time-symbol-minutes-short", rtn_minutes} end
if units.seconds then rtn[#rtn+1] = {"time-symbol-seconds-short", rtn_seconds} end
join = " "
else
-- Example 12 days, 34 hours, and 56 minutes or -- days, -- hours, and -- minutes
if units.days then rtn[#rtn+1] = {"days", rtn_days} end
if units.hours then rtn[#rtn+1] = {"hours", rtn_hours} end
if units.minutes then rtn[#rtn+1] = {"minutes", rtn_minutes} end
if units.seconds then rtn[#rtn+1] = {"seconds", rtn_seconds} end
rtn[#rtn] = {"", { "and" }, " ", rtn[#rtn]}
end
local joined = { "" }
for k, v in ipairs(rtn) do
joined[2*k] = v
joined[2*k+1] = join
end
return joined
end
--- Insert a copy of the given items into the found / created entities. If no entities are found then they will be created if possible.
-- @tparam table items The items which are to be inserted into the entities, an array of LuaItemStack
-- @tparam LuaSurface surface The surface which will be searched to find the entities
-- @tparam table options A table of various optional options similar to find_entities_filtered
-- position + radius or area can be used to define a search area on the surface
-- type can be used to find all entities of a given type, such as a chest
-- name can be used to further specify which entity to insert into, this field is required if entity creation is desired
-- allow_creation is a boolean which when true will allow the function to create new entities in order to insert all items
-- force is the force which new entities will be created to, the default is the neutral force
-- @treturn LuaEntity the last entity that had items inserted into it
function Common.insert_item_stacks(items, surface, options)
local entities = surface.find_entities_filtered(options)
local count, current, last_entity = #entities, 0, nil
for _, item in ipairs(items) do
if item.valid_for_read then
local inserted = false
-- Attempt to insert the items
for i = 1, count do
local entity = entities[((current+i-1)%count)+1]
if entity.can_insert(item) then
last_entity = entity
current = current + 1
entity.insert(item)
inserted = true
end
end
-- If it was not inserted then a new entity is needed
if not inserted then
if not options.allow_creation then error("Unable to insert items into a valid entity, consider enabling allow_creation") end
if options.name == nil then error("Name must be provided to allow creation of new entities") end
local position
if options.position then
position = surface.find_non_colliding_position(options.name, options.position, options.radius, 1, true)
elseif options.area then
position = surface.find_non_colliding_position_in_box(options.name, options.area, 1, true)
else
position = surface.find_non_colliding_position(options.name, {0,0}, 0, 1, true)
end
last_entity = surface.create_entity{name = options.name, position = position, force = options.force or "neutral"}
count = count + 1
entities[count] = last_entity
last_entity.insert(item)
end
end
end
return last_entity
end
--- Move the given items into the found / created entities. If no entities are found then they will be created if possible.
-- @tparam table items The items which are to be inserted into the entities, an array of LuaItemStack
-- @tparam LuaSurface surface The surface which will be searched to find the entities
-- @tparam table options A table of various optional options similar to find_entities_filtered
-- position + radius or area can be used to define a search area on the surface
-- type can be used to find all entities of a given type, such as a chest
-- name can be used to further specify which entity to insert into, this field is required if entity creation is desired
-- allow_creation is a boolean which when true will allow the function to create new entities in order to insert all items
-- @treturn LuaEntity the last entity that had items inserted into it
function Common.transfer_item_stacks(inventory, surface, options)
Common.insert_item_stacks(inventory, surface, options)
inventory.clear()
end
return Common

View File

@@ -0,0 +1,180 @@
--[[-- Util Module - FloatingText
- Provides a method of creating floating text and tags in the world
@core FloatingText
@alias FloatingText
@usage-- Show player chat message in world
local function on_console_chat(event)
local player = game.get_player(event.player_index)
FloatingText.print_as_player(player, event.message)
end
@usage-- Show player tags above their characters
local function on_player_respawned(event)
local player = game.get_player(event.player_index)
FloatingText.create_tag_as_player(player, player.tag)
end
@usage-- Show placed an entity in alt mode
local function on_built_entity(event)
local entity = event.created_entity
local player = game.get_player(event.player_index)
FloatingText.create_tag_above_entity(entity, player.name, player.color, true)
end
]]
local FloatingText = {}
FloatingText.color = require("modules.exp_util.include.color")
--- Print Messages.
-- Short lived messages that last at most a few seconds
-- @section floating-text_print
--- Print floating text at the given position on the given surface
-- @tparam LuaSurface surface The surface where the floating text will be created
-- @tparam MapPosition position The position to create the floating text at
-- @tparam string text The text which will be printed
-- @tparam[opt=FloatingText.color.white] Color color The colour to print the text in
-- @treturn LuaEntity The floating text entity which was created for the message
function FloatingText.print(surface, position, text, color)
return surface.create_entity{
text = text,
name = 'tutorial-flying-text',
color = color or FloatingText.color.white,
position = position
}
end
--- Print floating text above the given entity
-- @tparam LuaEntity The entity to create the text above
-- @tparam string text The text which will be printed
-- @tparam[opt=FloatingText.color.white] Color color The colour to print the text in
-- @treturn LuaEntity The floating text entity which was created for the message
function FloatingText.print_above_entity(entity, text, color)
local size_y = entity.bounding_box.left_top.y - entity.bounding_box.right_bottom.y
return entity.surface.create_entity{
text = text,
name = 'tutorial-flying-text',
color = color or FloatingText.color.white,
position = {
x = entity.position.x,
y = entity.position.y - size_y * 0.25
}
}
end
--- Print floating text above the given player
-- @tparam LuaPlayer The player to create the text above
-- @tparam string text The text which will be printed
-- @tparam[opt=FloatingText.color.white] Color color The colour to print the text in
-- @treturn LuaEntity The floating text entity which was created for the message
function FloatingText.print_above_player(player, text, color)
return player.surface.create_entity{
text = text,
name = 'tutorial-flying-text',
color = color or FloatingText.color.white,
position = {
x = player.position.x,
y = player.position.y - 1.5
}
}
end
--- Print floating text above the given player in their chat color
-- @tparam LuaPlayer The player to create the text above
-- @tparam string text The text which will be printed
-- @treturn LuaEntity The floating text entity which was created for the message
function FloatingText.print_as_player(player, text)
return player.surface.create_entity{
text = text,
name = 'tutorial-flying-text',
color = player.chat_color,
position = {
x = player.position.x,
y = player.position.y - 1.5
}
}
end
--- Tag Messages.
-- Long lived messages that last until their are removed
-- @section floating-text_tags
--- Create floating text at the given position on the given surface
-- @tparam LuaSurface surface The surface where the floating text will be created
-- @tparam MapPosition position The position to create the floating text at
-- @tparam string text The text which will be printed
-- @tparam[opt=FloatingText.color.white] Color color The colour to print the text in
-- @tparam[opt=false] boolean alt_mode When true, the text will only appear when a player is in alt mode
-- @treturn LuaEntity The floating text entity which was created for the message
function FloatingText.create_tag(surface, position, text, color, alt_mode)
return rendering.draw_text{
text = text,
surface = surface,
color = color or FloatingText.color.white,
only_in_alt_mode = alt_mode,
target = position
}
end
--- Create floating text above the given entity
-- @tparam LuaEntity The entity to create the text above
-- @tparam string text The text which will be printed
-- @tparam[opt=FloatingText.color.white] Color color The colour to print the text in
-- @tparam[opt=false] boolean alt_mode When true, the text will only appear when a player is in alt mode
-- @treturn LuaEntity The floating text entity which was created for the message
function FloatingText.create_tag_above_entity(entity, text, color, alt_mode)
return rendering.draw_text{
text = text,
surface = entity.surface,
color = color or FloatingText.color.white,
only_in_alt_mode = alt_mode,
target = entity,
target_offset = {
x = 0,
y = (entity.bounding_box.left_top.y - entity.bounding_box.right_bottom.y) * -0.25
}
}
end
--- Create floating text above the given player
-- @tparam LuaPlayer The player to create the text above
-- @tparam string text The text which will be printed
-- @tparam[opt=FloatingText.color.white] Color color The colour to print the text in
-- @tparam[opt=false] boolean alt_mode When true, the text will only appear when a player is in alt mode
-- @treturn LuaEntity The floating text entity which was created for the message
function FloatingText.create_tag_above_player(player, text, color, alt_mode)
return rendering.draw_text{
text = text,
surface = player.surface,
color = color or FloatingText.color.white,
only_in_alt_mode = alt_mode,
target = player.character,
target_offset = {
x = 0,
y = -1.5
}
}
end
--- Create floating text above the given player in their character color
-- @tparam LuaPlayer The player to create the text above
-- @tparam string text The text which will be printed
-- @tparam[opt=false] boolean alt_mode When true, the text will only appear when a player is in alt mode
-- @treturn LuaEntity The floating text entity which was created for the message
function FloatingText.create_tag_as_player(player, text, alt_mode)
return rendering.draw_text{
text = text,
surface = player.surface,
color = player.color,
only_in_alt_mode = alt_mode,
target = player.character,
target_offset = {
x = 0,
y = -1.5
}
}
end
return FloatingText

101
exp_util/module/global.lua Normal file
View File

@@ -0,0 +1,101 @@
--[[-- Util Module - Global
- Provides a method of using global with the guarantee that keys will not conflict
@core Global
@alias Global
@usage--- Drop in boiler plate:
-- Below is a drop in boiler plate which ensures your global access will not conflict with other modules
local global = {}
Global.register(global, function(tbl)
global = tbl
end)
@usage--- Registering new global tables:
-- The boiler plate above is not recommend because it is not descriptive in its function
-- Best practice is to list out all variables you are storing in global and their function
local MyModule = {
public_data = {} -- Stores data which other modules can access
}
local private_data = {} -- Stores data which other modules cant access
local more_private_data = {} -- Stores more data which other modules cant access
-- You can not store a whole module in global because not all data types are serialisable
Global.register({
MyModule.public_data,
private_data,
more_private_data
}, function(tbl)
-- You can also use this callback to set metatable on class instances you have stored in global
MyModule.public_data = tbl[1]
private_data = tbl[2]
more_private_data = tbl[3]
end)
]]
local Clustorio = require("modules/clusterio/api")
local ExpUtil = require("modules.exp_util.common")
local Global = {
registered = {}, -- Map of all registered values and their initial values
}
--- Register a new table to be stored in global, can only be called once per file, can not be called during runtime
-- @tparam table tbl The initial value for the table you are registering, this should be a local variable
-- @tparam function callback The callback used to replace local references and metatables
function Global.register(tbl, callback)
ExpUtil.assert_not_runtime()
ExpUtil.assert_argument_type(tbl, "table", 1, "tbl")
ExpUtil.assert_argument_type(callback, "function", 2, "callback")
local name = ExpUtil.safe_file_path(2)
if Global.registered[name] then
error("Global.register can only be called once per file", 2)
end
Global.registered[name] = {
init = tbl,
callback = callback
}
end
--- Register a metatable which will be automatically restored during on_load
-- @tparam string name The name of the metatable to register, must be unique within your module
function Global.register_metatable(name, tbl)
local module_name = ExpUtil.get_module_name(2)
script.register_metatable(module_name.."."..name, tbl)
end
--- Restore aliases on load, we do not need to initialise data during this event
function Global.on_load()
local globals = global.exp_global
if globals == nil then return end
for name, data in pairs(Global.registered) do
if globals[name] ~= nil then
data.callback(globals[name])
end
end
end
--- Event Handler, sets initial values if needed and calls all callbacks
local function on_server_startup()
local globals = global.exp_global
if globals == nil then
globals = {}
global.exp_global = globals
end
for name, data in pairs(Global.registered) do
if globals[name] == nil then
globals[name] = data.init
end
data.callback(globals[name])
end
end
Global.on_init = on_server_startup
Global.events = {
[Clustorio.events.on_server_startup] = on_server_startup
}
return Global

View File

@@ -0,0 +1,147 @@
-- source: https://www.rapidtables.com/web/color/RGB_Color.html
return {
maroon = {r = 128, g = 0, b = 0},
dark_red = {r = 139, g = 0, b = 0},
brown = {r = 165, g = 42, b = 42},
firebrick = {r = 178, g = 34, b = 34},
crimson = {r = 220, g = 20, b = 60},
red = {r = 255, g = 0, b = 0},
tomato = {r = 255, g = 99, b = 71},
coral = {r = 255, g = 127, b = 80},
indian_red = {r = 205, g = 92, b = 92},
light_coral = {r = 240, g = 128, b = 128},
dark_salmon = {r = 233, g = 150, b = 122},
salmon = {r = 250, g = 128, b = 114},
light_salmon = {r = 255, g = 160, b = 122},
orange_red = {r = 255, g = 69, b = 0},
dark_orange = {r = 255, g = 140, b = 0},
orange = {r = 255, g = 165, b = 0},
gold = {r = 255, g = 215, b = 0},
dark_golden_rod = {r = 184, g = 134, b = 11},
golden_rod = {r = 218, g = 165, b = 32},
pale_golden_rod = {r = 238, g = 232, b = 170},
dark_khaki = {r = 189, g = 183, b = 107},
khaki = {r = 240, g = 230, b = 140},
olive = {r = 128, g = 128, b = 0},
yellow = {r = 255, g = 255, b = 0},
yellow_green = {r = 154, g = 205, b = 50},
dark_olive_green = {r = 85, g = 107, b = 47},
olive_drab = {r = 107, g = 142, b = 35},
lawn_green = {r = 124, g = 252, b = 0},
chart_reuse = {r = 127, g = 255, b = 0},
green_yellow = {r = 173, g = 255, b = 47},
dark_green = {r = 0, g = 100, b = 0},
green = {r = 0, g = 128, b = 0},
forest_green = {r = 34, g = 139, b = 34},
lime = {r = 0, g = 255, b = 0},
lime_green = {r = 50, g = 205, b = 50},
light_green = {r = 144, g = 238, b = 144},
pale_green = {r = 152, g = 251, b = 152},
dark_sea_green = {r = 143, g = 188, b = 143},
medium_spring_green = {r = 0, g = 250, b = 154},
spring_green = {r = 0, g = 255, b = 127},
sea_green = {r = 46, g = 139, b = 87},
medium_aqua_marine = {r = 102, g = 205, b = 170},
medium_sea_green = {r = 60, g = 179, b = 113},
light_sea_green = {r = 32, g = 178, b = 170},
dark_slate_gray = {r = 47, g = 79, b = 79},
teal = {r = 0, g = 128, b = 128},
dark_cyan = {r = 0, g = 139, b = 139},
aqua = {r = 0, g = 255, b = 255},
cyan = {r = 0, g = 255, b = 255},
light_cyan = {r = 224, g = 255, b = 255},
dark_turquoise = {r = 0, g = 206, b = 209},
turquoise = {r = 64, g = 224, b = 208},
medium_turquoise = {r = 72, g = 209, b = 204},
pale_turquoise = {r = 175, g = 238, b = 238},
aqua_marine = {r = 127, g = 255, b = 212},
powder_blue = {r = 176, g = 224, b = 230},
cadet_blue = {r = 95, g = 158, b = 160},
steel_blue = {r = 70, g = 130, b = 180},
corn_flower_blue = {r = 100, g = 149, b = 237},
deep_sky_blue = {r = 0, g = 191, b = 255},
dodger_blue = {r = 30, g = 144, b = 255},
light_blue = {r = 173, g = 216, b = 230},
sky_blue = {r = 135, g = 206, b = 235},
light_sky_blue = {r = 135, g = 206, b = 250},
midnight_blue = {r = 25, g = 25, b = 112},
navy = {r = 0, g = 0, b = 128},
dark_blue = {r = 0, g = 0, b = 139},
medium_blue = {r = 0, g = 0, b = 205},
blue = {r = 0, g = 0, b = 255},
royal_blue = {r = 65, g = 105, b = 225},
blue_violet = {r = 138, g = 43, b = 226},
indigo = {r = 75, g = 0, b = 130},
dark_slate_blue = {r = 72, g = 61, b = 139},
slate_blue = {r = 106, g = 90, b = 205},
medium_slate_blue = {r = 123, g = 104, b = 238},
medium_purple = {r = 147, g = 112, b = 219},
dark_magenta = {r = 139, g = 0, b = 139},
dark_violet = {r = 148, g = 0, b = 211},
dark_orchid = {r = 153, g = 50, b = 204},
medium_orchid = {r = 186, g = 85, b = 211},
purple = {r = 128, g = 0, b = 128},
thistle = {r = 216, g = 191, b = 216},
plum = {r = 221, g = 160, b = 221},
violet = {r = 238, g = 130, b = 238},
magenta = {r = 255, g = 0, b = 255},
fuchsia = {r = 255, g = 0, b = 255},
orchid = {r = 218, g = 112, b = 214},
medium_violet_red = {r = 199, g = 21, b = 133},
pale_violet_red = {r = 219, g = 112, b = 147},
deep_pink = {r = 255, g = 20, b = 147},
hot_pink = {r = 255, g = 105, b = 180},
light_pink = {r = 255, g = 182, b = 193},
pink = {r = 255, g = 192, b = 203},
antique_white = {r = 250, g = 235, b = 215},
beige = {r = 245, g = 245, b = 220},
bisque = {r = 255, g = 228, b = 196},
blanched_almond = {r = 255, g = 235, b = 205},
wheat = {r = 245, g = 222, b = 179},
corn_silk = {r = 255, g = 248, b = 220},
lemon_chiffon = {r = 255, g = 250, b = 205},
light_golden_rod_yellow = {r = 250, g = 250, b = 210},
light_yellow = {r = 255, g = 255, b = 224},
saddle_brown = {r = 139, g = 69, b = 19},
sienna = {r = 160, g = 82, b = 45},
chocolate = {r = 210, g = 105, b = 30},
peru = {r = 205, g = 133, b = 63},
sandy_brown = {r = 244, g = 164, b = 96},
burly_wood = {r = 222, g = 184, b = 135},
tan = {r = 210, g = 180, b = 140},
rosy_brown = {r = 188, g = 143, b = 143},
moccasin = {r = 255, g = 228, b = 181},
navajo_white = {r = 255, g = 222, b = 173},
peach_puff = {r = 255, g = 218, b = 185},
misty_rose = {r = 255, g = 228, b = 225},
lavender_blush = {r = 255, g = 240, b = 245},
linen = {r = 250, g = 240, b = 230},
old_lace = {r = 253, g = 245, b = 230},
papaya_whip = {r = 255, g = 239, b = 213},
sea_shell = {r = 255, g = 245, b = 238},
mint_cream = {r = 245, g = 255, b = 250},
slate_gray = {r = 112, g = 128, b = 144},
light_slate_gray = {r = 119, g = 136, b = 153},
light_steel_blue = {r = 176, g = 196, b = 222},
lavender = {r = 230, g = 230, b = 250},
floral_white = {r = 255, g = 250, b = 240},
alice_blue = {r = 240, g = 248, b = 255},
ghost_white = {r = 248, g = 248, b = 255},
honeydew = {r = 240, g = 255, b = 240},
ivory = {r = 255, g = 255, b = 240},
azure = {r = 240, g = 255, b = 255},
snow = {r = 255, g = 250, b = 250},
black = {r = 0, g = 0, b = 0},
silver = {r = 192, g = 192, b = 192},
dim_grey = {r = 105, g = 105, b = 105},
grey = {r = 128, g = 128, b = 128},
dark_grey = {r = 169, g = 169, b = 169},
light_grey = {r = 211, g = 211, b = 211},
gainsboro = {r = 220, g = 220, b = 220},
white_smoke = {r = 245, g = 245, b = 245},
white = {r = 255, g = 255, b = 255},
success = {r = 0, g = 255, b = 0},
warning = {r = 255, g = 255, b = 0},
fail = {r = 255, g = 0, b = 0},
info = {r = 255, g = 255, b = 255}
}

View File

@@ -0,0 +1,342 @@
local inspect = {
_VERSION = 'inspect.lua 3.1.0',
_URL = 'http://github.com/kikito/inspect.lua',
_DESCRIPTION = 'human-readable representations of tables',
_LICENSE = [[
MIT LICENSE
Copyright (c) 2013 Enrique García Cota
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
}
local tostring = tostring
inspect.KEY = setmetatable({}, {__tostring = function() return 'inspect.KEY' end})
inspect.METATABLE = setmetatable({}, {__tostring = function() return 'inspect.METATABLE' end})
-- Apostrophizes the string if it has quotes, but not aphostrophes
-- Otherwise, it returns a regular quoted string
local function smartQuote(str)
if str:match('"') and not str:match("'") then
return "'" .. str .. "'"
end
return '"' .. str:gsub('"', '\\"') .. '"'
end
-- \a => '\\a', \0 => '\\0', 31 => '\31'
local shortControlCharEscapes = {
["\a"] = "\\a", ["\b"] = "\\b", ["\f"] = "\\f", ["\n"] = "\\n",
["\r"] = "\\r", ["\t"] = "\\t", ["\v"] = "\\v"
}
local longControlCharEscapes = {} -- \a => nil, \0 => \000, 31 => \031
for i=0, 31 do
local ch = string.char(i)
if not shortControlCharEscapes[ch] then
shortControlCharEscapes[ch] = "\\"..i
longControlCharEscapes[ch] = string.format("\\%03d", i)
end
end
local function escape(str)
return (str:gsub("\\", "\\\\")
:gsub("(%c)%f[0-9]", longControlCharEscapes)
:gsub("%c", shortControlCharEscapes))
end
local function isIdentifier(str)
return type(str) == 'string' and str:match( "^[_%a][_%a%d]*$" )
end
local function isSequenceKey(k, sequenceLength)
return type(k) == 'number'
and 1 <= k
and k <= sequenceLength
and math.floor(k) == k
end
local defaultTypeOrders = {
['number'] = 1, ['boolean'] = 2, ['string'] = 3, ['table'] = 4,
['function'] = 5, ['userdata'] = 6, ['thread'] = 7
}
local function sortKeys(a, b)
local ta, tb = type(a), type(b)
-- strings and numbers are sorted numerically/alphabetically
if ta == tb and (ta == 'string' or ta == 'number') then return a < b end
local dta, dtb = defaultTypeOrders[ta], defaultTypeOrders[tb]
-- Two default types are compared according to the defaultTypeOrders table
if dta and dtb then return defaultTypeOrders[ta] < defaultTypeOrders[tb]
elseif dta then return true -- default types before custom ones
elseif dtb then return false -- custom types after default ones
end
-- custom types are sorted out alphabetically
return ta < tb
end
-- For implementation reasons, the behavior of rawlen & # is "undefined" when
-- tables aren't pure sequences. So we implement our own # operator.
local function getSequenceLength(t)
local len = 1
local v = rawget(t, len)
while v ~= nil do
len = len + 1
v = rawget(t, len)
end
return len - 1
end
local function getNonSequentialKeys(t)
local keys = {}
local sequenceLength = getSequenceLength(t)
for k, _ in pairs(t) do
if not isSequenceKey(k, sequenceLength) then table.insert(keys, k) end
end
table.sort(keys, sortKeys)
return keys, sequenceLength
end
local function getToStringResultSafely(t, mt)
local __tostring = type(mt) == 'table' and rawget(mt, '__tostring')
local str, ok
if type(__tostring) == 'function' then
ok, str = pcall(__tostring, t)
str = ok and str or 'error: ' .. tostring(str)
end
if type(str) == 'string' and #str > 0 then return str end
end
local function countTableAppearances(t, tableAppearances)
tableAppearances = tableAppearances or {}
if type(t) == 'table' then
if not tableAppearances[t] then
tableAppearances[t] = 1
for k, v in pairs(t) do
countTableAppearances(k, tableAppearances)
countTableAppearances(v, tableAppearances)
end
countTableAppearances(getmetatable(t), tableAppearances)
else
tableAppearances[t] = tableAppearances[t] + 1
end
end
return tableAppearances
end
local copySequence = function(s)
local copy, len = {}, #s
for i=1, len do copy[i] = s[i] end
return copy, len
end
local function makePath(path, ...)
local keys = {...}
local newPath, len = copySequence(path)
for i=1, #keys do
newPath[len + i] = keys[i]
end
return newPath
end
-- Cooldude2606: Modified this to respect the depth option
local function processRecursive(process, item, path, visited, depth)
if item == nil then return nil end
if visited[item] then return visited[item] end
local processed = process(item, path)
if type(processed) == 'table' and (depth == nil or depth > 0) then
local processedCopy = {}
visited[item] = processedCopy
local processedKey
for k, v in pairs(processed) do
processedKey = processRecursive(process, k, makePath(path, k, inspect.KEY), visited, depth and depth - 1)
if processedKey ~= nil then
processedCopy[processedKey] = processRecursive(process, v, makePath(path, processedKey), visited, depth and depth - 1)
end
end
local mt = processRecursive(process, getmetatable(processed), makePath(path, inspect.METATABLE), visited, depth and depth - 1)
setmetatable(processedCopy, mt)
processed = processedCopy
end
return processed
end
-------------------------------------------------------------------
local Inspector = {}
local Inspector_mt = {__index = Inspector}
function Inspector:puts(...)
local args = {...}
local buffer = self.buffer
local len = #buffer
for i=1, #args do
len = len + 1
buffer[len] = args[i]
end
end
function Inspector:down(f)
self.level = self.level + 1
f()
self.level = self.level - 1
end
function Inspector:tabify()
self:puts(self.newline, string.rep(self.indent, self.level))
end
function Inspector:alreadyVisited(v)
return self.ids[v] ~= nil
end
function Inspector:getId(v)
local id = self.ids[v]
if not id then
local tv = type(v)
id = (self.maxIds[tv] or 0) + 1
self.maxIds[tv] = id
self.ids[v] = id
end
return tostring(id)
end
function Inspector:putKey(k)
if isIdentifier(k) then return self:puts(k) end
self:puts("[")
self:putValue(k)
self:puts("]")
end
function Inspector:putTable(t)
if t == inspect.KEY or t == inspect.METATABLE then
self:puts(tostring(t))
elseif self:alreadyVisited(t) then
self:puts('<table ', self:getId(t), '>')
elseif self.level >= self.depth then
self:puts('{...}')
else
if self.tableAppearances[t] > 1 then self:puts('<', self:getId(t), '>') end
local nonSequentialKeys, sequenceLength = getNonSequentialKeys(t)
local mt = getmetatable(t)
local toStringResult = getToStringResultSafely(t, mt)
self:puts('{')
self:down(function()
if toStringResult then
self:puts(' -- ', escape(toStringResult))
if sequenceLength >= 1 then self:tabify() end
end
local count = 0
for i=1, sequenceLength do
if count > 0 then self:puts(', ') end
self:puts(' ')
self:putValue(t[i])
count = count + 1
end
for _, k in ipairs(nonSequentialKeys) do
if count > 0 then self:puts(', ') end
self:tabify()
self:putKey(k)
self:puts(' = ')
self:putValue(t[k])
count = count + 1
end
if mt then
if count > 0 then self:puts(', ') end
self:tabify()
self:puts('<metatable> = ')
self:putValue(mt)
end
end)
if #nonSequentialKeys > 0 or mt then -- result is multi-lined. Justify closing }
self:tabify()
elseif sequenceLength > 0 then -- array tables have one extra space before closing }
self:puts(' ')
end
self:puts('}')
end
end
function Inspector:putValue(v)
local tv = type(v)
if tv == 'string' then
self:puts(smartQuote(escape(v)))
elseif tv == 'number' or tv == 'boolean' or tv == 'nil' or
tv == 'cdata' or tv == 'ctype' then
self:puts(tostring(v))
elseif tv == 'table' then
self:putTable(v)
else
self:puts('<', tv, ' ', self:getId(v), '>')
end
end
-------------------------------------------------------------------
function inspect.inspect(root, options)
options = options or {}
local depth = options.depth or math.huge
local newline = options.newline or '\n'
local indent = options.indent or ' '
local process = options.process
if process then
root = processRecursive(process, root, {}, {})
end
local inspector = setmetatable({
depth = depth,
level = 0,
buffer = {},
ids = {},
maxIds = {},
newline = newline,
indent = indent,
tableAppearances = countTableAppearances(root)
}, Inspector_mt)
inspector:putValue(root)
return table.concat(inspector.buffer)
end
setmetatable(inspect, { __call = function(_, ...) return inspect.inspect(...) end })
return inspect

View File

@@ -0,0 +1,64 @@
--luacheck:ignore global math
local floor = math.floor
local abs = math.abs
--- Constant value representing the square root of 2
math.sqrt2 = math.sqrt(2)
--- Constant value representing the reciprocal of square root of 2
math.inv_sqrt2 = 1 / math.sqrt2
--- Constant value representing the value of Tau aka 2*Pi
math.tau = 2 * math.pi
--- Rounds a number to certain number of decimal places, does not work on significant figures
-- @tparam number num The number to be rounded
-- @tparam[opt=0] number idp The number of decimal places to round to
-- @treturn number The input number rounded to the given number of decimal places
math.round = function(num, idp)
local mult = 10 ^ (idp or 0)
return floor(num * mult + 0.5) / mult
end
--- Clamp a number better a minimum and maximum value, preserves NaN (not the same as nil)
-- @tparam number num The number to be clamped
-- @tparam number min The lower bound of the accepted range
-- @tparam number max The upper bound of the accepted range
-- @treturn number The input number clamped to the given range
math.clamp = function(num, min, max)
if num < min then
return min
elseif num > max then
return max
else
return num
end
end
--- Returns the slope / gradient of a line given two points on the line
-- @tparam number x1 The X coordinate of the first point on the line
-- @tparam number y1 The Y coordinate of the first point on the line
-- @tparam number x2 The X coordinate of the second point on the line
-- @tparam number y2 The Y coordinate of the second point on the line
-- @treturn number The slope of the line
math.slope = function(x1, y1, x2, y2)
return abs((y2 - y1) / (x2 - x1))
end
--- Returns the y-intercept of a line given ibe point on the line and its slope
-- @tparam number x The X coordinate of point on the line
-- @tparam number y The Y coordinate of point on the line
-- @tparam number slope The slope / gradient of the line
-- @treturn number The y-intercept of the line
math.y_intercept = function(x, y, slope)
return y - (slope * x)
end
local deg_to_rad = math.tau / 360
--- Returns the angle x (given in radians) in degrees
math.degrees = function(x)
return x * deg_to_rad
end
return math

View File

@@ -0,0 +1,33 @@
--luacheck:ignore global package
local Clustorio = require("modules/clusterio/api")
--- Enum values for the different lifecycle stages within a factorio module
-- Info on the data lifecycle and how we use it: https://lua-api.factorio.com/latest/auxiliary/data-lifecycle.html
-- We start in control stage and so values 1 thorough 3 are only present for completeness
package.lifecycle_stage = {
settings = 1,
data = 2,
migration = 3,
control = 4,
init = 5,
load = 6,
config_change = 7,
runtime = 8
}
--- Stores the current lifecycle stage we are in, compare values against package.lifecycle_stage
package.lifecycle = package.lifecycle_stage.control
return setmetatable({
on_init = function() package.lifecycle = package.lifecycle_stage.init end,
on_load = function() package.lifecycle = package.lifecycle_stage.load end,
on_configuration_changed = function() package.lifecycle = package.lifecycle_stage.config_change end,
events = {
-- TODO find a reliable way to set to runtime because currently it will desync if accessed before player joined
[defines.events.on_player_joined_game] = function() package.lifecycle = package.lifecycle_stage.runtime end,
[Clustorio.events.on_server_startup] = function() package.lifecycle = package.lifecycle_stage.runtime end,
}
}, {
__index = package
})

View File

@@ -0,0 +1,23 @@
--luacheck:ignore global require
local package = require 'modules.exp_util.include.package'
local loaded = package.loaded
local _require = require
-- This replace function is used to avoid additional lines in stack traces during control stage
local function replace()
require = function(path)
if package.lifecycle == package.lifecycle_stage.runtime then
return loaded[path] or loaded[path:gsub(".", "/")] or error('Can only require files at runtime that have been required in the control stage.', 2)
else
return _require(path)
end
end
end
return setmetatable({
on_init = replace,
on_load = replace,
}, {
__call = _require
})

View File

@@ -0,0 +1,427 @@
--luacheck:ignore global table
local random = math.random
local floor = math.floor
local remove = table.remove
local tonumber = tonumber
local pairs = pairs
local table_size = table_size
--- Adds all keys of the source table to destination table as a shallow copy
-- @tparam table dst The table to insert into
-- @tparam table src The table to insert from
function table.merge(dst, src)
local dst_len = #dst
for k, v in pairs(src) do
if tonumber(k) then
dst_len = dst_len + 1
dst[dst_len] = v
else
dst[k] = v
end
end
end
--[[-- Much faster method for inserting items into an array
@tparam table tbl the table that will have the values added to it
@tparam[opt] number start_index the index at which values will be added, nil means end of the array
@tparam table values the new values that will be added to the table
@treturn table the table that was passed as the first argument
@usage-- Adding 1000 values into the middle of the array
local tbl = {}
local values = {}
for i = 1, 1000 do tbl[i] = i values[i] = i end
table.array_insert(tbl, 500, values) -- around 0.4ms
]]
function table.array_insert(tbl, start_index, values)
if not values then
values = start_index
start_index = nil
end
if start_index then
local starting_length = #tbl
local adding_length = #values
local move_to = start_index+adding_length+1
for offset = starting_length-start_index, 0, -1 do
tbl[move_to+offset] = tbl[starting_length+offset]
end
start_index = start_index-1
else
start_index = #tbl
end
for offset, item in ipairs(values) do
tbl[start_index+offset] = item
end
return tbl
end
--[[-- Much faster method for inserting keys into a table
@tparam table tbl the table that will have keys added to it
@tparam[opt] number start_index the index at which values will be added, nil means end of the array, numbered indexs only
@tparam table tbl2 the table that may contain both string and numbered keys
@treturn table the table passed as the first argument
@usage-- Merging two tables
local tbl = {}
local tbl2 = {}
for i = 1, 100 do tbl[i] = i tbl['_'..i] = i tbl2[i] = i tbl2['__'..i] = i end
table.table_insert(tbl, 50, tbl2)
]]
function table.table_insert(tbl, start_index, tbl2)
if not tbl2 then
tbl2 = start_index
start_index = nil
end
table.array_insert(tbl, start_index, tbl2)
for key, value in pairs(tbl2) do
if not tonumber(key) then
tbl[key] = value
end
end
return tbl
end
--- Searches an array to remove a specific element without an index
-- @tparam table tbl The array to remove the element from
-- @param element The element to search for
function table.remove_element(tbl, element)
for k, v in pairs(tbl) do
if v == element then
remove(tbl, k)
break
end
end
end
--- Removes an item from an array in O(1) time. Does not guarantee the order of elements.
-- @tparam table tbl The array to remove the element from
-- @tparam number index Must be >= 0. The case where index > #tbl is handled.
function table.remove_index(tbl, index)
local count = #tbl
if index > count then
return
end
tbl[index] = tbl[count]
tbl[count] = nil
end
--- Removes an item from an array in O(1) time. Does not guarantee the order of elements.
-- @tparam table tbl The array to remove the element from
-- @tparam number index Must be >= 0. The case where index > #tbl is handled.
table.fast_remove = table.remove_index
--- Return the key which holds this element element
-- @tparam table tbl The table to search
-- @param element The element to find
-- @return The key of the element or nil
function table.get_key(tbl, element)
for k, v in pairs(tbl) do
if v == element then
return k
end
end
return nil
end
--- Checks if the arrayed portion of a table contains an element
-- @tparam table tbl The table to search
-- @param element The element to find
-- @treturn ?number The index of the element or nil
function table.get_index(tbl, element)
for i = 1, #tbl do
if tbl[i] == element then
return i
end
end
return nil
end
--- Checks if a table contains an element
-- @tparam table tbl The table to search
-- @param e The element to find
-- @treturn boolean True if the element was found
function table.contains(tbl, element)
return table.get_key(tbl, element) and true or false
end
--- Checks if the arrayed portion of a table contains an element
-- @tparam table tbl The table to search
-- @param e The element to find
-- @treturn boolean True if the element was found
function table.array_contains(tbl, element)
return table.get_index(tbl, element) and true or false
end
--[[-- Extracts certain keys from a table, similar to deconstruction in other languages
@tparam table tbl table the which contains the keys
@tparam string ... the names of the keys you want extracted
@return the keys in the order given
@usage -- Deconstruction of a required module
local format_number, distance = table.deconstruct(require('util'), 'format_number', 'distance')
]]
function table.deconstruct(tbl, ...)
local values = {}
for _, key in pairs({...}) do
table.insert(values, tbl[key])
end
return table.unpack(values)
end
--- Chooses a random entry from a table, can only be used during runtime
-- @tparam table tbl The table to select from
-- @tparam[opt=false] boolean key When true the key will be returned rather than the value
-- @return The selected element from the table
function table.get_random(tbl, key)
local target_index = random(1, table_size(tbl))
local count = 1
for k, v in pairs(tbl) do
if target_index == count then
if key then
return k
else
return v
end
end
count = count + 1
end
end
--- Chooses a random entry from a weighted table, can only be used during runtime
-- @tparam table weighted_table The table of items and their weights
-- @param[opt=1] item_key The index / key of items within each element
-- @param[opt=2] weight_key The index / key of the weights within each element
-- @return The selected element from the table
function table.get_random_weighted(weighted_table, item_key, weight_index)
local total_weight = 0
item_key = item_key or 1
weight_index = weight_index or 2
for _, w in pairs(weighted_table) do
total_weight = total_weight + w[weight_index]
end
local index = random() * total_weight
local weight_sum = 0
for _, w in pairs(weighted_table) do
weight_sum = weight_sum + w[weight_index]
if weight_sum >= index then
return w[item_key]
end
end
end
--- Clears all existing entries in a table
-- @tparam table tbl The table to clear
-- @tparam[opt=false] boolean array When true only the array portion of the table is cleared
function table.clear(t, array)
if array then
for i = 1, #t do
t[i] = nil
end
else
for i in pairs(t) do
t[i] = nil
end
end
end
--- Creates a fisher-yates shuffle of a sequential number-indexed table
-- because this uses math.random, it cannot be used outside of events if no rng is supplied
-- from: http://www.sdknews.com/cross-platform/corona/tutorial-how-to-shuffle-table-items
-- @tparam table tbl The table to shuffle
-- @tparam[opt=math.random] function rng The function to provide random numbers
function table.shuffle(t, rng)
local rand = rng or math.random
local iterations = #t
if iterations == 0 then
error('Not a sequential table')
return
end
local j
for i = iterations, 2, -1 do
j = rand(i)
t[i], t[j] = t[j], t[i]
end
end
--- Default table comparator sort function.
-- @local
-- @param x one comparator operand
-- @param y the other comparator operand
-- @return true if x logically comes before y in a list, false otherwise
local function sortFunc(x, y) --sorts tables with mixed index types.
local tx = type(x)
local ty = type(y)
if tx == ty then
if type(x) == 'string' then
return string.lower(x) < string.lower(y)
else
return x < y
end
elseif tx == 'number' then
return true --only x is a number and goes first
else
return false --only y is a number and goes first
end
end
--- Returns a copy of all of the values in the table.
-- @tparam table tbl the to copy the keys from, or an empty table if tbl is nil
-- @tparam[opt] boolean sorted whether to sort the keys (slower) or keep the random order from pairs()
-- @tparam[opt] boolean as_string whether to try and parse the values as strings, or leave them as their existing type
-- @treturn array an array with a copy of all the values in the table
function table.get_values(tbl, sorted, as_string)
if not tbl then return {} end
local valueset = {}
local n = 0
if as_string then --checking as_string /before/ looping is faster
for _, v in pairs(tbl) do
n = n + 1
valueset[n] = tostring(v)
end
else
for _, v in pairs(tbl) do
n = n + 1
valueset[n] = v
end
end
if sorted then
table.sort(valueset, sortFunc)
end
return valueset
end
--- Returns a copy of all of the keys in the table.
-- @tparam table tbl the to copy the keys from, or an empty table if tbl is nil
-- @tparam[opt] boolean sorted whether to sort the keys (slower) or keep the random order from pairs()
-- @tparam[opt] boolean as_string whether to try and parse the keys as strings, or leave them as their existing type
-- @treturn array an array with a copy of all the keys in the table
function table.get_keys(tbl, sorted, as_string)
if not tbl then return {} end
local keyset = {}
local n = 0
if as_string then --checking as_string /before/ looping is faster
for k, _ in pairs(tbl) do
n = n + 1
keyset[n] = tostring(k)
end
else
for k, _ in pairs(tbl) do
n = n + 1
keyset[n] = k
end
end
if sorted then
table.sort(keyset, sortFunc)
end
return keyset
end
--- Returns the list is a sorted way that would be expected by people (this is by key)
-- @tparam table tbl the table to be sorted
-- @treturn table the sorted table
function table.alphanum_sort(tbl)
local o = table.get_keys(tbl)
local function padnum(d) local dec, n = string.match(d, "(%.?)0*(.+)")
return #dec > 0 and ("%.12f"):format(d) or ("%s%03d%s"):format(dec, #n, n) end
table.sort(o, function(a, b)
return tostring(a):gsub("%.?%d+", padnum)..("%3d"):format(#b)
< tostring(b):gsub("%.?%d+", padnum)..("%3d"):format(#a) end)
local _tbl = {}
for _, k in pairs(o) do _tbl[k] = tbl[k] end
return _tbl
end
--- Returns the list is a sorted way that would be expected by people (this is by key) (faster alternative than above)
-- @tparam table tbl the table to be sorted
-- @treturn table the sorted table
function table.key_sort(tbl)
local o = table.get_keys(tbl, true)
local _tbl = {}
for _, k in pairs(o) do _tbl[k] = tbl[k] end
return _tbl
end
--[[
Returns the index where t[index] == target.
If there is no such index, returns a negative value such that bit32.bnot(value) is
the index that the value should be inserted to keep the list ordered.
t must be a list in ascending order for the return value to be valid.
Usage example:
local t = {1, 3,5, 7,9}
local x = 5
local index = table.binary_search(t, x)
if index < 0 then
game.print("value not found, smallest index where t[index] > x is: " .. bit32.bnot(index))
else
game.print("value found at index: " .. index)
end
]]
function table.binary_search(t, target)
--For some reason bit32.bnot doesn't return negative numbers so I'm using ~x = -1 - x instead.
local lower = 1
local upper = #t
if upper == 0 then
return -2 -- ~1
end
repeat
local mid = floor((lower + upper) * 0.5)
local value = t[mid]
if value == target then
return mid
elseif value < target then
lower = mid + 1
else
upper = mid - 1
end
until lower > upper
return -1 - lower -- ~lower
end
-- add table-related functions that exist in base factorio/util to the 'table' table
require 'util'
--- Similar to serpent.block, returns a string with a pretty representation of a table.
-- Notice: This method is not appropriate for saving/restoring tables. It is meant to be used by the programmer mainly while debugging a program.
-- @tparam table root tTe table to serialize
-- @tparam table options Options are depth, newline, indent, process
-- depth sets the maximum depth that will be printed out. When the max depth is reached, inspect will stop parsing tables and just return {...}
-- process is a function which allow altering the passed object before transforming it into a string.
-- A typical way to use it would be to remove certain values so that they don't appear at all.
-- return <string> the prettied table
table.inspect = require 'modules.exp_util.include.inspect' --- @dep modules.exp_util.includes.inspect
--- Takes a table and returns the number of entries in the table. (Slower than #table, faster than iterating via pairs)
table.size = table_size
--- Creates a deepcopy of a table. Metatables and LuaObjects inside the table are shallow copies.
-- Shallow copies meaning it copies the reference to the object instead of the object itself.
-- @tparam table object The object to copy
-- @treturn table The copied object
table.deep_copy = table.deepcopy -- added by util
--- Merges multiple tables. Tables later in the list will overwrite entries from tables earlier in the list.
-- Ex. merge({{1, 2, 3}, {[2] = 0}, {[3] = 0}}) will return {1, 0, 0}
-- @tparam table tables A table of tables to merge
-- @treturn table The merged table
table.deep_merge = util.merge
--- Determines if two tables are structurally equal.
-- Notice: tables that are LuaObjects or contain LuaObjects won't be compared correctly, use == operator for LuaObjects
-- @tparam table tbl1 The first table
-- @tparam table tbl2 The second table
-- @treturn boolean True if the tables are equal
table.equals = table.compare -- added by util
return table

View File

@@ -0,0 +1 @@
time-symbol-days-short=__1__d

View File

@@ -0,0 +1,16 @@
{
"name": "exp_util",
"load": [
"include/package.lua",
"include/require.lua",
"global.lua",
"async.lua"
],
"require": [
"include/math.lua",
"include/table.lua"
],
"dependencies": {
"clusterio": "*"
}
}

View File

@@ -0,0 +1 @@
return require("modules/exp_util/common")

33
exp_util/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@explosivegaming/util",
"version": "0.0.1",
"description": "Clusterio module for Lua overrides and compatibility utils",
"author": "Cooldude2606 <https://github.com/Cooldude2606>",
"license": "MIT",
"repository": "explosivegaming/clustorio-exp-util",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@clusterio/lib": "workspace:*"
},
"devDependencies": {
"@clusterio/lib": "workspace:*",
"@types/node": "^20.14.9",
"typescript": "^5.5.3"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"clusterio",
"factorio"
]
}

6
exp_util/tsconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.node.json",
"references": [
{ "path": "../../../packages/lib/tsconfig.node.json" },
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"outDir": "dist/node",
},
}