From a1a8824c51393b37b37ae2c5fbb2c1c7ebf8b226 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Fri, 18 Sep 2026 03:58:20 +0200 Subject: [PATCH 01/12] multihaul: let citizens haul multiple items to a stockpile in one trip When a citizen picks up an item for a stockpile, they also grab up to "max" nearby loose items of the same type and drop everything off in one trip. Disabled by default. Extras are carried as Hauled inventory items; the job's own item refs are never touched (mutating job.items cancels the job). Since the game drops inventory items that are not linked to the job, extras are re-attached each poll while the job is in flight. On job end, extras are released onto the primary item's pile tile, the nearest pile tile, or the unit's feet if the job was cancelled far from the pile. --- changelog.txt | 1 + docs/multihaul.rst | 31 ++++++ multihaul.lua | 268 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 docs/multihaul.rst create mode 100644 multihaul.lua diff --git a/changelog.txt b/changelog.txt index 1ed30ae50..7e986a0b6 100644 --- a/changelog.txt +++ b/changelog.txt @@ -29,6 +29,7 @@ Template for new versions: ## New Tools - `gui/export-world-map`: New GUI tool to configure world map exports from the embark selection screen. +- `multihaul`: optionally let citizens carry extra nearby items of the same type to a stockpile in one trip (disabled by default) ## New Features diff --git a/docs/multihaul.rst b/docs/multihaul.rst new file mode 100644 index 000000000..cc9e09e47 --- /dev/null +++ b/docs/multihaul.rst @@ -0,0 +1,31 @@ +multihaul +========= + +.. dfhack-tool:: + :summary: Let citizens haul multiple items to a stockpile in one trip. + :tags: fort productivity items + +When a citizen picks up an item for a stockpile, they will also grab up to +``max`` additional loose items of the same type within ``radius`` tiles of +the pickup, then drop everything off in one trip. Extras that are dropped or +interrupted along the way are simply left for normal hauling, so a cancelled +job never leaves items stuck or claimed. + +This tool is not enabled by default. Enable it with ``enable multihaul`` or +by running ``multihaul enable``. + +Usage +----- + + ``multihaul enable|disable`` + Turn multi-hauling on or off. + ``multihaul status`` + Show whether the tool is enabled and the current limits. + ``multihaul max `` + Maximum extra items carried per trip (default 4). + ``multihaul radius `` + Search radius in tiles around the pickup (default 2). + +Only unclaimed, loose items that are not forbidden, owned, rotten, marked for +dumping/melting, inside containers, or already inside a stockpile are picked +up as extras. diff --git a/multihaul.lua b/multihaul.lua new file mode 100644 index 000000000..0fbf8f989 --- /dev/null +++ b/multihaul.lua @@ -0,0 +1,268 @@ +-- Citizens carry extra loose items when hauling to a stockpile. +--[====[ + +multihaul +========= + +When a citizen picks up an item for a stockpile, they also grab up to +``max`` additional loose items of the same type within ``radius`` tiles, +then drop everything off in one trip. Not enabled by default; run +``multihaul enable`` or ``enable multihaul`` to turn it on. + +Usage:: + + multihaul enable|disable + multihaul status + multihaul max (default 4, max extra items per trip) + multihaul radius (default 2, tiles around the pickup) + +]====] + +--@ enable=true +--@ module=true + +local repeatutil = require('repeat-util') + +local GLOBAL_KEY = 'multihaul' +local TIMER_NAME = 'multihaul' + +local POLL_FRAMES = 3 + +-- persisted state +enabled = enabled or false +s_max = s_max or 4 +s_radius = s_radius or 2 + +-- transient state +-- unit_id -> {job_id=number, primary_id=number, pile=building, extras={item_id -> item}} +tracked = tracked or {} + +function isEnabled() + return enabled +end + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, { + enabled=enabled, + s_max=s_max, + s_radius=s_radius, + }) +end + +local function load_state() + local data = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) + enabled = data.enabled or false + s_max = data.s_max or 4 + s_radius = data.s_radius or 2 +end + +local function get_job_stockpile(job) + for _,gr in ipairs(job.general_refs) do + if df.general_ref_building_holderst:is_instance(gr) then + local bld = gr:getBuilding() + if bld and df.building_stockpilest:is_instance(bld) then + return bld + end + end + end +end + +local function is_loose_item(item) + local f = item.flags + return f.on_ground and not f.in_job and not f.in_inventory + and not f.in_building and not f.forbid and not f.owned + and not f.hostile and not f.trader and not f.spider_web + and not f.construction and not f.encased and not f.removed + and not f.garbage_collect and not f.container and not f.rotten + and not f.dump and not f.melt and item:getStockpile() == nil +end + +local function held_by(unit, item) + for _,e in ipairs(unit.inventory) do + if e.item == item then return true end + end +end + +-- find the stockpile tile nearest to pos, or nil if pos is far from the pile +local function nearest_pile_tile(pile, pos, max_dist) + if not pile or df.isvalid(pile) ~= 'ref' then return nil end + local best, best_dist + for x = pile.x1, pile.x2 do + for y = pile.y1, pile.y2 do + local d = math.abs(x - pos.x) + math.abs(y - pos.y) + + 50 * math.abs(pile.z - pos.z) + if (not best_dist or d < best_dist) + and dfhack.buildings.findAtTile(x, y, pile.z) == pile then + best, best_dist = xyz2pos(x, y, pile.z), d + end + end + end + return (best_dist and best_dist <= max_dist) and best or nil +end + +-- release all piggybacked items. If the job completed normally, the primary +-- item was just placed on a stockpile tile: land the extras on that same +-- tile so they are stocked too. If the job ended while the unit was at or +-- next to the pile (e.g. a vehicle handoff), land them on the closest pile +-- tile instead. Otherwise drop them at the unit's feet. +local function release_all(unit_id) + local t = tracked[unit_id] + if not t then return end + tracked[unit_id] = nil + + local unit = df.unit.find(unit_id) + local drop_pos + local primary = t.primary_id and df.item.find(t.primary_id) + if primary and df.isvalid(primary) == 'ref' + and dfhack.buildings.findAtTile( + primary.pos.x, primary.pos.y, primary.pos.z) == t.pile then + drop_pos = primary.pos + elseif unit then + drop_pos = nearest_pile_tile(t.pile, unit.pos, 4) or unit.pos + end + for item_id, item in pairs(t.extras) do + if df.isvalid(item) == 'ref' then + item.flags.in_job = false + if unit and drop_pos and held_by(unit, item) then + dfhack.items.moveToGround(item, drop_pos) + end + end + t.extras[item_id] = nil + end +end + +local function job_item_of(job) + if #job.items == 0 then return nil end + local item = job.items[0].item + return (item and df.isvalid(item) == 'ref') and item or nil +end + +local function scan_unit(unit) + local job = unit.job.current_job + local t = tracked[unit.id] + + -- drop tracking if the job ended or changed + if t then + if not job or job.id ~= t.job_id then + release_all(unit.id) + t = nil + end + end + + if not job or job.job_type ~= df.job_type.StoreItemInStockpile then + return + end + + local item = job_item_of(job) + if not item then return end + local pile = get_job_stockpile(job) + if not pile then return end + + if not item.flags.in_inventory then + -- still walking to the pickup + return + end + + -- job item has been picked up: deliver any extras we're carrying, or + -- attach extras if this is the first poll after pickup + if not t then + t = {job_id=job.id, primary_id=item.id, pile=pile, extras={}} + tracked[unit.id] = t + local job_type = item:getType() + local attached = 0 + for _,cand in ipairs(df.global.world.items.other[ + df.items_other_id.IN_PLAY]) do + if attached >= s_max then break end + if cand ~= item and is_loose_item(cand) + and cand:getType() == job_type + and not dfhack.buildings.findAtTile( + cand.pos.x, cand.pos.y, cand.pos.z) + and cand.pos.z == unit.pos.z + and math.abs(cand.pos.x - unit.pos.x) <= s_radius + and math.abs(cand.pos.y - unit.pos.y) <= s_radius + and dfhack.items.moveToInventory( + cand, unit, df.inv_item_role_type.Hauled, -1) then + cand.flags.in_job = true + t.extras[cand.id] = cand + attached = attached + 1 + end + end + else + -- the game drops non-job-linked hauled items; keep re-attaching our + -- extras while the job is in flight so they ride along + for item_id, extra in pairs(t.extras) do + local valid = df.isvalid(extra) == 'ref' + if not valid or extra.flags.forbid + or extra:getStockpile() ~= nil then + if valid then extra.flags.in_job = false end + t.extras[item_id] = nil + elseif extra.flags.on_ground and dfhack.buildings.findAtTile( + extra.pos.x, extra.pos.y, extra.pos.z) == t.pile then + -- the game dropped it directly inside the pile: done + extra.flags.in_job = false + t.extras[item_id] = nil + elseif not extra.flags.in_inventory then + dfhack.items.moveToInventory( + extra, unit, df.inv_item_role_type.Hauled, -1) + end + end + end +end + +local function event_loop() + if not enabled then return end + for _,unit in ipairs(dfhack.units.getCitizens()) do + local ok, err = pcall(scan_unit, unit) + if not ok then + dfhack.printerr(('multihaul: scan_unit(%d): %s\n'):format( + unit.id, tostring(err))) + end + end + repeatutil.scheduleUnlessAlreadyScheduled( + TIMER_NAME, POLL_FRAMES, 'frames', event_loop) +end + +local function print_status() + print(('multihaul is %s (max=%d, radius=%d)'):format( + enabled and 'enabled' or 'disabled', s_max, s_radius)) +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_WORLD_UNLOADED or sc == SC_MAP_UNLOADED then + tracked = {} + elseif sc == SC_WORLD_LOADED then + load_state() + if enabled then event_loop() end + end +end + +local args = {...} +if dfhack_flags and dfhack_flags.enable then + table.insert(args, dfhack_flags.enable_state and 'enable' or 'disable') +end +local cmd = args[1] + +if cmd == 'enable' then + enabled = true + persist_state() + event_loop() + print_status() +elseif cmd == 'disable' then + enabled = false + for unit_id in pairs(tracked) do release_all(unit_id) end + repeatutil.cancel(TIMER_NAME) + persist_state() + print_status() +elseif cmd == 'max' then + s_max = math.max(1, math.floor(tonumber(args[2]) or s_max)) + persist_state() + print_status() +elseif cmd == 'radius' then + s_radius = math.max(0, math.floor(tonumber(args[2]) or s_radius)) + persist_state() + print_status() +elseif cmd == 'status' or not cmd then + print_status() +else + qerror(('unknown command: %s'):format(cmd)) +end From 429a2f6952971c1518ef7984a975c0c481e05afa Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Fri, 18 Sep 2026 05:03:41 +0200 Subject: [PATCH 02/12] multihaul: extend to container destinations, weight cap, and cross-job batching - 'targets all' also piggybacks loads into minecarts, barrels, and bins - 'types all' grabs items already claimed by other jobs bound for the same destination, letting one trip do several jobs' work - 'weight ' caps the total carried load in DF mass units - extras claimed by real jobs are never unflagged (JOB specific_ref check) - extras that fail container insertion fall back to the ground instead of stranding in inventory - stockpile anchors are picked by job ref role so wheelbarrow legs don't confuse the pickup item - item:getStockpile() is pcall-wrapped since it throws on item classes that lack the field - self-healing sweep clears orphaned in_job flags left by env reloads - in_job flags are cleared on map/world unload so saves never persist bogus claims --- changelog.txt | 2 +- docs/multihaul.rst | 26 ++- multihaul.lua | 413 +++++++++++++++++++++++++++++++++++++-------- 3 files changed, 365 insertions(+), 76 deletions(-) diff --git a/changelog.txt b/changelog.txt index 7e986a0b6..1cd585f32 100644 --- a/changelog.txt +++ b/changelog.txt @@ -29,7 +29,7 @@ Template for new versions: ## New Tools - `gui/export-world-map`: New GUI tool to configure world map exports from the embark selection screen. -- `multihaul`: optionally let citizens carry extra nearby items of the same type to a stockpile in one trip (disabled by default) +- `multihaul`: optionally let citizens carry extra nearby items to a stockpile in one trip, with configurable item count, radius, carried weight, item types, and container destinations (disabled by default) ## New Features diff --git a/docs/multihaul.rst b/docs/multihaul.rst index cc9e09e47..db2cfe565 100644 --- a/docs/multihaul.rst +++ b/docs/multihaul.rst @@ -2,7 +2,7 @@ multihaul ========= .. dfhack-tool:: - :summary: Let citizens haul multiple items to a stockpile in one trip. + :summary: Let citizens haul multiple items in one trip. :tags: fort productivity items When a citizen picks up an item for a stockpile, they will also grab up to @@ -18,14 +18,30 @@ Usage ----- ``multihaul enable|disable`` - Turn multi-hauling on or off. + Turn multi-hauling on or off. Disabling releases all items that are + currently being piggybacked. ``multihaul status`` Show whether the tool is enabled and the current limits. ``multihaul max `` Maximum extra items carried per trip (default 4). ``multihaul radius `` Search radius in tiles around the pickup (default 2). + ``multihaul weight `` + Maximum combined weight of the whole carried load, in DF mass units + (default 0 = unlimited). Covers the job's own item plus all extras, + so a dwarf never carries more than this total. + ``multihaul types same|all`` + ``same`` (default) only grabs loose items of the same type as the + job's item. ``all`` also grabs items of other types that a different + haul job has already claimed for the same destination, effectively + letting one trip do the work of several jobs. + ``multihaul targets piles|all`` + ``piles`` (default) only piggybacks stockpile jobs. ``all`` also + piggybacks loads destined for minecarts, barrels, and bins, where + the extras are inserted into the container along with the job's + own item. -Only unclaimed, loose items that are not forbidden, owned, rotten, marked for -dumping/melting, inside containers, or already inside a stockpile are picked -up as extras. +Only loose items that are not forbidden, owned, rotten, marked for +dumping/melting, inside containers, or already inside a stockpile or other +building are picked up as extras. Items claimed by unrelated jobs are never +taken. diff --git a/multihaul.lua b/multihaul.lua index 0fbf8f989..4c5621602 100644 --- a/multihaul.lua +++ b/multihaul.lua @@ -1,4 +1,4 @@ --- Citizens carry extra loose items when hauling to a stockpile. +-- Citizens carry extra loose items when hauling goods. --[====[ multihaul @@ -13,8 +13,16 @@ Usage:: multihaul enable|disable multihaul status - multihaul max (default 4, max extra items per trip) - multihaul radius (default 2, tiles around the pickup) + multihaul max (default 4, max extra items per trip) + multihaul radius (default 2, tiles around the pickup) + multihaul weight (default 0 = unlimited, max combined weight + of everything carried, in DF mass units) + multihaul types same|all (default same; "all" also grabs items that + other haul jobs are taking to the same + destination, regardless of type) + multihaul targets piles|all + (default piles; "all" also piggybacks loads + into minecarts, barrels, and bins) ]====] @@ -32,9 +40,17 @@ local POLL_FRAMES = 3 enabled = enabled or false s_max = s_max or 4 s_radius = s_radius or 2 +s_weight = s_weight or 0 +s_types_all = s_types_all or false +s_targets_all = s_targets_all or false -- transient state --- unit_id -> {job_id=number, primary_id=number, pile=building, extras={item_id -> item}} +-- unit_id -> { +-- job_id=number, primary_id=number, extras={item_id -> item}, +-- weight=number (weight of primary + container + extras), +-- pile=building (stockpile destination), or +-- container=item (vehicle/barrel/bin destination) +-- } tracked = tracked or {} function isEnabled() @@ -46,6 +62,9 @@ local function persist_state() enabled=enabled, s_max=s_max, s_radius=s_radius, + s_weight=s_weight, + s_types_all=s_types_all, + s_targets_all=s_targets_all, }) end @@ -54,6 +73,9 @@ local function load_state() enabled = data.enabled or false s_max = data.s_max or 4 s_radius = data.s_radius or 2 + s_weight = data.s_weight or 0 + s_types_all = data.s_types_all or false + s_targets_all = data.s_targets_all or false end local function get_job_stockpile(job) @@ -67,14 +89,145 @@ local function get_job_stockpile(job) end end -local function is_loose_item(item) - local f = item.flags - return f.on_ground and not f.in_job and not f.in_inventory - and not f.in_building and not f.forbid and not f.owned - and not f.hostile and not f.trader and not f.spider_web - and not f.construction and not f.encased and not f.removed - and not f.garbage_collect and not f.container and not f.rotten - and not f.dump and not f.melt and item:getStockpile() == nil +-- resolve the job's delivery destination and the item that anchors the +-- pickup. For piles and vehicles the anchor is the carried item; for +-- barrels/bins it is the item being stored, since the dwarf carries the +-- container to the goods. +local function get_job_dest(job) + if job.job_type == df.job_type.StoreItemInStockpile then + local pile = get_job_stockpile(job) + if not pile then return end + for _,ref in ipairs(job.items) do + -- wheelbarrows appear as extra refs on assisted haul jobs; + -- the goods are what we want to anchor on + if ref.item and not ref.item:isWheelbarrow() + and (ref.role == df.job_role_type.Hauled + or ref.role == df.job_role_type.Reagent) then + return {pile=pile, anchor=ref.item} + end + end + elseif s_targets_all and #job.items > 1 then + if job.job_type == df.job_type.StoreItemInVehicle then + local load, vehicle + for _,ref in ipairs(job.items) do + if ref.role == df.job_role_type.TargetContainer then + vehicle = ref.item + elseif ref.item and (ref.role == df.job_role_type.Hauled + or ref.role == df.job_role_type.Reagent) then + load = ref.item + end + end + -- wheelbarrows only ever hold one item + if load and vehicle and not vehicle:isWheelbarrow() then + return {container=vehicle, anchor=load} + end + elseif job.job_type == df.job_type.StoreItemInBarrel + or job.job_type == df.job_type.StoreItemInBin then + local container, queued + for _,ref in ipairs(job.items) do + if ref.role == df.job_role_type.Hauled then + container = ref.item + elseif ref.role == df.job_role_type.QueuedContainer then + queued = ref.item + end + end + if container and queued then + return {container=container, anchor=queued} + end + end + end +end + +local function near(a, b, radius) + return a.z == b.z and math.abs(a.x - b.x) <= radius + and math.abs(a.y - b.y) <= radius +end + +-- the container (vehicle, barrel, bin, ...) an item is inside, or nil +local function contained_in(item) + for _,ref in ipairs(item.general_refs) do + if ref:getType() == df.general_ref_type.CONTAINED_IN_ITEM then + return ref:getItem() + end + end +end + +-- true if a real job claims the item. Our own piggybacked extras only +-- carry the in_job flag and never have a JOB specific_ref, so this +-- distinguishes the two. +local function real_job_claim(item) + for _,sref in ipairs(item.specific_refs) do + if sref.type == df.specific_ref_type.JOB and sref.data.job + and df.isvalid(sref.data.job) == 'ref' then + return true + end + end + return false +end + +-- item:getStockpile() reads a .stockpile field that only exists on some +-- item classes (tools, containers) and throws on others (cloth, bags) +local function stockpile_assigned(item) + local ok, pile = pcall(function() return item:getStockpile() end) + return ok and pile ~= nil +end + +-- the item is already claimed by a job; true only if that job is also +-- delivering it to the given destination +local function claimed_for_dest(item, dest) + if not item.flags.in_job then return false end + for _,sref in ipairs(item.specific_refs) do + if sref.type == df.specific_ref_type.JOB and sref.data.job + and df.isvalid(sref.data.job) == 'ref' then + local job = sref.data.job + if job.job_type == df.job_type.StoreItemInStockpile + and dest.pile + and get_job_stockpile(job) == dest.pile then + return true + end + if dest.container then + local other = get_job_dest(job) + if other and other.container == dest.container then + return true + end + end + end + end + return false +end + +local function extra_ok(cand, anchor, dest, origin, taken_weight) + local f = cand.flags + if cand.id == anchor.id or not f.on_ground or f.in_inventory + or f.in_building or f.forbid or f.owned or f.hostile + or f.trader or f.spider_web or f.construction or f.encased + or f.removed or f.garbage_collect or f.rotten or f.dump + or f.melt or f.hidden or f.on_fire + or not near(cand.pos, origin, s_radius) + or contained_in(cand) then + return false + end + if f.in_job then + -- claimed item: only valid if a job is already taking it to our + -- destination (lets one trip do several jobs' work) + if not (s_types_all and claimed_for_dest(cand, dest)) then + return false + end + else + -- unclaimed items must match the anchor's type: we cannot verify + -- that the destination accepts anything else + if cand:getType() ~= anchor:getType() + or stockpile_assigned(cand) + or dfhack.buildings.findAtTile( + cand.pos.x, cand.pos.y, cand.pos.z) then + return false + end + end + if s_weight > 0 and cand.weight + and taken_weight + cand.weight.whole > s_weight then + return false + end + return true end local function held_by(unit, item) @@ -100,11 +253,23 @@ local function nearest_pile_tile(pile, pos, max_dist) return (best_dist and best_dist <= max_dist) and best or nil end --- release all piggybacked items. If the job completed normally, the primary --- item was just placed on a stockpile tile: land the extras on that same --- tile so they are stocked too. If the job ended while the unit was at or --- next to the pile (e.g. a vehicle handoff), land them on the closest pile --- tile instead. Otherwise drop them at the unit's feet. +-- did the job's primary item actually reach the destination? +local function primary_delivered(t, primary) + if t.pile then + return primary and df.isvalid(primary) == 'ref' + and dfhack.buildings.findAtTile( + primary.pos.x, primary.pos.y, primary.pos.z) == t.pile + end + -- containers: a stored item can merge into a stack inside the container + -- and be deleted, so a missing anchor counts as delivered + if not primary or df.isvalid(primary) ~= 'ref' then return true end + return contained_in(primary) == t.container +end + +-- release all piggybacked items. If the job completed normally, land the +-- extras at the destination too: on the primary's pile tile (or the nearest +-- pile tile if the unit finished next to the pile, e.g. vehicle handoffs), +-- or inside the container. Otherwise drop them at the unit's feet. local function release_all(unit_id) local t = tracked[unit_id] if not t then return end @@ -112,29 +277,59 @@ local function release_all(unit_id) local unit = df.unit.find(unit_id) local drop_pos + local container local primary = t.primary_id and df.item.find(t.primary_id) - if primary and df.isvalid(primary) == 'ref' - and dfhack.buildings.findAtTile( - primary.pos.x, primary.pos.y, primary.pos.z) == t.pile then - drop_pos = primary.pos - elseif unit then - drop_pos = nearest_pile_tile(t.pile, unit.pos, 4) or unit.pos + if t.pile then + if primary_delivered(t, primary) then + drop_pos = primary.pos + elseif unit then + drop_pos = nearest_pile_tile(t.pile, unit.pos, 4) or unit.pos + end + elseif t.container then + if primary_delivered(t, primary) + and df.isvalid(t.container) == 'ref' then + container = t.container + elseif unit then + drop_pos = unit.pos + end end for item_id, item in pairs(t.extras) do - if df.isvalid(item) == 'ref' then + t.extras[item_id] = nil + -- if a real job claimed the extra in the meantime its in_job flag + -- is legitimate; leave it alone + if df.isvalid(item) == 'ref' and not real_job_claim(item) then item.flags.in_job = false - if unit and drop_pos and held_by(unit, item) then - dfhack.items.moveToGround(item, drop_pos) + if container then + -- insert items the unit still holds, and any the engine + -- dropped at the unit's feet during the handoff + if unit and (held_by(unit, item) + or near(item.pos, unit.pos, 4)) then + if not dfhack.items.moveToContainer(item, container) then + dfhack.items.moveToGround(item, unit.pos) + end + end + -- otherwise it was dropped mid-route: leave it there + elseif unit and drop_pos then + if held_by(unit, item) then + dfhack.items.moveToGround(item, drop_pos) + elseif t.pile and item.flags.on_ground + and near(item.pos, drop_pos, 4) then + -- dropped by the engine at the destination + dfhack.items.moveToGround(item, drop_pos) + end end end - t.extras[item_id] = nil end end -local function job_item_of(job) - if #job.items == 0 then return nil end - local item = job.items[0].item - return (item and df.isvalid(item) == 'ref') and item or nil +local function attached_count(t) + local n = 0 + for _ in pairs(t.extras) do n = n + 1 end + return n +end + +local function item_weight(item) + return (item and item.weight) and item.weight.whole or 0 end local function scan_unit(unit) @@ -142,49 +337,48 @@ local function scan_unit(unit) local t = tracked[unit.id] -- drop tracking if the job ended or changed - if t then - if not job or job.id ~= t.job_id then - release_all(unit.id) - t = nil - end + if t and (not job or job.id ~= t.job_id) then + release_all(unit.id) + t = nil end - if not job or job.job_type ~= df.job_type.StoreItemInStockpile then - return - end + if not job then return end + local dest = get_job_dest(job) + if not dest then return end + local anchor = dest.anchor + if not anchor or df.isvalid(anchor) ~= 'ref' then return end - local item = job_item_of(job) - if not item then return end - local pile = get_job_stockpile(job) - if not pile then return end - - if not item.flags.in_inventory then - -- still walking to the pickup - return + local ready + if dest.pile or job.job_type == df.job_type.StoreItemInVehicle then + -- wait until the anchor item is actually picked up + ready = anchor.flags.in_inventory and held_by(unit, anchor) + else + -- barrel/bin jobs: the dwarf carries the container to the goods; + -- grab extras once the dwarf reaches the goods + ready = near(unit.pos, anchor.pos, math.max(s_radius, 2)) end + if not ready then return end - -- job item has been picked up: deliver any extras we're carrying, or - -- attach extras if this is the first poll after pickup if not t then - t = {job_id=job.id, primary_id=item.id, pile=pile, extras={}} + -- weight covers the whole carried load: the primary item, the + -- container itself for barrel/bin runs, and every extra + local weight = item_weight(anchor) + if job.job_type == df.job_type.StoreItemInBarrel + or job.job_type == df.job_type.StoreItemInBin then + weight = weight + item_weight(dest.container) + end + t = {job_id=job.id, primary_id=anchor.id, pile=dest.pile, + container=dest.container, extras={}, weight=weight} tracked[unit.id] = t - local job_type = item:getType() - local attached = 0 for _,cand in ipairs(df.global.world.items.other[ df.items_other_id.IN_PLAY]) do - if attached >= s_max then break end - if cand ~= item and is_loose_item(cand) - and cand:getType() == job_type - and not dfhack.buildings.findAtTile( - cand.pos.x, cand.pos.y, cand.pos.z) - and cand.pos.z == unit.pos.z - and math.abs(cand.pos.x - unit.pos.x) <= s_radius - and math.abs(cand.pos.y - unit.pos.y) <= s_radius + if attached_count(t) >= s_max then break end + if extra_ok(cand, anchor, dest, anchor.pos, t.weight) and dfhack.items.moveToInventory( cand, unit, df.inv_item_role_type.Hauled, -1) then cand.flags.in_job = true t.extras[cand.id] = cand - attached = attached + 1 + t.weight = t.weight + item_weight(cand) end end else @@ -192,23 +386,54 @@ local function scan_unit(unit) -- extras while the job is in flight so they ride along for item_id, extra in pairs(t.extras) do local valid = df.isvalid(extra) == 'ref' - if not valid or extra.flags.forbid - or extra:getStockpile() ~= nil then - if valid then extra.flags.in_job = false end + local real_claim = valid and real_job_claim(extra) + if not valid or real_claim or extra.flags.forbid + or extra.flags.removed + or stockpile_assigned(extra) then + if valid and not real_claim then + extra.flags.in_job = false + end t.extras[item_id] = nil - elseif extra.flags.on_ground and dfhack.buildings.findAtTile( - extra.pos.x, extra.pos.y, extra.pos.z) == t.pile then + elseif t.pile and extra.flags.on_ground + and dfhack.buildings.findAtTile( + extra.pos.x, extra.pos.y, extra.pos.z) == t.pile then -- the game dropped it directly inside the pile: done extra.flags.in_job = false t.extras[item_id] = nil + elseif t.container and contained_in(extra) == t.container then + -- it is already inside the destination container: done + extra.flags.in_job = false + t.extras[item_id] = nil elseif not extra.flags.in_inventory then - dfhack.items.moveToInventory( - extra, unit, df.inv_item_role_type.Hauled, -1) + if dfhack.items.moveToInventory( + extra, unit, df.inv_item_role_type.Hauled, -1) then + extra.flags.in_job = true + end end end end end +-- self-healing sweep: our extras carry only the in_job flag, never a JOB +-- specific_ref. If the tracking table is ever lost (script reload, env +-- reset) a dropped extra would keep that flag forever and become +-- unclaimable. Clear any in_job flag that has no job behind it and is not +-- a currently-tracked extra. +local function sweep_orphans() + local riding = {} + for _,t in pairs(tracked) do + for id in pairs(t.extras) do riding[id] = true end + end + for _,item in ipairs(df.global.world.items.all) do + if item.flags.in_job and not riding[item.id] + and not real_job_claim(item) then + item.flags.in_job = false + end + end +end + +local sweep_countdown = 0 + local function event_loop() if not enabled then return end for _,unit in ipairs(dfhack.units.getCitizens()) do @@ -218,17 +443,40 @@ local function event_loop() unit.id, tostring(err))) end end + sweep_countdown = sweep_countdown - 1 + if sweep_countdown <= 0 then + sweep_countdown = 500 + local ok, err = pcall(sweep_orphans) + if not ok then + dfhack.printerr(('multihaul: sweep: %s\n'):format(tostring(err))) + end + end repeatutil.scheduleUnlessAlreadyScheduled( TIMER_NAME, POLL_FRAMES, 'frames', event_loop) end local function print_status() - print(('multihaul is %s (max=%d, radius=%d)'):format( - enabled and 'enabled' or 'disabled', s_max, s_radius)) + print(('multihaul is %s (max=%d, radius=%d, weight=%s, types=%s, targets=%s)') + :format(enabled and 'enabled' or 'disabled', s_max, s_radius, + s_weight > 0 and tostring(s_weight) or 'unlimited', + s_types_all and 'all' or 'same', + s_targets_all and 'all' or 'piles')) end dfhack.onStateChange[GLOBAL_KEY] = function(sc) if sc == SC_WORLD_UNLOADED or sc == SC_MAP_UNLOADED then + -- best effort: unmark extras before the save is written so they + -- don't persist with a bogus in_job flag + for _,t in pairs(tracked) do + for _,item in pairs(t.extras) do + pcall(function() + if df.isvalid(item) == 'ref' + and not real_job_claim(item) then + item.flags.in_job = false + end + end) + end + end tracked = {} elseif sc == SC_WORLD_LOADED then load_state() @@ -236,6 +484,10 @@ dfhack.onStateChange[GLOBAL_KEY] = function(sc) end end +if dfhack_flags.module then + return +end + local args = {...} if dfhack_flags and dfhack_flags.enable then table.insert(args, dfhack_flags.enable_state and 'enable' or 'disable') @@ -245,6 +497,7 @@ local cmd = args[1] if cmd == 'enable' then enabled = true persist_state() + sweep_orphans() event_loop() print_status() elseif cmd == 'disable' then @@ -261,6 +514,26 @@ elseif cmd == 'radius' then s_radius = math.max(0, math.floor(tonumber(args[2]) or s_radius)) persist_state() print_status() +elseif cmd == 'weight' then + s_weight = math.max(0, math.floor(tonumber(args[2]) or s_weight)) + persist_state() + print_status() +elseif cmd == 'types' then + if args[2] ~= 'same' and args[2] ~= 'all' then + qerror('usage: multihaul types same|all') + end + s_types_all = args[2] == 'all' + persist_state() + print_status() +elseif cmd == 'targets' then + if args[2] ~= 'piles' and args[2] ~= 'all' then + qerror('usage: multihaul targets piles|all') + end + s_targets_all = args[2] == 'all' + persist_state() + print_status() +elseif cmd == 'help' or cmd == '--help' then + print(dfhack.script_help()) elseif cmd == 'status' or not cmd then print_status() else From ef1e9c88eed5115967e8bcaa41438d66a4f3738c Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Fri, 18 Sep 2026 05:14:41 +0200 Subject: [PATCH 03/12] multihaul: stats-based carry cap, en-route grabs, control panel entry - 'weight auto' derives a per-unit carry cap from strength scaled by body size (a typical dwarf can manage ~900 units, about three boulders) - dwarves opportunistically grab additional eligible items they pass en route to the destination, not just at the pickup site - extras are now picked nearest-first instead of scan order - wheelbarrow-assisted haul jobs are skipped: the wheelbarrow already multi-hauls for the job - registered in the control panel gameplay group (off by default) --- docs/multihaul.rst | 15 ++-- internal/control-panel/registry.lua | 2 + multihaul.lua | 125 +++++++++++++++++++++------- 3 files changed, 109 insertions(+), 33 deletions(-) diff --git a/docs/multihaul.rst b/docs/multihaul.rst index db2cfe565..fc0d9a195 100644 --- a/docs/multihaul.rst +++ b/docs/multihaul.rst @@ -6,10 +6,12 @@ multihaul :tags: fort productivity items When a citizen picks up an item for a stockpile, they will also grab up to -``max`` additional loose items of the same type within ``radius`` tiles of -the pickup, then drop everything off in one trip. Extras that are dropped or -interrupted along the way are simply left for normal hauling, so a cancelled -job never leaves items stuck or claimed. +``max`` additional loose items of the same type within ``radius`` tiles -- +both at the pickup site and opportunistically along the way -- then drop +everything off in one trip. Extras that are dropped or interrupted along +the way are simply left for normal hauling, so a cancelled job never leaves +items stuck or claimed. Jobs already assisted by a wheelbarrow are skipped, +since the wheelbarrow already carries multiple items. This tool is not enabled by default. Enable it with ``enable multihaul`` or by running ``multihaul enable``. @@ -29,7 +31,10 @@ Usage ``multihaul weight `` Maximum combined weight of the whole carried load, in DF mass units (default 0 = unlimited). Covers the job's own item plus all extras, - so a dwarf never carries more than this total. + so a dwarf never carries more than this total. Use + ``multihaul weight auto`` to derive the cap from each citizen's + strength and body size instead (a typical dwarf can manage roughly + three boulders), or ``multihaul weight unlimited`` to remove it. ``multihaul types same|all`` ``same`` (default) only grabs loose items of the same type as the job's item. ``all`` also grabs items of other types that a different diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index 0759ed398..16238c57d 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -141,6 +141,8 @@ COMMANDS_BY_IDX = { {command='immortal-cravings', group='gameplay', mode='enable'}, {command='light-aquifers-only', group='gameplay', mode='run'}, {command='misery', group='gameplay', mode='enable'}, + {command='multihaul', group='gameplay', mode='enable', + desc='Let citizens carry extra nearby items to the same destination in one trip.'}, {command='orders-reevaluate', help_command='orders', group='gameplay', mode='repeat', desc='Invalidates all work orders once a month, allowing conditions to be rechecked.', params={'--time', '1', '--timeUnits', 'months', '--command', '[', 'orders', 'recheck', ']'}}, diff --git a/multihaul.lua b/multihaul.lua index 4c5621602..65dc7d49f 100644 --- a/multihaul.lua +++ b/multihaul.lua @@ -5,9 +5,10 @@ multihaul ========= When a citizen picks up an item for a stockpile, they also grab up to -``max`` additional loose items of the same type within ``radius`` tiles, -then drop everything off in one trip. Not enabled by default; run -``multihaul enable`` or ``enable multihaul`` to turn it on. +``max`` additional loose items of the same type within ``radius`` tiles -- +both at the pickup site and along the way -- then drop everything off in +one trip. Not enabled by default; run ``multihaul enable`` or +``enable multihaul`` to turn it on. Usage:: @@ -15,8 +16,11 @@ Usage:: multihaul status multihaul max (default 4, max extra items per trip) multihaul radius (default 2, tiles around the pickup) - multihaul weight (default 0 = unlimited, max combined weight - of everything carried, in DF mass units) + multihaul weight (max combined weight of everything carried, + in DF mass units; default 0 = unlimited) + multihaul weight auto (derive the cap per dwarf from their + strength and body size) + multihaul weight unlimited multihaul types same|all (default same; "all" also grabs items that other haul jobs are taking to the same destination, regardless of type) @@ -97,15 +101,19 @@ local function get_job_dest(job) if job.job_type == df.job_type.StoreItemInStockpile then local pile = get_job_stockpile(job) if not pile then return end + local anchor for _,ref in ipairs(job.items) do - -- wheelbarrows appear as extra refs on assisted haul jobs; - -- the goods are what we want to anchor on - if ref.item and not ref.item:isWheelbarrow() - and (ref.role == df.job_role_type.Hauled - or ref.role == df.job_role_type.Reagent) then - return {pile=pile, anchor=ref.item} + if ref.item and ref.item:isWheelbarrow() then + -- the wheelbarrow already multi-hauls for this job + return + elseif ref.item and (ref.role == df.job_role_type.Hauled + or ref.role == df.job_role_type.Reagent) then + anchor = anchor or ref.item end end + if anchor then + return {pile=pile, anchor=anchor} + end elseif s_targets_all and #job.items > 1 then if job.job_type == df.job_type.StoreItemInVehicle then local load, vehicle @@ -196,7 +204,7 @@ local function claimed_for_dest(item, dest) return false end -local function extra_ok(cand, anchor, dest, origin, taken_weight) +local function extra_ok(cand, anchor, dest, origin, taken_weight, cap) local f = cand.flags if cand.id == anchor.id or not f.on_ground or f.in_inventory or f.in_building or f.forbid or f.owned or f.hostile @@ -223,8 +231,8 @@ local function extra_ok(cand, anchor, dest, origin, taken_weight) return false end end - if s_weight > 0 and cand.weight - and taken_weight + cand.weight.whole > s_weight then + if cap > 0 and cand.weight + and taken_weight + cand.weight.whole > cap then return false end return true @@ -332,6 +340,58 @@ local function item_weight(item) return (item and item.weight) and item.weight.whole or 0 end +-- 'weight auto': derive a per-unit carry cap from strength scaled by body +-- size (a child or small race carries less). A typical dwarf (~1200 +-- strength) can manage ~900 units -- roughly three boulders of load. +local AUTO_CAP_FACTOR = 0.75 +local AUTO_CAP_FALLBACK = 900 + +local function unit_carry_cap(unit) + local ok, cap = pcall(function() + local attrs = unit.body.physical_attrs + local str = attrs.STRENGTH and attrs.STRENGTH.value or 0 + local base = unit.body.size_info.size_base + local cur = unit.body.size_info.size_cur + local ratio = (base and base > 0) and (cur / base) or 1 + ratio = math.max(0.25, math.min(ratio, 1.5)) + return math.floor(str * ratio * AUTO_CAP_FACTOR) + end) + return (ok and cap > 0) and cap or AUTO_CAP_FALLBACK +end + +-- 0 = unlimited, 'auto' = per-unit, >0 = fixed cap +local function effective_weight_cap(unit) + if s_weight == 'auto' then return unit_carry_cap(unit) end + return s_weight +end + +-- attach extras near origin until the count and weight caps are reached, +-- nearest first +local function grab_extras(t, unit, dest, anchor, origin) + local cands = {} + for _,cand in ipairs(df.global.world.items.other[ + df.items_other_id.IN_PLAY]) do + if near(cand.pos, origin, s_radius) then + cands[#cands+1] = cand + end + end + table.sort(cands, function(a, b) + return math.abs(a.pos.x - origin.x) + math.abs(a.pos.y - origin.y) + < math.abs(b.pos.x - origin.x) + math.abs(b.pos.y - origin.y) + end) + local cap = effective_weight_cap(unit) + for _,cand in ipairs(cands) do + if attached_count(t) >= s_max then break end + if extra_ok(cand, anchor, dest, origin, t.weight, cap) + and dfhack.items.moveToInventory( + cand, unit, df.inv_item_role_type.Hauled, -1) then + cand.flags.in_job = true + t.extras[cand.id] = cand + t.weight = t.weight + item_weight(cand) + end + end +end + local function scan_unit(unit) local job = unit.job.current_job local t = tracked[unit.id] @@ -368,19 +428,10 @@ local function scan_unit(unit) weight = weight + item_weight(dest.container) end t = {job_id=job.id, primary_id=anchor.id, pile=dest.pile, - container=dest.container, extras={}, weight=weight} + container=dest.container, extras={}, weight=weight, + scan_cd=0} tracked[unit.id] = t - for _,cand in ipairs(df.global.world.items.other[ - df.items_other_id.IN_PLAY]) do - if attached_count(t) >= s_max then break end - if extra_ok(cand, anchor, dest, anchor.pos, t.weight) - and dfhack.items.moveToInventory( - cand, unit, df.inv_item_role_type.Hauled, -1) then - cand.flags.in_job = true - t.extras[cand.id] = cand - t.weight = t.weight + item_weight(cand) - end - end + grab_extras(t, unit, dest, anchor, anchor.pos) else -- the game drops non-job-linked hauled items; keep re-attaching our -- extras while the job is in flight so they ride along @@ -411,6 +462,13 @@ local function scan_unit(unit) end end end + -- opportunistically pick up more extras the dwarf walks past, + -- throttled to every few polls + t.scan_cd = (t.scan_cd or 0) - 1 + if t.scan_cd <= 0 and attached_count(t) < s_max then + t.scan_cd = 5 + grab_extras(t, unit, dest, anchor, unit.pos) + end end end @@ -458,7 +516,8 @@ end local function print_status() print(('multihaul is %s (max=%d, radius=%d, weight=%s, types=%s, targets=%s)') :format(enabled and 'enabled' or 'disabled', s_max, s_radius, - s_weight > 0 and tostring(s_weight) or 'unlimited', + s_weight == 'auto' and 'auto' + or (s_weight > 0 and tostring(s_weight) or 'unlimited'), s_types_all and 'all' or 'same', s_targets_all and 'all' or 'piles')) end @@ -515,7 +574,17 @@ elseif cmd == 'radius' then persist_state() print_status() elseif cmd == 'weight' then - s_weight = math.max(0, math.floor(tonumber(args[2]) or s_weight)) + if args[2] == 'auto' then + s_weight = 'auto' + elseif args[2] == 'unlimited' then + s_weight = 0 + else + local n = tonumber(args[2]) + if not n then + qerror('usage: multihaul weight |auto|unlimited') + end + s_weight = math.max(0, math.floor(n)) + end persist_state() print_status() elseif cmd == 'types' then From b0b22491f006326169ba03247655901e632220b1 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Fri, 18 Sep 2026 05:23:08 +0200 Subject: [PATCH 04/12] multihaul: skip redundant en-route scan right after initial grab --- multihaul.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multihaul.lua b/multihaul.lua index 65dc7d49f..294ac4e3f 100644 --- a/multihaul.lua +++ b/multihaul.lua @@ -429,7 +429,7 @@ local function scan_unit(unit) end t = {job_id=job.id, primary_id=anchor.id, pile=dest.pile, container=dest.container, extras={}, weight=weight, - scan_cd=0} + scan_cd=5} tracked[unit.id] = t grab_extras(t, unit, dest, anchor, anchor.pos) else From 62b9faf7154f5eb4e0d578a4b4cea3a9b6ebaba5 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Fri, 18 Sep 2026 05:29:08 +0200 Subject: [PATCH 05/12] multihaul: 'fetch' option - dwarves detour around the pickup site Adds a separate fetch radius (default 8) controlling how far around the pickup site a dwarf will go to collect extras, so they actively gather nearby goods rather than only taking what is directly adjacent. The existing 'radius' option (default 2) now only controls opportunistic pickups made along the route. --- docs/multihaul.rst | 6 +++++- multihaul.lua | 32 ++++++++++++++++++++++---------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/multihaul.rst b/docs/multihaul.rst index fc0d9a195..9254724f7 100644 --- a/docs/multihaul.rst +++ b/docs/multihaul.rst @@ -27,7 +27,11 @@ Usage ``multihaul max `` Maximum extra items carried per trip (default 4). ``multihaul radius `` - Search radius in tiles around the pickup (default 2). + Search radius in tiles around the dwarf for opportunistic pickups + made along the way (default 2). + ``multihaul fetch `` + How far around the pickup site the dwarf will detour to collect + extras before departing (default 8). ``multihaul weight `` Maximum combined weight of the whole carried load, in DF mass units (default 0 = unlimited). Covers the job's own item plus all extras, diff --git a/multihaul.lua b/multihaul.lua index 294ac4e3f..8d092240a 100644 --- a/multihaul.lua +++ b/multihaul.lua @@ -15,7 +15,10 @@ Usage:: multihaul enable|disable multihaul status multihaul max (default 4, max extra items per trip) - multihaul radius (default 2, tiles around the pickup) + multihaul radius (default 2, tiles around the dwarf that are + collected while passing) + multihaul fetch (default 8, how far around the pickup site the + dwarf detours to collect extras) multihaul weight (max combined weight of everything carried, in DF mass units; default 0 = unlimited) multihaul weight auto (derive the cap per dwarf from their @@ -44,6 +47,7 @@ local POLL_FRAMES = 3 enabled = enabled or false s_max = s_max or 4 s_radius = s_radius or 2 +s_fetch = s_fetch or 8 s_weight = s_weight or 0 s_types_all = s_types_all or false s_targets_all = s_targets_all or false @@ -66,6 +70,7 @@ local function persist_state() enabled=enabled, s_max=s_max, s_radius=s_radius, + s_fetch=s_fetch, s_weight=s_weight, s_types_all=s_types_all, s_targets_all=s_targets_all, @@ -77,6 +82,7 @@ local function load_state() enabled = data.enabled or false s_max = data.s_max or 4 s_radius = data.s_radius or 2 + s_fetch = data.s_fetch or 8 s_weight = data.s_weight or 0 s_types_all = data.s_types_all or false s_targets_all = data.s_targets_all or false @@ -204,14 +210,15 @@ local function claimed_for_dest(item, dest) return false end -local function extra_ok(cand, anchor, dest, origin, taken_weight, cap) +local function extra_ok(cand, anchor, dest, origin, radius, + taken_weight, cap) local f = cand.flags if cand.id == anchor.id or not f.on_ground or f.in_inventory or f.in_building or f.forbid or f.owned or f.hostile or f.trader or f.spider_web or f.construction or f.encased or f.removed or f.garbage_collect or f.rotten or f.dump or f.melt or f.hidden or f.on_fire - or not near(cand.pos, origin, s_radius) + or not near(cand.pos, origin, radius) or contained_in(cand) then return false end @@ -367,11 +374,11 @@ end -- attach extras near origin until the count and weight caps are reached, -- nearest first -local function grab_extras(t, unit, dest, anchor, origin) +local function grab_extras(t, unit, dest, anchor, origin, radius) local cands = {} for _,cand in ipairs(df.global.world.items.other[ df.items_other_id.IN_PLAY]) do - if near(cand.pos, origin, s_radius) then + if near(cand.pos, origin, radius) then cands[#cands+1] = cand end end @@ -382,7 +389,7 @@ local function grab_extras(t, unit, dest, anchor, origin) local cap = effective_weight_cap(unit) for _,cand in ipairs(cands) do if attached_count(t) >= s_max then break end - if extra_ok(cand, anchor, dest, origin, t.weight, cap) + if extra_ok(cand, anchor, dest, origin, radius, t.weight, cap) and dfhack.items.moveToInventory( cand, unit, df.inv_item_role_type.Hauled, -1) then cand.flags.in_job = true @@ -431,7 +438,8 @@ local function scan_unit(unit) container=dest.container, extras={}, weight=weight, scan_cd=5} tracked[unit.id] = t - grab_extras(t, unit, dest, anchor, anchor.pos) + -- the dwarf detours around the pickup site to collect extras + grab_extras(t, unit, dest, anchor, anchor.pos, s_fetch) else -- the game drops non-job-linked hauled items; keep re-attaching our -- extras while the job is in flight so they ride along @@ -467,7 +475,7 @@ local function scan_unit(unit) t.scan_cd = (t.scan_cd or 0) - 1 if t.scan_cd <= 0 and attached_count(t) < s_max then t.scan_cd = 5 - grab_extras(t, unit, dest, anchor, unit.pos) + grab_extras(t, unit, dest, anchor, unit.pos, s_radius) end end end @@ -514,8 +522,8 @@ local function event_loop() end local function print_status() - print(('multihaul is %s (max=%d, radius=%d, weight=%s, types=%s, targets=%s)') - :format(enabled and 'enabled' or 'disabled', s_max, s_radius, + print(('multihaul is %s (max=%d, radius=%d, fetch=%d, weight=%s, types=%s, targets=%s)') + :format(enabled and 'enabled' or 'disabled', s_max, s_radius, s_fetch, s_weight == 'auto' and 'auto' or (s_weight > 0 and tostring(s_weight) or 'unlimited'), s_types_all and 'all' or 'same', @@ -573,6 +581,10 @@ elseif cmd == 'radius' then s_radius = math.max(0, math.floor(tonumber(args[2]) or s_radius)) persist_state() print_status() +elseif cmd == 'fetch' then + s_fetch = math.max(0, math.floor(tonumber(args[2]) or s_fetch)) + persist_state() + print_status() elseif cmd == 'weight' then if args[2] == 'auto' then s_weight = 'auto' From 9fba791329fc6c80fef3ef9824e34fe31b90d4e6 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Sat, 19 Sep 2026 22:18:03 +0200 Subject: [PATCH 06/12] multihaul: 'types pile' mode - grab anything the destination accepts 'types all' only extends to items already claimed by other jobs for the same destination, since an unclaimed item of a different type might not be allowed by the pile's filter. 'pile' lifts that restriction by re-implementing item-vs-filter matching for the stockpile categories whose settings have usable indices: stone, wood (via plant raws), bars/blocks, gems, coins, weapons/trapcomps/ammo, all armor slots, furniture, and finished goods. The matcher checks every filter dimension DF exposes: category flags, type/subtype vectors, matgloss-indexed metal/stone vectors, the other_mats enums (coal/potash/ash/pearlash/soap, glass, wood/plant and creature material classes via material id), core and improvement quality, and links-only piles. Settings vectors surface as numbers and populate lazily, so lookups normalize 0/1 to strict booleans and treat empty vectors as the unconfigured all-allowed default. Anything that cannot be proven acceptable - unmapped categories like food or refuse, and ambiguous restrictions like usable/dyed/color - is rejected, so a wrong answer only means fewer extras, never mis-stored items. s_types_all (bool) migrates to s_types ('same'|'all'|'pile'). --- docs/multihaul.rst | 8 +- multihaul.lua | 226 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 218 insertions(+), 16 deletions(-) diff --git a/docs/multihaul.rst b/docs/multihaul.rst index 9254724f7..73c7c1ab4 100644 --- a/docs/multihaul.rst +++ b/docs/multihaul.rst @@ -39,11 +39,15 @@ Usage ``multihaul weight auto`` to derive the cap from each citizen's strength and body size instead (a typical dwarf can manage roughly three boulders), or ``multihaul weight unlimited`` to remove it. - ``multihaul types same|all`` + ``multihaul types same|all|pile`` ``same`` (default) only grabs loose items of the same type as the job's item. ``all`` also grabs items of other types that a different haul job has already claimed for the same destination, effectively - letting one trip do the work of several jobs. + letting one trip do the work of several jobs. ``pile`` is like + ``all``, plus it grabs unclaimed items of any type that the + destination stockpile's filter provably accepts, so a dwarf heading + to a mixed goods pile sweeps up everything nearby that belongs + there. ``multihaul targets piles|all`` ``piles`` (default) only piggybacks stockpile jobs. ``all`` also piggybacks loads destined for minecarts, barrels, and bins, where diff --git a/multihaul.lua b/multihaul.lua index 8d092240a..34c0e61cc 100644 --- a/multihaul.lua +++ b/multihaul.lua @@ -24,9 +24,13 @@ Usage:: multihaul weight auto (derive the cap per dwarf from their strength and body size) multihaul weight unlimited - multihaul types same|all (default same; "all" also grabs items that + multihaul types same|all|pile + (default same; "all" also grabs items that other haul jobs are taking to the same - destination, regardless of type) + destination, regardless of type; "pile" is + like "all" plus unclaimed items of any type + the destination stockpile's filter provably + accepts) multihaul targets piles|all (default piles; "all" also piggybacks loads into minecarts, barrels, and bins) @@ -49,7 +53,7 @@ s_max = s_max or 4 s_radius = s_radius or 2 s_fetch = s_fetch or 8 s_weight = s_weight or 0 -s_types_all = s_types_all or false +s_types = s_types or (s_types_all and 'all' or 'same') s_targets_all = s_targets_all or false -- transient state @@ -72,7 +76,7 @@ local function persist_state() s_radius=s_radius, s_fetch=s_fetch, s_weight=s_weight, - s_types_all=s_types_all, + s_types=s_types, s_targets_all=s_targets_all, }) end @@ -84,7 +88,9 @@ local function load_state() s_radius = data.s_radius or 2 s_fetch = data.s_fetch or 8 s_weight = data.s_weight or 0 - s_types_all = data.s_types_all or false + -- s_types_all was the original persisted shape: a boolean that is + -- now the 'same'/'all' modes of s_types + s_types = data.s_types or (data.s_types_all and 'all' or 'same') s_targets_all = data.s_targets_all or false end @@ -186,6 +192,191 @@ local function stockpile_assigned(item) return ok and pile ~= nil end +-- the settings vectors surface as numbers (0/1), not booleans, and 0 is +-- truthy in Lua -- normalize to a real boolean. an empty vector means +-- the sub-filter was never configured (DF populates it lazily when the +-- settings panel opens), which is the default "all allowed" state +local function vget(vec, idx) + if #vec == 0 then return true end + if idx == nil or idx < 0 or idx >= #vec then return false end + local v = vec[idx] + return v ~= nil and v ~= false and v ~= 0 +end + +-- 'GLASS_GREEN' -> 'GlassGreen', matching the stockpile_*_mat enum names +local function enum_slot(enum, mat_id) + local name = mat_id:lower():gsub('_(%l)', string.upper) + :gsub('^%l', string.upper) + return enum[name] +end + +-- does the item's material pass a mats/other_mats pair? inorganic +-- materials index into mats except glass, which lives in other_mats; +-- organic and builtin materials map to other_mats slots via material id +local function mat_ok(item, info, mats, other_mats, enum) + if item:getMaterial() == 0 then + local mi = item:getMaterialIndex() + local inorg = df.global.world.raws.inorganics.all[mi] + if inorg and inorg.id:sub(1, 6) == 'GLASS_' then + -- the material id of inorganic items is blank, so glass has + -- to be detected through the inorganic raw + local slot = enum_slot(enum, inorg.id) + return slot ~= nil and vget(other_mats, slot) + end + return vget(mats, mi) + end + if info == nil or info.material == nil then return false end + local id = info.material.id + local slot = enum_slot(enum, id) + if slot == nil and info.plant then + slot = enum[id == 'WOOD' and 'Wood' or 'Plant'] + end + return slot ~= nil and vget(other_mats, slot) +end + +-- a category that was never opened in the pile UI leaves its quality +-- arrays entirely unset; like an empty vector, that means all allowed +local function all_unset(vec) + for i = 0, #vec - 1 do + if vec[i] ~= 0 and vec[i] ~= false and vec[i] ~= nil then + return false + end + end + return true +end + +-- core quality covers the item itself; total quality covers each +-- improvement. undecorated items count their own quality as total +local function quality_ok(item, params) + if all_unset(params.quality_core) and all_unset(params.quality_total) then + return true + end + local q = item:getQuality() + if not (vget(params.quality_core, q) and vget(params.quality_total, q)) then + return false + end + for _, imp in ipairs(item.improvements) do + if not vget(params.quality_total, imp.quality) then return false end + end + return true +end + +-- usable/unusable and dyed/undyed need civ-context and dye state we +-- cannot read reliably, so a pile that restricts either is a reject. +-- both-unset again means unconfigured = all allowed +local function unrestricted(a, b) + return (a and b) or (not a and not b) +end + +-- color restrictions only apply to dyed goods; since dye state is not +-- readable, any configured restriction is a reject +local function colors_ok(params) + if all_unset(params.color) then return true end + for i = 0, #params.color - 1 do + if params.color[i] == 0 or params.color[i] == false then + return false + end + end + return true +end + +-- does the pile's filter provably accept this item? DF exposes no +-- item-vs-filter check, so this re-implements matching for the +-- categories whose settings have usable indices (stone, wood, +-- bars/blocks, gems, coins, weapons/trapcomps/ammo, armor, furniture, +-- finished goods). Anything we cannot prove is rejected, so a wrong +-- answer never mis-stores an item; it only means fewer extras are +-- grabbed. Exported for tests. +function pile_accepts(pile, item) + if not pile or df.isvalid(pile) ~= 'ref' then return false end + -- a links-only pile only receives items delivered via its links; + -- opportunistic extras would violate that intent + if pile.stockpile_flag.use_links_only then return false end + local s = pile.settings + local f = s.flags + local it = item:getType() + -- the raw field names differ per item class; the vmethods always work + local mi = item:getMaterialIndex() + local st = item:getSubtype() + local info = dfhack.matinfo.decode(item) + + if it == df.item_type.BOULDER then + return f.stone and item:getMaterial() == 0 + and vget(s.stone.mats, mi) + elseif it == df.item_type.WOOD then + if not f.wood then return false end + -- wood.mats is indexed by plant raw index, not material index + return info ~= nil and info.plant ~= nil + and vget(s.wood.mats, info.plant.index) + elseif it == df.item_type.BAR then + return f.bars_blocks and mat_ok(item, info, s.bars_blocks.bars_mats, + s.bars_blocks.bars_other_mats, df.stockpile_bar_mat) + elseif it == df.item_type.BLOCKS then + return f.bars_blocks and mat_ok(item, info, s.bars_blocks.blocks_mats, + s.bars_blocks.blocks_other_mats, df.stockpile_block_mat) + elseif it == df.item_type.ROUGH then + if not f.gems then return false end + -- other_mats is indexed by mat_type for non-inorganic roughs + if item:getMaterial() == 0 then return vget(s.gems.rough_mats, mi) end + return vget(s.gems.rough_other_mats, item:getMaterial()) + elseif it == df.item_type.GEM or it == df.item_type.SMALLGEM then + if not f.gems then return false end + if item:getMaterial() == 0 then return vget(s.gems.cut_mats, mi) end + return vget(s.gems.cut_other_mats, item:getMaterial()) + elseif it == df.item_type.COIN then + return f.coins and item:getMaterial() == 0 + and vget(s.coins.mats, mi) + elseif it == df.item_type.WEAPON or it == df.item_type.TRAPCOMP then + local p = s.weapons + return f.weapons and unrestricted(p.usable, p.unusable) + and vget(it == df.item_type.WEAPON + and p.weapon_type or p.trapcomp_type, st) + and quality_ok(item, p) + and mat_ok(item, info, p.mats, p.other_mats, + df.stockpile_weapon_mat) + elseif it == df.item_type.AMMO then + local p = s.ammo + return f.ammo and vget(p.type, st) and quality_ok(item, p) + and mat_ok(item, info, p.mats, p.other_mats, + df.stockpile_ammo_mat) + elseif it == df.item_type.ARMOR or it == df.item_type.HELM + or it == df.item_type.SHOES or it == df.item_type.GLOVES + or it == df.item_type.PANTS or it == df.item_type.SHIELD then + local p = s.armor + local vec = it == df.item_type.ARMOR and p.body + or it == df.item_type.HELM and p.head + or it == df.item_type.SHOES and p.feet + or it == df.item_type.GLOVES and p.hands + or it == df.item_type.PANTS and p.legs + or p.shield + return f.armor and unrestricted(p.usable, p.unusable) + and unrestricted(p.dyed, p.undyed) + and vget(vec, st) and quality_ok(item, p) + and colors_ok(p) + and mat_ok(item, info, p.mats, p.other_mats, + df.stockpile_armor_mat) + end + + -- furniture and finished goods have type-indexed vectors that only + -- hold true for types in their category, so a single generic check + -- covers every member type; TOOL items are skipped because their + -- furniture_type slot depends on itemdef flags we cannot map + local ft = df.furniture_type[df.item_type[it]] + if f.furniture and ft and ft >= 0 and vget(s.furniture.type, ft) then + return quality_ok(item, s.furniture) + and mat_ok(item, info, s.furniture.mats, s.furniture.other_mats, + df.stockpile_furniture_mat) + end + if f.finished_goods and vget(s.finished_goods.type, it) then + return unrestricted(s.finished_goods.dyed, s.finished_goods.undyed) + and quality_ok(item, s.finished_goods) + and colors_ok(s.finished_goods) + and mat_ok(item, info, s.finished_goods.mats, + s.finished_goods.other_mats, df.stockpile_finished_mat) + end + return false +end + -- the item is already claimed by a job; true only if that job is also -- delivering it to the given destination local function claimed_for_dest(item, dest) @@ -225,14 +416,21 @@ local function extra_ok(cand, anchor, dest, origin, radius, if f.in_job then -- claimed item: only valid if a job is already taking it to our -- destination (lets one trip do several jobs' work) - if not (s_types_all and claimed_for_dest(cand, dest)) then + if s_types == 'same' or not claimed_for_dest(cand, dest) then return false end else - -- unclaimed items must match the anchor's type: we cannot verify - -- that the destination accepts anything else - if cand:getType() ~= anchor:getType() - or stockpile_assigned(cand) + -- unclaimed items: 'same' and 'all' only take the anchor's type + -- (a real job claim is the only trustworthy signal there); + -- 'pile' also takes anything the destination filter provably + -- accepts + if cand:getType() ~= anchor:getType() then + if not (s_types == 'pile' and dest.pile + and pile_accepts(dest.pile, cand)) then + return false + end + end + if stockpile_assigned(cand) or dfhack.buildings.findAtTile( cand.pos.x, cand.pos.y, cand.pos.z) then return false @@ -526,7 +724,7 @@ local function print_status() :format(enabled and 'enabled' or 'disabled', s_max, s_radius, s_fetch, s_weight == 'auto' and 'auto' or (s_weight > 0 and tostring(s_weight) or 'unlimited'), - s_types_all and 'all' or 'same', + s_types, s_targets_all and 'all' or 'piles')) end @@ -600,10 +798,10 @@ elseif cmd == 'weight' then persist_state() print_status() elseif cmd == 'types' then - if args[2] ~= 'same' and args[2] ~= 'all' then - qerror('usage: multihaul types same|all') + if args[2] ~= 'same' and args[2] ~= 'all' and args[2] ~= 'pile' then + qerror('usage: multihaul types same|all|pile') end - s_types_all = args[2] == 'all' + s_types = args[2] persist_state() print_status() elseif cmd == 'targets' then From 90f84a15009347629cd130641433a2743bf922d9 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Sun, 20 Sep 2026 00:13:41 +0200 Subject: [PATCH 07/12] multihaul: cover food/refuse/cloth/leather/animal piles in 'types pile' Extends the destination-filter matcher to the remaining stockpile categories so pile mode can piggyback far more item types: food categories via the organic material tables (including creature/caste indexed fish and eggs), leather, cloth and thread by material class, paper/parchment sheets, corpses and corpse pieces/remains by race and part kind (with fresh vs rotten hide handled separately), and cages or traps by occupant race or the empty-container toggles. Also fixes a mis-store where a rotten hide would be accepted by a pile allowing only fresh hide, adds REMAINS to the refuse branch, and adds a unit test suite exercising the matcher's discrimination on synthetic pile settings. --- multihaul.lua | 270 ++++++++++++++++++++++++++-- test/multihaul.lua | 437 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 689 insertions(+), 18 deletions(-) create mode 100644 test/multihaul.lua diff --git a/multihaul.lua b/multihaul.lua index 34c0e61cc..7806d3b77 100644 --- a/multihaul.lua +++ b/multihaul.lua @@ -210,9 +210,48 @@ local function enum_slot(enum, mat_id) return enum[name] end +-- organic settings vectors (food, leather, cloth, sheets) are indexed +-- by position in world.raws.mat_table.organic_types/indexes[category]. +-- For most categories the entry is a (mat_type, mat_index) pair; for +-- fish, unprepared fish, and eggs it is a (creature, caste) pair. +-- organic_pos caches the reverse map per category -- raws are immutable +-- during play, and pile_accepts is called once per candidate item +local organic_pos_cache = {} +local function organic_pos(cat_name, mtype, mindx) + local cat = df.organic_mat_category[cat_name] + local tab = organic_pos_cache[cat] + if not tab then + tab = {} + local types = df.global.world.raws.mat_table.organic_types[cat] + local idxs = df.global.world.raws.mat_table.organic_indexes[cat] + for i = 0, #types - 1 do + local sub = tab[types[i]] + if not sub then + sub = {} + tab[types[i]] = sub + end + sub[idxs[i]] = i + end + organic_pos_cache[cat] = tab + end + local sub = tab[mtype] + return sub and sub[mindx] +end + +-- other_mats slots are filled by the material's organic class, not its +-- id: a silk glove's material id is just 'THREAD', but its class +-- membership tells us it belongs in the Silk slot. a material can sit +-- in several usage-class tables (a plant fiber is also valid Paper/ +-- Paste), so only the material-kind classes are consulted, in order +local ORGANIC_SLOT_CATS = {'Silk', 'PlantFiber', 'Yarn', 'MetalThread', + 'Leather', 'Bone', 'Tooth', 'Horn', 'Pearl', 'Shell'} +local ORGANIC_SLOT_NAME = {PlantFiber='Plant'} + -- does the item's material pass a mats/other_mats pair? inorganic -- materials index into mats except glass, which lives in other_mats; --- organic and builtin materials map to other_mats slots via material id +-- organic materials map to other_mats slots via material id first, then +-- via their organic class (a silk glove's material id is just 'THREAD', +-- its class tells us it belongs in the Silk slot) local function mat_ok(item, info, mats, other_mats, enum) if item:getMaterial() == 0 then local mi = item:getMaterialIndex() @@ -231,9 +270,73 @@ local function mat_ok(item, info, mats, other_mats, enum) if slot == nil and info.plant then slot = enum[id == 'WOOD' and 'Wood' or 'Plant'] end + if slot == nil then + for _, cat_name in ipairs(ORGANIC_SLOT_CATS) do + if organic_pos(cat_name, item:getMaterial(), + item:getMaterialIndex()) then + slot = enum[ORGANIC_SLOT_NAME[cat_name] or cat_name] + break + end + end + end return slot ~= nil and vget(other_mats, slot) end +local CRITTER_CATS = {Fish=true, UnpreparedFish=true, Eggs=true} + +local function organic_item_pos(cat_name, item) + local mtype, mindx + if CRITTER_CATS[cat_name] then + mtype, mindx = item.race, item.caste + else + mtype, mindx = item:getMaterial(), item:getMaterialIndex() + end + if not mtype or not mindx or mtype < 0 or mindx < 0 then return nil end + return organic_pos(cat_name, mtype, mindx) +end + +-- item types the food filter covers, each mapped to the (settings +-- vector, organic category) pairs it can match +local FOOD_SPECS = { + [df.item_type.MEAT] = {{'meat', 'Meat'}}, + [df.item_type.FISH] = {{'fish', 'Fish'}}, + [df.item_type.FISH_RAW] = {{'unprepared_fish', 'UnpreparedFish'}}, + [df.item_type.EGG] = {{'egg', 'Eggs'}}, + [df.item_type.PLANT] = {{'plants', 'Plants'}}, + [df.item_type.DRINK] = {{'drink_plant', 'PlantDrink'}, + {'drink_animal', 'CreatureDrink'}}, + [df.item_type.CHEESE] = {{'cheese_plant', 'PlantCheese'}, + {'cheese_animal', 'CreatureCheese'}}, + [df.item_type.SEEDS] = {{'seeds', 'Seed'}}, + [df.item_type.PLANT_GROWTH] = {{'leaves', 'PlantGrowth'}}, + [df.item_type.POWDER_MISC] = {{'powder_plant', 'PlantPowder'}, + {'powder_creature', 'CreaturePowder'}}, + [df.item_type.GLOB] = {{'glob', 'Glob'}, {'glob_paste', 'Paste'}, + {'glob_pressed', 'Pressed'}}, + [df.item_type.LIQUID_MISC] = {{'liquid_plant', 'PlantLiquid'}, + {'liquid_animal', 'CreatureLiquid'}, + {'liquid_misc', 'MiscLiquid'}}, +} + +-- threads and cloth are gated by which organic class the material is in +local CLOTH_SPECS = { + [df.item_type.THREAD] = {{'thread_silk', 'Silk'}, + {'thread_plant', 'PlantFiber'}, + {'thread_yarn', 'Yarn'}, + {'thread_metal', 'MetalThread'}}, + [df.item_type.CLOTH] = {{'cloth_silk', 'Silk'}, + {'cloth_plant', 'PlantFiber'}, + {'cloth_yarn', 'Yarn'}, + {'cloth_metal', 'MetalThread'}}, +} + +-- corpsepiece materials that have their own per-race toggle in the +-- refuse filter; pieces of other materials must pass all of them +local PIECE_PART_VEC = { + SKULL='skulls', BONE='bones', HAIR='hair', SHELL='shells', + TOOTH='teeth', HORN='horns', HOOF='horns', +} + -- a category that was never opened in the pile UI leaves its quality -- arrays entirely unset; like an empty vector, that means all allowed local function all_unset(vec) @@ -255,23 +358,60 @@ local function quality_ok(item, params) if not (vget(params.quality_core, q) and vget(params.quality_total, q)) then return false end - for _, imp in ipairs(item.improvements) do - if not vget(params.quality_total, imp.quality) then return false end + -- improvements only exist on item_constructed subclasses + if df.item_constructed:is_instance(item) then + for _, imp in ipairs(item.improvements) do + if not vget(params.quality_total, imp.quality) then + return false + end + end end return true end --- usable/unusable and dyed/undyed need civ-context and dye state we --- cannot read reliably, so a pile that restricts either is a reject. --- both-unset again means unconfigured = all allowed +-- usable/unusable needs civ-context we cannot read reliably, so a pile +-- that restricts it is a reject. both-unset again means unconfigured = +-- all allowed local function unrestricted(a, b) return (a and b) or (not a and not b) end --- color restrictions only apply to dyed goods; since dye state is not --- readable, any configured restriction is a reject -local function colors_ok(params) +-- the dye on an item is a COLORATION improvement whose dye material +-- reports the stockpile color index via mill_dye_color. returns the +-- color index, false when undyed, nil when dyed but undecidable +local function dye_color(item) + if not df.item_constructed:is_instance(item) then return false end + for _, imp in ipairs(item.improvements) do + if imp:getType() == df.improvement_type.COLORATION then + local info = dfhack.matinfo.decode(imp.dye_matgloss, + imp.dye_material) + if info and info.material then + local c = info.material.mill_dye_color + if c and c >= 0 then return c end + end + return nil + end + end + return false +end + +-- dyed/undyed is a real filter pair; equal values (both on or the +-- unconfigured both-off) accept everything. an unreadable dye still +-- counts as dyed -- the coloration improvement exists even when its +-- profile is empty +local function dye_ok(item, params) + if params.dyed == params.undyed then return true end + return (dye_color(item) ~= false) == params.dyed +end + +-- the color vector gates the dye color of dyed goods; undyed items +-- carry no dye color so they pass. when the dye cannot be resolved, +-- only a fully-enabled vector is provably compatible +local function colors_ok(item, params) if all_unset(params.color) then return true end + local c = dye_color(item) + if c == false then return true end + if c ~= nil then return vget(params.color, c) end for i = 0, #params.color - 1 do if params.color[i] == 0 or params.color[i] == false then return false @@ -281,12 +421,13 @@ local function colors_ok(params) end -- does the pile's filter provably accept this item? DF exposes no --- item-vs-filter check, so this re-implements matching for the --- categories whose settings have usable indices (stone, wood, +-- item-vs-filter check, so this re-implements matching for every +-- category whose settings have usable indices: stone, wood, -- bars/blocks, gems, coins, weapons/trapcomps/ammo, armor, furniture, --- finished goods). Anything we cannot prove is rejected, so a wrong --- answer never mis-stores an item; it only means fewer extras are --- grabbed. Exported for tests. +-- finished goods, food, leather, cloth, sheets, corpses, corpse +-- pieces/remains, and caged/trapped animals. Anything we cannot prove is +-- rejected, so a wrong answer never mis-stores an item; it only means +-- fewer extras are grabbed. Exported for tests. function pile_accepts(pile, item) if not pile or df.isvalid(pile) ~= 'ref' then return false end -- a links-only pile only receives items delivered via its links; @@ -350,13 +491,106 @@ function pile_accepts(pile, item) or it == df.item_type.PANTS and p.legs or p.shield return f.armor and unrestricted(p.usable, p.unusable) - and unrestricted(p.dyed, p.undyed) + and dye_ok(item, p) and vget(vec, st) and quality_ok(item, p) - and colors_ok(p) + and colors_ok(item, p) and mat_ok(item, info, p.mats, p.other_mats, df.stockpile_armor_mat) end + if it == df.item_type.FOOD then + -- prepared meals are a single toggle, not a vector + return f.food and s.food.prepared_meals == true + end + local fspec = FOOD_SPECS[it] + if fspec then + if not f.food then return false end + for _, spec in ipairs(fspec) do + local pos = organic_item_pos(spec[2], item) + if pos and vget(s.food[spec[1]], pos) then return true end + end + return false + end + + if it == df.item_type.SKIN_TANNED then + local p = s.leather + local pos = organic_item_pos('Leather', item) + return f.leather and pos ~= nil and vget(p.mats, pos) + and dye_ok(item, p) and colors_ok(item, p) + end + local cspec = CLOTH_SPECS[it] + if cspec then + local p = s.cloth + if not f.cloth then return false end + if not dye_ok(item, p) or not colors_ok(item, p) then + return false + end + for _, spec in ipairs(cspec) do + local pos = organic_item_pos(spec[2], item) + if pos and vget(p[spec[1]], pos) then return true end + end + return false + end + if it == df.item_type.SHEET then + if not f.sheet then return false end + local pos = organic_item_pos('Paper', item) + if pos and vget(s.sheet.paper, pos) then return true end + pos = organic_item_pos('Parchment', item) + return pos ~= nil and vget(s.sheet.parchment, pos) + end + if it == df.item_type.CORPSE then + -- citizen corpses are graveyard-bound, wildlife goes to refuse; + -- either filter accepting the race means DF stores it there + if f.corpses and vget(s.corpses.corpses, item.race) then + return true + end + return f.refuse and vget(s.refuse.type, it) + and vget(s.refuse.corpses, item.race) + end + if it == df.item_type.CORPSEPIECE or it == df.item_type.REMAINS then + if not (f.refuse and vget(s.refuse.type, it)) then + return false + end + local race = item.race + if not vget(s.refuse.body_parts, race) then return false end + -- pieces of specific part materials must also pass that part's + -- per-race toggle; raw hide has its own freshness flags + local mat_id = info and info.material and info.material.id + if mat_id == 'SKIN' then + return s.refuse[item.flags.rotten + and 'rotten_raw_hide' or 'fresh_raw_hide'] + end + local vec = mat_id and PIECE_PART_VEC[mat_id] + if vec then return vget(s.refuse[vec], race) end + -- unrecognized part material: only provably allowed when the + -- race is enabled in every part-kind vector + for _, v in ipairs({'skulls', 'bones', 'hair', 'shells', 'teeth', + 'horns'}) do + if not vget(s.refuse[v], race) then return false end + end + return true + end + if it == df.item_type.CAGE or it == df.item_type.ANIMALTRAP then + if f.animals then + local unit + for _, ref in ipairs(item.general_refs) do + if df.general_ref_contains_unitst:is_instance(ref) then + unit = df.unit.find(ref.unit_id) + break + end + end + if unit then + if vget(s.animals.enabled, unit.race) then return true end + elseif (it == df.item_type.CAGE and s.animals.empty_cages) + or (it == df.item_type.ANIMALTRAP + and s.animals.empty_traps) then + return true + end + end + -- cages and traps without an accepted occupant may still be + -- storable as furniture, so fall through + end + -- furniture and finished goods have type-indexed vectors that only -- hold true for types in their category, so a single generic check -- covers every member type; TOOL items are skipped because their @@ -368,9 +602,9 @@ function pile_accepts(pile, item) df.stockpile_furniture_mat) end if f.finished_goods and vget(s.finished_goods.type, it) then - return unrestricted(s.finished_goods.dyed, s.finished_goods.undyed) + return dye_ok(item, s.finished_goods) and quality_ok(item, s.finished_goods) - and colors_ok(s.finished_goods) + and colors_ok(item, s.finished_goods) and mat_ok(item, info, s.finished_goods.mats, s.finished_goods.other_mats, df.stockpile_finished_mat) end diff --git a/test/multihaul.lua b/test/multihaul.lua new file mode 100644 index 000000000..3ba3c3ddd --- /dev/null +++ b/test/multihaul.lua @@ -0,0 +1,437 @@ +-- unit tests for multihaul's pile_accepts destination filter matcher. +-- piles and items are plain mock tables; df enums and the organic +-- material raws come from the real loaded world. + +config = {mode = 'fortress', target = 'multihaul'} + +local m = reqscript('multihaul') + +-- a 0-based settings vector: keys 0..n-1 hold values, a dummy entry at +-- n makes #vec == n so bounds checks behave like df vectors. an empty +-- table stays empty, matching the "never configured = allow all" +-- lazy-fill semantics the matcher relies on +local function vec(vals) + local t, n = {}, 0 + for i in pairs(vals or {}) do + if i + 1 > n then n = i + 1 end + end + for i = 0, n - 1 do t[i] = vals[i] or 0 end + if n > 0 then t[n] = 0 end + return t +end + +local function mock_item(itype, opts) + opts = opts or {} + local item = { + id = opts.id or 1, + flags = opts.flags or {}, + general_refs = {}, + pos = {x=0, y=0, z=0}, + race = opts.race or 0, + caste = opts.caste or 0, + improvements = opts.improvements, + } + function item:getType() return itype end + function item:getMaterial() return opts.mat or -1 end + function item:getMaterialIndex() return opts.mindx or -1 end + function item:getSubtype() return opts.subtype or -1 end + function item:getQuality() return opts.quality or 0 end + function item:isWheelbarrow() return false end + return item +end + +local function base_flags() + return {stone=false, wood=false, gems=false, bars_blocks=false, + coins=false, weapons=false, armor=false, ammo=false, + furniture=false, finished_goods=false, food=false, + leather=false, cloth=false, sheet=false, refuse=false, + corpses=false, animals=false} +end + +-- real stockpile_settings always carries every category struct and +-- every vector field, so the mock does too: a missing key yields the +-- shared empty vector, matching "never configured" semantics +local EMPTY = {} +local function empty_category() + return setmetatable({}, {__index=function() return EMPTY end}) +end +local CATEGORY_KEYS = {'stone', 'wood', 'gems', 'bars_blocks', 'coins', + 'weapons', 'armor', 'ammo', 'furniture', 'finished_goods', 'food', + 'leather', 'cloth', 'sheet', 'refuse', 'corpses', 'animals'} + +local function mock_pile(flags, settings, use_links_only) + local s = {flags=flags} + for _, k in ipairs(CATEGORY_KEYS) do s[k] = empty_category() end + for k, v in pairs(settings or {}) do + s[k] = setmetatable(v, {__index=function() return EMPTY end}) + end + return {stockpile_flag={use_links_only=use_links_only or false}, + settings=s} +end + +-- df and dfhack are read-only, so pile_accepts's df.isvalid and +-- dfhack.matinfo.decode calls are redirected by patching the module +-- env's own bindings with proxy tables +local INFO_MAP = {} +local DF_PROXY = setmetatable( + {isvalid=function() return 'ref' end, + -- is_instance errors on plain mock tables, so the check for + -- improvement-bearing items is proxied to the improvements field + item_constructed={is_instance=function(_, item) + return item.improvements ~= nil + end}}, + {__index=df}) +local MATINFO_PROXY = setmetatable( + {decode=function(item) return INFO_MAP[item] end}, + {__index=dfhack.matinfo}) +local DFHACK_PROXY = setmetatable( + {matinfo=MATINFO_PROXY}, + {__index=dfhack}) + +local function with_info(info_map, fn) + INFO_MAP = info_map + fn() +end + +local function run(fn) + mock.patch({{m, 'df', DF_PROXY}, {m, 'dfhack', DFHACK_PROXY}}, fn) +end + +-- find a real (mat_type, mat_index) pair occupying position 0 of the +-- given organic category table, so tests ride on real raws +local function organic_pair(cat_name, pos) + local cat = df.organic_mat_category[cat_name] + local types = df.global.world.raws.mat_table.organic_types[cat] + return types[pos or 0], df.global.world.raws.mat_table.organic_indexes[cat][pos or 0] +end + +function test.links_only_rejects() + run(function() + local pile = mock_pile(base_flags(), {}, true) + local item = mock_item(df.item_type.BOULDER, {mat=0, mindx=0}) + expect.false_(m.pile_accepts(pile, item)) + end) +end + +function test.stone_boulder() + run(function() + local flags = base_flags() + flags.stone = true + -- only slot 2 enabled + local mats = vec({[2]=1}) + local pile = mock_pile(flags, {stone={mats=mats}}) + local yes = mock_item(df.item_type.BOULDER, {mat=0, mindx=2}) + local no = mock_item(df.item_type.BOULDER, {mat=0, mindx=5}) + local organic = mock_item(df.item_type.BOULDER, {mat=42, mindx=1}) + expect.true_(m.pile_accepts(pile, yes)) + expect.false_(m.pile_accepts(pile, no)) + expect.false_(m.pile_accepts(pile, organic)) + end) +end + +function test.stone_empty_vector_allows() + -- a pile whose filter was never opened has an empty mats vector, + -- which means "all allowed", not "none" + run(function() + local flags = base_flags() + flags.stone = true + local pile = mock_pile(flags, {stone={mats=vec({})}}) + local item = mock_item(df.item_type.BOULDER, {mat=0, mindx=37}) + expect.true_(m.pile_accepts(pile, item)) + end) +end + +function test.flag_off_rejects() + run(function() + -- everything configured, but the category flag is off + local pile = mock_pile(base_flags(), {stone={mats=vec({})}}) + local item = mock_item(df.item_type.BOULDER, {mat=0, mindx=0}) + expect.false_(m.pile_accepts(pile, item)) + end) +end + +function test.wood_via_plant_index() + run(function() + local flags = base_flags() + flags.wood = true + local pile = mock_pile(flags, {wood={mats=vec({[3]=1})}}) + local item = mock_item(df.item_type.WOOD, {mat=419, mindx=10}) + -- decode reports a plant raw whose index is 3 + with_info({[item]={material={id='PLANT:TEST'}, plant={index=3}}}, + function() + expect.true_(m.pile_accepts(pile, item)) + end) + -- a non-plant "wood" cannot be verified: reject + with_info({[item]=nil}, function() + expect.false_(m.pile_accepts(pile, item)) + end) + end) +end + +function test.bar_metal_and_other() + run(function() + local flags = base_flags() + flags.bars_blocks = true + local pile = mock_pile(flags, {bars_blocks={ + bars_mats=vec({[7]=1}), + bars_other_mats=vec({[df.stockpile_bar_mat.Soap]=1}), + }}) + local metal = mock_item(df.item_type.BAR, {mat=0, mindx=7}) + local metal_no = mock_item(df.item_type.BAR, {mat=0, mindx=9}) + expect.true_(m.pile_accepts(pile, metal)) + expect.false_(m.pile_accepts(pile, metal_no)) + -- a soap bar: creature material whose id maps to the Soap slot + local soap = mock_item(df.item_type.BAR, {mat=200, mindx=5}) + with_info({[soap]={material={id='SOAP'}}}, function() + expect.true_(m.pile_accepts(pile, soap)) + end) + end) +end + +function test.food_meat() + run(function() + local flags = base_flags() + flags.food = true + local mtype, mindx = organic_pair('Meat') + -- enable only position 0 of the meat vector + local pile = mock_pile(flags, {food={meat=vec({[0]=1})}}) + local meat = mock_item(df.item_type.MEAT, {mat=mtype, mindx=mindx}) + expect.true_(m.pile_accepts(pile, meat)) + -- a meat item whose (type,index) maps to a disabled position + local mtype2, mindx2 = organic_pair('Meat', 1) + local meat2 = mock_item(df.item_type.MEAT, {mat=mtype2, mindx=mindx2}) + expect.false_(m.pile_accepts(pile, meat2)) + end) +end + +function test.prepared_meals_toggle() + run(function() + local flags = base_flags() + flags.food = true + local meal = mock_item(df.item_type.FOOD) + local on = mock_pile(flags, {food={prepared_meals=true}}) + local off = mock_pile(flags, {food={prepared_meals=false}}) + expect.true_(m.pile_accepts(on, meal)) + expect.false_(m.pile_accepts(off, meal)) + end) +end + +function test.corpse_race_vector() + run(function() + local flags = base_flags() + flags.refuse = true + local type_vec = vec({}) + type_vec[df.item_type.CORPSE] = 1 + local corpses = vec({[526]=1}) + local pile = mock_pile(flags, {refuse={ + type=type_vec, corpses=corpses}}) + local yes = mock_item(df.item_type.CORPSE, {race=526}) + local no = mock_item(df.item_type.CORPSE, {race=7}) + expect.true_(m.pile_accepts(pile, yes)) + expect.false_(m.pile_accepts(pile, no)) + -- refuse.type entry off: reject even with the race enabled. + -- an empty vector would mean "all allowed", so another slot + -- is enabled to make the vector configured + local type_vec2 = vec({}) + type_vec2 = vec({[df.item_type.REMAINS]=1}) + local pile2 = mock_pile(flags, {refuse={ + type=type_vec2, corpses=corpses}}) + expect.false_(m.pile_accepts(pile2, yes)) + end) +end + +function test.corpsepiece_hide_freshness() + run(function() + local flags = base_flags() + flags.refuse = true + local type_vec = vec({}) + type_vec[df.item_type.CORPSEPIECE] = 1 + local parts = vec({[10]=1}) + local fresh = mock_pile(flags, {refuse={ + type=type_vec, body_parts=parts, + fresh_raw_hide=true, rotten_raw_hide=false}}) + local rotten = mock_pile(flags, {refuse={ + type=type_vec, body_parts=parts, + fresh_raw_hide=false, rotten_raw_hide=true}}) + local fresh_hide = mock_item(df.item_type.CORPSEPIECE, + {race=10, mat=1, mindx=1, flags={rotten=false}}) + local rotten_hide = mock_item(df.item_type.CORPSEPIECE, + {race=10, mat=1, mindx=1, flags={rotten=true}}) + local info = {material={id='SKIN'}} + with_info({[fresh_hide]=info, [rotten_hide]=info}, function() + expect.true_(m.pile_accepts(fresh, fresh_hide)) + -- a rotten hide must not fall through to the fresh toggle + expect.false_(m.pile_accepts(fresh, rotten_hide)) + expect.true_(m.pile_accepts(rotten, rotten_hide)) + expect.false_(m.pile_accepts(rotten, fresh_hide)) + end) + end) +end + +function test.quality_unset_means_all() + run(function() + local flags = base_flags() + flags.finished_goods = true + local type_vec = vec({}) + type_vec[df.item_type.CROWN] = 1 + local pile = mock_pile(flags, {finished_goods={ + type=type_vec, + quality_core=vec({}), quality_total=vec({}), + mats=vec({}), other_mats=vec({}), color=vec({}), + }}) + local item = mock_item(df.item_type.CROWN, + {quality=4, mat=0, mindx=0}) + expect.true_(m.pile_accepts(pile, item)) + end) +end + +function test.quality_restricted() + run(function() + local flags = base_flags() + flags.finished_goods = true + local type_vec = vec({}) + type_vec[df.item_type.CROWN] = 1 + -- only masterwork (quality 4) allowed + local q = vec({[4]=1}) + local pile = mock_pile(flags, {finished_goods={ + type=type_vec, quality_core=q, quality_total=q, + mats=vec({}), other_mats=vec({}), color=vec({}), + }}) + local good = mock_item(df.item_type.CROWN, + {quality=4, mat=0, mindx=0}) + local bad = mock_item(df.item_type.CROWN, + {quality=1, mat=0, mindx=0}) + expect.true_(m.pile_accepts(pile, good)) + expect.false_(m.pile_accepts(pile, bad)) + end) +end + +function test.dye_filters() + run(function() + local flags = base_flags() + flags.finished_goods = true + local type_vec = vec({}) + type_vec[df.item_type.CROWN] = 1 + local function pile(dyed, undyed, color) + return mock_pile(flags, {finished_goods={ + type=type_vec, dyed=dyed, undyed=undyed, + quality_core=vec({}), quality_total=vec({}), + mats=vec({}), other_mats=vec({}), color=color or vec({}), + }}) + end + local imp = {} + function imp:getType() return df.improvement_type.COLORATION end + imp.dye_matgloss, imp.dye_material = -1, -1 + local dyed_item = mock_item(df.item_type.CROWN, + {improvements={imp}, mat=0, mindx=0}) + local plain_item = mock_item(df.item_type.CROWN, + {mat=0, mindx=0}) + do + -- unrestricted pile takes both + expect.true_(m.pile_accepts(pile(true, true), dyed_item)) + expect.true_(m.pile_accepts(pile(true, true), plain_item)) + -- unconfigured (both off) also takes both + expect.true_(m.pile_accepts(pile(false, false), dyed_item)) + expect.true_(m.pile_accepts(pile(false, false), plain_item)) + -- dyed-only takes only the dyed item + expect.true_(m.pile_accepts(pile(true, false), dyed_item)) + expect.false_(m.pile_accepts(pile(true, false), plain_item)) + -- undyed-only takes only the plain item + expect.false_(m.pile_accepts(pile(false, true), dyed_item)) + expect.true_(m.pile_accepts(pile(false, true), plain_item)) + -- restricted colors reject a dye whose color is unreadable + local colors = vec({[2]=1}) + expect.false_(m.pile_accepts(pile(true, true, colors), + dyed_item)) + -- ...but undyed items carry no color and pass + expect.true_(m.pile_accepts(pile(true, true, colors), + plain_item)) + end + end) +end + +function test.usable_restriction_rejects() + run(function() + local flags = base_flags() + flags.weapons = true + -- usable-only is a restriction we cannot verify item-side + local pile = mock_pile(flags, {weapons={ + usable=true, unusable=false, + weapon_type=vec({}), trapcomp_type=vec({}), + quality_core=vec({}), quality_total=vec({}), + mats=vec({}), other_mats=vec({}), + }}) + local item = mock_item(df.item_type.WEAPON, + {subtype=1, mat=0, mindx=0}) + expect.false_(m.pile_accepts(pile, item)) + -- both enabled is no restriction + local pile2 = mock_pile(flags, {weapons={ + usable=true, unusable=true, + weapon_type=vec({}), trapcomp_type=vec({}), + quality_core=vec({}), quality_total=vec({}), + mats=vec({}), other_mats=vec({}), + }}) + expect.true_(m.pile_accepts(pile2, item)) + end) +end + +function test.empty_cage() + run(function() + local flags = base_flags() + flags.animals = true + local pile = mock_pile(flags, {animals={ + enabled=vec({}), empty_cages=true, empty_traps=false}}) + local cage = mock_item(df.item_type.CAGE) + local trap = mock_item(df.item_type.ANIMALTRAP) + expect.true_(m.pile_accepts(pile, cage)) + expect.false_(m.pile_accepts(pile, trap)) + end) +end + +function test.furniture_type_and_material() + run(function() + local flags = base_flags() + flags.furniture = true + local ft = df.furniture_type[df.item_type[df.item_type.CHAIR]] + local type_vec = vec({}) + type_vec[ft] = 1 + -- wooden materials disallowed via mats + local pile = mock_pile(flags, {furniture={ + type=type_vec, + quality_core=vec({}), quality_total=vec({}), + mats=vec({}), other_mats=vec({[0]=1}), -- Wood only + }}) + local chair = mock_item(df.item_type.CHAIR, + {mat=419, mindx=5}) + -- the chair's material resolves to a wood (organic) material + -- with no matching other_mats slot enabled: reject + with_info({[chair]={material={id='PLANT:OAK'}, + plant={index=1}}}, function() + expect.false_(m.pile_accepts(pile, chair)) + end) + -- inorganic chair with mats slot enabled: accept + local mats = vec({[3]=1}) + local pile2 = mock_pile(flags, {furniture={ + type=type_vec, + quality_core=vec({}), quality_total=vec({}), + mats=mats, other_mats=vec({}), + }}) + local stone_chair = mock_item(df.item_type.CHAIR, + {mat=0, mindx=3}) + with_info({[stone_chair]={material={id='SLATE'}}}, function() + expect.true_(m.pile_accepts(pile2, stone_chair)) + end) + end) +end + +function test.unhandled_type_rejects() + run(function() + local flags = base_flags() + for k in pairs(flags) do flags[k] = true end + local pile = mock_pile(flags, {}) + -- TOOL is deliberately unsupported: its furniture slot depends + -- on itemdef flags we cannot map + local tool = mock_item(df.item_type.TOOL) + expect.false_(m.pile_accepts(pile, tool)) + end) +end From 410b6b4709a84a5551f2e183b9a4cdac14d29052 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Sun, 20 Sep 2026 01:21:04 +0200 Subject: [PATCH 08/12] multihaul: 'types pile' handles tools via their itemdef tool_use Tool items were the remaining common item type rejected outright. They now map onto furniture buckets through the itemdef's tool_use (TRACK_CART -> minecarts, HEAVY_OBJECT_HAULING -> wheelbarrows, FOOD_STORAGE -> large pots) or OTHER_LARGE_TOOLS via the FURNITURE flag, and non-furniture tools go through the finished-goods slot. --- multihaul.lua | 39 ++++++++++++++++++++++++-- test/multihaul.lua | 69 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/multihaul.lua b/multihaul.lua index 7806d3b77..8230434b8 100644 --- a/multihaul.lua +++ b/multihaul.lua @@ -593,8 +593,43 @@ function pile_accepts(pile, item) -- furniture and finished goods have type-indexed vectors that only -- hold true for types in their category, so a single generic check - -- covers every member type; TOOL items are skipped because their - -- furniture_type slot depends on itemdef flags we cannot map + -- covers every member type + if it == df.item_type.TOOL then + -- tools map onto furniture buckets through their itemdef's + -- tool_use (minecarts, wheelbarrows, large pots); furniture- + -- flagged tools without a named bucket are "other large tools". + -- small tools go to finished goods instead + local def = df.global.world.raws.itemdefs.tools[st] + if def then + local ft + for _, use in ipairs(def.tool_use) do + if use == df.tool_uses.TRACK_CART then + ft = df.furniture_type.MINECART + elseif use == df.tool_uses.HEAVY_OBJECT_HAULING then + ft = df.furniture_type.WHEELBARROW + elseif use == df.tool_uses.FOOD_STORAGE then + ft = df.furniture_type.FOOD_STORAGE + end + end + if not ft and def.flags.FURNITURE then + ft = df.furniture_type.OTHER_LARGE_TOOLS + end + if ft then + return f.furniture and vget(s.furniture.type, ft) + and quality_ok(item, s.furniture) + and mat_ok(item, info, s.furniture.mats, + s.furniture.other_mats, + df.stockpile_furniture_mat) + end + end + -- not a furniture tool: only finished goods can take it + return f.finished_goods and vget(s.finished_goods.type, it) + and dye_ok(item, s.finished_goods) + and quality_ok(item, s.finished_goods) + and colors_ok(item, s.finished_goods) + and mat_ok(item, info, s.finished_goods.mats, + s.finished_goods.other_mats, df.stockpile_finished_mat) + end local ft = df.furniture_type[df.item_type[it]] if f.furniture and ft and ft >= 0 and vget(s.furniture.type, ft) then return quality_ok(item, s.furniture) diff --git a/test/multihaul.lua b/test/multihaul.lua index 3ba3c3ddd..0acd1f740 100644 --- a/test/multihaul.lua +++ b/test/multihaul.lua @@ -424,14 +424,75 @@ function test.furniture_type_and_material() end) end +function test.tool_furniture_buckets() + run(function() + local tools = df.global.world.raws.itemdefs.tools + local function find_tool(pred) + for i = 0, #tools - 1 do + if pred(tools[i]) then return i end + end + end + local function has_use(def, use) + for _, u in ipairs(def.tool_use) do + if u == use then return true end + end + end + local wheelbarrow = find_tool(function(d) + return has_use(d, df.tool_uses.HEAVY_OBJECT_HAULING) + end) + local bookcase = find_tool(function(d) + return d.flags.FURNITURE and not has_use(d, + df.tool_uses.HEAVY_OBJECT_HAULING) + and not has_use(d, df.tool_uses.TRACK_CART) + and not has_use(d, df.tool_uses.FOOD_STORAGE) + end) + expect.true_(wheelbarrow ~= nil) + expect.true_(bookcase ~= nil) + + -- a furniture pile allowing only wheelbarrows + local flags = base_flags() + flags.furniture = true + local wb_pile = mock_pile(flags, {furniture={ + type=vec({[df.furniture_type.WHEELBARROW]=1})}}) + local wb = mock_item(df.item_type.TOOL, + {subtype=wheelbarrow, mat=0, mindx=0}) + local bk = mock_item(df.item_type.TOOL, + {subtype=bookcase, mat=0, mindx=0}) + expect.true_(m.pile_accepts(wb_pile, wb)) + expect.false_(m.pile_accepts(wb_pile, bk)) + + -- the same wheelbarrow must not leak into a finished-goods + -- pile even when its TOOL slot is enabled + local flags2 = base_flags() + flags2.finished_goods = true + local fg_vec = vec({}) + fg_vec[df.item_type.TOOL] = 1 + local fg_pile = mock_pile(flags2, {finished_goods={type=fg_vec}}) + expect.false_(m.pile_accepts(fg_pile, wb)) + + -- a small tool (jug: no FURNITURE flag, no bucket use) is + -- finished-goods only + local jug = find_tool(function(d) + return not d.flags.FURNITURE and #d.tool_use > 0 + and not has_use(d, df.tool_uses.HEAVY_OBJECT_HAULING) + and not has_use(d, df.tool_uses.TRACK_CART) + and not has_use(d, df.tool_uses.FOOD_STORAGE) + end) + expect.true_(jug ~= nil) + local jug_item = mock_item(df.item_type.TOOL, + {subtype=jug, mat=0, mindx=0}) + expect.true_(m.pile_accepts(fg_pile, jug_item)) + expect.false_(m.pile_accepts(wb_pile, jug_item)) + end) +end + function test.unhandled_type_rejects() run(function() local flags = base_flags() for k in pairs(flags) do flags[k] = true end local pile = mock_pile(flags, {}) - -- TOOL is deliberately unsupported: its furniture slot depends - -- on itemdef flags we cannot map - local tool = mock_item(df.item_type.TOOL) - expect.false_(m.pile_accepts(pile, tool)) + -- loose vermin have no provable home in any category + local vermin = mock_item(df.item_type.VERMIN) + expect.false_(m.pile_accepts(pile, vermin)) end) end From e4f06f0c7e531b55fb15c16f6fa5141fad01d6a3 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Sun, 20 Sep 2026 01:21:04 +0200 Subject: [PATCH 09/12] multihaul: 'types pile' honors the organic/inorganic misc toggles A stockpile's misc settings can forbid organic or inorganic items pile-wide (a real fort had a pile with organic disabled). Only mat_type 0 counts as provably inorganic; organic, builtin, and unknown materials are all gated by allow_organic. --- multihaul.lua | 9 +++++++++ test/multihaul.lua | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/multihaul.lua b/multihaul.lua index 8230434b8..392495bdb 100644 --- a/multihaul.lua +++ b/multihaul.lua @@ -441,6 +441,15 @@ function pile_accepts(pile, item) local st = item:getSubtype() local info = dfhack.matinfo.decode(item) + -- pile-wide material gate: organic vs inorganic toggles. only + -- mat_type 0 counts as provably inorganic; anything else (organic, + -- builtin, or missing material info) is treated as organic + local inorg = item:getMaterial() == 0 + if s.misc and ((inorg and s.misc.allow_inorganic == false) + or (not inorg and s.misc.allow_organic == false)) then + return false + end + if it == df.item_type.BOULDER then return f.stone and item:getMaterial() == 0 and vget(s.stone.mats, mi) diff --git a/test/multihaul.lua b/test/multihaul.lua index 0acd1f740..043c8a12c 100644 --- a/test/multihaul.lua +++ b/test/multihaul.lua @@ -62,6 +62,7 @@ local CATEGORY_KEYS = {'stone', 'wood', 'gems', 'bars_blocks', 'coins', local function mock_pile(flags, settings, use_links_only) local s = {flags=flags} for _, k in ipairs(CATEGORY_KEYS) do s[k] = empty_category() end + s.misc = {allow_organic=true, allow_inorganic=true} for k, v in pairs(settings or {}) do s[k] = setmetatable(v, {__index=function() return EMPTY end}) end @@ -496,3 +497,24 @@ function test.unhandled_type_rejects() expect.false_(m.pile_accepts(pile, vermin)) end) end + +function test.misc_organic_gate() + run(function() + local flags = base_flags() + flags.stone = true + local no_organic = mock_pile(flags, + {stone={mats=vec({})}}, + false) + no_organic.settings.misc.allow_organic = false + local no_inorg = mock_pile(flags, {stone={mats=vec({})}}) + no_inorg.settings.misc.allow_inorganic = false + local rock = mock_item(df.item_type.BOULDER, {mat=0, mindx=2}) + -- inorganic item on an organic-forbidding pile: still fine + expect.true_(m.pile_accepts(no_organic, rock)) + -- inorganic item on an inorganic-forbidding pile: rejected + expect.false_(m.pile_accepts(no_inorg, rock)) + -- organic item on an organic-forbidding pile: rejected + local log = mock_item(df.item_type.BOULDER, {mat=42, mindx=1}) + expect.false_(m.pile_accepts(no_organic, log)) + end) +end From 77a5c47aa29a30472ce041bda49cfd4f9b1c87b5 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Sun, 20 Sep 2026 01:21:04 +0200 Subject: [PATCH 10/12] multihaul: changelog mentions the pile-filter types mode --- changelog.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index 1cd585f32..18dd596d7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -29,7 +29,7 @@ Template for new versions: ## New Tools - `gui/export-world-map`: New GUI tool to configure world map exports from the embark selection screen. -- `multihaul`: optionally let citizens carry extra nearby items to a stockpile in one trip, with configurable item count, radius, carried weight, item types, and container destinations (disabled by default) +- `multihaul`: optionally let citizens carry extra nearby items to a stockpile in one trip, with configurable item count, radius, carried weight, item types (including a mode that takes whatever the destination pile's filter accepts), and container destinations (disabled by default) ## New Features From 3b1b63b00055d5512b6f1e550b1d8fb1e69ea095 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Sun, 20 Sep 2026 01:03:54 +0200 Subject: [PATCH 11/12] multihaul: fix candidate stealing, weight accounting, and scan cost - never grab the destination container itself: a vehicle waiting on the ground for its StoreItemInVehicle job matched claimed_for_dest against its own job and could be pocketed as an extra - do not steal items claimed by wheelbarrow-assisted haul jobs: they already multi-haul, and the grab would orphan their job's goods - credit extra weight back when an extra drops out mid-flight so the carry cap is not inflated by items no longer riding along - scan the map blocks overlapping the search square instead of all of IN_PLAY when looking for candidates (25k+ items in a mature fort) - keep the repeat timer alive across map unload and transient getCitizens failures, and validate persisted settings on load - reject garbage numeric args on the CLI instead of silently keeping the old value --- multihaul.lua | 142 ++++++++++++++++++++++++++++------------- test/multihaul.lua | 156 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 249 insertions(+), 49 deletions(-) diff --git a/multihaul.lua b/multihaul.lua index 392495bdb..8436a556d 100644 --- a/multihaul.lua +++ b/multihaul.lua @@ -58,7 +58,7 @@ s_targets_all = s_targets_all or false -- transient state -- unit_id -> { --- job_id=number, primary_id=number, extras={item_id -> item}, +-- job_id=number, primary_id=number, extras={item_id -> {item,w}}, -- weight=number (weight of primary + container + extras), -- pile=building (stockpile destination), or -- container=item (vehicle/barrel/bin destination) @@ -83,15 +83,19 @@ end local function load_state() local data = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) - enabled = data.enabled or false - s_max = data.s_max or 4 - s_radius = data.s_radius or 2 - s_fetch = data.s_fetch or 8 - s_weight = data.s_weight or 0 + enabled = not not data.enabled + s_max = math.max(1, math.floor(tonumber(data.s_max) or 4)) + s_radius = math.max(0, math.floor(tonumber(data.s_radius) or 2)) + s_fetch = math.max(0, math.floor(tonumber(data.s_fetch) or 8)) + s_weight = data.s_weight == 'auto' and 'auto' + or math.max(0, math.floor(tonumber(data.s_weight) or 0)) -- s_types_all was the original persisted shape: a boolean that is -- now the 'same'/'all' modes of s_types s_types = data.s_types or (data.s_types_all and 'all' or 'same') - s_targets_all = data.s_targets_all or false + if s_types ~= 'same' and s_types ~= 'all' and s_types ~= 'pile' then + s_types = 'same' + end + s_targets_all = not not data.s_targets_all end local function get_job_stockpile(job) @@ -657,32 +661,34 @@ end -- the item is already claimed by a job; true only if that job is also -- delivering it to the given destination -local function claimed_for_dest(item, dest) +-- module-level for unit tests +function claimed_for_dest(item, dest) if not item.flags.in_job then return false end for _,sref in ipairs(item.specific_refs) do if sref.type == df.specific_ref_type.JOB and sref.data.job and df.isvalid(sref.data.job) == 'ref' then - local job = sref.data.job - if job.job_type == df.job_type.StoreItemInStockpile - and dest.pile - and get_job_stockpile(job) == dest.pile then + -- resolve the claiming job the same way ours are resolved; + -- unresolvable jobs (e.g. wheelbarrow-assisted hauls, which + -- already multi-haul on their own) are not stealable + local other = get_job_dest(sref.data.job) + if other and ((dest.pile and other.pile == dest.pile) + or (dest.container + and other.container == dest.container)) then return true end - if dest.container then - local other = get_job_dest(job) - if other and other.container == dest.container then - return true - end - end end end return false end -local function extra_ok(cand, anchor, dest, origin, radius, - taken_weight, cap) +-- module-level for unit tests +function extra_ok(cand, anchor, dest, origin, radius, taken_weight, cap) local f = cand.flags - if cand.id == anchor.id or not f.on_ground or f.in_inventory + -- the destination container itself (e.g. a minecart waiting on the + -- ground for its StoreItemInVehicle job) is a claimed item whose job + -- matches our destination; it must never be pocketed as an extra + if cand.id == anchor.id or cand == dest.container + or not f.on_ground or f.in_inventory or f.in_building or f.forbid or f.owned or f.hostile or f.trader or f.spider_web or f.construction or f.encased or f.removed or f.garbage_collect or f.rotten or f.dump @@ -784,8 +790,9 @@ local function release_all(unit_id) drop_pos = unit.pos end end - for item_id, item in pairs(t.extras) do + for item_id, e in pairs(t.extras) do t.extras[item_id] = nil + local item = e.item -- if a real job claimed the extra in the meantime its in_job flag -- is legitimate; leave it alone if df.isvalid(item) == 'ref' and not real_job_claim(item) then @@ -848,14 +855,35 @@ local function effective_weight_cap(unit) return s_weight end +-- drop an extra from tracking and credit its weight back so later scans +-- do not see a cap inflated by items no longer riding along +local function drop_extra(t, item_id) + local e = t.extras[item_id] + if not e then return end + t.extras[item_id] = nil + t.weight = t.weight - (e.w or 0) +end + -- attach extras near origin until the count and weight caps are reached, --- nearest first +-- nearest first. candidates come from the map blocks overlapping the +-- search square instead of a full IN_PLAY scan (tens of thousands of +-- items in a mature fort); held and contained items are not registered +-- in block item lists, which filters out a large share of rejects upfront local function grab_extras(t, unit, dest, anchor, origin, radius) local cands = {} - for _,cand in ipairs(df.global.world.items.other[ - df.items_other_id.IN_PLAY]) do - if near(cand.pos, origin, radius) then - cands[#cands+1] = cand + local bx1 = math.max(0, math.floor((origin.x - radius) / 16)) + local by1 = math.max(0, math.floor((origin.y - radius) / 16)) + local bx2 = math.floor((origin.x + radius) / 16) + local by2 = math.floor((origin.y + radius) / 16) + for by = by1, by2 do + for bx = bx1, bx2 do + local blk = dfhack.maps.getTileBlock(bx * 16, by * 16, origin.z) + if blk then + for _,id in ipairs(blk.items) do + local cand = df.item.find(id) + if cand then cands[#cands+1] = cand end + end + end end end table.sort(cands, function(a, b) @@ -869,8 +897,9 @@ local function grab_extras(t, unit, dest, anchor, origin, radius) and dfhack.items.moveToInventory( cand, unit, df.inv_item_role_type.Hauled, -1) then cand.flags.in_job = true - t.extras[cand.id] = cand - t.weight = t.weight + item_weight(cand) + local w = item_weight(cand) + t.extras[cand.id] = {item=cand, w=w} + t.weight = t.weight + w end end end @@ -919,7 +948,8 @@ local function scan_unit(unit) else -- the game drops non-job-linked hauled items; keep re-attaching our -- extras while the job is in flight so they ride along - for item_id, extra in pairs(t.extras) do + for item_id, e in pairs(t.extras) do + local extra = e.item local valid = df.isvalid(extra) == 'ref' local real_claim = valid and real_job_claim(extra) if not valid or real_claim or extra.flags.forbid @@ -928,17 +958,17 @@ local function scan_unit(unit) if valid and not real_claim then extra.flags.in_job = false end - t.extras[item_id] = nil + drop_extra(t, item_id) elseif t.pile and extra.flags.on_ground and dfhack.buildings.findAtTile( extra.pos.x, extra.pos.y, extra.pos.z) == t.pile then -- the game dropped it directly inside the pile: done extra.flags.in_job = false - t.extras[item_id] = nil + drop_extra(t, item_id) elseif t.container and contained_in(extra) == t.container then -- it is already inside the destination container: done extra.flags.in_job = false - t.extras[item_id] = nil + drop_extra(t, item_id) elseif not extra.flags.in_inventory then if dfhack.items.moveToInventory( extra, unit, df.inv_item_role_type.Hauled, -1) then @@ -977,8 +1007,13 @@ end local sweep_countdown = 0 local function event_loop() - if not enabled then return end - for _,unit in ipairs(dfhack.units.getCitizens()) do + if not enabled or not dfhack.isMapLoaded() then return end + local ok, units = pcall(dfhack.units.getCitizens) + if not ok then + dfhack.printerr(('multihaul: %s\n'):format(tostring(units))) + return + end + for _,unit in ipairs(units) do local ok, err = pcall(scan_unit, unit) if not ok then dfhack.printerr(('multihaul: scan_unit(%d): %s\n'):format( @@ -993,6 +1028,11 @@ local function event_loop() dfhack.printerr(('multihaul: sweep: %s\n'):format(tostring(err))) end end +end + +-- scheduleEvery invokes the callback immediately and re-arms after each +-- call, so this both starts the first scan and keeps the chain alive +local function start() repeatutil.scheduleUnlessAlreadyScheduled( TIMER_NAME, POLL_FRAMES, 'frames', event_loop) end @@ -1011,11 +1051,11 @@ dfhack.onStateChange[GLOBAL_KEY] = function(sc) -- best effort: unmark extras before the save is written so they -- don't persist with a bogus in_job flag for _,t in pairs(tracked) do - for _,item in pairs(t.extras) do + for _,e in pairs(t.extras) do pcall(function() - if df.isvalid(item) == 'ref' - and not real_job_claim(item) then - item.flags.in_job = false + if df.isvalid(e.item) == 'ref' + and not real_job_claim(e.item) then + e.item.flags.in_job = false end end) end @@ -1023,7 +1063,7 @@ dfhack.onStateChange[GLOBAL_KEY] = function(sc) tracked = {} elseif sc == SC_WORLD_LOADED then load_state() - if enabled then event_loop() end + if enabled then start() end end end @@ -1041,7 +1081,7 @@ if cmd == 'enable' then enabled = true persist_state() sweep_orphans() - event_loop() + start() print_status() elseif cmd == 'disable' then enabled = false @@ -1050,15 +1090,27 @@ elseif cmd == 'disable' then persist_state() print_status() elseif cmd == 'max' then - s_max = math.max(1, math.floor(tonumber(args[2]) or s_max)) + local n = tonumber(args[2]) + if args[2] ~= nil and not n then + qerror('usage: multihaul max ') + end + if n then s_max = math.max(1, math.floor(n)) end persist_state() print_status() elseif cmd == 'radius' then - s_radius = math.max(0, math.floor(tonumber(args[2]) or s_radius)) + local n = tonumber(args[2]) + if args[2] ~= nil and not n then + qerror('usage: multihaul radius ') + end + if n then s_radius = math.max(0, math.floor(n)) end persist_state() print_status() elseif cmd == 'fetch' then - s_fetch = math.max(0, math.floor(tonumber(args[2]) or s_fetch)) + local n = tonumber(args[2]) + if args[2] ~= nil and not n then + qerror('usage: multihaul fetch ') + end + if n then s_fetch = math.max(0, math.floor(n)) end persist_state() print_status() elseif cmd == 'weight' then @@ -1066,7 +1118,7 @@ elseif cmd == 'weight' then s_weight = 'auto' elseif args[2] == 'unlimited' then s_weight = 0 - else + elseif args[2] ~= nil then local n = tonumber(args[2]) if not n then qerror('usage: multihaul weight |auto|unlimited') diff --git a/test/multihaul.lua b/test/multihaul.lua index 043c8a12c..469a764bf 100644 --- a/test/multihaul.lua +++ b/test/multihaul.lua @@ -1,6 +1,7 @@ --- unit tests for multihaul's pile_accepts destination filter matcher. --- piles and items are plain mock tables; df enums and the organic --- material raws come from the real loaded world. +-- unit tests for multihaul's pile_accepts destination filter matcher and +-- the extra_ok/claimed_for_dest candidate filters. piles, items, jobs and +-- refs are plain mock tables; df enums and the organic material raws come +-- from the real loaded world. config = {mode = 'fortress', target = 'multihaul'} @@ -26,6 +27,7 @@ local function mock_item(itype, opts) id = opts.id or 1, flags = opts.flags or {}, general_refs = {}, + specific_refs = opts.specific_refs or {}, pos = {x=0, y=0, z=0}, race = opts.race or 0, caste = opts.caste or 0, @@ -80,13 +82,24 @@ local DF_PROXY = setmetatable( -- improvement-bearing items is proxied to the improvements field item_constructed={is_instance=function(_, item) return item.improvements ~= nil + end}, + -- mock building-holder general refs carry the building in .bld + general_ref_building_holderst={is_instance=function(_, ref) + return type(ref) == 'table' and ref.bld ~= nil + end}, + -- mock stockpiles are plain tables carrying .settings + building_stockpilest={is_instance=function(_, bld) + return type(bld) == 'table' and bld.settings ~= nil end}}, {__index=df}) local MATINFO_PROXY = setmetatable( {decode=function(item) return INFO_MAP[item] end}, {__index=dfhack.matinfo}) local DFHACK_PROXY = setmetatable( - {matinfo=MATINFO_PROXY}, + {matinfo=MATINFO_PROXY, + -- extra_ok rejects candidates standing on a building tile; mock + -- positions never sit on one + buildings={findAtTile=function() return nil end}}, {__index=dfhack}) local function with_info(info_map, fn) @@ -518,3 +531,138 @@ function test.misc_organic_gate() expect.false_(m.pile_accepts(no_organic, log)) end) end + +-- helpers for extra_ok/claimed_for_dest tests: mock jobs and the refs +-- connecting them to items and buildings + +local ORIGIN = {x=0, y=0, z=0} + +local function loose(itype, opts) + opts = opts or {} + opts.flags = opts.flags or {on_ground=true} + return mock_item(itype, opts) +end + +local function bld_ref(bld) + local r = {bld=bld} + function r:getBuilding() return self.bld end + return r +end + +local function job_ref(item, job) + item.specific_refs = {{type=df.specific_ref_type.JOB, data={job=job}}} +end + +local function pile_job(pile, item) + return {job_type=df.job_type.StoreItemInStockpile, + general_refs={bld_ref(pile)}, + items={{role=df.job_role_type.Hauled, item=item}}} +end + +-- s_types/s_targets_all are module globals; restore them even if the +-- test fails so later tests still see the real settings +local function with_settings(fn) + local saved_types, saved_targets = m.s_types, m.s_targets_all + local ok, err = pcall(fn) + m.s_types, m.s_targets_all = saved_types, saved_targets + if not ok then error(err, 0) end +end + +function test.extra_ok_type_modes() + run(function() with_settings(function() + local flags = base_flags() + flags.wood = true + -- a wood pile allowing all wood materials (empty mats vector) + local pile = mock_pile(flags, {wood={mats=vec({})}}) + local anchor = mock_item(df.item_type.BOULDER, {id=30}) + local dest = {pile=pile, anchor=anchor} + local rock = loose(df.item_type.BOULDER, {id=31}) + local log = loose(df.item_type.WOOD, {id=32, mat=419, mindx=10}) + m.s_types = 'same' + -- unclaimed same-type items pass, cross-type do not + expect.true_(m.extra_ok(rock, anchor, dest, ORIGIN, 2, 0, 0)) + expect.false_(m.extra_ok(log, anchor, dest, ORIGIN, 2, 0, 0)) + m.s_types = 'all' + -- 'all' widens only through real job claims, not pile filters + expect.false_(m.extra_ok(log, anchor, dest, ORIGIN, 2, 0, 0)) + m.s_types = 'pile' + -- 'pile' admits anything the destination filter provably accepts + expect.true_(m.extra_ok(rock, anchor, dest, ORIGIN, 2, 0, 0)) + with_info({[log]={material={id='PLANT:TEST'}, plant={index=0}}}, + function() + expect.true_(m.extra_ok(log, anchor, dest, + ORIGIN, 2, 0, 0)) + end) + end) end) +end + +function test.extra_ok_weight_cap() + run(function() with_settings(function() + m.s_types = 'same' + local pile = mock_pile(base_flags(), {}) + local anchor = mock_item(df.item_type.BOULDER, {id=60}) + local dest = {pile=pile, anchor=anchor} + local heavy = loose(df.item_type.BOULDER, {id=61}) + heavy.weight = {whole=500} + -- taken 401 + 500 would exceed a 900 cap; exactly 900 is allowed + expect.false_(m.extra_ok(heavy, anchor, dest, ORIGIN, 2, 401, 900)) + expect.true_(m.extra_ok(heavy, anchor, dest, ORIGIN, 2, 400, 900)) + -- cap 0 means unlimited + expect.true_(m.extra_ok(heavy, anchor, dest, ORIGIN, 2, 99999, 0)) + end) end) +end + +function test.extra_ok_rejects_dest_container() + run(function() with_settings(function() + m.s_types = 'all' + m.s_targets_all = true + -- a vehicle sitting on the ground, claimed by the very job we are + -- hauling to: claimed_for_dest matches it, but the destination + -- itself must never be pocketed as an extra + local veh = loose(df.item_type.TOOL, {id=40, + flags={on_ground=true, in_job=true}}) + local load = loose(df.item_type.WOOD, {id=41}) + local job = {job_type=df.job_type.StoreItemInVehicle, + general_refs={}, + items={{role=df.job_role_type.TargetContainer, item=veh}, + {role=df.job_role_type.Hauled, item=load}}} + job_ref(veh, job) + local dest = {container=veh, anchor=load} + expect.true_(m.claimed_for_dest(veh, dest)) + expect.false_(m.extra_ok(veh, load, dest, ORIGIN, 2, 0, 0)) + end) end) +end + +function test.claimed_for_dest_pile_matching() + run(function() with_settings(function() + m.s_types = 'all' + local pile = mock_pile(base_flags(), {}) + local other_pile = mock_pile(base_flags(), {}) + local dest = {pile=pile, + anchor=mock_item(df.item_type.BOULDER, {id=50})} + -- unclaimed item: not claimed for anyone + local free = loose(df.item_type.WOOD, {id=51}) + expect.false_(m.claimed_for_dest(free, dest)) + -- claimed by a plain haul job to our pile: stealable + local same = loose(df.item_type.WOOD, {id=52, + flags={on_ground=true, in_job=true}}) + job_ref(same, pile_job(pile, same)) + expect.true_(m.claimed_for_dest(same, dest)) + -- claimed by a job to a different pile: not stealable + local away = loose(df.item_type.WOOD, {id=53, + flags={on_ground=true, in_job=true}}) + job_ref(away, pile_job(other_pile, away)) + expect.false_(m.claimed_for_dest(away, dest)) + -- claimed by a wheelbarrow-assisted job to our pile: wheelbarrows + -- already multi-haul on their own, so the item is left alone + local wb = mock_item(df.item_type.TOOL, {id=54}) + function wb:isWheelbarrow() return true end + local carted = loose(df.item_type.WOOD, {id=55, + flags={on_ground=true, in_job=true}}) + local wbjob = pile_job(pile, carted) + table.insert(wbjob.items, 1, + {role=df.job_role_type.Hauled, item=wb}) + job_ref(carted, wbjob) + expect.false_(m.claimed_for_dest(carted, dest)) + end) end) +end From db3aedeea3643416e56cf5df9845c0662a90ef8a Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Sun, 20 Sep 2026 01:10:03 +0200 Subject: [PATCH 12/12] multihaul: release extras when a hauler leaves the citizen list A unit that dies or goes off-map is never scanned again, so its tracked entry and extras would linger until unload. Prune tracked entries for units absent from getCitizens each pass so their extras are released through the normal path (dropped at the corpse or destination). Also add unit tests covering extra_ok's type-mode discrimination, weight cap boundary, destination-container exclusion, and claimed_for_dest's pile matching including the wheelbarrow exclusion. --- multihaul.lua | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/multihaul.lua b/multihaul.lua index 8436a556d..b55d25ac1 100644 --- a/multihaul.lua +++ b/multihaul.lua @@ -1013,13 +1013,27 @@ local function event_loop() dfhack.printerr(('multihaul: %s\n'):format(tostring(units))) return end + local seen = {} for _,unit in ipairs(units) do + seen[unit.id] = true local ok, err = pcall(scan_unit, unit) if not ok then dfhack.printerr(('multihaul: scan_unit(%d): %s\n'):format( unit.id, tostring(err))) end end + -- units that leave the citizen list (died, went off-map) can never + -- finish their job and are never scanned again; release their extras + -- here instead of letting the tracking entry leak until unload + for uid in pairs(tracked) do + if not seen[uid] then + local ok, err = pcall(release_all, uid) + if not ok then + dfhack.printerr(('multihaul: release(%d): %s\n'):format( + uid, tostring(err))) + end + end + end sweep_countdown = sweep_countdown - 1 if sweep_countdown <= 0 then sweep_countdown = 500