From c55fd2370676af075423b6b16b6459102d024de0 Mon Sep 17 00:00:00 2001 From: Christian Doczkal Date: Fri, 17 Nov 2023 17:02:52 +0100 Subject: [PATCH] first draft of `item` bulk forbid/dump/melt tool --- docs/item.rst | 58 ++++++++++ item.lua | 294 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 docs/item.rst create mode 100755 item.lua diff --git a/docs/item.rst b/docs/item.rst new file mode 100644 index 0000000000..eeea8e6650 --- /dev/null +++ b/docs/item.rst @@ -0,0 +1,58 @@ +item +==== + +.. dfhack-tool:: + :summary: Perform bulk operations on items based on various properties. + :tags: fort productivity items + +Filter items in you fort by various properties (e.g., item type, material, +wear-level, quality, ...), and perform the bulk operations forbid, dump, melt, +and their inverses. By default, the tool does not act on artifacts. + +Usage +----- + +``item [ help | count | [un]forbid | [un]dump | [un]melt ] `` + +Options +------- + +``-h, --help`` + Print help. + +``-r, --reachable`` + Only include items reachable by one of your citizens. + +``-u, --unreachable`` + Only include items not reachable by one of your citizens. + +``-t, --type `` + Filter by item type (e.g., BOULDER, CORPSE, ...). Also accepts lower case + spelling (e.g. "corpse") + +``-m, --material `` + Filter by material the item is made out of (e.g., "iron"). + +``-d, --description `` + Filter by item description (singular form without stack sizes). Example: + "cave spider silk web". Note: This does not work for animal products such as + wool, because their description always includes the stack size. + +``-a, --include-artifacts`` + Include artifacts in the item list. + +``--min-wear `` + Only include items whose wear/damage level is at least ``integer``. Useful + values are 0 (pristine) to 3 (XX). + +``--min-wear `` + Only include items whose wear/damage level is at most ``integer``. Useful + values are 0 (pristine) to 3 (XX). + +``--min-quality `` + Only include items whose quality level is at least ``integer``. Useful + values are 0 (standard) to 5 (masterwork). + +``--min-quality `` + Only include items whose quality level is at most ``integer``. Useful + values are 0 (standard) to 5 (masterwork). diff --git a/item.lua b/item.lua new file mode 100755 index 0000000000..65c4ff67c1 --- /dev/null +++ b/item.lua @@ -0,0 +1,294 @@ +----------------------------------------------------------- +-- helper functions that should probably be moved elsewhere +----------------------------------------------------------- + +-- check whether an item is inside a burrow +local function containsItem(burrow,item) + local res = false + local x,y,z = dfhack.items.getPosition(item) + if x then + res = dfhack.burrows.isAssignedTile(burrow, xyz2pos(x,y,z)) + end + return res +end + +-- check whether the item is reachable by one of the citizens +local function isReachable(item) + local reachable = false + local x, y, z = dfhack.items.getPosition(item) + if x then -- item has a valid position + local citizens = dfhack.units.getCitizens(true) + for _, unit in pairs(citizens) do + if dfhack.maps.canWalkBetween(unit.pos, xyz2pos(x, y, z)) then + reachable = true + break + end + end + end + return reachable +end + +-- fast reachability test for items that requires precomputed walkability groups for +-- all citizens. Returns false for items w/o valid position (e.g., items in inventories). +--- @param item item +--- @param wgroups table +--- @return boolean +local function fastReachable(item,wgroups) + local x, y, z = dfhack.items.getPosition(item) + if x then -- item has a valid position + local igroup = dfhack.maps.getWalkableGroup(xyz2pos(x, y, z)) + if wgroups[igroup] then + return true + else + return false + end + else + return false + end +end + +local function citizenWalkabilityGroups() + local cgroups = {} + for _, unit in pairs(dfhack.units.getCitizens(true)) do + local wgroup = dfhack.maps.getWalkableGroup(unit.pos) + cgroups[wgroup] = true + end + cgroups[0] = false -- exclude unwarkable tiles + return cgroups +end + +-- see: https://discord.com/channels/793331351645323264/807443277132595211/1173979892036141137 +--- @param item item +local function isMeltable(item) + local type = item:getType() + local matinfo = dfhack.matinfo.decode(item) + return + matinfo:matches{metal=true} and + df.item_type[type] ~= 'CORPSE' and + df.item_type[type] ~= 'CORPSEPIECE' and + df.item_type[type] ~= 'REMAINS' and + df.item_type[type] ~= 'FISH' and + df.item_type[type] ~= 'FISH_RAW' and + df.item_type[type] ~= 'VERMIN' and + df.item_type[type] ~= 'PET' and + df.item_type[type] ~= 'FOOD' and + df.item_type[type] ~= 'EGG' +end + +local function addCondition(tab, cond) + if cond then + table.insert(tab, cond) + end +end +----------------------------------------------------------------------- +-- external API: helpers to assemble filters and `item.act` to execute. +----------------------------------------------------------------------- +item = {} + +--- @param tab table +function item.condition_burrow(tab, burrow, outside) + if outside then + addCondition( + tab, + function (item) return not containsItem(burrow,item) end + ) + else + addCondition( + tab, + function (item) return containsItem(burrow,item) end + ) + end +end + +function item.condition_type(tab, type) + addCondition( + tab, + function(item) + return df.item_type[item:getType()] == string.upper(type) + end + ) +end + +function item.condition_reachable(tab) + local cgroups = citizenWalkabilityGroups() + addCondition(tab, function (item) return fastReachable(item,cgroups) end) +end + +function item.condition_unreachable(tab) + local cgroups = citizenWalkabilityGroups() + addCondition(tab, function (item) return not fastReachable(item,cgroups) end) +end + +-- uses the singular form without stack size (i.e., prickle berry) +-- does not work for corpse pieces like "wool" +function item.condition_description(tab, desc) + addCondition( + tab, + function(item) return dfhack.items.getDescription(item, 1) == desc end + ) +end + +function item.condition_material(tab, material) + addCondition( + tab, + function(item) return dfhack.matinfo.decode(item):toString() == material end + ) +end + +function item.condition_wear(tab, level, upper) + if upper then + addCondition(tab, function(item) return item.wear <= level end) + else + addCondition(tab, function(item) return item.wear >= level end) + end +end + +function item.condition_quality(tab, levelst, upper) + local level = argparse.nonnegativeInt(levelst, 'wear') + if upper then + addCondition(tab, function(item) return item.quality <= level end) + else + addCondition(tab, function(item) return item.quality >= level end) + end +end + +function item.act(action,conditons,options) + local count = 0 + local changed = 0 + local invisible = 0 + for _, item in pairs(df.global.world.items.other.IN_PLAY) do + -- never act on items used for constructions/building materials and carried by hostiles + -- also skip artifacts, unless explicitly told to include them + if item.flags.construction or + item.flags.in_building or + item.flags.hostile or + (item.flags.artifact and not options.artifact) + then + goto skipitem + end + + -- check conditions provided via options + for _, condition in pairs(conditons) do + if not condition(item) then goto skipitem end + end + + -- only try to melt things that are actually meltable + -- TOTHINK: treated as implicit filter, should we count the item? + if action == 'melt' and not isMeltable(item) then + goto skipitem + end + + -- item matches the filters + count = count + 1 + + -- skip items that are in unrevealed parts of the map + local x,y,z = dfhack.items.getPosition(item) + if x and not dfhack.maps.isTileVisible(x,y,z) then + invisible = invisible+1 + goto skipitem + end + + -- carry out the action + if action == 'forbid' and not item.flags.forbid then + item.flags.forbid = true + changed = changed + 1 + elseif action == 'unforbid' and item.flags.forbid then + item.flags.forbid = false + changed = changed + 1 + elseif action == 'dump' and not item.flags.dump then + item.flags.dump = true + changed = changed + 1 + elseif action == 'undump' and item.flags.dump then + item.flags.dump = false + changed = changed + 1 + elseif action == 'melt' and not item.flags.melt then + item.flags.melt = true + changed = changed + 1 + elseif action == 'unmelt' and item.flags.melt then + item.flags.melt = false + changed = changed + 1 + end + :: skipitem :: + end + print(count, 'items matched the filter options') + print(invisible, 'invisible items were skipped') + print(changed, 'items were acted upon') +end + +----------------------------------------------------------------------- +-- script action: check for arguments and main action and run item.act +----------------------------------------------------------------------- +local argparse = require('argparse') + +local options, args = { + help = false, + artifact = false +}, { ... } + +local conditions = {} + +local positionals = argparse.processArgsGetopt(args, { + { 'h', 'help', handler = function() options.help = true end }, + { 'a', 'include-artifacts', handler = function() options.artifact = true end }, + { 'i', 'inside', hasArg = true, + handler = function (name) + local burrow = dfhack.burrows.findByName(name) + if burrow then item.condition_burrow(conditions, burrow, false) + else qerror('burrow '..name..' not found') end + end + }, + { 'o', 'outside', hasArg = true, + handler = function (name) + local burrow = dfhack.burrows.findByName(name) + if burrow then item.condition_burrow(conditions, burrow, true) + else qerror('burrow '..name..' not found') end + end + }, + { 'r', 'reachable', + handler = function () item.condition_reachable(conditions) end }, + { 'u', 'unreachable', + handler = function () item.condition_unreachable(conditions) end }, + { 't', 'type', hasArg = true, + handler = function (type) item.condition_type(conditions, type) end }, + { 'd', 'description', hasArg = true, + handler = function (desc) item.condition_description(conditions, desc) end }, + { 'm', 'material', hasArg = true, + handler = function (material) item.condition_material(conditions, material) end }, + { nil, 'min-wear', hasArg = true, + handler = function(levelst) + local level = argparse.nonnegativeInt(levelst, 'min-wear') + item.condition_wear(conditions, level, false) end }, + { nil, 'max-wear', hasArg = true, + handler = function(levelst) + local level = argparse.nonnegativeInt(levelst, 'max-wear') + item.condition_wear(conditions, level, true) end }, + { nil, 'min-quality', hasArg = true, + handler = function(levelst) + local level = argparse.nonnegativeInt(levelst, 'min-quality') + item.condition_quality(conditions, level, false) end }, + { nil, 'max-quality', hasArg = true, + handler = function(levelst) + local level = argparse.nonnegativeInt(levelst, 'max-quality') + item.condition_quality(conditions, level, true) end } +}) + +local action = nil + +if options.help or positionals[1] == 'help' then + -- print(dfhack.script_help()) + print("HELP!") + return +elseif positionals[1] == 'forbid' then action = 'forbid' +elseif positionals[1] == 'unforbid' then action = 'unforbid' +elseif positionals[1] == 'dump' then action = 'dump' +elseif positionals[1] == 'undump' then action = 'undump' +elseif positionals[1] == 'melt' then action = 'melt' +elseif positionals[1] == 'unmelt' then action = 'unmelt' +elseif positionals[1] == 'count' then action = 'count' +end + +if not action then + qerror('main action not recognized') +else + item.act(action,conditions,options) +end