diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c0bdaf..8689923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Chat-Command-Reference.md b/Chat-Command-Reference.md index a7581c0..c995229 100644 --- a/Chat-Command-Reference.md +++ b/Chat-Command-Reference.md @@ -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 diff --git a/worldeditadditions/lib/nodeapply.lua b/worldeditadditions/lib/nodeapply.lua index d09ee2c..2c7a9bc 100644 --- a/worldeditadditions/lib/nodeapply.lua +++ b/worldeditadditions/lib/nodeapply.lua @@ -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 diff --git a/worldeditadditions_core/init.lua b/worldeditadditions_core/init.lua index bad30be..ae3030f 100644 --- a/worldeditadditions_core/init.lua +++ b/worldeditadditions_core/init.lua @@ -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 diff --git a/worldeditadditions_core/utils/NodeListMatcher.lua b/worldeditadditions_core/utils/NodeListMatcher.lua new file mode 100644 index 0000000..cd69b80 --- /dev/null +++ b/worldeditadditions_core/utils/NodeListMatcher.lua @@ -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 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 \ No newline at end of file