2024-07-06 15:24:04 -07:00

690 lines
26 KiB
Lua

--local bc = better_commands
local S = minetest.get_translator(minetest.get_current_modname())
---Parses parameters out of a string
---@param str string
---@return splitParam[] split_param
function better_commands.parse_params(str)
local i = 1
local tmp
local found = {}
-- selectors, @?[data]
repeat
tmp = {str:find("(@[parsen])%s*(%[.-%])", i)}
if tmp[1] then
i = tmp[2] + 1
tmp.type = "selector"
tmp.extra_data = true
table.insert(found, table.copy(tmp))
end
until not tmp[1]
-- items/entities with extra data
i = 1
repeat
-- modname:id[data] count wear (everything but id and data optional)
tmp = {str:find("%s([_%w]*:?[_%w]+)%s*(%[.-%])%s*(%d*)%s*(%d*)", i)}
if tmp[1] then
tmp[1] = tmp[1] + 1 -- ignore the space
local overlap
for _, thing in pairs(found) do
if tmp[1] >= thing[1] and tmp[1] <= thing[2]
or tmp[2] >= thing[1] and tmp[2] <= thing[2]
or tmp[1] <= thing[1] and tmp[2] >= thing[2] then
overlap = true
break
end
end
i = tmp[2] + 1
if not overlap then
if better_commands.handle_alias(tmp[3]) then
tmp.type = "item"
tmp.extra_data = true
table.insert(found, table.copy(tmp))
elseif better_commands.entity_from_alias(tmp[3]) then
tmp.type = "entity"
tmp.extra_data = true
table.insert(found, table.copy(tmp))
end
end
end
until not tmp[1]
-- items/entities without extra data
i = 1
repeat
-- modname:id count wear (everything but id optional)
tmp = {str:find("(%s[_%w]*:?[_%w]+)%s*(%d*)%s*(%d*)", i)}
if tmp[1] then
tmp[1] = tmp[1] + 1 -- ignore the space
local overlap
for _, thing in pairs(found) do
if tmp[1] >= thing[1] and tmp[1] <= thing[2]
or tmp[2] >= thing[1] and tmp[2] <= thing[2]
or tmp[1] <= thing[1] and tmp[2] >= thing[2] then
overlap = true
break
end
end
i = tmp[2] + 1
if not overlap then
if better_commands.handle_alias(tmp[3]) then
tmp.type = "item"
table.insert(found, table.copy(tmp))
elseif better_commands.entity_from_alias(tmp[3]) then
tmp.type = "entity"
table.insert(found, table.copy(tmp))
end
end
end
until not tmp[1]
-- everything else
i = 1
repeat
tmp = {str:find("(%S+)", i)}
if tmp[1] then
i = tmp[2] + 1
local overlap
for _, thing in pairs(found) do
if tmp[1] >= thing[1] and tmp[1] <= thing[2]
or tmp[2] >= thing[1] and tmp[2] <= thing[2]
or tmp[1] <= thing[1] and tmp[2] >= thing[2] then
overlap = true
break
end
end
if not overlap then
if tmp[3]:find("^@[parsen]$") then
tmp.type = "selector"
elseif better_commands.players[tmp[3]] then
tmp.type = "selector"
elseif better_commands.handle_alias(tmp[3]) then
tmp.type = "item"
elseif tonumber(tmp[3]) then
tmp.type = "number"
elseif tmp[3]:lower() == "true" or tmp[3]:lower() == "false" then
tmp.type = "boolean"
elseif tmp[3]:find("^~%-?%d*%.?%d*$") then
tmp.type = "relative"
elseif tmp[3]:find("^%^%-?%d*%.?%d*$") then
tmp.type = "look_relative"
else
tmp.type = "string"
end
table.insert(found, table.copy(tmp))
end
end
until not tmp[1]
-- sort
table.sort(found, function(a,b)
return a[1] < b[1]
end)
return found
end
---Returns true if num is in the range string, false if not, nil on failure
---@param num number
---@param range string
---@return boolean?
function better_commands.parse_range(num, range)
if not (num and range) then return end
if tonumber(range) then return num == range end
-- "min..max" where both numbers are optional
local _, _, min, max = range:find("(%d*%.?%d*)%s*%.%.%s*(%d*%.?%d*)")
if not min then return end
min = tonumber(min)
max = tonumber(max)
if min and num < min then return false end
if max and num > max then return false end
return true
end
-- key = handle duplicates automatically?
better_commands.supported_keys = {
distance = true,
name = false,
type = false,
r = true,
rm = true,
sort = true,
limit = false,
c = false,
x = true,
y = true,
z = true,
gamemode = false,
m = false,
}
if better_commands.mcl then
local mcl_only = {
level = true,
l = true,
lm = true,
}
for key, value in pairs(mcl_only) do
better_commands.supported_keys[key] = value
end
end
---Parses a selector and returns a list of entities
---@param selector_data splitParam
---@param context contextTable
---@param require_one? boolean
---@return (minetest.ObjectRef|vector.Vector)[]? results
---@return string? err
---@nodiscard
function better_commands.parse_selector(selector_data, context, require_one)
local caller = context.executor
local pos = table.copy(context.pos)
local result = {}
if selector_data[3]:sub(1,1) ~= "@" then
local player = minetest.get_player_by_name(selector_data[3])
if not player then
return nil, S("No player was found")
else
return {player}
end
end
---@type table<integer, {any: string}>
local arg_table = {}
if selector_data[4] then
-- basically matching "(thing)=(thing)[,%]]"
for key, value in selector_data[4]:gmatch("([%w_]+)%s*=%s*([^,%]]+)%s*[,%]]") do
table.insert(arg_table, {key:trim(), value:trim()})
end
end
local objects = {}
local selector = selector_data[3]
if selector == "@s" then
return {caller}
end
-- Always include players
for _, player in pairs(minetest.get_connected_players()) do
if player:get_pos() then
table.insert(objects, player)
end
end
if selector == "@e" or selector == "@n" then
for _, luaentity in pairs(minetest.luaentities) do
if luaentity.object:get_pos() then
table.insert(objects, luaentity.object)
end
end
end
-- Make type selector work for @r
if selector == "@r" or selector == "@p" then
for _, arg in ipairs(arg_table) do
if arg[1] == "type" and arg[2]:lower() ~= "player" then
for _, luaentity in pairs(minetest.luaentities) do
if luaentity.object:get_pos() then
table.insert(objects, luaentity.object)
end
end
end
end
end
local sort
if selector == "@p" or selector == "@n" then
sort = "nearest"
elseif selector == "@r" then
sort = "random"
else
sort = "arbitrary"
end
local limit
if selector == "@p" or selector == "@n" or selector == "@r" then limit = 1 end
if arg_table then
-- Look for pos first
local checked = {}
for _, arg in ipairs(arg_table) do
local key, value = unpack(arg)
if better_commands.supported_keys[key] == nil then
return nil, S("Unknown option '@1'", key)
elseif key == "x" or key == "y" or key == "z" then
if checked[key] then
return nil, S("Duplicate option '@1'", key)
end
if value:sub(1,1) == "!" then
value = value:sub(2,-1)
if value == "" then value = 0 end
end
checked[key] = true
pos[key] = tonumber(value)
if not pos[key] then
return nil, S("Expected number for option '@1'", key)
end
checked[key] = true
elseif key == "sort" then
sort = value
elseif key == "limit" or key == "c" then
if checked.limit then
return nil, S("Only 1 of keys c and limit can exist")
end
checked.limit = true
value = tonumber(value)
if not value then
return nil, S("@1 must be a non-zero integer", key)
end
limit = math.floor(value)
if limit == 0 then
return nil, S("@1 must be a non-zero integer", key)
end
end
end
for _, obj in pairs(objects) do
checked = {}
if obj.is_player then -- checks if it is a valid entity
local matches = true
for _, arg in pairs(arg_table) do
local key, value = unpack(arg)
if better_commands.supported_keys[key] == true then
if checked[key] then
return nil, S("Duplicate option '@1'", key)
end
checked[key] = true
end
if key == "distance" then
local distance = vector.distance(obj:get_pos(), pos)
if not better_commands.parse_range(distance, value) then
matches = false
end
elseif key == "type" then
value = value:lower()
local type_table = {}
if obj:is_player() then
type_table.player = true
else
local obj_type = obj:get_luaentity().name
local aliases = better_commands.entity_aliases[obj_type]
type_table = aliases and table.copy(aliases) or {}
type_table[obj_type] = true
end
if value:sub(1,1) == "!" then
if type_table[value:sub(2, -1)] then
matches = false
end
else
if checked.type then
return nil, S("Duplicate option '@1'", key)
end
checked.type = true
if not type_table[value] then
matches = false
end
end
elseif key == "name" then
local obj_name = better_commands.get_entity_name(obj, true, true)
if value:sub(1,1) == "!" then
if obj_name == value:sub(2, -1) then
matches = false
end
else
if checked.name then
return nil, S("Duplicate option '@1'", key)
end
checked.name = true
if obj_name ~= value then
matches = false
end
end
elseif key == "r" then
value = tonumber(value)
if not value then return nil, S("Expected number for option '@1'", key) end
matches = vector.distance(obj:get_pos(), pos) <= value
elseif key == "rm" then
value = tonumber(value)
if not value then return nil, S("Expected number for option '@1'", key) end
matches = vector.distance(obj:get_pos(), pos) >= value
elseif key == "level" then
if not (obj.is_player and obj:is_player()) then
matches = false
else
mcl_experience.update(obj)
local level = mcl_experience.get_level(obj)
if not better_commands.parse_range(level, value) then
matches = false
end
end
elseif key == "l" then
value = tonumber(value)
if not value then return nil, S("Expected number for option '@1'", key) end
if not (obj.is_player and obj:is_player()) then
matches = false
else
mcl_experience.update(obj)
local level = mcl_experience.get_level(obj)
matches = level <= value
end
elseif key == "lm" then
value = tonumber(value)
if not value then return nil, S("Expected number for option '@1'", key) end
if not (obj.is_player and obj:is_player()) then
matches = false
else
mcl_experience.update(obj)
local level = mcl_experience.get_level(obj)
matches = level >= value
end
elseif key == "gamemode" or key == "m" then
if checked.gamemode then
return nil, S("Only 1 of keys m and gamemode can exist")
end
checked.gamemode = true
if not (obj.is_player and obj:is_player()) then
matches = false
else
local gamemode = better_commands.gamemode_aliases[value] or value
if better_commands.mcl then
if table.indexof(mcl_gamemode.gamemodes, gamemode) == -1 then
return nil, S("Unknown game mode: @1", gamemode)
end
elseif gamemode ~= "creative" and gamemode ~= "survival" then
return nil, S("Unknown game mode: @1", gamemode)
end
matches = better_commands.get_gamemode(obj) == gamemode
end
end
if not matches then
break
end
end
if matches then
table.insert(result, obj)
end
end
end
else
result = objects
end
-- Sort
if sort == "random" then
table.shuffle(result)
elseif sort == "nearest" or (sort == "furthest" and limit < 0) then
table.sort(result, function(a,b) return vector.distance(a:get_pos(), pos) < vector.distance(b:get_pos(), pos) end)
elseif sort == "furthest" or (sort == "nearest" and limit < 0) then
table.sort(result, function(a,b) return vector.distance(a:get_pos(), pos) > vector.distance(b:get_pos(), pos) end)
end
-- Limit
if limit then
local new_result = {}
local i = 1
while i <= limit do
if not result[i] then break end
table.insert(new_result, result[i])
i = i + 1
end
result = new_result
end
if require_one then
if #result == 0 then
return nil, S("No entity was found")
elseif #result > 1 then
return nil, S("Multiple matching entities found")
end
end
return result
end
---Parses a position
---@param data splitParam[]
---@param start integer
---@param context contextTable
---@return vector.Vector? result
---@return string? err
---@nodiscard
function better_commands.parse_pos(data, start, context)
local axes = {"x","y","z"}
local result = table.copy(context.pos)
local look
for i = 0, 2 do
if not data[start + i] then
return nil, S("Missing coordinate")
end
local coordinate, _type = data[start + i][3], data[start + i].type
if _type == "number" or tonumber(coordinate) then
if look then
return nil, S("Cannot mix local and global coordinates")
end
result[axes[i+1]] = tonumber(coordinate)
look = false
elseif _type == "relative" then
if look then
return nil, S("Cannot mix local and global coordinates")
end
result[axes[i+1]] = result[axes[i+1]] + (tonumber(coordinate:sub(2,-1)) or 0)
look = false
elseif _type == "look_relative" then
if look == false then
return nil, S("Cannot mix local and global coordinates")
end
result[axes[i+1]] = tonumber(coordinate:sub(2,-1)) or 0
look = true
else
return nil, S("Invalid coordinate '@1'", coordinate)
end
end
if look then
-- There's almost definitely a better way to do this...
-- All I know is when moving in the Y direction,
-- X/Z are backwards, and when moving in the Z direction,
-- Y is backwards... so I fixed it (probably badly)
local result_x = vector.rotate(vector.new(result.x,0,0), context.rot)
local result_y = vector.rotate(vector.new(0,result.y,0), context.rot)
result_y.z = -result_y.z
result_y.x = -result_y.x
local result_z = vector.rotate(vector.new(0,0,result.z), context.rot)
result_z.y = -result_z.y
result = vector.add(vector.add(vector.add(context.pos, result_x), result_y), result_z)
end
return result
end
---Parses item data, returning an itemstack or err message
---@param item_data splitParam
---@return minetest.ItemStack? result
---@return string? err
---@nodiscard
function better_commands.parse_item(item_data, ignore_count)
if not better_commands.handle_alias(item_data[3]) then
return nil, S("Invalid item '@1'", item_data[3])
end
if item_data.type == "item" and not item_data.extra_data then
local stack = ItemStack(item_data[3])
if not ignore_count then
stack:set_count(tonumber(item_data[4]) or 1)
end
stack:set_wear(tonumber(item_data[5]) or 0)
return stack
elseif item_data.type == "item" then
local arg_table = {}
if item_data[4] then
-- basically matching (thing)=(thing) followed by , or ]
for key, value in item_data[4]:gmatch("([%w_]+)%s*=%s*([^,%]]+)%s*[,%]]") do
arg_table[key:trim()] = value:trim()
end
end
local stack = ItemStack(item_data[3])
if arg_table then
local meta = stack:get_meta()
for key, value in pairs(arg_table) do
if key == "wear" then
stack:set_wear(tonumber(value) or 0)
else
meta:set_string(key, value)
end
end
end
if not ignore_count then
stack:set_count(tonumber(item_data[5]) or 1)
end
stack:set_wear(tonumber(item_data[6]) or stack:get_wear())
return stack
end
return nil, S("Invalid item: '@1'", item_data[3])
end
---Parses node data, returns node and metadata table
---@param item_data splitParam
---@return minetest.Node? node
---@return table? metadata
---@return string? err
---@nodiscard
function better_commands.parse_node(item_data)
if not item_data or item_data.type ~= "item" then
return nil, nil, S("Invalid item")
end
local itemstring = better_commands.handle_alias(item_data[3])
if not itemstring then
return nil, nil, S("Unknown node '@1'", item_data[3])
end
if not minetest.registered_nodes[itemstring] then
return nil, nil, S("'@1' is not a node", itemstring)
end
if item_data.type == "item" and not item_data.extra_data then
return {name = itemstring}
elseif item_data.type == "item" then
local meta_table = {}
local node_table = {name = itemstring}
if item_data[4] then
-- basically matching "(thing)=(thing)[,%]]"
for key, value in item_data[4]:gmatch("([%w_]+)%s*=%s*([^,%]]+)%s*[,%]]") do
local trimmed_key, trimmed_value = key:trim(), value:trim()
if trimmed_key == "param1" or trimmed_key == "param2" then
node_table[trimmed_key] = trimmed_value
else
meta_table[trimmed_key] = trimmed_value
end
end
end
return node_table, meta_table
end
return nil, nil, S("Invalid item '@1'", item_data[3])
end
---Parses a time string and returns a time (between 0 and 1)
---@param time string The time string to parse
---@param absolute? boolean Whether to add the result to the current time or not
---@return number? result
---@return string? err
---@nodiscard
function better_commands.parse_time_string(time, absolute)
local result
if better_commands.times[time] then return better_commands.times[time] end
local amount, unit = time:match("^(%d*%.?%d+)(.?)$")
if not amount or not tonumber(amount) then
local hours, minutes = time:match("^([0-2]?%d):([0-5]%d)$")
if not hours then
return nil, S("Invalid amount")
end
amount = (tonumber(hours) + tonumber(minutes)/60) * 1000
unit = "t"
else
if unit == "" then unit = "t" end
amount = tonumber(amount)
end
-- The pattern shouldn't let through any negative numbers... but just in case
if amount < 0 then return nil, S("Amount must not be negative") end
if unit == "s" then
local tps = tonumber(minetest.settings:get("time_speed")) or 72
amount = amount * tps / 3.6 -- (3.6s = 1 millihour)
elseif unit == "d" then
amount = amount * 24000
elseif unit ~= "t" then
return nil, S("Invalid unit '@1'", unit)
end
if not absolute then
result = (minetest.get_timeofday() + (amount/24000)) % 1
elseif better_commands.settings.acovg_time then
result = ((amount + 6000)/24000) % 1
else
result = (amount/24000) % 1
end
return result
end
---Takes command parameters (with the split version) and expands all selectors
---@param str string
---@param split_param splitParam[]
---@param index integer
---@param context contextTable
---@return string? result
---@return string? err
---@nodiscard
function better_commands.expand_selectors(str, split_param, index, context)
local message = ""
for i=index,#split_param do
local data = split_param[i]
local next_part = ""
if data.type ~= "selector" then
if split_param[i+1] then
---@diagnostic disable-next-line: param-type-mismatch
next_part = str:sub(split_param[i][1], split_param[i+1][1]-1)
else
---@diagnostic disable-next-line: param-type-mismatch
next_part = str:sub(split_param[i][1], -1)
end
else
local targets, err = better_commands.parse_selector(data, context)
if err or not targets then
return nil, err
end
for j, obj in ipairs(targets) do
if j > 1 then next_part = next_part.." " end
if not obj.is_player then -- this should only happen with @s
next_part = next_part..S("Command Block")
break
end
next_part = next_part..better_commands.get_entity_name(obj)
if #targets == 1 then
break
elseif j < #targets then
next_part = next_part..","
end
end
if split_param[i+1] then
if split_param[i][2]+1 < split_param[i+1][1] then
next_part = next_part..str:sub(split_param[i][2]+1, split_param[i+1][1]-1)
end
elseif split_param[i][2] < #str then
next_part = next_part..str:sub(split_param[i][2]+1, -1)
end
end
message = message..next_part
end
return message
end
---Handles item aliases
---@param itemstring string
---@return string|false itemstring corrected itemstring if valid, otherwise false
function better_commands.handle_alias(itemstring)
local stack = ItemStack(itemstring)
if (stack:is_known() and stack:get_name() ~= "unknown" and stack:get_name() ~= "") then
return stack:get_name()
end
return false
end
---I wish #table would work for non-arrays...
---@param table table
---@return integer?
function better_commands.count_table(table)
if type(table) ~= "table" then return end
local count = 0
for _ in pairs(table) do
count = count + 1
end
return count
end