//nodeapply: Generalise matching engine into NodeListMatcher

Also add support for @groups, i.e. @crumbly matches nodes that are a member of the "crumbly" group
This groups feature is untested atm as I need to implement //ninfo....
This commit is contained in:
Starbeamrainbowlabs 2023-11-27 22:27:20 +00:00
parent 5bddeb5bb5
commit 050bd80cf3
No known key found for this signature in database
GPG Key ID: 1BE5172E637709C2
5 changed files with 146 additions and 22 deletions

@ -8,7 +8,7 @@ Note to self: See the bottom of this file for the release template text.
- Added the optional argument `all` to [`//unmark`](https://worldeditadditions.mooncarrot.space/Reference/#unmark)
- Added a (rather nuclear) fix (attempt 4) at finally exterminating all zombie region marker walls forever
- This is not a hotfix to avoid endless small releases fixing the bug, as it's clear it's much more difficult to fix on all systems than initially expected
- Added [`//nodeapply`](https://worldeditadditions.mooncarrot.space/Reference/#nodeapply), a generalisation of [`//airapply`](https://worldeditadditions.mooncarrot.space/Reference/#airapply) that works with a defined list of nodes (thanks for suggesting, @kliv91 from the Discord server!)
- Added [`//nodeapply`](https://worldeditadditions.mooncarrot.space/Reference/#nodeapply), a generalisation of [`//airapply`](https://worldeditadditions.mooncarrot.space/Reference/#airapply) that works with a defined list of nodes. Check out [the reference](https://worldeditadditions.mooncarrot.space/Reference/#nodeapply) - it has some cool tricks to it! (thanks for suggesting, @kliv91 from the Discord server!)
## v1.14.5: The multipoint update, hotfix 5 (1st August 2023)

@ -1333,6 +1333,14 @@ To give a further example, consider this:
...this will replace all liquid-like nodes (e.g. water, lava, etc) with river water.
In addition, any node names prefixed an at sign `@` are considered group names. For example: `@crumbly` would allow changes only to nodes that are a member of the `crumbly` group:
```weacmd
//nodeapply @crumbly -- layers dirt_with_grass dirt 3 stone 10
```
More misc examples to end this command description, as is customary:
```weacmd
//nodeapply stone -- layers dirt_with_grass dirt 3
//nodeapply stone dirt sand -- layers bakedclay:natural 3 bakedclay:orange 2 bakedclay:red 3 bakedclay:natural 3

@ -1,6 +1,6 @@
local wea_c = worldeditadditions_core
local Vector3 = wea_c.Vector3
local NodeListMatcher = wea_c.NodeListMatcher
@ -40,14 +40,8 @@ function worldeditadditions.nodeapply(pos1, pos2, nodelist, func)
local data_after = manip_after:get_data()
-- Cache node ids for speed. Even if minetest.get_content_id is efficient, an extra function call is still relatively expensive when called 10K+ times on a large region.
local nodeids = {}
for i,nodename in ipairs(nodelist) do
if nodename == "liquidlike" or nodename == "airlike" then
table.insert(nodeids, nodename)
else
table.insert(nodeids, minetest.get_content_id(nodename))
end
end
local success, matcher = NodeListMatcher.new(nodelist)
if not success then return success, matcher end
local allowed_changes = 0
local denied_changes = 0
@ -56,20 +50,9 @@ function worldeditadditions.nodeapply(pos1, pos2, nodelist, func)
for x = pos2.x, pos1.x, -1 do
local i_before = area_before:index(x, y, z)
local i_after = area_after:index(x, y, z)
local old_is_airlike = wea_c.is_airlike(data_before[i_before])
-- Filter on the list of node ids
local allow_replacement = false
for i,nodeid in ipairs(nodeids) do
if nodeid == "airlike" then
allow_replacement = wea_c.is_airlike(data_before[i_before])
elseif nodeid == "liquidlike" then
allow_replacement = wea_c.is_liquidlike(data_before[i_before])
else
allow_replacement = data_before[i_before] == nodeid
end
if allow_replacement then break end
end
local allow_replacement = matcher:match_id(data_before[i_before])
-- Roll back any changes that aren't allowed
-- ...but ensure we only count changed nodes

@ -29,6 +29,7 @@ wea_c.Mesh, wea_c.Face = dofile(wea_c.modpath.."/utils/mesh.lua")
wea_c.Queue = dofile(wea_c.modpath.."/utils/queue.lua")
wea_c.LRU = dofile(wea_c.modpath.."/utils/lru.lua")
wea_c.NodeListMatcher = dofile(wea_c.modpath.."/utils/NodeListMatcher.lua")
wea_c.inspect = dofile(wea_c.modpath.."/utils/inspect.lua")
-- I/O compatibility layer

@ -0,0 +1,132 @@
local weac = worldeditadditions_core
local table_map = dofile(weac.modpath.."/utils/table/table_map.lua")
--- Matcherer that compares node ids to given list of node names.
-- This is a class so that caching can be done locally and per-task.
-- @class worldeditadditions_core.NodeListMatcher
local NodeListMatcher = {}
NodeListMatcher.__index = NodeListMatcher
NodeListMatcher.__name = "NodeListMatcher" -- A hack to allow identification in wea.inspect
local node_names_special = {
airlike = function(node_id)
return weac.is_airlike(node_id)
end,
liquidlike = function(node_id)
return weac.is_liquidlike(node_id)
end
}
--- Parses a list of node names, accounting for special keywords and group names.
-- @param nodelist string[] The list of nodes to parse.
-- @returns bool,string|table<string|function|number> A success bool (true = success), followed by eitheran error string if success == false or the parsed nodelist if success == true.
--
-- Different data types in the parsed nodelist mean different things:
--
-- - **string:** The name of a group
-- - **function:** A special function to call with the node id to match against as the first and only argument.
-- - **number:** The id of the node to check against.
local function __parse_nodelist(nodelist)
local failed_msg = nil
local parsed = table_map(nodelist, function(node_name)
-- 1: match against defined special keywords
if node_names_special[node_name] ~= nil then
return node_names_special[node_name]
end
-- 2: Is it a group name? Group names start with an at sign @.
if node_name:sub(1, 1) == "@" then
return node_name:sub(2)
end
-- 3: It's probably a node name then.
local node_id = minetest.get_content_id(node_name)
if node_id == nil then failed_msg = "Error: Failed to resolve node name '"..node_name.."' to a node id. This is probably a bug. Did you remember to normalise the input node names?" end
return node_id
end)
if failed_msg ~= nil then return false, failed_msg end
return true, parsed
end
--- Creates a new NodeListMatcher instance with the given node list.
-- Once created, you probably want to call the :match_id(node_id) function.
-- @param nodelist string[] The list of nodes to match against.
--
-- `airlike` and `liquidlike` are special keywords that instead check if a given node id behaves like air or a liquid respectively.
--
-- Node names prefixed with an at sign (@) are considered group names, and given node ids are checked for membership of the given group.
-- @returns bool,string|NodeListMatcher A success bool (true == success), and then either a string (if succes == false) or otherwise the newly created NodeListMatcher instance.
function NodeListMatcher.new(nodelist)
local success, nodelist_parsed = __parse_nodelist(nodelist)
if not success then return success, nodelist_parsed end
local result = {
-- The parsed nodelist
nodelist = nodelist_parsed,
-- The group cache, since node id → group membership checking requires 2 minetest.* calls.
groupcache = {}
}
setmetatable(result, NodeListMatcher)
return true, result
end
--- Matches the given node id against the nodelist provided at the instantiation of this NodeListMatcher instance.
-- Returns true if the given node_id matches against any one of the items in the nodelist. In other words, performs a logical OR operation.
--
-- We use the term 'item' and not 'node' here since not all items in the nodelist are nodes: nodelists support special keywords such as 'liquidlike' and 'airlike', as well as group names (prefixed with an at sign @).
-- @param matcher NodeListMatcher The NodeListMatcher instance to query against. Use some_matcher:match_id(node_id) to avoid specifying this manually (note the colon : and not a dot . there).
-- @param node_id number The numerical id of the node to match against the internal nodelist.
-- @returns bool True if the given node id matches against any of the items in the nodelist.
function NodeListMatcher.match_id(matcher, node_id)
print("DEBUG matcher", weac.inspect(matcher))
for i,target in ipairs(matcher.nodelist) do
local target_type = type(target)
if target_type == "number" then
-- It's a node id!
if target == node_id then return true end
elseif target_type == "function" then
-- It's a special function!
local result = target(node_id)
if result then return true end
elseif target_type == "string" then
local result = matcher:match_group(node_id, target)
if result then return true end
end
end
return false
end
--- Determines if the given node id has the given group name.
-- Caches the result for performance. You probably want NodeListMatcher:match_id(node_id).
-- @param matcher NodeListMatcher The NodeListMatcher instance to use to make the query.
-- @param node_id number The numerical id of the node to check.
-- @param group_name string The name of the group to check if the given node id has.
-- @returns bool True if the given node id belongs to the specified group, and false if it does not.
function NodeListMatcher.match_group(matcher, node_id, group_name)
-- 0: Preamble
if matcher.groupcache[node_id] == nil then
matcher.groupcache[node_id] = {}
end
-- 1: Check the cache
if matcher.groupcache[node_id][group_name] ~= nil then
return matcher.groupcache[node_id][group_name]
end
-- 2: Nope, not in the cache. Time to query!
local node_name = minetest.get_name_from_content_id(node_id)
local group_value = minetest.get_item_group(node_name, group_name)
if group_value == 0 then group_value = false
else group_value = true end
-- 3: Update the cache
matcher.groupcache[node_id][group_name] = group_value
-- 4: Return the value now it's in the cache
return group_value
end
return NodeListMatcher