diff --git a/changelog.txt b/changelog.txt index 438a02cacf..eada935a6b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -28,8 +28,10 @@ Template for new versions: ## New Tools - `control-panel`: new commandline interface for control panel functions +- `uniform-unstick`: (reinstated) force squad members to drop items that they picked up in the wrong order so they can get everything equipped properly ## New Features +- `uniform-unstick`: add overlay to the squad equipment screen to show a equipment conflict report and give you a one-click button to fix ## Fixes - `warn-stranded`: Automatically ignore citizens who are gathering plants or digging to avoid issues with gathering fruit via stepladders and weird issues with digging diff --git a/docs/uniform-unstick.rst b/docs/uniform-unstick.rst index 3e877aae2d..58a207e064 100644 --- a/docs/uniform-unstick.rst +++ b/docs/uniform-unstick.rst @@ -3,10 +3,14 @@ uniform-unstick .. dfhack-tool:: :summary: Make military units reevaluate their uniforms. - :tags: unavailable + :tags: fort bugfix military This tool prompts military units to reevaluate their uniform, making them -remove and drop potentially conflicting worn items. +remove and drop potentially conflicting worn items. If multiple units claim the +same item, the item will be unassigned from all units that are not already +wearing the item. If this happens, you'll have to click the "Update equipment" +button on the Squads "Equip" screen in order for them to get new equipment +assigned. Unlike a "replace clothing" designation, it won't remove additional clothing if it's coexisting with a uniform item already on that body part. It also won't @@ -39,6 +43,19 @@ Strategy options Force the unit to drop conflicting worn items onto the ground, where they can then be reclaimed in the correct order. ``--free`` - Remove to-equip items from containers or other's inventories and place them - on the ground, ready to be claimed. This is most useful when someone else - is wearing/holding the required items. + Remove items from the uniform assignment if someone else has a claim on + them. This will also remove items from containers and place them on the + ground, ready to be claimed. +``--multi`` + Attempt to fix issues with uniforms that allow multiple items per body part. + +Overlay +------- + +This script adds a small link to the squad equipment page that will run +``uniform-unstick --all`` and show the report when clicked. After reviewing the +report, you can right click to exit and do nothing or you can click the "Try to +resolve conflicts" button, which runs the equivalent of +``uniform-unstick --all --drop --free``. If any items are unassigned (they'll +turn red on the equipment screen), hit the "Update Equipment" button to +reassign equipment. diff --git a/uniform-unstick.lua b/uniform-unstick.lua index 469686e2a2..67016e2f85 100644 --- a/uniform-unstick.lua +++ b/uniform-unstick.lua @@ -1,279 +1,362 @@ --- Prompt units to adjust their uniform. -local help = [====[ - -uniform-unstick -=============== - -Prompt units to reevaluate their uniform, by removing/dropping potentially conflicting worn items. - -Unlike a "replace clothing" designation, it won't remove additional clothing -if it's coexisting with a uniform item already on that body part. -It also won't remove clothing (e.g. shoes, trousers) if the unit has yet to claim an -armor item for that bodypart. (e.g. if you're still manufacturing them.) - -By default it simply prints info about the currently selected unit, -to actually drop items, you need to provide it the -drop option. - -The default algorithm assumes that there's only one armor item assigned per body part, -which means that it may miss cases where one piece of armor is blocked but the other -is present. The -multi option can possibly get around this, but at the cost of ignoring -left/right distinctions when dropping items. - -In some cases, an assigned armor item can't be put on because someone else is wearing/holding it. -The -free option will cause the assigned item to be removed from the container/dwarven inventory -and placed onto the ground, ready for pickup. - -In no cases should the command cause a uniform item that is being properly worn to be removed/dropped. - -Targets: - -:(no target): Force the selected dwarf to put on their uniform. -:-all: Force the uniform on all military dwarves. - -Options: - -:(none): Simply show identified issues (dry-run). -:-drop: Cause offending worn items to be placed on ground under unit. -:-free: Remove to-equip items from containers or other's inventories, and place on ground. -:-multi: Be more agressive in removing items, best for when uniforms have muliple items per body part. -]====] +--@ module=true +local gui = require('gui') +local overlay = require('plugins.overlay') local utils = require('utils') +local widgets = require('gui.widgets') local validArgs = utils.invert({ - 'all', - 'drop', - 'free', - 'multi', - 'help' + 'all', + 'drop', + 'free', + 'multi', + 'help' }) -- Functions -function item_description(item) - return dfhack.df2console( dfhack.items.getDescription(item, 0, true) ) +local function item_description(item) + return dfhack.df2console(dfhack.items.getDescription(item, 0, true)) end -function get_item_pos(item) - local x, y, z = dfhack.items.getPosition(item) - if x == nil or y == nil or z == nil then - return nil - end - - if not dfhack.maps.isValidTilePos(x,y,z) then - print("NOT VALID TILE") - return nil - end - if not dfhack.maps.isTileVisible(x,y,z) then - print("NOT VISIBLE TILE") - return nil - end - return xyz2pos(x, y, z) -end +local function get_item_pos(item) + local x, y, z = dfhack.items.getPosition(item) + if not x or not y or not z then + return + end -function find_squad_position(unit) - for i, squad in pairs( df.global.world.squads.all ) do - for i, position in pairs( squad.positions ) do - if position.occupant == unit.hist_figure_id then - return position - end + if dfhack.maps.isTileVisible(x, y, z) then + return xyz2pos(x, y, z) end - end - return nil end -function bodyparts_that_can_wear(unit, item) +local function get_squad_position(unit) + local squad = df.squad.find(unit.military.squad_id) + if not squad then return end + if #squad.positions > unit.military.squad_position then + return squad.positions[unit.military.squad_position] + end +end - local bodyparts = {} - local unitparts = df.creature_raw.find(unit.race).caste[unit.caste].body_info.body_parts +local function bodyparts_that_can_wear(unit, item) + local bodyparts = {} + local unitparts = df.creature_raw.find(unit.race).caste[unit.caste].body_info.body_parts - if item._type == df.item_helmst then - for index, part in pairs(unitparts) do - if part.flags.HEAD then - table.insert(bodyparts, index) - end - end - elseif item._type == df.item_armorst then - for index, part in pairs(unitparts) do - if part.flags.UPPERBODY then - table.insert(bodyparts, index) - end - end - elseif item._type == df.item_glovesst then - for index, part in pairs(unitparts) do - if part.flags.GRASP then - table.insert(bodyparts, index) - end - end - elseif item._type == df.item_pantsst then - for index, part in pairs(unitparts) do - if part.flags.LOWERBODY then - table.insert(bodyparts, index) - end - end - elseif item._type == df.item_shoesst then - for index, part in pairs(unitparts) do - if part.flags.STANCE then - table.insert(bodyparts, index) - end + if item._type == df.item_helmst then + for index, part in ipairs(unitparts) do + if part.flags.HEAD then + table.insert(bodyparts, index) + end + end + elseif item._type == df.item_armorst then + for index, part in ipairs(unitparts) do + if part.flags.UPPERBODY then + table.insert(bodyparts, index) + end + end + elseif item._type == df.item_glovesst then + for index, part in ipairs(unitparts) do + if part.flags.GRASP then + table.insert(bodyparts, index) + end + end + elseif item._type == df.item_pantsst then + for index, part in ipairs(unitparts) do + if part.flags.LOWERBODY then + table.insert(bodyparts, index) + end + end + elseif item._type == df.item_shoesst then + for index, part in ipairs(unitparts) do + if part.flags.STANCE then + table.insert(bodyparts, index) + end + end + else + -- print("Ignoring item type for "..item_description(item) ) end - else - -- print("Ignoring item type for "..item_description(item) ) - end - return bodyparts + return bodyparts +end + +local function print_bad_labor(unit_name, labor_name) + print("WARNING: Unit " .. unit_name .. " has the " .. labor_name .. + " labor enabled, which conflicts with military uniforms.") end -- Will figure out which items need to be moved to the floor, returns an item_id:item map -function process(unit, args) - local silent = args.all -- Don't print details if we're iterating through all dwarves - local unit_name = dfhack.df2console( dfhack.TranslateName( dfhack.units.getVisibleName(unit) ) ) +local function process(unit, args) + local silent = args.all -- Don't print details if we're iterating through all dwarves + local unit_name = dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit))) - if not silent then - print("Processing unit "..unit_name) - end + if not silent then + print("Processing unit " .. unit_name) + end - -- The return value - local to_drop = {} -- item id to item object + -- The return value + local to_drop = {} -- item id to item object - -- First get squad position for an early-out for non-military dwarves - local squad_position = find_squad_position(unit) - if squad_position == nil then - if not silent then - print("Unit "..unit_name.." does not have a military uniform.") + -- First get squad position for an early-out for non-military dwarves + local squad_position = get_squad_position(unit) + if not squad_position then + if not silent then + print("Unit " .. unit_name .. " does not have a military uniform.") + end + return end - return nil - end - - -- Find all worn items which may be at issue. - local worn_items = {} -- map of item ids to item objects - local worn_parts = {} -- map of item ids to body part ids - for k, inv_item in pairs(unit.inventory) do - local item = inv_item.item - if inv_item.mode == df.unit_inventory_item.T_mode.Worn or inv_item.mode == df.unit_inventory_item.T_mode.Weapon then -- Include weapons so we can check we have them later - worn_items[ item.id ] = item - worn_parts[ item.id ] = inv_item.body_part_id - end - end - - -- Now get info about which items have been assigned as part of the uniform - local assigned_items = {} -- assigned item ids mapped to item objects - for loc, specs in pairs( squad_position.uniform ) do - for i, spec in pairs(specs) do - for i, assigned in pairs( spec.assigned ) do - -- Include weapon and shield so we can avoid dropping them, or pull them out of container/inventory later - assigned_items[ assigned ] = df.item.find( assigned ) - end + + if unit.status.labors.MINE then + print_bad_labor(unit_name, "mining") + elseif unit.status.labors.CUTWOOD then + print_bad_labor(unit_name, "woodcutting") + elseif unit.status.labors.HUNT then + print_bad_labor(unit_name, "hunting") end - end - - -- Figure out which assigned items are currently not being worn - - local present_ids = {} -- map of item ID to item object - local missing_ids = {} -- map of item ID to item object - for u_id, item in pairs(assigned_items) do - if worn_items[ u_id ] == nil then - print("Unit "..unit_name.." is missing an assigned item, object #"..u_id.." '"..item_description(item).."'" ) - missing_ids[ u_id ] = item - if args.free then - to_drop[ u_id ] = item - end - else - present_ids[ u_id ] = item + + -- Find all worn items which may be at issue. + local worn_items = {} -- map of item ids to item objects + local worn_parts = {} -- map of item ids to body part ids + for _, inv_item in ipairs(unit.inventory) do + local item = inv_item.item + -- Include weapons so we can check we have them later + if inv_item.mode == df.unit_inventory_item.T_mode.Worn or + inv_item.mode == df.unit_inventory_item.T_mode.Weapon or + inv_item.mode == df.unit_inventory_item.T_mode.Strapped + then + worn_items[item.id] = item + worn_parts[item.id] = inv_item.body_part_id + end + end + + -- Now get info about which items have been assigned as part of the uniform + local assigned_items = {} -- assigned item ids mapped to item objects + for _, specs in ipairs(squad_position.uniform) do + for _, spec in ipairs(specs) do + for _, assigned in ipairs(spec.assigned) do + -- Include weapon and shield so we can avoid dropping them, or pull them out of container/inventory later + assigned_items[assigned] = df.item.find(assigned) + end + end end - end - -- Figure out which worn items should be dropped + -- Figure out which assigned items are currently not being worn + -- and if some other unit is carrying the item, unassign it from this unit's uniform + + local present_ids = {} -- map of item ID to item object + local missing_ids = {} -- map of item ID to item object + for u_id, item in pairs(assigned_items) do + if not worn_items[u_id] then + print("Unit " .. unit_name .. " is missing an assigned item, object #" .. u_id .. " '" .. + item_description(item) .. "'") + if dfhack.items.getGeneralRef(item, df.general_ref_type.UNIT_HOLDER) then + print(" Another unit has a claim on object #" .. u_id .. " '" .. item_description(item) .. "'") + if args.free then + print(" Removing from uniform") + assigned_items[u_id] = nil + for _, specs in ipairs(squad_position.uniform) do + for _, spec in ipairs(specs) do + for idx, assigned in ipairs(spec.assigned) do + if assigned == u_id then + spec.assigned:erase(idx) + break + end + end + end + end + unit.military.pickup_flags.update = true + end + else + missing_ids[u_id] = item + if args.free then + to_drop[u_id] = item + end + end + else + present_ids[u_id] = item + end + end - -- First, figure out which body parts are covered by the uniform pieces we have. - local covered = {} -- map of body part id to true/nil - for id, item in pairs( present_ids ) do - if item._type ~= df.item_weaponst and item._type ~= df.item_shieldst then -- weapons and shields don't "cover" the bodypart they're assigned to. (Needed to figure out if we're missing gloves.) - covered[ worn_parts[ id ] ] = true + -- Figure out which worn items should be dropped + + -- First, figure out which body parts are covered by the uniform pieces we have. + -- unless --multi is specified, in which we don't care + local covered = {} -- map of body part id to true/nil + if not args.multi then + for id, item in pairs(present_ids) do + -- weapons and shields don't "cover" the bodypart they're assigned to. (Needed to figure out if we're missing gloves.) + if item._type ~= df.item_weaponst and item._type ~= df.item_shieldst then + covered[worn_parts[id]] = true + end + end end - end - - if multi then - covered = {} -- Don't consider current covers - drop for anything which is missing - end - - -- Figure out body parts which should be covered but aren't - local uncovered = {} - for id, item in pairs(missing_ids) do - for i, bp in pairs( bodyparts_that_can_wear(unit, item) ) do - if not covered[bp] then - uncovered[bp] = true - end + + -- Figure out body parts which should be covered but aren't + local uncovered = {} + for _, item in pairs(missing_ids) do + for _, bp in ipairs(bodyparts_that_can_wear(unit, item)) do + if not covered[bp] then + uncovered[bp] = true + end + end end - end - - -- Drop everything (except uniform pieces) from body parts which should be covered but aren't - for w_id, item in pairs(worn_items) do - if assigned_items[ w_id ] == nil then -- don't drop uniform pieces (including shields, weapons for hands) - if uncovered[ worn_parts[ w_id ] ] then - print("Unit "..unit_name.." potentially has object #"..w_id.." '"..item_description(item).."' blocking a missing uniform item.") - if args.drop then - to_drop[ w_id ] = item + + -- Drop everything (except uniform pieces) from body parts which should be covered but aren't + for w_id, item in pairs(worn_items) do + if assigned_items[w_id] == nil then -- don't drop uniform pieces (including shields, weapons for hands) + if uncovered[worn_parts[w_id]] then + print("Unit " .. + unit_name .. + " potentially has object #" .. + w_id .. " '" .. item_description(item) .. "' blocking a missing uniform item.") + if args.drop then + to_drop[w_id] = item + end + end end - end end - end - return to_drop + return to_drop end +local function do_drop(item_list) + if not item_list then + return + end -function do_drop( item_list ) - if item_list == nil then - return nil - end + for id, item in pairs(item_list) do + local pos = get_item_pos(item) + if not pos then + dfhack.printerr("Could not find drop location for item #" .. id .. " " .. item_description(item)) + else + if dfhack.items.moveToGround(item, pos) then + print("Dropped item #" .. id .. " '" .. item_description(item) .. "'") + else + dfhack.printerr("Could not drop object #" .. id .. " " .. item_description(item)) + end + end + end +end - local mode_swap = false - if df.global.plotinfo.main.mode == df.ui_sidebar_mode.ViewUnits then - df.global.plotinfo.main.mode = df.ui_sidebar_mode.Default - mode_swap = true - end +local function main(args) + args = utils.processArgs(args, validArgs) - for id, item in pairs(item_list) do - local pos = get_item_pos(item) - if pos == nil then - dfhack.printerr("Could not find drop location for item #"..id.." "..item_description(item)) - else - local retval = dfhack.items.moveToGround( item, pos ) - if retval == false then - dfhack.printerr("Could not drop object #"..id.." "..item_description(item)) - else - print("Dropped item #"..id.." '"..item_description(item).."'") - end + if args.help then + print(dfhack.script_help()) + return end - end - if mode_swap then - df.global.plotinfo.main.mode = df.ui_sidebar_mode.ViewUnits - end + if args.all then + for _, unit in ipairs(dfhack.units.getCitizens(false)) do + do_drop(process(unit, args)) + end + else + local unit = dfhack.gui.getSelectedUnit() + if unit then + do_drop(process(unit, args)) + else + qerror("Please select a unit if not running with --all") + end + end end +ReportWindow = defclass(ReportWindow, widgets.Window) +ReportWindow.ATTRS { + frame_title='Equipment conflict report', + frame={w=100, h=45}, + resizable=true, -- if resizing makes sense for your dialog + resize_min={w=50, h=20}, -- try to allow users to shrink your windows + autoarrange_subviews=1, + autoarrange_gap=1, + report=DEFAULT_NIL, +} + +function ReportWindow:init() + self:addviews{ + widgets.HotkeyLabel{ + frame={t=0, l=0, r=0}, + label='Try to resolve conflicts', + key='CUSTOM_CTRL_T', + auto_width=true, + on_activate=function() + dfhack.run_script('uniform-unstick', '--all', '--drop', '--free') + self.parent_view:dismiss() + end, + }, + widgets.WrappedLabel{ + frame={t=2, l=0, r=0}, + text_pen=COLOR_LIGHTRED, + text_to_wrap='After resolving conflicts, be sure to click the "Update equipment" button to reassign new equipment!', + }, + widgets.WrappedLabel{ + frame={t=4, l=0, r=0}, + text_to_wrap=self.report, + }, + } +end --- Main +ReportScreen = defclass(ReportScreen, gui.ZScreenModal) +ReportScreen.ATTRS { + focus_path='equipreport', + report=DEFAULT_NIL, +} -local args = utils.processArgs({...}, validArgs) +function ReportScreen:init() + self:addviews{ReportWindow{report=self.report}} +end -if args.help then - print(help) - return +EquipOverlay = defclass(EquipOverlay, overlay.OverlayWidget) +EquipOverlay.ATTRS{ + desc='Adds a link to the equip screen to fix equipment conflicts.', + default_pos={x=-101,y=21}, + default_enabled=true, + viewscreens='dwarfmode/SquadEquipment', + frame={w=26, h=1}, +} + +function EquipOverlay:init() + self:addviews{ + widgets.TextButton{ + view_id='button', + frame={t=0, l=0, r=0, h=1}, + label='Detect conflicts', + key='CUSTOM_CTRL_T', + on_activate=self:callback('run_report'), + }, + widgets.TextButton{ + view_id='button_good', + frame={t=0, l=0, r=0, h=1}, + label=' All good! ', + text_pen=COLOR_GREEN, + key='CUSTOM_CTRL_T', + visible=false, + }, + } end -if args.all then - for k,unit in ipairs(df.global.world.units.active) do - if dfhack.units.isCitizen(unit) then - local to_drop = process(unit,args) - do_drop( to_drop ) +function EquipOverlay:run_report() + local output = dfhack.run_command_silent({'uniform-unstick', '--all'}) + if #output == 0 then + self.subviews.button.visible = false + self.subviews.button_good.visible = true + local end_ms = dfhack.getTickCount() + 5000 + local function label_reset() + if dfhack.getTickCount() < end_ms then + dfhack.timeout(10, 'frames', label_reset) + else + self.subviews.button_good.visible = false + self.subviews.button.visible = true + end + end + label_reset() + else + ReportScreen{report=output}:show() end - end -else - local unit=dfhack.gui.getSelectedUnit() - if unit then - local to_drop = process(unit,args) - do_drop( to_drop ) - end end + +OVERLAY_WIDGETS = {overlay=EquipOverlay} + +if dfhack_flags.module then + return +end + +main({...})