diff --git a/.cdb.json b/.cdb.json
new file mode 100644
index 0000000..ada3089
--- /dev/null
+++ b/.cdb.json
@@ -0,0 +1,11 @@
+{
+ "type": "MOD",
+ "title": "Cleaner",
+ "short_description": "Remove/Replace unknown entities, nodes, & items.",
+ "license": "MIT",
+ "media_license": "CC0-1.0",
+ "tags": ["world_tools"],
+ "repo": "https://codeberg.org/AntumLuanti/mod-cleaner",
+ "issue_tracker": "https://codeberg.org/AntumLuanti/mod-cleaner/issues",
+ "forums": 18381
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..62c4b62
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+.* export-ignore
+*.py export-ignore
diff --git a/.github/workflows/reference.yml b/.github/workflows/reference.yml
new file mode 100644
index 0000000..29dec4c
--- /dev/null
+++ b/.github/workflows/reference.yml
@@ -0,0 +1,30 @@
+
+name: Build Reference
+on:
+ push:
+ tags:
+ - 'v[0-9]*'
+ workflow_dispatch:
+
+jobs:
+ build:
+ name: Build Reference
+ runs-on: ubuntu-latest
+ steps:
+ - name: Setup Lua
+ uses: leafo/gh-actions-lua@v8
+ with:
+ luaVersion: 5.4
+ - name: Setup Lua Rocks
+ uses: leafo/gh-actions-luarocks@v4
+ - name: Setup dependencies
+ run: luarocks install --only-deps https://raw.githubusercontent.com/lunarmodules/LDoc/master/ldoc-scm-3.rockspec
+ - name: Setup LDoc
+ run: git clone --single-branch --branch=custom https://github.com/AntumDeluge/LDoc.git ldoc
+ - name: Checkout & Build Docs
+ run: git clone https://github.com/AntumMT/mod-cleaner.git cleaner && cd cleaner && chmod +x .ldoc/build_versioned_docs.sh && ./.ldoc/build_versioned_docs.sh
+ - name: Deploy
+ uses: peaceiris/actions-gh-pages@v3
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: cleaner/docs/
diff --git a/.ldoc/build_versioned_docs.sh b/.ldoc/build_versioned_docs.sh
new file mode 100755
index 0000000..728103f
--- /dev/null
+++ b/.ldoc/build_versioned_docs.sh
@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+
+# place this file in mod ".ldoc" directory
+
+
+d_config="$(dirname $(readlink -f $0))"
+
+cd "${d_config}/.."
+
+d_root="$(pwd)"
+d_export="${d_export:-${d_root}/docs/reference}"
+
+cmd_ldoc="${d_root}/../ldoc/ldoc.lua"
+if test -f "${cmd_ldoc}"; then
+ if test ! -x "${cmd_ldoc}"; then
+ chmod +x "${cmd_ldoc}"
+ fi
+else
+ cmd_ldoc="ldoc"
+fi
+
+# clean old files
+rm -rf "${d_export}"
+
+# store current branch
+main_branch="$(git branch --show-current)"
+
+html_out="\n
\n\n\n\n\n"
+
+# generate new doc files
+mkdir -p "${d_export}"
+for vinfo in $(git tag -l --sort=-v:refname | grep "^v[0-9]"); do
+ echo -e "\nbuilding ${vinfo} docs ..."
+ git checkout ${vinfo}
+ d_temp="${d_config}/temp"
+ mkdir -p "${d_temp}"
+
+ # backward compat
+ f_config="${d_root}/docs/config.ld"
+ if test ! -f "${f_config}"; then
+ f_config="${d_config}/config.ld"
+ fi
+
+ if test ! -f "${f_config}"; then
+ echo -e "\nLDoc config not available for ${vinfo}, skipping build ..."
+ continue
+ fi
+
+ "${cmd_ldoc}" --UNSAFE_NO_SANDBOX --multimodule -c "${f_config}" -d "${d_temp}" "${d_root}"; retval=$?
+ if test ${retval} -ne 0; then
+ echo -e "\nERROR: doc build for ${vinfo} failed!"
+ rm -rf "${d_temp}"
+ continue
+ fi
+
+ # show version info
+ for html in $(find "${d_temp}" -type f -name "*.html"); do
+ sed -i -e "s|^[cC]leaner
$|Cleaner (${vinfo})
|" \
+ "${html}"
+ done
+
+ if test -d "${d_root}/textures"; then
+ # copy textures to data directory
+ echo -e "\ncopying textures ..."
+ d_data="${d_temp}/data"
+ mkdir -p "${d_data}"
+ texture_count=0
+ for png in $(find "${d_root}/textures" -maxdepth 1 -type f -name "*.png"); do
+ t_png="${d_data}/$(basename ${png})"
+ if test -f "${t_png}"; then
+ echo "WARNING: not overwriting existing file: ${t_png}"
+ else
+ cp "${png}" "${d_data}"
+ texture_count=$((texture_count + 1))
+ printf "\rcopied ${texture_count} textures"
+ fi
+ done
+ fi
+
+ mv "${d_temp}" "${d_export}/${vinfo}"
+ if test -z ${vcur+x}; then
+ vcur="${vinfo}"
+ ln -s "${d_export}/${vinfo}" "${d_export}/current"
+ ln -s "${d_export}/${vinfo}" "${d_export}/latest"
+ html_out="${html_out} - current
\n"
+ html_out="${html_out} - latest
\n"
+ fi
+ html_out="${html_out} - ${vinfo}
\n"
+done
+
+html_out="${html_out}
\n\n"
+
+cd "${d_root}"
+git checkout ${main_branch}
+
+echo -e "${html_out}" > "${d_export}/index.html"
+
+echo -e "\nDone!"
diff --git a/.ldoc/config.ld b/.ldoc/config.ld
new file mode 100644
index 0000000..10e46db
--- /dev/null
+++ b/.ldoc/config.ld
@@ -0,0 +1,201 @@
+
+local dofile, print, error, type, table, ipairs, string, tostring
+if import then
+ dofile = import("dofile")
+ print = import("print")
+ error = import("error")
+ type = import("type")
+ table = import("table")
+ ipairs = import("ipairs")
+ string = import("string")
+ tostring = import("tostring")
+end
+
+
+project = "Cleaner"
+title = "Cleaner mod for Luanti"
+format = "markdown"
+not_luadoc=true
+boilerplate = false
+icon = "textures/cleaner_pencil.png"
+favicon = "https://www.luanti.org/media/icon.svg"
+
+file = {
+ "settings.lua",
+ "api.lua",
+ "chat.lua",
+ "tools.lua",
+ ".ldoc/config.luadoc",
+}
+
+new_type("chatcmd", "Chat Commands")
+new_type("setting", "Settings")
+new_type("tool", "Tools")
+new_type("json", "JSON Configurations")
+
+
+local function video_frame(src)
+ return ''
+end
+
+
+local tags
+tags, custom_tags = dofile(".ldoc/tags.ld")
+
+
+-- START: handling items to prevent re-parsing
+
+local registered_items = {}
+
+local function is_registered(item)
+ if not registered_items[item.type] then return false end
+
+ for _, tbl in ipairs(registered_items[item.type]) do
+ if item == tbl then
+ return true
+ end
+ end
+
+ return false
+end
+
+local function register(item)
+ if not registered_items[item.type] then
+ registered_items[item.type] = {}
+ end
+
+ if not is_registered(item) then
+ table.insert(registered_items[item.type], item)
+ end
+end
+
+-- END:
+
+
+local function format_setting_tag(desc, value)
+ return "\n- `" .. desc .. ":` `" .. value .. "`"
+end
+
+local function setting_handler(item)
+ if not ipairs or not type then
+ return item
+ end
+
+ local tags = {
+ {"settype", "type"},
+ {"default"},
+ {"min", "minimum value"},
+ {"max", "maximum value"},
+ }
+
+ local def = {
+ ["settype"] = format_setting_tag("type", "string"),
+ }
+
+ for _, t in ipairs(tags) do
+ local name = t[1]
+ local desc = t[2]
+ if not desc then desc = name end
+
+ local value = item.tags[name]
+ if type(value) == "table" then
+ if #value > 1 then
+ local msg = item.file.filename .. " (line " .. item.lineno
+ .. "): multiple instances of tag \"" .. name .. "\" found"
+ if error then
+ error(msg)
+ elseif print then
+ print("WARNING: " .. msg)
+ end
+ end
+
+ if value[1] then
+ def[name] = format_setting_tag(desc, value[1])
+ end
+ end
+ end
+
+ item.description = item.description .. "\n\n**Definition:**\n" .. def.settype
+ for _, t in ipairs({def.default, def.min, def.max}) do
+ if t then
+ item.description = item.description .. t
+ end
+ end
+
+ return item
+end
+
+local function chatcmd_handler(item)
+ for _, p in ipairs(item.params) do
+ if item.modifiers.param[p].opt then
+ item.name = item.name .. " [" .. p .. "]"
+ else
+ item.name = item.name .. " <" .. p .. ">"
+ end
+ end
+
+ if #item.params > 0 then
+ local pstring = "### Parameters:\n"
+ for k, param in pairs(item.params) do
+ if type(k) == "number" then
+ local value = item.params.map[param]
+
+ pstring = pstring .. '\n- '
+ .. param .. ''
+
+ local modifiers = item.modifiers.param[param]
+ if modifiers and modifiers.type then
+ pstring = pstring .. ' `' .. modifiers.type .. '`'
+ end
+
+ if value then
+ pstring = pstring .. value
+ end
+
+ if modifiers and modifiers.opt then
+ pstring = pstring .. " *(optional)*"
+ end
+ end
+ end
+
+ item.description = item.description .. "\n\n" .. pstring
+ -- clear parameter list
+ item.params = {}
+ end
+
+ return item
+end
+
+function custom_display_name_handler(item, default_handler)
+ if not is_registered(item) then
+ if item.type == "setting" then
+ item = setting_handler(item)
+ elseif item.type == "chatcmd" then
+ item = chatcmd_handler(item)
+ end
+
+ local parse_tags = {"priv", "note"}
+ for _, pt in ipairs(parse_tags) do
+ local tvalues = item.tags[pt]
+ if tvalues then
+ local tstring = ""
+
+ local title = tags.get_title(pt)
+ if title then
+ tstring = tstring .. "\n\n### " .. title .. ":\n"
+ end
+
+ for _, tv in ipairs(tvalues) do
+ tstring = tstring .. "\n- " .. tags.format(pt, tv)
+ end
+
+ item.description = item.description .. tstring
+ end
+ end
+ end
+
+ register(item)
+ return default_handler(item)
+end
diff --git a/.ldoc/config.luadoc b/.ldoc/config.luadoc
new file mode 100644
index 0000000..7cb0044
--- /dev/null
+++ b/.ldoc/config.luadoc
@@ -0,0 +1,55 @@
+
+--- World Path Configuration
+--
+-- @topic config
+
+
+--- Main configuration file.
+--
+-- Registering items, entities, etc. for cleaning can be done in ***cleaner.json***
+-- in the world directory (`/cleaner.json`). If it does not exist
+-- it will be created automatically when the server is started.
+--
+-- It is formatted as follows:
+--
+-- {
+-- "entities" :
+-- {
+-- "remove" : []
+-- },
+-- "items" :
+-- {
+-- "replace" : {}
+-- },
+-- "nodes" :
+-- {
+-- "remove" : [],
+-- "replace" : {}
+-- },
+-- "ores" :
+-- {
+-- "remove" : []
+-- }
+-- }
+--
+-- `remove` key works for nodes, entities, & ores. `replace` key works for
+-- nodes & items. Their functions are self-explanatory.
+--
+-- @json cleaner.json
+-- @usage
+-- Cleaning nodes example:
+-- {
+-- "nodes" :
+-- {
+-- "remove" :
+-- [
+-- "old:node_1",
+-- "old:node_2",
+-- ],
+-- "replace" :
+-- {
+-- "old:node_3" : "new:node_1",
+-- "old:node_4" : "new:node_2",
+-- },
+-- },
+-- }
diff --git a/.ldoc/gendoc.sh b/.ldoc/gendoc.sh
new file mode 100755
index 0000000..75ef2c3
--- /dev/null
+++ b/.ldoc/gendoc.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+
+# place this file in mod ".ldoc" directory
+
+
+d_ldoc="$(dirname $(readlink -f $0))"
+f_config="${d_ldoc}/config.ld"
+
+cd "${d_ldoc}/.."
+
+d_root="$(pwd)"
+d_export="${d_export:-${d_root}/docs/reference}"
+
+cmd_ldoc="${d_ldoc}/ldoc/ldoc.lua"
+if test ! -x "${cmd_ldoc}"; then
+ cmd_ldoc="ldoc"
+fi
+
+# clean old files
+rm -rf "${d_export}"
+
+vinfo="v$(grep "^version = " "${d_root}/mod.conf" | head -1 | sed -e 's/version = //')"
+d_data="${d_export}/${vinfo}/data"
+
+# generate new doc files
+"${cmd_ldoc}" --UNSAFE_NO_SANDBOX --multimodule -c "${f_config}" -d "${d_export}/${vinfo}" "${d_root}"; retval=$?
+
+# check exit status
+if test ${retval} -ne 0; then
+ echo -e "\nan error occurred (ldoc return code: ${retval})"
+ exit ${retval}
+fi
+
+# show version info
+for html in $(find "${d_export}/${vinfo}" -type f -name "*.html"); do
+ sed -i -e "s|^[cC]leaner
$|Cleaner (${vinfo})
|" "${html}"
+done
+
+# copy textures to data directory
+echo -e "\ncopying textures ..."
+mkdir -p "${d_data}"
+texture_count=0
+for png in $(find "${d_root}/textures" -maxdepth 1 -type f -name "*.png"); do
+ t_png="${d_data}/$(basename ${png})"
+ if test -f "${t_png}"; then
+ echo "WARNING: not overwriting existing file: ${t_png}"
+ else
+ cp "${png}" "${d_data}"
+ texture_count=$((texture_count + 1))
+ printf "\rcopied ${texture_count} textures"
+ fi
+done
+
+echo -e "\n\nDone!"
diff --git a/.ldoc/tags.ld b/.ldoc/tags.ld
new file mode 100644
index 0000000..91ab6dd
--- /dev/null
+++ b/.ldoc/tags.ld
@@ -0,0 +1,81 @@
+
+local tags = {}
+local tag_list = {}
+local custom_tags = {}
+
+local register_tag = function(name, tag)
+ local new_tag = {name, title=tag.title, hidden=tag.hidden, format=tag.format}
+ table.insert(custom_tags, new_tag)
+ tag_list[name] = {title=tag.title, format=tag.format}
+end
+
+tags.get_title = function(tname)
+ local t = tag_list[tname]
+ if t then
+ return t.title
+ end
+end
+
+tags.format = function(tname, value)
+ local t = tag_list[tname]
+ if t then
+ if type(t.format) == "function" then
+ value = t.format(value)
+ end
+ end
+
+ return value
+end
+
+
+local new_tags = {
+ ["priv"] = {
+ title = "Required Privileges",
+ hidden = true,
+ },
+ ["note"] = {
+ title = "Notes",
+ hidden = true,
+ format = function(value)
+ return "*" .. value .. "*"
+ end,
+ },
+ ["video"] = {
+ title = "Video",
+ format = video_frame,
+ },
+ ["youtube"] = {
+ title = "Video",
+ format = function(value)
+ return video_frame("https://www.youtube.com/embed/" .. value)
+ end,
+ },
+ -- settings
+ ["settype"] = {
+ title = "Setting Type",
+ hidden = true,
+ },
+ ["default"] = {
+ title = "Default Value",
+ hidden = true,
+ },
+ -- craft items/tools
+ ["img"] = {
+ title = "Image",
+ format = function(value)
+ return "
"
+ end,
+ },
+ -- chat commands
+ ["chatparam"] = {
+ title = "Parameters",
+ hidden = true,
+ },
+}
+
+for k, v in pairs(new_tags) do
+ register_tag(k, v)
+end
+
+
+return tags, custom_tags
diff --git a/LICENSE.txt b/LICENSE.txt
index 9042c25..357bcd0 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,13 +1,21 @@
- DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
- Version 2, December 2004
-
- Copyright (C) 2004 Sam Hocevar
-
- Everyone is permitted to copy and distribute verbatim or modified
- copies of this license document, and changing it is allowed as long
- as the name is changed.
-
- DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
- TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
-
- 0. You just DO WHAT THE FUCK YOU WANT TO.
+The MIT License (MIT)
+
+Copyright © 2021 Jordan Irwin (AntumDeluge)
+
+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.
diff --git a/README.md b/README.md
index 77b9f63..70253ef 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,86 @@
-## Clean mod for [Minetest][]
+## Cleaner mod for Luanti
+
+### Description:
+
+A [Luanti (Minetest)][Luanti] mod that can be used to remove/replace unknown entities, nodes, & items. Originally forked from [PilzAdam's ***clean*** mod][f.pilzadam].
+
+
+
+### Licensing:
+
+- Code: [MIT](LICENSE.txt)
+- Textures: CC0
+
+### Requirements:
+
+- Luanti minimum version: 5.0
+- Depends: none
+
+### Usage:
+
+Registering items, entities, etc. for cleaning can be done in `cleaner.json` in the world directory. If it does not exist it will be created automatically when the server is started.
+
+It is formatted as follows:
+```json
+{
+ "entities" :
+ {
+ "remove" : []
+ },
+ "items" :
+ {
+ "replace" : {}
+ },
+ "nodes" :
+ {
+ "remove" : [],
+ "replace" : {}
+ },
+ "ores" :
+ {
+ "remove" : []
+ }
+}
+```
+
+Cleaning nodes example:
+```json
+{
+ "nodes" :
+ {
+ "remove" : [
+ "old:node_1",
+ "old:node_2",
+ ],
+ "replace" : {
+ "old:node_3" : "new:node_1",
+ "old:node_4" : "new:node_2",
+ }
+ },
+}
+```
+
+`remove` key works for nodes, entities, & ores. `replace` key works for nodes & items. Their functions are self-explanatory.
+
+#### Settings:
+
+```
+cleaner.unsafe
+- Enables unsafe methods & commands (remove_ore).
+- type: bool
+- default: false
+```
+
+### Links:
+
+- [][ContentDB]
+- [Forum](https://forum.luanti.org/viewtopic.php?t=18381)
+- [Git repo](https://github.com/AntumMT/mod-cleaner)
+- [Reference](https://antummt.github.io/mod-cleaner/reference/latest/)
+- [Changelog](changelog.txt)
+- [TODO](TODO.txt)
----
-### **Description:**
-
-Fork of [PilzAdam's ***clean*** mod][f.pilzadam] for Minetest.
-
-
----
-### **Licensing:**
-
-[WTFPL](LICENSE.txt)
-
-
-[Minetest]: http://www.minetest.net/
-
-[f.pilzadam]: https://forum.minetest.net/viewtopic.php?t=2777
+[Luanti]: https://luanti.org/
+[f.pilzadam]: https://forum.luanti.org/viewtopic.php?t=2777
+[ContentDB]: https://content.luanti.org/packages/AntumDeluge/cleaner/
diff --git a/TODO.txt b/TODO.txt
new file mode 100644
index 0000000..b800544
--- /dev/null
+++ b/TODO.txt
@@ -0,0 +1,14 @@
+
+TODO:
+- update world file when chat commands are used
+- update inventories when items are replaced:
+ - creative
+ - storage (chests, etc.)
+- add LBM when removing an item if it is a node
+- add "radius" option for pencil or "xlen", "ylen", & "zlen" options
+- add "xrotate" & "zrorate" modes for pencil
+- don't require "server" priv for "find_unknown_nodes" & "find_neaby_nodes" commands
+- add chat command to find nodes with specified attributes
+- may be better to update player inventories on login than add aliases for items
+- use aliases for unknown nodes instead of LBM
+- only use LBM when a node to replace is still registered
diff --git a/api.lua b/api.lua
new file mode 100644
index 0000000..a894148
--- /dev/null
+++ b/api.lua
@@ -0,0 +1,194 @@
+
+--- Cleaner API
+--
+-- @topic api
+
+
+local replace_items = {}
+local replace_nodes = {}
+
+
+--- Retrieves list of items to be replaced.
+--
+-- @treturn table Items to be replaced.
+function cleaner.get_replace_items()
+ return replace_items
+end
+
+--- Retrieves list of nodes to be replaced.
+--
+-- @treturn table Nodes to be replaced.
+function cleaner.get_replace_nodes()
+ return replace_nodes
+end
+
+
+--- Registers an entity to be removed.
+--
+-- @tparam string src Entity technical name.
+function cleaner.register_entity_removal(src)
+ core.register_entity(":" .. src, {
+ on_activate = function(self, ...)
+ self.object:remove()
+ end,
+ })
+end
+
+--- Registers a node to be removed.
+--
+-- @tparam string src Node technical name.
+function cleaner.register_node_removal(src)
+ core.register_node(":" .. src, {
+ groups = {to_remove=1},
+ })
+end
+
+local function update_list(inv, listname, src, tgt)
+ if not inv then
+ cleaner.log("error", "cannot update list of unknown inventory")
+ return
+ end
+
+ local list = inv:get_list(listname)
+ if not list then
+ cleaner.log("warning", "unknown inventory list: " .. listname)
+ return
+ end
+
+ for idx, stack in pairs(list) do
+ if stack:get_name() == src then
+ local new_stack = ItemStack(tgt)
+ new_stack:set_count(stack:get_count())
+ inv:set_stack(listname, idx, new_stack)
+ end
+ end
+end
+
+--- Replaces an item with another registered item.
+--
+-- @tparam string src Technical name of item to be replaced.
+-- @tparam string tgt Technical name of item to be used in place.
+-- @tparam[opt] bool update_players `true` updates inventory lists associated with players (default: `false`).
+function cleaner.replace_item(src, tgt, update_players)
+ update_players = not (update_players ~= true)
+
+ if not core.registered_items[tgt] then
+ return false, S('Cannot use unknown item "@1" as replacement.', tgt)
+ end
+
+ if not core.registered_items[src] then
+ cleaner.log("info", "\"" .. src .. "\" not registered, not unregistering")
+ else
+ cleaner.log("warning", "overriding registered item \"" .. src .. "\"")
+
+ core.unregister_item(src)
+ if core.registered_items[src] then
+ cleaner.log("error", "could not unregister \"" .. src .. "\"")
+ end
+ end
+
+ core.register_alias(src, tgt)
+ if core.registered_aliases[src] == tgt then
+ cleaner.log("info", "registered alias \"" .. src .. "\" for \"" .. tgt .. "\"")
+ else
+ cleaner.log("error", "could not register alias \"" .. src .. "\" for \"" .. tgt .. "\"")
+ end
+
+ local bags = core.get_modpath("bags") ~= nil
+ local armor = core.get_modpath("3d_armor") ~= nil
+
+ -- update player inventories
+ if update_players then
+ for _, player in ipairs(core.get_connected_players()) do
+ local pinv = player:get_inventory()
+ update_list(pinv, "main", src, tgt)
+
+ if bags then
+ for i = 1, 4 do
+ update_list(pinv, "bag" .. i .. "contents", src, tgt)
+ end
+ end
+
+ if armor then
+ local armor_inv = core.get_inventory({type="detached", name=player:get_player_name() .. "_armor"})
+ update_list(armor_inv, "armor", src, tgt)
+ end
+ end
+ end
+
+ return true
+end
+
+--- Registeres an item to be replaced.
+--
+-- @tparam string src Technical name of item to be replaced.
+-- @tparam string tgt Technical name of item to be used in place.
+function cleaner.register_item_replacement(src, tgt)
+ replace_items[src] = tgt
+end
+
+--- Registers a node to be replaced.
+--
+-- @tparam string src Technical name of node to be replaced.
+-- @tparam string tgt Technical name of node to be used in place.
+function cleaner.register_node_replacement(src, tgt)
+ core.register_node(":" .. src, {
+ groups = {to_replace=1},
+ })
+
+ replace_nodes[src] = tgt
+ cleaner.register_item_replacement(src, tgt)
+end
+
+
+--- Unsafe Methods.
+--
+-- Enabled with [cleaner.unsafe](settings.html#cleaner.unsafe) setting.
+--
+-- @section unsafe
+
+
+if cleaner.unsafe then
+ local remove_ores = {}
+
+ --- Retrieves list of ores to be removed.
+ --
+ -- @treturn table Ores to be removed.
+ function cleaner.get_remove_ores()
+ return remove_ores
+ end
+
+ --- Registers an ore to be removed after server startup.
+ --
+ -- @tparam string src Ore technical name.
+ function cleaner.register_ore_removal(src)
+ table.insert(remove_ores, src)
+ end
+
+ --- Removes an ore definition.
+ --
+ -- @tparam string src Ore technical name.
+ function cleaner.remove_ore(src)
+ local remove_ids = {}
+ local total_removed = 0
+ local registered = false
+
+ for id, def in pairs(core.registered_ores) do
+ if def.ore == src then
+ table.insert(remove_ids, id)
+ registered = true
+ end
+ end
+
+ for _, id in ipairs(remove_ids) do
+ core.registered_ores[id] = nil
+ if core.registered_ores[id] then
+ cleaner.log("error", "unable to unregister ore " .. id)
+ else
+ total_removed = total_removed + 1
+ end
+ end
+
+ return registered, total_removed
+ end
+end
diff --git a/changelog.txt b/changelog.txt
new file mode 100644
index 0000000..0214081
--- /dev/null
+++ b/changelog.txt
@@ -0,0 +1,63 @@
+
+2025-01-18
+----------
+- fix undeclared global
+- added nil check after reading world data file
+
+
+v1.2.1
+----
+- use sounds mod for sounds
+- added nil check after reading world data file
+
+
+v1.2
+----
+- added API
+- added support for unregistering ores (unsafe)
+- added setting for enabling "unsafe" methods & commands
+- all types are loaded from /cleaner.json file
+- added localization support
+- added Spanish localization
+- added pencil tool for erasing, adding, & swapping nodes
+- added chat commands:
+ - remove_entities
+ - remove_nodes
+ - replace_items
+ - replace_nodes
+ - find_unknown_nodes
+ - find_nearby_nodes
+ - remove_ores (unsafe)
+ - ctool (manages wielded cleaner tool settings)
+
+v1.1
+----
+- uses "register_lbm" with "run_at_every_load" instead of "register_abm" to save resources
+ - suggested by bell07 ( https://forum.luanti.org/viewtopic.php?p=325519#p325519 )
+
+v1.0
+----
+- changed license to MIT
+- "clean_entities" & "clean_nodes" files now use json format
+- nodes can be replaced with other nodes
+- items can be replaced with other items (/clean_items.json file)
+
+v0.4
+----
+- changed technical name to "cleaner"
+- re-added functionality to clean nodes
+
+v0.3
+----
+- removed functionality for cleaning anything other than entities
+
+v0.2
+----
+- changed license to CC0
+- added some log output
+- entities to be cleaned can be configured & loaded from world directory
+
+v0.1
+----
+- forked from PilzAdam's "clean" mod @ forum post updated: 2013-06-08
+- replaced deprecated call to "minetest.env"
diff --git a/chat.lua b/chat.lua
new file mode 100644
index 0000000..3f7cf43
--- /dev/null
+++ b/chat.lua
@@ -0,0 +1,660 @@
+
+--- Cleaner Chat Commands
+--
+-- @topic commands
+
+
+local S = core.get_translator(cleaner.modname)
+
+
+local aux = dofile(cleaner.modpath .. "/misc_functions.lua")
+
+local function pos_list(ppos, radius)
+ local plist = {}
+
+ for x = ppos.x - radius, ppos.x + radius, 1 do
+ for y = ppos.y - radius, ppos.y + radius, 1 do
+ for z = ppos.z - radius, ppos.z + radius, 1 do
+ table.insert(plist, {x=x, y=y, z=z})
+ end
+ end
+ end
+
+ return plist
+end
+
+local param_def = {
+ radius = {name=S("radius"), desc=S("Search radius.")},
+ entity = {name=S("entity"), desc=S("Entity technical name.")},
+ node = {name=S("node"), desc=S("Node technical name.")},
+ old_node = {name=S("old_node"), desc=S("Technical name of node to be replaced.")},
+ new_node = {name=S("new_node"), desc=S("Technical name of node to be used in place.")},
+ old_item = {name=S("old_item"), desc=S("Technical name of item to be replaced.")},
+ new_item = {name=S("new_item"), desc=S("Technical name of item to be used in place.")},
+ ore = {name=S("ore"), desc=S("Ore technical name.")},
+ action = {name=S("action"),
+ desc=S('Action to execute. Can be one of "@1", "@2", or "@3".', "status", "setmode", "setnode")},
+ value = {name=S("value"), desc=S('Mode or node to be set for tool (not required for "@1" action).', "status")},
+}
+
+local cmd_repo = {
+ entity = {
+ cmd = "remove_entities",
+ params = {"entity"},
+ oparams = {radius=100},
+ },
+ rem_node = {
+ cmd = "remove_nodes",
+ params = {"node"},
+ oparams = {radius=5},
+ },
+ rep_node = {
+ cmd = "replace_nodes",
+ params = {"old_node", "new_node"},
+ oparams = {radius=5},
+ },
+ find_node = {
+ cmd = "find_unknown_nodes",
+ oparams = {radius=100},
+ },
+ near_node = {
+ cmd = "find_nearby_nodes",
+ oparams = {radius=5},
+ },
+ item = {
+ cmd = "replace_items",
+ params = {"old_item", "new_item"},
+ },
+ ore = {
+ cmd = "remove_ores",
+ params = {"ore"},
+ },
+ tool = {
+ cmd = "ctool",
+ params = {"action", "value"},
+ },
+ param = {
+ missing = S("Missing parameter."),
+ excess = S("Too many parameters."),
+ mal_radius = S("Radius must be a number."),
+ },
+}
+
+for k, def in pairs(cmd_repo) do
+ if k ~= "param" then
+ local cmd_help = {
+ param_string = "",
+ usage_string = "/" .. def.cmd,
+ }
+
+ if def.params or def.oparams then
+ if def.params then
+ local params = {}
+ for _, p in ipairs(def.params) do
+ -- translate
+ table.insert(params, S(p))
+ end
+
+ cmd_help.param_string = "<" .. table.concat(params, "> <") .. ">"
+ end
+ end
+
+ if def.oparams then
+ for k, v in pairs(def.oparams) do
+ local op = k
+ if type(op) == "number" then
+ op = v
+ end
+
+ cmd_help.param_string = cmd_help.param_string .. " [" .. S(op) .. "]"
+ end
+ end
+
+ if cmd_help.param_string ~= "" then
+ cmd_help.usage_string = cmd_help.usage_string .. " " .. cmd_help.param_string
+ end
+
+ cmd_repo[k].help = cmd_help
+ end
+end
+
+local function get_cmd_def(cmd)
+ for k, v in pairs(cmd_repo) do
+ if v.cmd == cmd then return v end
+ end
+end
+
+local function format_usage(cmd)
+ local def = get_cmd_def(cmd)
+ if def then
+ return S("Usage:") .. "\n " .. def.help.usage_string
+ end
+end
+
+local function format_params(cmd)
+ local def = get_cmd_def(cmd)
+
+ local param_count
+ -- FIXME: unused?
+ local all_params = {}
+ if def.params then
+ for _, p in ipairs(def.params) do
+ table.insert(all_params, p)
+ end
+ end
+ if def.oparams then
+ for k, v in pairs(def.oparams) do
+
+ end
+ end
+
+ local retval = ""
+ local p_count = 0
+
+ if def.params then
+ for _, p in ipairs(def.params) do
+ if p_count == 0 then
+ retval = retval .. S("Params:")
+ end
+
+ retval = retval .. "\n " .. S(p) .. ": " .. param_def[p].desc
+
+ p_count = p_count + 1
+ end
+ end
+
+ if def.oparams then
+ for k, v in pairs(def.oparams) do
+ if p_count == 0 then
+ retval = retval .. S("Params:")
+ end
+
+ local p = k
+ local dvalue = v
+ if type(p) == "number" then
+ p = v
+ dvalue = nil
+ end
+
+ retval = retval .. "\n " .. S(p) .. ": " .. param_def[p].desc
+ if dvalue then
+ retval = retval .. " (" .. S("default: @1", dvalue) .. ")"
+ end
+
+ p_count = p_count + 1
+ end
+ end
+
+ return retval
+end
+
+local function format_help(cmd)
+ return format_usage(cmd) .. "\n\n" .. format_params(cmd)
+end
+
+
+local function check_radius(radius, pname)
+ local is_admin = core.check_player_privs(pname, {server=true})
+
+ if not is_admin and radius > 10 then
+ radius = 10
+ return radius, S("You do not have permission to set radius that high. Reduced to @1.", radius)
+ end
+
+ if radius > 100 then
+ radius = 100
+ return radius, S("Radius is too high. Reduced to @1.", radius)
+ end
+
+ return radius
+end
+
+
+--- Removes nearby entities.
+--
+-- @chatcmd remove_entities
+-- @param entity Entity technical name.
+-- @tparam[opt] int radius Search radius (default: 100).
+-- @priv server
+-- @usage
+-- # remove all mobs:horse entities within a radius of 10 nodes
+-- /remove_entities mobs:horse 10
+core.register_chatcommand(cmd_repo.entity.cmd, {
+ privs = {server=true},
+ description = S("Remove an entity from game.") .. "\n\n"
+ .. format_params(cmd_repo.entity.cmd),
+ params = cmd_repo.entity.help.param_string,
+ func = function(name, param)
+ local entity
+ local radius = cmd_repo.entity.oparams.radius
+ if param:find(" ") then
+ entity = param:split(" ")
+ radius = tonumber(entity[2])
+ entity = entity[1]
+ else
+ entity = param
+ end
+
+ local err
+ if not entity or entity:trim() == "" then
+ err = cmd_repo.param.missing
+ elseif not radius then
+ err = cmd_repo.param.mal_radius
+ end
+
+ local radius, msg = check_radius(radius, name)
+ if msg then
+ core.chat_send_player(name, msg)
+ end
+
+ if err then
+ return false, err .. "\n\n" .. format_help(cmd_repo.entity.cmd)
+ end
+
+ local player = core.get_player_by_name(name)
+
+ local total_removed = 0
+ for _, object in ipairs(core.get_objects_inside_radius(player:get_pos(), radius)) do
+ local lent = object:get_luaentity()
+
+ if lent then
+ if lent.name == entity then
+ object:remove()
+ total_removed = total_removed + 1
+ end
+ else
+ if object:get_properties().infotext == entity then
+ object:remove()
+ total_removed = total_removed + 1
+ end
+ end
+ end
+
+ return true, S("Removed @1 entities.", total_removed)
+ end,
+})
+
+--- Removes nearby nodes.
+--
+-- @chatcmd remove_nodes
+-- @param node Node technical name.
+-- @tparam[opt] int radius Search radius (default: 5).
+-- @priv server
+-- @usage
+-- # remove all default:dirt nodes within a radius of 10
+-- /remove_nodes default:dirt 10
+core.register_chatcommand(cmd_repo.rem_node.cmd, {
+ privs = {server=true},
+ description = S("Remove a node from game.") .. "\n\n"
+ .. format_params(cmd_repo.rem_node.cmd),
+ params = cmd_repo.rem_node.help.param_string,
+ func = function(name, param)
+ local nname
+ local radius = cmd_repo.rem_node.oparams.radius
+ if param:find(" ") then
+ nname = param:split(" ")
+ radius = tonumber(nname[2])
+ nname = nname[1]
+ else
+ nname = param
+ end
+
+ local err
+ if not nname or nname:trim() == "" then
+ err = cmd_repo.param.missing
+ elseif not radius then
+ err = cmd_repo.param.mal_radius
+ end
+
+ local radius, msg = check_radius(radius, name)
+ if msg then
+ core.chat_send_player(name, msg)
+ end
+
+ if err then
+ return false, err .. "\n\n" .. format_help(cmd_repo.rem_node.cmd)
+ end
+
+ local ppos = core.get_player_by_name(name):get_pos()
+
+ local total_removed = 0
+ for _, npos in ipairs(pos_list(ppos, radius)) do
+ local node = core.get_node_or_nil(npos)
+ if node and node.name == nname then
+ core.remove_node(npos)
+ total_removed = total_removed + 1
+ end
+ end
+
+ return true, S("Removed @1 nodes.", total_removed)
+ end,
+})
+
+--- Replaces an item.
+--
+-- @chatcmd replace_items
+-- @param old_item Technical name of item to replace.
+-- @param new_item Technical name of item to be used in place.
+-- @priv server
+-- @usage
+-- # replace default:sword_wood with default:sword_mese
+-- /replace_items default:sword_wood default:sword_mese
+core.register_chatcommand(cmd_repo.item.cmd, {
+ privs = {server=true},
+ description = S("Replace an item in game.") .. "\n\n"
+ .. format_params(cmd_repo.item.cmd),
+ params = cmd_repo.item.help.param_string,
+ func = function(name, param)
+ if not param:find(" ") then
+ return false, cmd_repo.param.missing .. "\n\n" .. format_help(cmd_repo.item.cmd)
+ end
+
+ local src = param:split(" ")
+ local tgt = src[2]
+ src = src[1]
+
+ local retval, msg = cleaner.replace_item(src, tgt, true)
+ if not retval then
+ return false, msg
+ end
+
+ return true, S("Success!")
+ end,
+})
+
+--- Replaces nearby nodes.
+--
+-- @chatcmd replace_nodes
+-- @param old_node Technical name of node to replace.
+-- @param new_node Technical name of node to be used in place.
+-- @tparam[opt] int radius Search radius (default: 5).
+-- @priv server
+-- @usage
+-- # replace all default:dirt nodes with default:cobble within a radius of 10
+-- /replace_nodes default:dirt default:cobble 10
+core.register_chatcommand(cmd_repo.rep_node.cmd, {
+ privs = {server=true},
+ description = S("Replace a node in game.") .. "\n\n"
+ .. format_params(cmd_repo.rep_node.cmd),
+ params = cmd_repo.rep_node.help.param_string,
+ func = function(name, param)
+ local help = format_help(cmd_repo.rep_node.cmd)
+
+ if not param:find(" ") then
+ return false, cmd_repo.param.missing .. "\n\n" .. help
+ end
+
+ local radius = cmd_repo.rep_node.oparams.radius
+ local params = param:split(" ")
+
+ local src = params[1]
+ local tgt = tostring(params[2])
+ if #params > 2 then
+ radius = tonumber(params[3])
+ end
+
+ if not radius then
+ return false, cmd_repo.param.mal_radius .. "\n\n" .. help
+ end
+
+ local radius, msg = check_radius(radius, name)
+ if msg then
+ core.chat_send_player(name, msg)
+ end
+
+ if not core.registered_nodes[tgt] then
+ return false, S('Cannot use unknown node "@1" as replacement.', tgt)
+ end
+
+ local total_replaced = 0
+ local ppos = core.get_player_by_name(name):get_pos()
+ for _, npos in ipairs(pos_list(ppos, radius)) do
+ local node = core.get_node_or_nil(npos)
+ if node and node.name == src then
+ if core.swap_node(npos, {name=tgt}) then
+ total_replaced = total_replaced + 1
+ else
+ cleaner.log("error", "could not replace node at " .. core.pos_to_string(npos, 0))
+ end
+ end
+ end
+
+ return true, S("Replaced @1 nodes.", total_replaced)
+ end,
+})
+
+--- Checks for nearby unknown nodes.
+--
+-- @chatcmd find_unknown_nodes
+-- @tparam[opt] int radius Search radius (default: 100).
+-- @priv server
+-- @usage
+-- # print names of all unknown nodes within radius of 10
+-- /find_unknown_nodes 10
+core.register_chatcommand(cmd_repo.find_node.cmd, {
+ privs = {server=true},
+ description = S("Find names of unknown nodes.") .. "\n\n"
+ .. format_params(cmd_repo.find_node.cmd),
+ params = cmd_repo.find_node.help.param_string,
+ func = function(name, param)
+ local help = format_help(cmd_repo.find_node.cmd)
+
+ if param:find(" ") then
+ return false, cmd_repo.param.excess .. "\n\n" .. help
+ end
+
+ local radius = cmd_repo.find_node.oparams.radius
+ if param and param:trim() ~= "" then
+ radius = tonumber(param)
+ end
+
+ if not radius then
+ return false, cmd_repo.param.mal_radius .. "\n\n" .. help
+ end
+
+ local radius, msg = check_radius(radius, name)
+ if msg then
+ core.chat_send_player(name, msg)
+ end
+
+ local ppos = core.get_player_by_name(name):get_pos()
+
+ local checked_nodes = {}
+ local unknown_nodes = {}
+ for _, npos in ipairs(pos_list(ppos, radius)) do
+ local node = core.get_node_or_nil(npos)
+ if node and not checked_nodes[node.name] then
+ if not core.registered_nodes[node.name] then
+ table.insert(unknown_nodes, node.name)
+ end
+
+ checked_nodes[node.name] = true
+ end
+ end
+
+ local node_count = #unknown_nodes
+ if node_count > 0 then
+ msg = S("Found unknown nodes: @1", node_count) .. "\n " .. table.concat(unknown_nodes, ", ")
+ else
+ msg = S("No unknown nodes found.")
+ end
+
+ return true, msg
+ end,
+})
+
+--- Finds names of nearby nodes.
+--
+-- @chatcmd find_nearby_nodes
+-- @tparam[opt] int radius Search radius (default: 5).
+-- @priv server
+-- @usage
+-- # print names of all node types found within radius of 10
+-- /find_nearby_nodes 10
+core.register_chatcommand(cmd_repo.near_node.cmd, {
+ privs = {server=true},
+ description = S("Find names of nearby nodes.") .. "\n\n"
+ .. format_params(cmd_repo.near_node.cmd),
+ params = cmd_repo.near_node.help.param_string,
+ func = function(name, param)
+ local help = format_help(cmd_repo.near_node.cmd)
+
+ if param:find(" ") then
+ return false, cmd_repo.param.excess .. "\n\n" .. help
+ end
+
+ local radius = cmd_repo.near_node.oparams.radius
+ if param and param:trim() ~= "" then
+ radius = tonumber(param)
+ end
+
+ if not radius then
+ return false, cmd_repo.param.mal_radius .. "\n\n" .. help
+ end
+
+ local radius, msg = check_radius(radius, name)
+ if msg then
+ core.chat_send_player(name, msg)
+ end
+
+ local ppos = core.get_player_by_name(name):get_pos()
+
+ local node_names = {}
+ for _, npos in ipairs(pos_list(ppos, radius)) do
+ local node = core.get_node_or_nil(npos)
+ if node and not node_names[node.name] then
+ node_names[node.name] = true
+ end
+ end
+
+ local found_nodes = {}
+ for k, _ in pairs(node_names) do
+ table.insert(found_nodes, k)
+ end
+
+ local msg
+ local node_count = #found_nodes
+ if node_count > 0 then
+ msg = S("Nearby nodes: @1", node_count) .. "\n " .. table.concat(found_nodes, ", ")
+ else
+ msg = S("No nearby nodes found.")
+ end
+
+ return true, msg
+ end,
+})
+
+
+--- Unsafe Commands.
+--
+-- Enabled with [cleaner.unsafe](settings.html#cleaner.unsafe) setting.
+--
+-- @section unsafe
+
+
+if cleaner.unsafe then
+ --- Registers an ore to be removed.
+ --
+ -- @chatcmd remove_ores
+ -- @param ore Ore technical name.
+ -- @priv server
+ -- @note This action is reverted after server restart. To make changes permanent,
+ -- use the [cleaner.json](config.html#cleaner.json) config.
+ -- @usage
+ -- # remove all registered ores that add default:stone_with_iron to world
+ -- /remove_ores default:stone_with_iron
+ core.register_chatcommand(cmd_repo.ore.cmd, {
+ privs = {server=true},
+ description = S("Remove an ore from game.") .. "\n\n"
+ .. format_params(cmd_repo.ore.cmd),
+ params = cmd_repo.ore.help.param_string,
+ func = function(name, param)
+ local err
+ if not param or param:trim() == "" then
+ err = cmd_repo.param.missing
+ elseif param:find(" ") then
+ err = cmd_repo.param.excess
+ end
+
+ if err then
+ return false, err .. "\n\n" .. format_help(cmd_repo.ore.cmd)
+ end
+
+ local success = false
+ local msg
+ local registered, total_removed = cleaner.remove_ore(param)
+
+ if not registered then
+ msg = S('Ore "@1" not found, not unregistering.', param)
+ else
+ msg = S("Unregistered @1 ores (this will be undone after server restart).", total_removed)
+ success = true
+ end
+
+ return success, msg
+ end
+ })
+end
+
+--- @section end
+
+
+--- Manages settings for wielded [cleaner tool](tools.html).
+--
+-- Required Privileges:
+--
+-- - server
+--
+-- @chatcmd ctool
+-- @param action Action to execute. Can be "status", "setmode", or "setnode".
+-- @param value Mode or node to be set for tool (not required for "status" action).
+-- @usage
+-- # while cleaner:pencil is wielded, configure to place default:dirt node when used
+-- /ctool setmode write
+-- /ctool setnode default:dirt
+core.register_chatcommand(cmd_repo.tool.cmd, {
+ privs = {server=true},
+ description = S("Manage settings for wielded cleaner tool.") .. "\n\n"
+ .. format_params(cmd_repo.tool.cmd),
+ params = cmd_repo.tool.help.param_string,
+ func = function(name, param)
+ local action, value = param
+ local idx = param:find(" ")
+ if idx then
+ param = string.split(param, " ")
+ action = param[1]
+ value = param[2]
+ end
+
+ local help = format_help(cmd_repo.tool.cmd)
+
+ local player = core.get_player_by_name(name)
+ local stack = player:get_wielded_item()
+ local iname = aux.tool:format_name(stack)
+ local imeta = stack:get_meta()
+
+ if iname ~= "cleaner:pencil" then
+ return false, S("Unrecognized wielded item: @1", iname) .. "\n\n" .. help
+ end
+
+ if action == "status" then
+ core.chat_send_player(name, iname .. ": " .. S("mode") .. "=" .. imeta:get_string("mode")
+ .. ", " .. S("node") .. "=" .. imeta:get_string("node"))
+ return true
+ end
+
+ if not action or not value then
+ return false, S("Missing parameter.") .. "\n\n" .. help
+ end
+
+ if action == "setmode" then
+ stack = aux.tool:set_mode(stack, value, name)
+ elseif action == "setnode" then
+ stack = aux.tool:set_node(stack, value, name)
+ else
+ return false, S("Unrecognized action: @1", action) .. "\n\n" .. help
+ end
+
+ return player:set_wielded_item(stack)
+ end,
+})
diff --git a/description.txt b/description.txt
deleted file mode 100644
index f04ad33..0000000
--- a/description.txt
+++ /dev/null
@@ -1 +0,0 @@
-A very simple mod that deletes unknown blocks and removes unknown entities.
diff --git a/entities.lua b/entities.lua
new file mode 100644
index 0000000..7b9da53
--- /dev/null
+++ b/entities.lua
@@ -0,0 +1,59 @@
+
+local aux = dofile(cleaner.modpath .. "/misc_functions.lua")
+
+-- populate entities list from file in world path
+local entities_data = aux.get_world_data().entities
+
+
+-- START: backward compat
+
+local e_path = core.get_worldpath() .. "/clean_entities.json"
+local e_file = io.open(e_path, "r")
+
+if e_file then
+ cleaner.log("action", "found deprecated clean_entities.json, updating")
+
+ local data_in = core.parse_json(e_file:read("*a"))
+ e_file:close()
+ if data_in and data_in.remove then
+ for _, r in ipairs(data_in.remove) do
+ table.insert(entities_data.remove, r)
+ end
+ end
+
+ -- don't read deprecated file again
+ os.rename(e_path, e_path .. ".old")
+end
+
+local e_path_old = core.get_worldpath() .. "/clean_entities.txt"
+e_file = io.open(e_path_old, "r")
+
+if e_file then
+ cleaner.log("action", "found deprecated clean_entities.txt, converting to json")
+
+ local data_in = string.split(e_file:read("*a"), "\n")
+ for _, e in ipairs(data_in) do
+ e = e:trim()
+ if e ~= "" and e:sub(1, 1) ~= "#" then
+ table.insert(entities_data.remove, e)
+ end
+ end
+
+ e_file:close()
+ os.rename(e_path_old, e_path_old .. ".bak") -- don't read deprecated file again
+end
+
+-- END: backward compat
+
+
+entities_data.remove = aux.clean_duplicates(entities_data.remove)
+
+-- update json file with any changes
+aux.update_world_data("entities", entities_data)
+
+core.register_on_mods_loaded(function()
+ for _, e in ipairs(entities_data.remove) do
+ cleaner.log("action", "registering entity for removal: " .. e)
+ cleaner.register_entity_removal(e)
+ end
+end)
diff --git a/init.lua b/init.lua
index 1cf3b37..71d9ef2 100644
--- a/init.lua
+++ b/init.lua
@@ -1,28 +1,48 @@
--- clean by PilzAdam
--- LICENSE: WTFPL
-local old_nodes = {"mod:a", "mod:b"}
-local old_entities = {}
+cleaner = {}
+cleaner.modname = core.get_current_modname()
+cleaner.modpath = core.get_modpath(cleaner.modname)
-for _,node_name in ipairs(old_nodes) do
- minetest.register_node(":"..node_name, {
- groups = {old=1},
- })
+local cleaner_debug = core.settings:get_bool("enable_debug_mods", false)
+
+function cleaner.log(lvl, msg)
+ if lvl == "debug" and not cleaner_debug then return end
+
+ if lvl and not msg then
+ msg = lvl
+ lvl = nil
+ end
+
+ msg = "[" .. cleaner.modname .. "] " .. msg
+ if lvl == "debug" then
+ msg = "[DEBUG] " .. msg
+ lvl = nil
+ end
+
+ if not lvl then
+ core.log(msg)
+ else
+ core.log(lvl, msg)
+ end
end
-minetest.register_abm({
- nodenames = {"group:old"},
- interval = 1,
- chance = 1,
- action = function(pos, node)
- minetest.env:remove_node(pos)
- end,
-})
+local aux = dofile(cleaner.modpath .. "/misc_functions.lua")
-for _,entity_name in ipairs(old_entities) do
- minetest.register_entity(":"..entity_name, {
- on_activate = function(self, staticdata)
- self.object:remove()
- end,
- })
+-- initialize world file
+aux.update_world_data()
+
+
+local scripts = {
+ "settings",
+ "api",
+ "chat",
+ "tools",
+ "entities",
+ "nodes",
+ "items",
+ "ores",
+}
+
+for _, script in ipairs(scripts) do
+ dofile(cleaner.modpath .. "/" .. script .. ".lua")
end
diff --git a/items.lua b/items.lua
new file mode 100644
index 0000000..02cde65
--- /dev/null
+++ b/items.lua
@@ -0,0 +1,49 @@
+
+local aux = dofile(cleaner.modpath .. "/misc_functions.lua")
+
+-- populate items list from file in world path
+local items_data = aux.get_world_data().items
+
+
+-- START: backward compat
+
+local i_path = core.get_worldpath() .. "/clean_items.json"
+local i_file = io.open(i_path, "r")
+
+if i_file then
+ cleaner.log("action", "found deprecated clean_items.json, updating")
+
+ local data_in = core.parse_json(i_file:read("*a"))
+ i_file:close()
+ if data_in and data_in.replace then
+ for k, v in pairs(data_in.replace) do
+ if not items_data.replace[k] then
+ items_data.replace[k] = v
+ end
+ end
+ end
+
+ -- don't read deprecated file again
+ os.rename(i_path, i_path .. ".old")
+end
+
+-- END: backward compat
+
+
+aux.update_world_data("items", items_data)
+
+for i_old, i_new in pairs(items_data.replace) do
+ cleaner.register_item_replacement(i_old, i_new)
+end
+
+-- register actions for after server startup
+core.register_on_mods_loaded(function()
+ for i_old, i_new in pairs(cleaner.get_replace_items()) do
+ cleaner.log("action", "registering item \"" .. i_old .. "\" to be replaced with \"" .. i_new .. "\"")
+
+ local retval, msg = cleaner.replace_item(i_old, i_new)
+ if not retval then
+ cleaner.log("warning", msg)
+ end
+ end
+end)
diff --git a/locale/cleaner.es.tr b/locale/cleaner.es.tr
new file mode 100644
index 0000000..bbc0007
--- /dev/null
+++ b/locale/cleaner.es.tr
@@ -0,0 +1,66 @@
+# textdomain:cleaner
+
+# Translators: Jordan Irwin (AntumDeluge)
+
+
+# chat commands
+entity=entidad
+mode=modo
+node=nodo
+radius=radio
+old_item=objeto_antiguo
+new_item=objeto_nuevo
+old_node=nodo_antiguo
+new_node=nodo_nuevo
+ore=mineral
+action=acción
+value=valor
+Usage:=Uso:
+Params:=Parámetros:
+default: @1=por defecto: @1
+Search radius.=Radio de búsqueda.
+Entity technical name.=Nombre técnico de entidad.
+Node technical name.=Nombre técnico de nodo.
+Technical name of node to be replaced.=Nombre técnico del nodo reemplazado.
+Technical name of node to be used in place.=Nombre técnico del nodo de reemplazo.
+Technical name of item to be replaced.=Nombre técnico del objeto reemplazado.
+Technical name of item to be used in place.=Nombre técnico del objeto de reemplazo.
+Ore technical name.=Nombre técnico de mineral.
+Action to execute. Can be one of "@1", "@2", or "@3".=La acción para ejecutar. Puede ser "@1", "@2", o "@3".
+Mode or node to be set for tool (not required for "@1" action).=Modo o nodo para configurar a la herramienta (no se requiere para la acción de "@1").
+Remove an entity from game.=Eliminar una entidad del juego.
+Remove a node from game.=Eliminar un nodo del juego.
+Replace an item in game.=Sustituir un objecto del juego.
+Replace a node in game.=Sustituir un nodo del juego.
+Find names of unknown nodes.=Descubrir los nombres de nodos desconocidos.
+Find names of nearby nodes.=Descubrir los nombres de nodos cercanos.
+Remove an ore from game.=Eliminar un mineral del juego.
+Missing parameter.=Parámetro extraviado.
+Too many parameters.=Demasiados parámetros.
+Radius must be a number.=El radio debe ser un número.
+Cannot use unknown item "@1" as replacement.=El objeto "@1" es desonocido, no se puede utilizar como sustitución.
+Cannot use unknown node "@1" as replacement.=El nodo "@1" es desonocido, no se puede utilizar como sustitución.
+Replaced @1 nodes.=Nodos sustituidos: @1
+Removed @1 nodes.=Se eliminaron @1 nodos.
+Removed @1 entities.=Se eliminaron @1 entidades.
+Found unknown nodes: @1=Se encontraron @1 nodos desconocidos.
+No unknown nodes found.=No se encontraron nodos desconocidos.
+Nearby nodes: @1=Nodos cercanos: @1
+No nearby nodes found.=No se encontraron nodos cercanos.
+Ore "@1" not found, not unregistering.=No se encontró el mineral "@1", se mantiene registrado.
+Unregistered @1 ores (this will be undone after server restart).=Se anuló @1 minerales del registro.
+Success!=¡Éxito!
+Manage settings for wielded cleaner tool.=Administrar a los ajustes de la herramienta cleaner empuñada.
+Unrecognized wielded item: @1=Objeto empuñado desconocido: @1
+Unrecognized action: @1=Acción desconocido: @1
+You do not have permission to set radius that high. Reduced to @1.=No tienes permiso para poner al radio tan alto. Se reduce a @1.
+Radius is too high. Reduced to @1.=El radio es demasiado alto. Se reduce a @1.
+
+# tools:
+@1: mode set to: @2=@1: modo configurado para: @2
+@1: node set to: @2=@1: nodo configurado para: @2
+Modes for tool "@1" not available.=Modos para herramienta "@1" no disponibles.
+You do not have permission to use this item. Missing privs: @1=No tienes permiso para usar este objeto. Privs que faltan: @1
+Unknown mode: @1=Modo desconocido: @1
+Can't place node there.=No se puede poner nodo allí.
+Cannot place unknown node: @1=No se puede poner nodo desconocido: @1
diff --git a/locale/template.txt b/locale/template.txt
new file mode 100644
index 0000000..5373fb7
--- /dev/null
+++ b/locale/template.txt
@@ -0,0 +1,66 @@
+# textdomain:cleaner
+
+# Translators:
+
+
+# chat commands
+entity=
+mode=
+node=
+radius=
+old_item=
+new_item=
+old_node=
+new_node=
+ore=
+action=
+value=
+Usage:=
+Params:=
+default: @1=
+Search radius.=
+Entity technical name.=
+Node technical name.=
+Technical name of node to be replaced.=
+Technical name of node to be used in place.=
+Technical name of item to be replaced.=
+Technical name of item to be used in place.=
+Ore technical name.=
+Action to execute. Can be one of "@1", "@2", or "@3".=
+Mode or node to be set for tool (not required for "@1" action).=
+Remove an entity from game.=
+Remove a node from game.=
+Replace an item in game.=
+Replace a node in game.=
+Find names of unknown nodes.=
+Find names of nearby nodes.=
+Remove an ore from game.=
+Missing parameter.=
+Too many parameters.=
+Radius must be a number.=
+Cannot use unknown item "@1" as replacement.=
+Cannot use unknown node "@1" as replacement.=
+Replaced @1 nodes.=
+Removed @1 nodes.=
+Removed @1 entities.=
+Found unknown nodes: @1=
+No unknown nodes found.=
+Nearby nodes: @1=
+No nearby nodes found.=
+Ore "@1" not found, not unregistering.=
+Unregistered @1 ores (this will be undone after server restart).=
+Success!=
+Manage settings for wielded cleaner tool.=
+Unrecognized wielded item: @1=
+Unrecognized action: @1=
+You do not have permission to set radius that high. Reduced to @1.=
+Radius is too high. Reduced to @1.=
+
+# tools:
+@1: mode set to: @2=
+Modes for tool "@1" not available.=
+@1: node set to: @2=
+You do not have permission to use this item. Missing privs: @1=
+Can't place node there.=
+Unknown mode: @1=
+Cannot place unknown node: @1=
diff --git a/misc_functions.lua b/misc_functions.lua
new file mode 100644
index 0000000..27c8654
--- /dev/null
+++ b/misc_functions.lua
@@ -0,0 +1,258 @@
+
+local S = core.get_translator(cleaner.modname)
+
+
+--- Cleans duplicate entries from indexed table.
+--
+-- @local
+-- @function clean_duplicates
+-- @tparam table t
+-- @treturn table
+local function clean_duplicates(t)
+ local tmp = {}
+ for _, v in ipairs(t) do
+ tmp[v] = true
+ end
+
+ t = {}
+ for k in pairs(tmp) do
+ table.insert(t, k)
+ end
+
+ return t
+end
+
+local world_file = core.get_worldpath() .. "/cleaner.json"
+
+local function get_world_data()
+ local wdata = {}
+ local buffer = io.open(world_file, "r")
+ if buffer then
+ local err
+ wdata, err = core.parse_json(buffer:read("*a"), nil, true)
+ buffer:close()
+ if wdata == nil then
+ cleaner.log("warning", "reading world data file failed: " .. world_file)
+ wdata = {}
+ end
+ end
+
+ local rem_types = {"entities", "nodes", "ores",}
+ local rep_types = {"items", "nodes",}
+
+ for _, t in ipairs(rem_types) do
+ wdata[t] = wdata[t] or {}
+ wdata[t].remove = wdata[t].remove or {}
+ end
+
+ for _, t in ipairs(rep_types) do
+ wdata[t] = wdata[t] or {}
+ wdata[t].replace = wdata[t].replace or {}
+ end
+
+ return wdata
+end
+
+local function update_world_data(t, data)
+ local wdata = get_world_data()
+ if t and data then
+ wdata[t].remove = data.remove
+ wdata[t].replace = data.replace
+ end
+
+ local json_string = core.write_json(wdata, true):gsub("\"remove\" : null", "\"remove\" : []")
+ :gsub("\"replace\" : null", "\"replace\" : {}")
+
+ local buffer = io.open(world_file, "w")
+ if buffer then
+ buffer:write(json_string)
+ buffer:close()
+
+ return true
+ end
+
+ return false
+end
+
+local tool = {
+ modes = {
+ ["cleaner:pencil"] = {"erase", "write", "swap"},
+ },
+
+ format_name = function(self, stack)
+ local iname = stack:get_name()
+ if iname == "cleaner:pencil_1" then
+ iname = "cleaner:pencil"
+ end
+
+ return iname
+ end,
+
+ set_mode = function(self, stack, mode, pname)
+ local iname = self:format_name(stack)
+
+ if not self.modes[iname] then
+ if pname then
+ core.chat_send_player(pname, iname .. ": " .. S("unknown mode: @1", mode))
+ end
+ cleaner.log("warning", iname .. ": unknown mode: " .. mode)
+ return stack
+ end
+
+ local imeta = stack:get_meta()
+ imeta:set_string("mode", mode)
+
+ if pname then
+ core.chat_send_player(pname, S("@1: mode set to: @2", iname, imeta:get_string("mode")))
+ end
+
+ local new_stack
+ if mode == "erase" then
+ new_stack = ItemStack("cleaner:pencil_1")
+ else
+ new_stack = ItemStack("cleaner:pencil")
+ end
+
+ local new_meta = new_stack:get_meta()
+ new_meta:from_table(imeta:to_table())
+
+ return new_stack
+ end,
+
+ next_mode = function(self, stack, pname)
+ local iname = self:format_name(stack)
+ local modes = self.modes[iname]
+
+ if not modes then
+ return false, stack, S('Modes for tool "@1" not available.', stack:get_name())
+ end
+
+ local imeta = stack:get_meta()
+ local current_mode = imeta:get_string("mode")
+ if not current_mode or current_mode:trim() == "" then
+ return true, self:set_mode(stack, modes[1], pname)
+ end
+
+ local idx = 1
+ for _, m in ipairs(modes) do
+ if current_mode == m then
+ break
+ end
+ idx = idx + 1
+ end
+
+ return true, self:set_mode(stack, modes[idx+1] or modes[1], pname)
+ end,
+
+ set_node = function(self, stack, node, pname)
+ local imeta = stack:get_meta()
+ imeta:set_string("node", node)
+
+ if pname then
+ core.chat_send_player(pname, S("@1: node set to: @2", stack:get_name(), imeta:get_string("node")))
+ end
+
+ return stack
+ end,
+}
+
+local use_sounds = core.global_exists("sounds")
+local sound_handle
+
+tool.on_use = function(stack, user, pointed_thing)
+ if not user:is_player() then return end
+
+ local pname = user:get_player_name()
+ if not core.get_player_privs(pname).server then
+ core.chat_send_player(pname, S("You do not have permission to use this item. Missing privs: @1", "server"))
+ return stack
+ end
+
+ if sound_handle then
+ core.sound_stop(sound_handle)
+ sound_handle = nil
+ end
+
+ if pointed_thing.type == "node" then
+ local npos = core.get_pointed_thing_position(pointed_thing)
+ local imeta = stack:get_meta()
+ local mode = imeta:get_string("mode")
+ local new_node_name = imeta:get_string("node")
+
+ if mode == "erase" then
+ core.remove_node(npos)
+ if use_sounds then
+ local sound_handle = sounds.pencil_erase({object=user})
+ end
+ return stack
+ elseif core.registered_nodes[new_node_name] then
+ if mode == "swap" then
+ core.swap_node(npos, {name=new_node_name})
+ if use_sounds then
+ local sound_handle = sounds.pencil_write({object=user})
+ end
+ elseif mode == "write" then
+ local node_above = core.get_node_or_nil(pointed_thing.above)
+ if not node_above or node_above.name == "air" then
+ core.set_node(pointed_thing.above, {name=new_node_name})
+ if use_sounds then
+ local sound_handle = sounds.pencil_write({object=user})
+ end
+ else
+ core.chat_send_player(pname, S("Can't place node there."))
+ end
+ else
+ core.chat_send_player(pname, S("Unknown mode: @1", mode))
+ end
+
+ return stack
+ end
+
+ core.chat_send_player(pname, S("Cannot place unknown node: @1", new_node_name))
+ return stack
+ end
+end
+
+tool.on_secondary_use = function(stack, user, pointed_thing)
+ if not user:is_player() then return end
+
+ local pname = user:get_player_name()
+ if not core.get_player_privs(pname).server then
+ core.chat_send_player(pname, S("You do not have permission to use this item. Missing privs: @1", "server"))
+ return stack
+ end
+
+ local success, stack, msg = tool.next_mode(tool, stack, pname)
+ if not success then
+ core.chat_send_player(pname, msg)
+ end
+
+ return stack
+end
+
+tool.on_place = function(stack, placer, pointed_thing)
+ if not placer:is_player() then return end
+
+ local pname = placer:get_player_name()
+ if not core.get_player_privs(pname).server then
+ core.chat_send_player(pname, S("You do not have permission to use this item. Missing privs: @1", "server"))
+ return stack
+ end
+
+ if pointed_thing.type == "node" then
+ local node = core.get_node_or_nil(core.get_pointed_thing_position(pointed_thing))
+ if node then
+ stack = tool:set_node(stack, node.name, pname)
+ end
+ end
+
+ return stack
+end
+
+
+return {
+ clean_duplicates = clean_duplicates,
+ get_world_data = get_world_data,
+ update_world_data = update_world_data,
+ tool = tool,
+}
diff --git a/mod.conf b/mod.conf
index 8cb9512..03b84d4 100644
--- a/mod.conf
+++ b/mod.conf
@@ -1,3 +1,7 @@
-name = clean
-author = PilzAdam
-license = WTFPL
+name = cleaner
+description = A mod that can be used to remove/replace unknown entities, nodes, & items.
+version = 2025-01-18
+license = MIT
+author = PilzAdam, Jordan Irwin (AntumDeluge)
+min_minetest_version = 5.0
+optional_depends = sounds
diff --git a/nodes.lua b/nodes.lua
new file mode 100644
index 0000000..f3878ab
--- /dev/null
+++ b/nodes.lua
@@ -0,0 +1,97 @@
+
+local aux = dofile(cleaner.modpath .. "/misc_functions.lua")
+
+-- populate nodes list from file in world path
+local nodes_data = aux.get_world_data().nodes
+
+
+-- START: backward compat
+
+local n_path = core.get_worldpath() .. "/clean_nodes.json"
+local n_file = io.open(n_path, "r")
+
+if n_file then
+ cleaner.log("action", "found deprecated clean_nodes.json, updating")
+
+ local data_in = core.parse_json(n_file:read("*a"))
+ n_file:close()
+ if data_in then
+ if data_in.remove then
+ for _, r in ipairs(data_in.remove) do
+ table.insert(nodes_data.remove, r)
+ end
+ end
+
+ if data_in.replace then
+ for k, v in pairs(data_in.replace) do
+ if not nodes_data.replace[k] then
+ nodes_data.replace[k] = v
+ end
+ end
+ end
+ end
+
+ -- don't read deprecated file again
+ os.rename(n_path, n_path .. ".old")
+end
+
+local n_path_old = core.get_worldpath() .. "/clean_nodes.txt"
+n_file = io.open(n_path_old, "r")
+
+if n_file then
+ cleaner.log("action", "found deprecated clean_nodes.txt, converting to json")
+
+ local data_in = string.split(n_file:read("*a"), "\n")
+ for _, e in ipairs(data_in) do
+ e = e:trim()
+ if e ~= "" and e:sub(1, 1) ~= "#" then
+ table.insert(nodes_data.remove, e)
+ end
+ end
+
+ n_file:close()
+ os.rename(n_path_old, n_path_old .. ".old") -- don't read deprecated file again
+end
+
+-- END: backward compat
+
+
+nodes_data.remove = aux.clean_duplicates(nodes_data.remove)
+
+-- update json file with any changes
+aux.update_world_data("nodes", nodes_data)
+
+core.register_lbm({
+ name = "cleaner:remove_nodes",
+ nodenames = {"group:to_remove"},
+ run_at_every_load = true,
+ action = function(pos, node)
+ core.remove_node(pos)
+ end,
+})
+
+core.register_lbm({
+ name = "cleaner:replace_nodes",
+ nodenames = {"group:to_replace"},
+ run_at_every_load = true,
+ action = function(pos, node)
+ local new_node_name = cleaner.get_replace_nodes()[node.name]
+ if core.registered_nodes[new_node_name] then
+ core.swap_node(pos, {name=new_node_name})
+ else
+ cleaner.log("error", "cannot replace with unregistered node \"" .. tostring(new_node_name) .. "\"")
+ end
+ end,
+})
+
+core.register_on_mods_loaded(function()
+ for _, n in ipairs(nodes_data.remove) do
+ cleaner.log("action", "registering node for removal: " .. n)
+ cleaner.register_node_removal(n)
+ end
+
+ for n_old, n_new in pairs(nodes_data.replace) do
+ cleaner.log("action", "registering node \"" .. n_old .. "\" to be replaced with \"" .. n_new .. "\"")
+ cleaner.register_node_replacement(n_old, n_new)
+ end
+end)
diff --git a/ores.lua b/ores.lua
new file mode 100644
index 0000000..517f60a
--- /dev/null
+++ b/ores.lua
@@ -0,0 +1,17 @@
+
+if not cleaner.unsafe then return end
+
+local aux = dofile(cleaner.modpath .. "/misc_functions.lua")
+
+local ores_data = aux.get_world_data().ores
+
+for _, ore in ipairs(ores_data.remove) do
+ cleaner.register_ore_removal(ore)
+end
+
+core.register_on_mods_loaded(function()
+ for _, ore in ipairs(cleaner.get_remove_ores()) do
+ cleaner.log("action", "unregistering ore: " .. ore)
+ cleaner.remove_ore(ore)
+ end
+end)
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000..639655d
Binary files /dev/null and b/screenshot.png differ
diff --git a/set_version.py b/set_version.py
new file mode 100755
index 0000000..8c40217
--- /dev/null
+++ b/set_version.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python
+
+import sys, os, codecs
+
+
+f_script = os.path.realpath(__file__)
+d_root = os.path.dirname(f_script)
+
+os.chdir(d_root)
+
+args = sys.argv[1:]
+if len(args) < 1:
+ print("ERROR: must supply version as parameter")
+ sys.exit(1)
+
+new_version = args[0]
+
+to_update = {
+ "mod.conf": "version =",
+ "changelog.txt": "next",
+ os.path.normpath(".ldoc/config.ld"): "local version =",
+}
+
+for f in to_update:
+ f_path = os.path.join(d_root, f)
+ if not os.path.isfile(f_path):
+ print("WARNING: {} not found, skipping ...".format(f))
+ continue
+
+ print("\nsetting version to {} in {}".format(new_version, f_path))
+
+ buffer = codecs.open(f_path, "r", "utf-8")
+ if not buffer:
+ print("WARNING: could not open {} for reading, skipping ...".format(f))
+ continue
+
+ read_in = buffer.read()
+ buffer.close()
+
+ read_in = read_in.replace("\r\n", "\n").replace("\r", "\n")
+ replacement = to_update[f]
+ new_lines = []
+
+ version_set = False
+ for li in read_in.split("\n"):
+ if not version_set:
+ if "=" in replacement and li.startswith(replacement):
+ key = li.split(" = ")[0]
+ li = "{} = {}".format(key, new_version)
+ version_set = True
+ elif li == replacement:
+ li = "v{}".format(new_version)
+ version_set = True
+
+ new_lines.append(li)
+
+ write_out = "\n".join(new_lines)
+ if write_out == read_in:
+ print("no changes for {}, skipping ...".format(f))
+ continue
+
+ buffer = codecs.open(f_path, "w", "utf-8")
+ if not buffer:
+ print("WARNING: could not open {} for writing, skipping ...".format(f))
+ continue
+
+ buffer.write("\n".join(new_lines))
+ buffer.close()
+
+ print("done")
diff --git a/settings.lua b/settings.lua
new file mode 100644
index 0000000..16c2f42
--- /dev/null
+++ b/settings.lua
@@ -0,0 +1,15 @@
+
+--- Cleaner Settings
+--
+-- @topic settings
+
+
+--- Enables unsafe methods & chat commands.
+--
+-- - `cleaner.remove_ore`
+-- - `/remove_ores`
+--
+-- @setting cleaner.unsafe
+-- @settype bool
+-- @default false
+cleaner.unsafe = core.settings:get_bool("cleaner.unsafe", false)
diff --git a/settingtypes.txt b/settingtypes.txt
new file mode 100644
index 0000000..b5debc7
--- /dev/null
+++ b/settingtypes.txt
@@ -0,0 +1,6 @@
+
+# Enables unsafe methods & chat commands.
+#
+# - cleaner.remove_ore
+# - /remove_ores
+cleaner.unsafe (Enable unsafe methods) bool false
diff --git a/textures/cleaner_pencil.png b/textures/cleaner_pencil.png
new file mode 100644
index 0000000..3582687
Binary files /dev/null and b/textures/cleaner_pencil.png differ
diff --git a/tools.lua b/tools.lua
new file mode 100644
index 0000000..cc1c405
--- /dev/null
+++ b/tools.lua
@@ -0,0 +1,44 @@
+
+--- Cleaner Tools
+--
+-- @topic tools
+
+
+local S = core.get_translator(cleaner.modname)
+
+
+local aux = dofile(cleaner.modpath .. "/misc_functions.lua")
+
+--- Master Pencil
+--
+-- @tool cleaner:pencil
+-- @img cleaner_pencil.png
+-- @priv server
+-- @usage
+-- place (right-click):
+-- - when not pointing at a node, changes modes
+-- - when pointing at a node, sets node to be used
+--
+-- use (left-click):
+-- - executes action for current mode:
+-- - erase: erases pointed node
+-- - write: adds node
+-- - swap: replaces pointed node
+core.register_tool(cleaner.modname .. ":pencil", {
+ description = S("Master Pencil"),
+ inventory_image = "cleaner_pencil.png",
+ liquids_pointable = true,
+ on_use = aux.tool.on_use,
+ on_secondary_use = aux.tool.on_secondary_use,
+ on_place = aux.tool.on_place,
+})
+
+core.register_tool(cleaner.modname .. ":pencil_1", {
+ description = S("Master Pencil"),
+ inventory_image = "cleaner_pencil.png^[transformFXFY",
+ liquids_pointable = true,
+ groups = {not_in_creative_inventory=1},
+ on_use = aux.tool.on_use,
+ on_secondary_use = aux.tool.on_secondary_use,
+ on_place = aux.tool.on_place,
+})