Refactor of commands

This commit is contained in:
Cooldude2606
2019-03-01 20:24:23 +00:00
parent e547f76d6f
commit 62dcfe8694
288 changed files with 5364 additions and 1067 deletions

View File

@@ -0,0 +1,179 @@
--[[-- info
Original (javascript) version: https://hastebin.com/udakacavap.js
Can be tested against: https://wiki.factorio.com/Enemies#Spawn_chances_by_evolution_factor
]]
-- dependencies
local Global = require 'utils.global'
local Debug = require 'utils.debug'
local table = require 'utils.table'
-- localized functions
local get_random_weighted = table.get_random_weighted
local round = math.round
local ceil = math.ceil
local floor = math.floor
local random = math.random
local pairs = pairs
local format = string.format
-- this
local AlienEvolutionProgress = {}
local memory = {
spawner_specifications = {},
spawner_specifications_count = 0,
evolution_cache = {
['biter-spawner'] = {
evolution = -1,
weight_table = {},
},
['spitters-spawner'] = {
evolution = -1,
weight_table = {},
},
},
}
Global.register_init({
memory = memory,
}, function(tbl)
for name, prototype in pairs(game.entity_prototypes) do
if prototype.type == 'unit-spawner' and prototype.subgroup.name == 'enemies' then
tbl.memory.spawner_specifications[name] = prototype.result_units
memory.spawner_specifications_count = memory.spawner_specifications_count + 1
end
end
end, function(tbl)
memory = tbl.memory
end)
local function lerp(low, high, pos)
local s = high.evolution_factor - low.evolution_factor;
local l = (pos - low.evolution_factor) / s;
return (low.weight * (1 - l)) + (high.weight * l)
end
local function get_values(map, evolution_factor)
local result = {}
local sum = 0
for _, spawner_data in pairs(map) do
local list = spawner_data.spawn_points;
local low = list[1];
local high = list[#list];
for _, val in pairs(list) do
local val_evolution = val.evolution_factor
if val_evolution <= evolution_factor and val_evolution > low.evolution_factor then
low = val;
end
if val_evolution >= evolution_factor and val_evolution < high.evolution_factor then
high = val
end
end
local val
if evolution_factor <= low.evolution_factor then
val = low.weight
elseif evolution_factor >= high.evolution_factor then
val = high.weight;
else
val = lerp(low, high, evolution_factor)
end
sum = sum + val;
result[spawner_data.unit] = val;
end
local weighted_table = {}
local count = 0
for index, _ in pairs(result) do
count = count + 1
weighted_table[count] = {index, result[index] / sum}
end
return weighted_table;
end
local function get_spawner_values(spawner, evolution)
local spawner_specification = memory.spawner_specifications[spawner]
if not spawner_specification then
Debug.print(format('Spawner "%s" does not exist in the prototype data', spawner))
return
end
local cache = memory.evolution_cache[spawner]
if not cache then
cache = {
evolution = -1,
weight_table = {},
}
memory.evolution_cache[spawner] = cache
end
local evolution_value = round(evolution * 100)
if (cache.evolution < evolution_value) then
cache.evolution = evolution_value
cache.weight_table = get_values(spawner_specification, evolution)
end
return cache.weight_table
end
local function calculate_total(count, spawner, evolution)
if count == 0 then
return {}
end
local spawner_values = get_spawner_values(spawner, evolution)
if not spawner_values then
return {}
end
local aliens = {}
for _ = 1, count do
local name = get_random_weighted(spawner_values)
aliens[name] = (aliens[name] or 0) + 1
end
return aliens
end
---Creates the spawner_request structure required for AlienEvolutionProgress.get_aliens for all
---available spawners. If dividing the total spawners by the total aliens causes a fraction, the
---fraction will decide a chance to spawn. 1 alien for 2 spawners will have 50% on both.
---@param total_aliens table
function AlienEvolutionProgress.create_spawner_request(total_aliens)
local per_spawner = total_aliens / memory.spawner_specifications_count
local fraction = per_spawner % 1
local spawner_request = {}
for spawner, _ in pairs(memory.spawner_specifications) do
local count = per_spawner
if fraction > 0 then
if random() > fraction then
count = ceil(count)
else
count = floor(count)
end
end
spawner_request[spawner] = count
end
return spawner_request
end
function AlienEvolutionProgress.get_aliens(spawner_requests, evolution)
local aliens = {}
for spawner, count in pairs(spawner_requests) do
for name, amount in pairs(calculate_total(count, spawner, evolution)) do
aliens[name] = (aliens[name] or 0) + amount
end
end
return aliens
end
return AlienEvolutionProgress

326
utils/command.lua Normal file
View File

@@ -0,0 +1,326 @@
local Event = require 'utils.event'
local Game = require 'utils.game'
local Utils = require 'utils.core'
local Timestamp = require 'utils.timestamp'
local Rank = require 'features.rank_system'
local Donator = require 'features.donator'
local Server = require 'features.server'
local Ranks = require 'resources.ranks'
local insert = table.insert
local format = string.format
local next = next
local serialize = serpent.line
local match = string.match
local gmatch = string.gmatch
local get_rank_name = Rank.get_rank_name
local Command = {}
local deprecated_command_alternatives = {
['silent-command'] = 'sc',
['tpplayer'] = 'tp <player>',
['tppos'] = 'tp',
['tpmode'] = 'tp mode',
['color-redmew'] = 'redmew-color'
}
local notify_on_commands = {
['version'] = 'RedMew has a version as well, accessible via /redmew-version',
['color'] = 'RedMew allows color saving and a color randomizer: check out /redmew-color',
['ban'] = 'In case your forgot: please remember to include a message on how to appeal a ban'
}
local option_names = {
['description'] = 'A description of the command',
['arguments'] = 'A table of arguments, example: {"foo", "bar"} would map the first 2 arguments to foo and bar',
['default_values'] = 'A default value for a given argument when omitted, example: {bar = false}',
['required_rank'] = 'Set this to determins what rank is required to execute a command',
['donator_only'] = 'Set this to true if only donators may execute this command',
['debug_only'] = 'Set this to true if it should be registered when _DEBUG is true',
['cheat_only'] = 'Set this to true if it should be registered when _CHEATS is true',
['allowed_by_server'] = 'Set to true if the server (host) may execute this command',
['allowed_by_player'] = 'Set to false to disable players from executing this command',
['log_command'] = 'Set to true to log commands. Always true when admin is required',
['capture_excess_arguments'] = 'Allows the last argument to be the remaining text in the command',
['custom_help_text'] = 'Sets a custom help text to override the auto-generated help',
}
---Validates if there aren't any wrong fields in the options.
---@param command_name string
---@param options table
local function assert_existing_options(command_name, options)
local invalid = {}
for name, _ in pairs(options) do
if not option_names[name] then
insert(invalid, name)
end
end
if next(invalid) then
error(format("The following options were given to the command '%s' but are invalid: %s", command_name, serialize(invalid)))
end
end
---Adds a command to be executed.
---
---Options table accepts the following structure: {
--- description = 'A description of the command',
--- arguments = {'foo', 'bar'}, -- maps arguments to these names in the given sequence
--- default_values = {bar = false}, -- gives a default value to 'bar' when omitted
--- required_rank = Ranks.regular, -- defaults to Ranks.guest
--- donator_only = true, -- defaults to false
--- debug_only = true, -- registers the command if _DEBUG is set to true, defaults to false
--- cheat_only = true, -- registers the command if _CHEATS is set to true, defaults to false
--- allowed_by_server = true, -- lets the server execute this, defaults to false
--- allowed_by_player = false, -- lets players execute this, defaults to true
--- log_command = true, -- defaults to false unless admin only, then always true
--- capture_excess_arguments = true, -- defaults to false, captures excess arguments in the last argument, useful for sentences
---}
---
---The callback receives the following arguments:
--- - arguments (indexed by name, value is extracted from the parameters)
--- - the LuaPlayer or nil if it doesn't exist (such as the server player)
--- - the game tick in which the command was executed
---
---@param command_name string
---@param options table
---@param callback function
function Command.add(command_name, options, callback)
local description = options.description or '[Undocumented command]'
local arguments = options.arguments or {}
local default_values = options.default_values or {}
local required_rank = options.required_rank or Ranks.guest
local donator_only = options.donator_only or false
local debug_only = options.debug_only or false
local cheat_only = options.cheat_only or false
local capture_excess_arguments = options.capture_excess_arguments or false
local custom_help_text = options.custom_help_text or false
local allowed_by_server = options.allowed_by_server or false
local allowed_by_player = options.allowed_by_player
local log_command = options.log_command or (required_rank >= Ranks.admin) or false
local argument_list_size = table_size(arguments)
local argument_list = ''
assert_existing_options(command_name, options)
if nil == options.allowed_by_player then
allowed_by_player = true
end
if (not _DEBUG and debug_only) and (not _CHEATS and cheat_only) then
return
end
if not allowed_by_player and not allowed_by_server then
error(format("The command '%s' is not allowed by the server nor player, please enable at least one of them.", command_name))
end
for index, argument_name in pairs(arguments) do
local argument_display = argument_name
for default_value_name, _ in pairs(default_values) do
if default_value_name == argument_name then
argument_display = argument_display .. ':optional'
break
end
end
if argument_list_size == index and capture_excess_arguments then
argument_display = argument_display .. ':sentence'
end
argument_list = format('%s<%s> ', argument_list, argument_display)
end
local extra = ''
if allowed_by_server and not allowed_by_player then
extra = ' (Server only)'
elseif allowed_by_player and (required_rank > Ranks.guest) then
extra = {'command.required_rank', get_rank_name(required_rank)}
elseif allowed_by_player and donator_only then
extra = ' (Donator only)'
end
local help_text = {'command.help_text_format',(custom_help_text or argument_list), description, extra}
commands.add_command(command_name, help_text, function (command)
local print -- custom print reference in case no player is present
local player = game.player
local player_name = player and player.valid and player.name or '<server>'
if not player or not player.valid then
print = log
if not allowed_by_server then
print(format("The command '%s' is not allowed to be executed by the server.", command_name))
return
end
else
print = player.print
if not allowed_by_player then
print(format("The command '%s' is not allowed to be executed by players.", command_name))
return
end
if Rank.less_than(player_name, required_rank) then
print({'command.higher_rank_needed', command_name, get_rank_name(required_rank)})
return
end
if donator_only and not Donator.is_donator(player_name) then
print(format("The command '%s' is only allowed for donators.", command_name))
return
end
end
local named_arguments = {}
local from_command = {}
local raw_parameter_index = 1
for param in gmatch(command.parameter or '', '%S+') do
if capture_excess_arguments and raw_parameter_index == argument_list_size then
if not from_command[raw_parameter_index] then
from_command[raw_parameter_index] = param
else
from_command[raw_parameter_index] = from_command[raw_parameter_index] .. ' ' .. param
end
else
from_command[raw_parameter_index] = param
raw_parameter_index = raw_parameter_index + 1
end
end
local errors = {}
for index, argument in pairs(arguments) do
local parameter = from_command[index]
if not parameter then
for default_value_name, default_value in pairs(default_values) do
if default_value_name == argument then
parameter = default_value
break
end
end
end
if parameter == nil then
insert(errors, format('Argument "%s" from command %s is missing.', argument, command_name))
else
named_arguments[argument] = parameter
end
end
local return_early = false
for _, error in pairs(errors) do
return_early = true
print(error)
end
if return_early then
return
end
if log_command then
local tick = 'pre-game'
if game then
tick = Utils.format_time(game.tick)
end
local server_time = Server.get_current_time()
if server_time then
server_time = format('(Server time: %s)', Timestamp.to_string(server_time))
else
server_time = ''
end
log(format('%s(Map time: %s) [%s Command] %s, used: %s %s', server_time, tick, (options.required_rank >= Ranks.admin) and 'Admin' or 'Player', player_name, command_name, serialize(named_arguments)))
end
local success, error = pcall(function ()
callback(named_arguments, player, command.tick)
end)
if not success then
local serialized_arguments = serialize(named_arguments)
if _DEBUG then
print(format("%s triggered an error running a command and has been logged: '%s' with arguments %s", player_name, command_name, serialized_arguments))
print(error)
return
end
print(format('There was an error running %s, it has been logged.', command_name))
log(format("Error while running '%s' with arguments %s: %s", command_name, serialized_arguments, error))
end
end)
end
function Command.search(keyword)
local matches = {}
local count = 0
keyword = keyword:lower()
for name, description in pairs(commands.commands) do
local command = format('%s %s', name, description)
if match(command:lower(), keyword) then
count = count + 1
matches[count] = command
end
end
-- built-in commands use LocalisedString, which cannot be translated until player.print is called
for name in pairs(commands.game_commands) do
name = name
if match(name:lower(), keyword) then
count = count + 1
matches[count] = name
end
end
return matches
end
--- Trigger messages on deprecated or defined commands, ignores the server
local function on_command(event)
if not event.player_index then
return
end
local alternative = deprecated_command_alternatives[event.command]
if alternative then
local player = Game.get_player_by_index(event.player_index)
if player then
player.print(format('Warning! Usage of the command "/%s" is deprecated. Please use "/%s" instead.', event.command, alternative))
end
end
local notification = notify_on_commands[event.command]
if notification and event.player_index then
local player = Game.get_player_by_index(event.player_index)
if player then
player.print(notification)
end
end
end
--- Traps command errors if not in DEBUG.
if not _DEBUG then
local old_add_command = commands.add_command
commands.add_command =
function(name, desc, func)
old_add_command(
name,
desc,
function(cmd)
local success, error = pcall(func, cmd)
if not success then
log(error)
Game.player_print('Sorry there was an error running ' .. cmd.name)
end
end
)
end
end
Event.add(defines.events.on_console_command, on_command)
return Command

246
utils/core.lua Normal file
View File

@@ -0,0 +1,246 @@
-- This file contains core utilities used by the redmew scenario.
-- Dependencies
local Game = require 'utils.game'
local Color = require 'resources.color_presets'
local Server = require 'features.server'
-- localized functions
local random = math.random
local sqrt = math.sqrt
local floor = math.floor
local format = string.format
local match = string.match
local insert = table.insert
local concat = table.concat
-- local constants
local prefix = '## - '
local minutes_to_ticks = 60 * 60
local hours_to_ticks = 60 * 60 * 60
local ticks_to_minutes = 1 / minutes_to_ticks
local ticks_to_hours = 1 / hours_to_ticks
-- local vars
local Module = {}
--- Measures distance between pos1 and pos2
function Module.distance(pos1, pos2)
local dx = pos2.x - pos1.x
local dy = pos2.y - pos1.y
return sqrt(dx * dx + dy * dy)
end
--- Takes msg and prints it to all players except provided player
-- @param msg <string|table> table if locale is used
-- @param player <LuaPlayer> the player not to send the message to
-- @param color <table> the color to use for the message, defaults to white
function Module.print_except(msg, player, color)
if not color then
color = Color.white
end
for _, p in pairs(game.connected_players) do
if p ~= player then
p.print(msg, color)
end
end
end
--- Prints a message to all online admins
-- @param msg <string|table> table if locale is used
-- @param source <LuaPlayer|string|nil> string must be the name of a player, nil for server.
function Module.print_admins(msg, source)
local source_name
local chat_color
if source then
if type(source) == 'string' then
source_name = source
chat_color = game.players[source].chat_color
else
source_name = source.name
chat_color = source.chat_color
end
else
source_name = 'Server'
chat_color = Color.yellow
end
local formatted_msg = {'utils_core.print_admins',prefix, source_name, msg}
log(formatted_msg)
for _, p in pairs(game.connected_players) do
if p.admin then
p.print(formatted_msg, chat_color)
end
end
end
--- Returns a valid string with the name of the actor of a command.
function Module.get_actor()
if game.player then
return game.player.name
end
return '<server>'
end
function Module.cast_bool(var)
if var then
return true
else
return false
end
end
function Module.find_entities_by_last_user(player, surface, filters)
if type(player) == 'string' or not player then
error("bad argument #1 to '" .. debug.getinfo(1, 'n').name .. "' (number or LuaPlayer expected, got " .. type(player) .. ')', 1)
return
end
if type(surface) ~= 'table' and type(surface) ~= 'number' then
error("bad argument #2 to '" .. debug.getinfo(1, 'n').name .. "' (number or LuaSurface expected, got " .. type(surface) .. ')', 1)
return
end
local entities = {}
local filter = filters or {}
if type(surface) == 'number' then
surface = game.surfaces[surface]
end
if type(player) == 'number' then
player = Game.get_player_by_index(player)
end
filter.force = player.force.name
for _, e in pairs(surface.find_entities_filtered(filter)) do
if e.last_user == player then
insert(entities, e)
end
end
return entities
end
function Module.ternary(c, t, f)
if c then
return t
else
return f
end
end
--- Takes a time in ticks and returns a string with the time in format "x hour(s) x minute(s)"
function Module.format_time(ticks)
local result = {}
local hours = floor(ticks * ticks_to_hours)
if hours > 0 then
ticks = ticks - hours * hours_to_ticks
insert(result, hours)
if hours == 1 then
insert(result, 'hour')
else
insert(result, 'hours')
end
end
local minutes = floor(ticks * ticks_to_minutes)
insert(result, minutes)
if minutes == 1 then
insert(result, 'minute')
else
insert(result, 'minutes')
end
return concat(result, ' ')
end
--- Prints a message letting the player know they cannot run a command
-- @param name string name of the command
function Module.cant_run(name)
Game.player_print("Can't run command (" .. name .. ') - insufficient permission.')
end
--- Logs the use of a command and its user
-- @param actor string with the actor's name (usually acquired by calling get_actor)
-- @param command the command's name as table element
-- @param parameters the command's parameters as a table (optional)
function Module.log_command(actor, command, parameters)
local action = concat {'[Admin-Command] ', actor, ' used: ', command}
if parameters then
action = concat {action, ' ', parameters}
end
log(action)
end
function Module.comma_value(n) -- credit http://richard.warburton.it
local left, num, right = match(n, '^([^%d]*%d)(%d*)(.-)$')
return left .. (num:reverse():gsub('(%d%d%d)', '%1,'):reverse()) .. right
end
--- Asserts the argument is one of type arg_types
-- @param arg the variable to check
-- @param arg_types the type as a table of sings
-- @return boolean
function Module.verify_mult_types(arg, arg_types)
for _, arg_type in pairs(arg_types) do
if type(arg) == arg_type then
return true
end
end
return false
end
--- Returns a random RGB color as a table
function Module.random_RGB()
return {r = random(0, 255), g = random(0, 255), b = random(0, 255)}
end
--- Sets a table element to value while also returning value.
-- @param tbl table to change the element of
-- @param key string
-- @param value nil|boolean|number|string|table to set the element to
-- @return value
function Module.set_and_return(tbl, key, value)
tbl[key] = value
return value
end
--- Takes msg and prints it to all players. Also prints to the log and discord
-- @param msg <string> The message to print
-- @param warning_prefix <string> The name of the module/warning
function Module.action_warning(warning_prefix, msg)
game.print(prefix .. msg, Color.yellow)
msg = format('%s %s', warning_prefix, msg)
log(msg)
Server.to_discord_bold(msg)
end
--- Takes msg and prints it to all players except provided player. Also prints to the log and discord
-- @param msg <string> The message to print
-- @param warning_prefix <string> The name of the module/warning
-- @param player <LuaPlayer> the player not to send the message to
function Module.silent_action_warning(warning_prefix, msg, player)
Module.print_except(prefix .. msg, player, Color.yellow)
msg = format('%s %s', warning_prefix, msg)
log(msg)
Server.to_discord_bold(msg)
end
-- add utility functions that exist in base factorio/util
require 'util'
--- Moves a position according to the parameters given
-- Notice: only accepts cardinal directions as direction
-- @param position <table> table containing a map position
-- @param direction <defines.direction> north, east, south, west
-- @param distance <number>
-- @return <table> modified position
Module.move_position = util.moveposition
--- Takes a direction and gives you the opposite
-- @param direction <defines.direction> north, east, south, west, northeast, northwest, southeast, southwest
-- @return <number> representing the direction
Module.opposite_direction = util.oppositedirection
--- Takes the string of a module and returns whether is it available or not
-- @param name <string> the name of the module (ex. 'utils.core')
-- @return <boolean>
Module.is_module_available = util.ismoduleavailable
return Module

View File

@@ -1,13 +0,0 @@
-- Info on the data lifecycle and how we use it: https://github.com/Refactorio/RedMew/wiki/The-data-lifecycle
-- Non-applicable stages are commented out.
_STAGE = {
--settings = 1,
--data = 2,
--migration = 3,
control = 4,
init = 5,
load = 6,
--config_change = 7,
runtime = 8
}
_LIFECYCLE = _STAGE.control

32
utils/dump_env.lua Normal file
View File

@@ -0,0 +1,32 @@
-- A small debugging tool that writes the contents of _ENV to a file when the game loads.
-- Useful for ensuring you get the same information when loading
-- the reference and desync levels in desync reports.
-- dependencies
local table = require 'utils.table'
local Event = require 'utils.event'
-- localized functions
local inspect = table.inspect
-- local constants
local filename = 'env_dump.lua'
-- Removes metatables and the package table
local filter = function(item, path)
if path[#path] ~= inspect.METATABLE and item ~= 'package' then
return item
end
end
local function player_joined(event)
local dump_string = inspect(_ENV, {process = filter})
if dump_string then
local s = string.format('tick on join: %s\n%s', event.tick, dump_string)
game.write_file(filename, s)
game.print('_ENV dumped into ' .. filename)
else
game.print('_ENV not dumped, dump_string was nil')
end
end
Event.add(defines.events.on_player_joined_game, player_joined)

120
utils/game.lua Normal file
View File

@@ -0,0 +1,120 @@
local Global = require 'utils.global'
local Color = require 'resources.color_presets'
local pairs = pairs
local Game = {}
local bad_name_players = {}
Global.register(
bad_name_players,
function(tbl)
bad_name_players = tbl
end
)
--[[
Due to a bug in the Factorio api the following expression isn't guaranteed to be true.
game.players[player.index] == player
get_player_by_index(index) will always return the correct player.
When looking up players by name or iterating through all players use game.players instead.
]]
function Game.get_player_by_index(index)
local p = game.players[index]
if not p then
return nil
end
if p.index == index then
return p
end
p = bad_name_players[index]
if p then
if p.valid then
return p
else
return nil
end
end
for k, v in pairs(game.players) do
if k == index then
bad_name_players[index] = v
return v
end
end
end
--- Returns a valid LuaPlayer if given a number, string, or LuaPlayer. Returns nil otherwise.
-- obj <number|string|LuaPlayer>
function Game.get_player_from_any(obj)
local o_type = type(obj)
local p
if type == 'number' then
p = Game.get_player_by_index(obj)
elseif o_type == 'string' then
p = game.players[obj]
elseif o_type == 'table' and obj.valid and obj.is_player() then
return obj
end
if p and p.valid then
return p
end
end
--- Prints to player or console.
-- @param str <string|table> table if locale is used
-- @param color <table> defaults to white
function Game.player_print(str, color)
color = color or Color.white
if game.player then
game.player.print(str, color)
else
print(str)
end
end
--[[
@param Position String to display at
@param text String to display
@param color table in {r = 0~1, g = 0~1, b = 0~1}, defaults to white.
@param surface LuaSurface
@return the created entity
]]
function Game.print_floating_text(surface, position, text, color)
color = color or Color.white
return surface.create_entity {
name = 'tutorial-flying-text',
color = color,
text = text,
position = position
}
end
--[[
Creates a floating text entity at the player location with the specified color in {r, g, b} format.
Example: "+10 iron" or "-10 coins"
@param text String to display
@param color table in {r = 0~1, g = 0~1, b = 0~1}, defaults to white.
@return the created entity
]]
function Game.print_player_floating_text_position(player_index, text, color, x_offset, y_offset)
local player = Game.get_player_by_index(player_index)
if not player or not player.valid then
return
end
local position = player.position
return Game.print_floating_text(player.surface, {x = position.x + x_offset, y = position.y + y_offset}, text, color)
end
function Game.print_player_floating_text(player_index, text, color)
Game.print_player_floating_text_position(player_index, text, color, 0, -1.5)
end
return Game

287
utils/gui.lua Normal file
View File

@@ -0,0 +1,287 @@
local Token = require 'utils.token'
local Event = require 'utils.event'
local Game = require 'utils.game'
local Global = require 'utils.global'
local Gui = {}
local data = {}
Global.register(
data,
function(tbl)
data = tbl
end
)
local top_elements = {}
local on_visible_handlers = {}
local on_pre_hidden_handlers = {}
function Gui.uid_name()
return tostring(Token.uid())
end
-- Associates data with the LuaGuiElement. If data is nil then removes the data
function Gui.set_data(element, value)
data[element.player_index * 0x100000000 + element.index] = value
end
-- Gets the Associated data with this LuaGuiElement if any.
function Gui.get_data(element)
return data[element.player_index * 0x100000000 + element.index]
end
-- Removes data associated with LuaGuiElement and its children recursively.
function Gui.remove_data_recursively(element)
Gui.set_data(element, nil)
local children = element.children
if not children then
return
end
for _, child in ipairs(children) do
if child.valid then
Gui.remove_data_recursively(child)
end
end
end
function Gui.remove_children_data(element)
local children = element.children
if not children then
return
end
for _, child in ipairs(children) do
if child.valid then
Gui.set_data(child, nil)
Gui.remove_children_data(child)
end
end
end
function Gui.destroy(element)
Gui.remove_data_recursively(element)
element.destroy()
end
function Gui.clear(element)
Gui.remove_children_data(element)
element.clear()
end
local function handler_factory(event_id)
local handlers
local function on_event(event)
local element = event.element
if not element or not element.valid then
return
end
local handler = handlers[element.name]
if not handler then
return
end
local player = Game.get_player_by_index(event.player_index)
if not player or not player.valid then
return
end
event.player = player
handler(event)
end
return function(element_name, handler)
if not handlers then
handlers = {}
Event.add(event_id, on_event)
end
handlers[element_name] = handler
end
end
local function custom_handler_factory(handlers)
return function(element_name, handler)
handlers[element_name] = handler
end
end
local function custom_raise(handlers, element, player)
local handler = handlers[element.name]
if not handler then
return
end
handler({element = element, player = player})
end
-- Register a handler for the on_gui_checked_state_changed event for LuaGuiElements with element_name.
-- Can only have one handler per element name.
-- Guarantees that the element and the player are valid when calling the handler.
-- Adds a player field to the event table.
Gui.on_checked_state_changed = handler_factory(defines.events.on_gui_checked_state_changed)
-- Register a handler for the on_gui_click event for LuaGuiElements with element_name.
-- Can only have one handler per element name.
-- Guarantees that the element and the player are valid when calling the handler.
-- Adds a player field to the event table.
Gui.on_click = handler_factory(defines.events.on_gui_click)
-- Register a handler for the on_gui_closed event for a custom LuaGuiElements with element_name.
-- Can only have one handler per element name.
-- Guarantees that the element and the player are valid when calling the handler.
-- Adds a player field to the event table.
Gui.on_custom_close = handler_factory(defines.events.on_gui_closed)
-- Register a handler for the on_gui_elem_changed event for LuaGuiElements with element_name.
-- Can only have one handler per element name.
-- Guarantees that the element and the player are valid when calling the handler.
-- Adds a player field to the event table.
Gui.on_elem_changed = handler_factory(defines.events.on_gui_elem_changed)
-- Register a handler for the on_gui_selection_state_changed event for LuaGuiElements with element_name.
-- Can only have one handler per element name.
-- Guarantees that the element and the player are valid when calling the handler.
-- Adds a player field to the event table.
Gui.on_selection_state_changed = handler_factory(defines.events.on_gui_selection_state_changed)
-- Register a handler for the on_gui_text_changed event for LuaGuiElements with element_name.
-- Can only have one handler per element name.
-- Guarantees that the element and the player are valid when calling the handler.
-- Adds a player field to the event table.
Gui.on_text_changed = handler_factory(defines.events.on_gui_text_changed)
-- Register a handler for the on_gui_value_changed event for LuaGuiElements with element_name.
-- Can only have one handler per element name.
-- Guarantees that the element and the player are valid when calling the handler.
-- Adds a player field to the event table.
Gui.on_value_changed = handler_factory(defines.events.on_gui_value_changed)
-- Register a handler for when the player shows the top LuaGuiElements with element_name.
-- Assuming the element_name has been added with Gui.allow_player_to_toggle_top_element_visibility.
-- Can only have one handler per element name.
-- Guarantees that the element and the player are valid when calling the handler.
-- Adds a player field to the event table.
Gui.on_player_show_top = custom_handler_factory(on_visible_handlers)
-- Register a handler for when the player hides the top LuaGuiElements with element_name.
-- Assuming the element_name has been added with Gui.allow_player_to_toggle_top_element_visibility.
-- Can only have one handler per element name.
-- Guarantees that the element and the player are valid when calling the handler.
-- Adds a player field to the event table.
Gui.on_pre_player_hide_top = custom_handler_factory(on_pre_hidden_handlers)
--- Allows the player to show / hide this element.
-- The element must be part in gui.top.
-- This function must be called in the control stage, i.e not inside an event.
-- @param element_name<string> This name must be globally unique.
function Gui.allow_player_to_toggle_top_element_visibility(element_name)
if _LIFECYCLE ~= _STAGE.control then
error('can only be called during the control stage', 2)
end
top_elements[#top_elements + 1] = element_name
end
local toggle_button_name = Gui.uid_name()
Event.add(
defines.events.on_player_created,
function(event)
local player = Game.get_player_by_index(event.player_index)
if not player or not player.valid then
return
end
local b =
player.gui.top.add {
type = 'button',
name = toggle_button_name,
caption = '<',
tooltip = 'Shows / hides the Redmew Gui buttons.'
}
local style = b.style
style.width = 18
style.height = 38
style.left_padding = 0
style.top_padding = 0
style.right_padding = 0
style.bottom_padding = 0
style.font = 'default-small-bold'
end
)
Gui.on_click(
toggle_button_name,
function(event)
local button = event.element
local player = event.player
local top = player.gui.top
if button.caption == '<' then
for i = 1, #top_elements do
local name = top_elements[i]
local ele = top[name]
if ele and ele.valid then
local style = ele.style
-- if visible is not set it has the value of nil.
-- Hence nil is treated as is visible.
local v = style.visible
if v or v == nil then
custom_raise(on_pre_hidden_handlers, ele, player)
style.visible = false
end
end
end
button.caption = '>'
button.style.height = 24
else
for i = 1, #top_elements do
local name = top_elements[i]
local ele = top[name]
if ele and ele.valid then
local style = ele.style
if not style.visible then
style.visible = true
custom_raise(on_visible_handlers, ele, player)
end
end
end
button.caption = '<'
button.style.height = 38
end
end
)
if _DEBUG then
local concat = table.concat
local names = {}
Gui.names = names
function Gui.uid_name()
local info = debug.getinfo(2, 'Sl')
local filepath = info.source:match('^.+/currently%-playing/(.+)$'):sub(1, -5)
local line = info.currentline
local token = tostring(Token.uid())
local name = concat {token, ' - ', filepath, ':line:', line}
names[token] = name
return token
end
end
return Gui

342
utils/inspect.lua Normal file
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
local function processRecursive(process, item, path, visited)
if item == nil then return nil end
if visited[item] then return visited[item] end
local processed = process(item, path)
if type(processed) == 'table' 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)
if processedKey ~= nil then
processedCopy[processedKey] = processRecursive(process, v, makePath(path, processedKey), visited)
end
end
local mt = processRecursive(process, getmetatable(processed), makePath(path, inspect.METATABLE), visited)
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

54
utils/math.lua Normal file
View File

@@ -0,0 +1,54 @@
--luacheck:ignore global math
local _sin = math.sin
local _cos = math.cos
math.sqrt2 = math.sqrt(2)
math.inv_sqrt2 = 1 / math.sqrt2
math.tau = 2 * math.pi
math.sin = function(x)
return math.floor(_sin(x) * 10000000 + 0.5) / 10000000
end
math.cos = function(x)
return math.floor(_cos(x) * 10000000 + 0.5) / 10000000
end
-- rounds number (num) to certain number of decimal places (idp)
math.round = function(num, idp)
local mult = 10 ^ (idp or 0)
return math.floor(num * mult + 0.5) / mult
end
math.clamp = function(num, min, max)
if num < min then
return min
elseif num > max then
return max
else
return num
end
end
--- Takes two points and calculates the slope of a line
-- @param x1, y1 numbers - cordinates of a point on a line
-- @param x2, y2 numbers - cordinates of a point on a line
-- @return number - the slope of the line
math.calculate_slope = function(x1, y1, x2, y2)
return math.abs((y2 - y1) / (x2 - x1))
end
--- Calculates the y-intercept of a line
-- @param x, y numbers - coordinates of point on line
-- @param slope number - the slope of a line
-- @return number - the y-intercept of a line
math.calculate_y_intercept = function(x, y, slope)
return y - (slope * x)
end
local deg_to_rad = math.tau / 360
math.degrees = function(angle)
return angle * deg_to_rad
end
return math

154
utils/player_rewards.lua Normal file
View File

@@ -0,0 +1,154 @@
local Global = require 'utils.global'
local Game = require 'utils.game'
local PlayerStats = require 'features.player_stats'
local Command = require 'utils.command'
local Ranks = require 'resources.ranks'
local format = string.format
local abs = math.abs
local concat = table.concat
local Public = {}
local reward_token = {global.config.player_rewards.token} or {global.config.market.currency} or {'coin'}
Global.register(
{
reward_token = reward_token
},
function(tbl)
reward_token = tbl.reward_token
end
)
--- Returns the single or plural form of the token name
local function get_token_plural(quantity)
if quantity and quantity > 1 then
return concat({reward_token[1], 's'})
else
return reward_token[1]
end
end
--- Set the item to use for rewards
-- @param reward string - item name to use as reward
-- @return boolean true - indicating success
Public.set_reward = function(reward)
if global.config.player_rewards.enabled == false then
return false
end
reward_token[1] = reward
return true
end
--- Returns the name of the reward item
Public.get_reward = function()
return reward_token[1]
end
--- Gives reward tokens to the player
-- @param player <number|LuaPlayer>
-- @param amount <number> of reward tokens
-- @param message <string> an optional message to send to the affected player
-- @return <number> indicating how many were inserted or if operation failed
Public.give_reward = function(player, amount, message)
if global.config.player_rewards.enabled == false then
return 0
end
local player_index
if type(player) == 'number' then
player_index = player
player = Game.get_player_by_index(player)
else
player_index = player.index
end
local reward = {name = reward_token[1], count = amount}
if not player.can_insert(reward) then
return 0
end
if message then
player.print(message)
end
local coin_difference = player.insert(reward)
if reward_token[1] == 'coin' then
PlayerStats.change_coin_earned(player_index, coin_difference)
end
return coin_difference
end
--- Removes reward tokens from the player
-- @param player <number|LuaPlayer>
-- @param amount <number> of reward tokens
-- @param message <string> an optional message to send to the affected player
-- @return <number> indicating how many were removed or if operation failed
Public.remove_reward = function(player, amount, message)
if global.config.player_rewards.enabled == false then
return 0
end
local player_index
if type(player) == 'number' then
player_index = player
player = Game.get_player_by_index(player)
else
player_index = player.index
end
local unreward = {name = reward_token[1], count = amount}
if message then
player.print(message)
end
local coin_difference = player.remove_item(unreward)
if reward_token[1] == 'coin' then
PlayerStats.change_coin_earned(player_index, -coin_difference)
end
return coin_difference
end
Command.add(
'reward',
{
description = 'Gives a reward to a target player (removes if quantity is negative)',
arguments = {'target', 'quantity', 'reason'},
default_values = {reason = false},
required_rank = Ranks.admin,
capture_excess_arguments = true,
allowed_by_server = true,
allowed_by_player = true
},
function(args, player)
local player_name = 'server'
if player then
player_name = player.name
end
local target_name = args.target
local target = game.players[target_name]
if not target then
player.print('Target not found.')
return
end
local quantity = tonumber(args.quantity)
if quantity > 0 then
Public.give_reward(target, quantity)
local string = format('%s has rewarded %s with %s %s', player_name, target_name, quantity, get_token_plural(quantity))
if args.reason then
string = format('%s for %s', string, args.reason)
end
game.print(string)
elseif quantity < 0 then
quantity = abs(quantity)
Public.remove_reward(target, quantity)
local string = format('%s has punished %s by taking %s %s', player_name, target_name, quantity, get_token_plural(quantity))
if args.reason then
string = format('%s for %s', string, args.reason)
end
game.print(string)
else
Game.player_print(" A reward of 0 is neither a reward nor a punishment, it's just dumb. Try harder.")
end
end
)
return Public

13
utils/print_override.lua Normal file
View File

@@ -0,0 +1,13 @@
local Public = {}
local locale_string = {'', '[PRINT] ', nil}
local raw_print = print
function print(str)
locale_string[3] = str
log(locale_string)
end
Public.raw_print = raw_print
return Public

74
utils/priority_queue.lua Normal file
View File

@@ -0,0 +1,74 @@
local PriorityQueue = {}
function PriorityQueue.new()
return {}
end
local function default_comp(a, b)
return a < b
end
local function HeapifyFromEndToStart(queue, comp)
comp = comp or default_comp
local pos = #queue
while pos > 1 do
local parent = bit32.rshift(pos, 1) -- integer division by 2
if comp(queue[pos], queue[parent]) then
queue[pos], queue[parent] = queue[parent], queue[pos]
pos = parent
else
break
end
end
end
local function HeapifyFromStartToEnd(queue, comp)
comp = comp or default_comp
local parent = 1
local smallest = 1
while true do
local child = parent * 2
if child > #queue then
break
end
if comp(queue[child], queue[parent]) then
smallest = child
end
child = child + 1
if child <= #queue and comp(queue[child], queue[smallest]) then
smallest = child
end
if parent ~= smallest then
queue[parent], queue[smallest] = queue[smallest], queue[parent]
parent = smallest
else
break
end
end
end
function PriorityQueue.size(queue)
return #queue
end
function PriorityQueue.push(queue, element, comp)
table.insert(queue, element)
HeapifyFromEndToStart(queue, comp)
end
function PriorityQueue.pop(queue, comp)
local element = queue[1]
queue[1] = queue[#queue]
queue[#queue] = nil
HeapifyFromStartToEnd(queue, comp)
return element
end
function PriorityQueue.peek(queue)
return queue[1]
end
return PriorityQueue

34
utils/queue.lua Normal file
View File

@@ -0,0 +1,34 @@
local Queue = {}
function Queue.new()
local queue = {_head = 0, _tail = 0}
return queue
end
function Queue.size(queue)
return queue._tail - queue._head
end
function Queue.push(queue, element)
local index = queue._head
queue[index] = element
queue._head = index - 1
end
function Queue.peek(queue)
return queue[queue._tail]
end
function Queue.pop(queue)
local index = queue._tail
local element = queue[index]
queue[index] = nil
if element then
queue._tail = index - 1
end
return element
end
return Queue

58
utils/recipe_locker.lua Normal file
View File

@@ -0,0 +1,58 @@
-- A module to prevent recipes from being unlocked by research. Accessed via the public functions.
local Event = require 'utils.event'
local Global = require 'utils.global'
local Public = {}
local recipes = {}
Global.register(
{
recipes = recipes
},
function(tbl)
recipes = tbl.recipes
end
)
Event.add(
defines.events.on_research_finished,
function(event)
local p_force = game.forces.player
local r = event.research
for _, effect in pairs(r.effects) do
local recipe = effect.recipe
if recipe and recipes[recipe] then
p_force.recipes[recipe].enabled = false
end
end
end
)
Event.on_init(
function()
for recipe in pairs(recipes) do
game.forces.player.recipes[recipe].enabled = false
end
end
)
--- Locks recipes, preventing them from being enabled by research.
-- Does not check if they should be enabled/disabled by existing research.
-- @param tbl <table> an array of recipe strings
function Public.lock_recipes(tbl)
for i = 1, #tbl do
recipes[tbl[i]] = true
end
end
--- Unlocks recipes, allowing them to be enabled by research.
-- Does not check if they should be enabled/disabled by existing research.
-- @param tbl <table> an array of recipe strings
function Public.unlock_recipes(tbl)
for i = 1, #tbl do
recipes[tbl[i]] = nil
end
end
return Public

178
utils/redmew_settings.lua Normal file
View File

@@ -0,0 +1,178 @@
local Global = require 'utils.global'
local type = type
local error = error
local tonumber = tonumber
local tostring = tostring
local pairs = pairs
local format = string.format
--- Contains a set of callables that will attempt to sanitize and transform the input
local settings_type = {
fraction = function (input)
input = tonumber(input)
if input == nil then
return false, 'fraction setting type requires the input to be a valid number between 0 and 1.'
end
if input < 0 then
input = 0
end
if input > 1 then
input = 1
end
return true, input
end,
string = function (input)
if input == nil then
return true, ''
end
local input_type = type(input)
if input_type == 'string' then
return true, input
end
if input_type == 'number' or input_type == 'boolean' then
return true, tostring(input)
end
return false, 'string setting type requires the input to be either a valid string or something that can be converted to a string.'
end,
boolean = function (input)
local input_type = type(input)
if input_type == 'boolean' then
return true, input
end
if input_type == 'string' then
if input == '0' or input == '' or input == 'false' or input == 'no' then
return true, false
end
if input == '1' or input == 'true' or input == 'yes' then
return true, true
end
return true, tonumber(input) ~= nil
end
if input_type == 'number' then
return true, input ~= 0
end
return false, 'boolean setting type requires the input to be either a boolean, number or string that can be transformed to a boolean.'
end,
}
local settings = {}
local memory = {}
Global.register(memory, function (tbl) memory = tbl end)
local Public = {}
Public.types = {fraction = 'fraction', string = 'string', boolean = 'boolean'}
---Register a specific setting with a sensitization setting type.
---
--- Available setting types:
--- - fraction (number between 0 and 1) in either number or string form
--- - string a string or anything that can be cast to a string
--- - boolean, 1, 0, yes, no, true, false or an empty string for false
---
--- This function must be called in the control stage, i.e. not inside an event.
---
---@param name string
---@param setting_type string
---@param default mixed
function Public.register(name, setting_type, default)
if _LIFECYCLE ~= _STAGE.control then
error(format('You can only register setting names in the control stage, i.e. not inside events. Tried setting "%s" with type "%s".', name, setting_type), 2)
end
if settings[name] then
error(format('Trying to register setting for "%s" while it has already been registered.', name), 2)
end
local callback = settings_type[setting_type]
if not callback then
error(format('Trying to register setting for "%s" with type "%s" while this type does not exist.', name, setting_type), 2)
end
local setting = {
default = default,
callback = callback,
}
settings[name] = setting
return setting
end
---Sets a setting to a specific value for a player.
---
---In order to get a setting value, it has to be registered via the "register" function.
---
---@param player_index number
---@param name string
---@param value mixed
function Public.set(player_index, name, value)
local setting = settings[name]
if not setting then
return error(format('Setting "%s" does not exist.', name), 2)
end
local success, sanitized_value = setting.callback(value)
if not success then
error(format('Setting "%s" failed: %s', name, sanitized_value), 2)
end
local player_settings = memory[player_index]
if not player_settings then
player_settings = {}
memory[player_index] = player_settings
end
player_settings[name] = sanitized_value
return sanitized_value
end
---Returns the value of a setting for this player.
---
---In order to set a setting value, it has to be registered via the "register" function.
---
---@param player_index number
---@param name string
function Public.get(player_index, name)
local setting = settings[name]
if not setting then
return error(format('Setting "%s" does not exist.', name), 2)
end
local player_settings = memory[player_index]
if not player_settings then
return setting.default
end
local player_setting = player_settings[name]
return player_setting ~= nil and player_setting or setting.default
end
---Returns a table of all settings for a given player in a key => value setup
---@param player_index number
function Public.all(player_index)
local player_settings = memory[player_index] or {}
local output = {}
for name, data in pairs(settings) do
output[name] = player_settings[name] or data.default
end
return output
end
return Public

119
utils/state_machine.lua Normal file
View File

@@ -0,0 +1,119 @@
--- This module provides a classical mealy/moore state machine.
-- Each machine in constructed by calling new()
-- States and Transitions are lazily added to the machine as transition handlers and state tick handlers are registered.
-- However the state machine must be fully defined after init is done. Dynamic machine changes are currently unsupported
-- An example usage can be found here: map_gen\combined\tetris\control.lua
local Module = {}
local Debug = require 'utils.debug'
local in_state_callbacks = {}
local transaction_callbacks = {}
local max_stack_depth = 20
local machine_count = 0
local control_stage = _STAGE.control
--- Transitions the supplied machine into a given state and executes all transaction_callbacks
-- @param self StateMachine
-- @param new_state number/string The new state to transition to
function Module.transition(self, new_state)
Debug.print(string.format('Transitioning from state %d to state %d.', self.state, new_state))
local old_state = self.state
local stack_depth = self.stack_depth
self.stack_depth = stack_depth + 1
if stack_depth > max_stack_depth then
if _DEBUG then
error('[WARNING] Stack overflow at:' .. debug.traceback())
else
log('[WARNING] Stack overflow at:' .. debug.traceback())
end
end
local exit_callbacks = transaction_callbacks[self.id][old_state]
if exit_callbacks then
local entry_callbacks = exit_callbacks[new_state]
if entry_callbacks then
for i = 1, #entry_callbacks do
local callback = entry_callbacks[i]
if callback then
callback()
end
end
end
end
self.state = new_state
end
--- Is this machine in this state?
-- @param self StateMachine
-- @param state number/string
-- @return boolean
function Module.in_state(self, state)
return self.state == state
end
--- Invoke a machine tick. Will execute all in_state_callbacks of the given machine
-- @param self StateMachine the machine, whose handlers will be invoked
function Module.machine_tick(self)
local callbacks = in_state_callbacks[self.id][self.state]
if callbacks then
for i=1, #callbacks do
local callback = callbacks[i]
if callback then
callback()
end
end
end
self.stack_depth = 0
end
--- Register a handler that will be invoked by StateMachine.machine_tick
-- You may register multiple handlers for the same transition
-- NOTICE: This function will invoke an error if called after init. Dynamic machine changes are currently unsupported
-- @param self StateMachine the machine
-- @param state number/string The state, that the machine will be in, when callback is invoked
-- @param callback function
function Module.register_state_tick_callback(self, state, callback)
if _LIFECYCLE ~= control_stage then
error('Calling StateMachine.register_state_tick_callback after the control stage is unsupported due to desyncs.', 2)
end
in_state_callbacks[self.id][state] = in_state_callbacks[self.id][state] or {}
table.insert(in_state_callbacks[self.id][state], callback)
end
--- Register a handler that will be invoked by StateMachine.transition
-- You may register multiple handlers for the same transition
-- NOTICE: This function will invoke an error if called after init. Dynamic machine changes are currently unsupported
-- @param self StateMachine the machine
-- @param state number/string exiting state
-- @param state number/string entering state
-- @param callback function
function Module.register_transition_callback(self, old, new, callback)
if _LIFECYCLE ~= control_stage then
error('Calling StateMachine.register_transition_callback after the control stage is unsupported due to desyncs.', 2)
end
transaction_callbacks[self.id][old] = transaction_callbacks[self.id][old] or {}
transaction_callbacks[self.id][old][new] = transaction_callbacks[self.id][old][new] or {}
table.insert(transaction_callbacks[self.id][old][new], callback)
end
--- Constructs a new state machine
-- @param init_state number/string The starting state of the machine
-- @return StateMachine The constructed state machine object
function Module.new(init_state)
if _LIFECYCLE ~= control_stage then
error('Calling StateMachine.new after the control stage is unsupported due to desyncs.', 2)
end
machine_count = machine_count + 1
in_state_callbacks[machine_count] = {}
transaction_callbacks[machine_count] = {}
return {
state = init_state,
stack_depth = 0,
id = machine_count,
}
end
return Module

265
utils/table.lua Normal file
View File

@@ -0,0 +1,265 @@
--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
--- Searches a table to remove a specific element without an index
-- @param t <table> to search
-- @param <any> table element to search for
function table.remove_element(t, element)
for k, v in pairs(t) do
if v == element then
remove(t, k)
break
end
end
end
--- Removes an item from an array in O(1) time.
-- The catch is that fast_remove doesn't guarantee to maintain the order of items in the array.
-- @param tbl <table> arrayed table
-- @param index <number> Must be >= 0. The case where index > #tbl is handled.
function table.fast_remove(tbl, index)
local count = #tbl
if index > count then
return
elseif index < count then
tbl[index] = tbl[count]
end
tbl[count] = nil
end
--- Adds the contents of table t2 to table t1
-- @param t1 <table> to insert into
-- @param t2 <table> to insert from
function table.add_all(t1, t2)
for k, v in pairs(t2) do
if tonumber(k) then
t1[#t1 + 1] = v
else
t1[k] = v
end
end
end
--- Checks if a table contains an element
-- @param t <table>
-- @param e <any> table element
-- @returns <any> the index of the element or nil
function table.index_of(t, e)
for k, v in pairs(t) do
if v == e then
return k
end
end
return nil
end
--- Checks if the arrayed portion of a table contains an element
-- @param t <table>
-- @param e <any> table element
-- @returns <number|nil> the index of the element or nil
function table.index_of_in_array(t, e)
for i = 1, #t do
if t[i] == e then
return i
end
end
return nil
end
local index_of = table.index_of
--- Checks if a table contains an element
-- @param t <table>
-- @param e <any> table element
-- @returns <boolean> indicating success
function table.contains(t, e)
return index_of(t, e) and true or false
end
local index_of_in_array = table.index_of_in_array
--- Checks if the arrayed portion of a table contains an element
-- @param t <table>
-- @param e <any> table element
-- @returns <boolean> indicating success
function table.array_contains(t, e)
return index_of_in_array(t, e) and true or false
end
--- Adds an element into a specific index position while shuffling the rest down
-- @param t <table> to add into
-- @param index <number> the position in the table to add to
-- @param element <any> to add to the table
function table.set(t, index, element)
local i = 1
for k in pairs(t) do
if i == index then
t[k] = element
return nil
end
i = i + 1
end
error('Index out of bounds', 2)
end
--- Chooses a random entry from a table
-- because this uses math.random, it cannot be used outside of events
-- @param t <table>
-- @param key <boolean> to indicate whether to return the key or value
-- @return <any> a random element of table t
function table.get_random_dictionary_entry(t, key)
local target_index = random(1, table_size(t))
local count = 1
for k, v in pairs(t) 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
-- because this uses math.random, it cannot be used outside of events
-- @param weight_table <table> of tables with items and their weights
-- @param item_index <number> of the index of items, defaults to 1
-- @param weight_index <number> of the index of the weights, defaults to 2
-- @return <any> table element
-- @see features.chat_triggers::hodor
function table.get_random_weighted(weighted_table, item_index, weight_index)
local total_weight = 0
item_index = item_index 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_index]
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
-- @param t <table> to shuffle
function table.shuffle_table(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
--- Clears all existing entries in a table
-- @param t <table> to clear
-- @param array <boolean> to indicate whether the table is an array or not
function table.clear_table(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
--[[
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.
-- @param table <table> the table to serialize
-- @param options <table> 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 'utils.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.
-- @param object <table> the object to copy
-- @return <table> the copied object
table.deep_copy = table.deepcopy
--- 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}
-- @param tables <table> takes a table of tables to merge
-- @return <table> a merged table
table.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
-- @param tbl1 <table>
-- @param tbl2 <table>
-- @return <boolean>
table.equals = table.compare
return table

115
utils/task.lua Normal file
View File

@@ -0,0 +1,115 @@
-- Threading simulation module
-- Task.sleep()
-- @author Valansch and Grilledham
-- github: https://github.com/Refactorio/RedMew
-- ======================================================= --
local Queue = require 'utils.queue'
local PriorityQueue = require 'utils.priority_queue'
local Event = require 'utils.event'
local Token = require 'utils.token'
local Task = {}
global.callbacks = global.callbacks or PriorityQueue.new()
global.next_async_callback_time = -1
global.task_queue = global.task_queue or Queue.new()
global.total_task_weight = 0
global.task_queue_speed = 1
local function comp(a, b)
return a.time < b.time
end
global.tpt = global.task_queue_speed
local function get_task_per_tick()
if game.tick % 300 == 0 then
local size = global.total_task_weight
global.tpt = math.floor(math.log10(size + 1)) * global.task_queue_speed
if global.tpt < 1 then
global.tpt = 1
end
end
return global.tpt
end
local function on_tick()
local queue = global.task_queue
for i = 1, get_task_per_tick() do
local task = Queue.peek(queue)
if task ~= nil then
-- result is error if not success else result is a boolean for if the task should stay in the queue.
local success, result = pcall(Token.get(task.func_token), task.params)
if not success then
if _DEBUG then
error(result)
else
log(result)
end
Queue.pop(queue)
global.total_task_weight = global.total_task_weight - task.weight
elseif not result then
Queue.pop(queue)
global.total_task_weight = global.total_task_weight - task.weight
end
end
end
local callbacks = global.callbacks
local callback = PriorityQueue.peek(callbacks)
while callback ~= nil and game.tick >= callback.time do
local success, result = pcall(Token.get(callback.func_token), callback.params)
if not success then
if _DEBUG then
error(result)
else
log(result)
end
end
PriorityQueue.pop(callbacks, comp)
callback = PriorityQueue.peek(callbacks)
end
end
--- Allows you to set a timer (in ticks) after which the tokened function will be run with params given as an argument
-- Cannot be called before init
-- @param ticks <number>
-- @param func_token <number> a token for a function store via the token system
-- @param params <any> the argument to send to the tokened function
function Task.set_timeout_in_ticks(ticks, func_token, params)
if not game then
error('cannot call when game is not available', 2)
end
local time = game.tick + ticks
local callback = {time = time, func_token = func_token, params = params}
PriorityQueue.push(global.callbacks, callback, comp)
end
--- Allows you to set a timer (in seconds) after which the tokened function will be run with params given as an argument
-- Cannot be called before init
-- @param sec <number>
-- @param func_token <number> a token for a function store via the token system
-- @param params <any> the argument to send to the tokened function
function Task.set_timeout(sec, func_token, params)
if not game then
error('cannot call when game is not available', 2)
end
Task.set_timeout_in_ticks(60 * sec, func_token, params)
end
--- Queueing allows you to split up heavy tasks which don't need to be completed in the same tick.
-- Queued tasks are generally run 1 per tick. If the queue backs up, more tasks will be processed per tick.
-- @param func_token <number> a token for a function stored via the token system
-- If this function returns `true` it will run again the next tick, delaying other queued tasks (see weight)
-- @param params <any> the argument to send to the tokened function
-- @param weight <number> (defaults to 1) weight is the number of ticks a task is expected to take.
-- Ex. if the task is expected to repeat multiple times (ie. the function returns true and loops several ticks)
function Task.queue_task(func_token, params, weight)
weight = weight or 1
global.total_task_weight = global.total_task_weight + weight
Queue.push(global.task_queue, {func_token = func_token, params = params, weight = weight})
end
Event.add(defines.events.on_tick, on_tick)
return Task

152
utils/timestamp.lua Normal file
View File

@@ -0,0 +1,152 @@
--- source https://github.com/daurnimator/luatz/blob/master/luatz/timetable.lua
-- edited down to just what is needed.
local Public = {}
local floor = math.floor
local strformat = string.format
local function borrow(tens, units, base)
local frac = tens % 1
units = units + frac * base
tens = tens - frac
return tens, units
end
local function carry(tens, units, base)
if units >= base then
tens = tens + floor(units / base)
units = units % base
elseif units < 0 then
tens = tens + floor(units / base)
units = (base + units) % base
end
return tens, units
end
local function is_leap(y)
if (y % 4) ~= 0 then
return false
elseif (y % 100) ~= 0 then
return true
else
return (y % 400) == 0
end
end
local mon_lengths = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
-- Number of days in year until start of month; not corrected for leap years
local months_to_days_cumulative = {0}
for i = 2, 12 do
months_to_days_cumulative[i] = months_to_days_cumulative[i - 1] + mon_lengths[i - 1]
end
local function month_length(m, y)
if m == 2 then
return is_leap(y) and 29 or 28
else
return mon_lengths[m]
end
end
local function day_of_year(day, month, year)
local yday = months_to_days_cumulative[month]
if month > 2 and is_leap(year) then
yday = yday + 1
end
return yday + day
end
local function leap_years_since(year)
return floor(year / 4) - floor(year / 100) + floor(year / 400)
end
local leap_years_since_1970 = leap_years_since(1970)
local function normalise(year, month, day, hour, min, sec)
-- `month` and `day` start from 1, need -1 and +1 so it works modulo
month, day = month - 1, day - 1
-- Convert everything (except seconds) to an integer
-- by propagating fractional components down.
year, month = borrow(year, month, 12)
-- Carry from month to year first, so we get month length correct in next line around leap years
year, month = carry(year, month, 12)
month, day = borrow(month, day, month_length(floor(month + 1), year))
day, hour = borrow(day, hour, 24)
hour, min = borrow(hour, min, 60)
min, sec = borrow(min, sec, 60)
-- Propagate out of range values up
-- e.g. if `min` is 70, `hour` increments by 1 and `min` becomes 10
-- This has to happen for all columns after borrowing, as lower radixes may be pushed out of range
min, sec = carry(min, sec, 60) -- TODO: consider leap seconds?
hour, min = carry(hour, min, 60)
day, hour = carry(day, hour, 24)
-- Ensure `day` is not underflowed
-- Add a whole year of days at a time, this is later resolved by adding months
-- TODO[OPTIMIZE]: This could be slow if `day` is far out of range
while day < 0 do
month = month - 1
if month < 0 then
year = year - 1
month = 11
end
day = day + month_length(month + 1, year)
end
year, month = carry(year, month, 12)
-- TODO[OPTIMIZE]: This could potentially be slow if `day` is very large
while true do
local i = month_length(month + 1, year)
if day < i then
break
end
day = day - i
month = month + 1
if month >= 12 then
month = 0
year = year + 1
end
end
-- Now we can place `day` and `month` back in their normal ranges
-- e.g. month as 1-12 instead of 0-11
month, day = month + 1, day + 1
return {year = year, month = month, day = day, hour = hour, min = min, sec = sec}
end
--- Converts unix epoch timestamp into table {year: number, month: number, day: number, hour: number, min: number, sec: number}
-- @param sec<number> unix epoch timestamp
-- @return {year: number, month: number, day: number, hour: number, min: number, sec: number}
function Public.to_timetable(secs)
return normalise(1970, 1, 1, 0, 0, secs)
end
--- Converts timetable into unix epoch timestamp
-- @param timetable<table> {year: number, month: number, day: number, hour: number, min: number, sec: number}
-- @return number
function Public.from_timetable(timetable)
local tt = normalise(timetable.year, timetable.month, timetable.day, timetable.hour, timetable.min, timetable.sec)
local year, month, day, hour, min, sec = tt.year, tt.month, tt.day, tt.hour, tt.min, tt.sec
local days_since_epoch =
day_of_year(day, month, year) + 365 * (year - 1970) + -- Each leap year adds one day
(leap_years_since(year - 1) - leap_years_since_1970) -
1
return days_since_epoch * (60 * 60 * 24) + hour * (60 * 60) + min * 60 + sec
end
--- Converts unix epoch timestamp into human readable string.
-- @param secs<type> unix epoch timestamp
-- @return string
function Public.to_string(secs)
local tt = normalise(1970, 1, 1, 0, 0, secs)
return strformat('%04u-%02u-%02u %02u:%02u:%02d', tt.year, tt.month, tt.day, tt.hour, tt.min, tt.sec)
end
return Public