Skip to content

multihaul: let citizens haul multiple items to a stockpile in one trip - #1636

Open
Alistair-Afton wants to merge 12 commits into
DFHack:masterfrom
Alistair-Afton:multihaul
Open

Alistair-Afton wants to merge 12 commits into
DFHack:masterfrom
Alistair-Afton:multihaul

Conversation

@Alistair-Afton

@Alistair-Afton Alistair-Afton commented Sep 18, 2026

Copy link
Copy Markdown

Summary

New opt-in script multihaul (disabled by default; also registered in the control panel's gameplay section). When a citizen picks up an item for a stockpile, they also grab up to max additional loose items of the same type — detouring around the pickup site to collect them (fetch radius) and opportunistically picking up more along the way (radius) — then drop everything off in one trip.

multihaul enable|disable
multihaul status
multihaul max <n>          (default 4)
multihaul radius <n>       (default 2, en-route grab distance)
multihaul fetch <n>        (default 8, detour distance around the
                           pickup site)
multihaul weight <n>       (default 0 = unlimited; caps the total
                           carried load incl. the job's own item)
multihaul weight auto      (derive the cap per dwarf from strength
                           scaled by body size — a typical dwarf can
                           manage ~900 mass units, about three boulders)
multihaul types same|all|pile
                           (default same; "all" also grabs items that
                           other haul jobs are already taking to the
                           same destination, regardless of type; "pile"
                           additionally grabs unclaimed loose items of
                           any type the destination stockpile's filter
                           provably accepts, so a dwarf heading to a
                           mixed pile sweeps up everything nearby that
                           belongs there)
multihaul targets piles|all
                           (default piles; "all" also piggybacks loads
                           into minecarts, barrels, and bins, inserting
                           extras into the container on delivery)

Implementation notes

The job's own job.items vector is never touched — adding job_item_refs to a live job reliably cancels it (verified in-game). Instead, extras ride as Hauled inventory items flagged in_job so other jobs don't claim them. Since the engine drops inventory items that aren't linked to the active job, a 3-frame poll re-attaches extras while the job is in flight. Wheelbarrow-assisted jobs are skipped since the wheelbarrow already multi-hauls.

Extras nearest to the origin are preferred. The pickup-site sweep uses fetch; en-route grabs (throttled to every few polls) use radius around the unit's current position.

On job end, extras are released onto the primary item's landing tile (if it landed on the destination pile), else the nearest pile tile when the unit finishes at/next to the pile (e.g. vehicle handoffs), else the unit's feet for mid-route cancellations. For container destinations, extras the unit still holds (or that were dropped at its feet during the handoff) are inserted via dfhack.items.moveToContainer, falling back to a ground drop if insertion fails (e.g. wrong material class). Extras dropped directly onto the destination pile or into the container mid-transit are released as delivered.

types all only ever takes items that provably share the job's destination: a claimed item's own JOB specific_ref must resolve to the same pile or container. Unrelated jobs are never touched, and an extra that gets legitimately re-claimed by a real job (flag vs. specific_ref check) is left alone rather than unflagged.

types pile re-implements DF's item-vs-stockpile-filter match in Lua (no such check is exposed). It covers every category with usable indices: stone, wood (via plant raws), bars/blocks (incl. coal/potash/ash/glass/soap other_mats mapping), gems, coins, weapons/trapcomps/ammo, armor, furniture (incl. tool buckets via the itemdef's tool_use), finished goods, food organics via mat_table.organic_types/indexes (incl. creature/caste-indexed fish and eggs), leather, cloth/thread by material class, paper/parchment sheets, corpses and corpse pieces/remains by race and part kind (fresh vs. rotten hide separately), and cages/traps by occupant race or the empty-container toggles. Quality (quality_core/quality_total, incl. improvements), dyed/undyed, dye color, usable/unusable, and links-only restrictions are honored; stockpile settings that were never configured (empty vectors, all-false arrays) correctly mean "all allowed". Anything that cannot be proven acceptable is rejected, so a wrong answer can only ever mean fewer extras, never a mis-stored item.

Candidate filter excludes items that are forbidden, owned, hostile, trader, rotten, dump/melt-designated, in containers, in buildings, spider webs, construction-designated, hidden, burning, storage-assigned (getStockpile(), pcall-wrapped since the field doesn't exist on all item classes), or already sitting on a building tile (so items are never pulled out of neighboring stockpiles).

Cleanup paths: disable releases all in-flight extras; a periodic sweep clears any in_job flag that has no JOB specific_ref behind it (heals leaks from script/env reloads); flags are also cleared on map/world unload so saves never persist bogus claims.

Additional audit fixes: the destination container itself can never be pocketed as an extra (a vehicle waiting on the ground for its own StoreItemInVehicle job matched the claim check against itself); items claimed by wheelbarrow-assisted haul jobs are not stolen (their jobs do not resolve through get_job_dest); dropped extras credit their weight back so the cap is not inflated; candidate scanning reads the overlapping map blocks' item lists instead of all of IN_PLAY (~26k items in the test fort); the repeat timer survives map unload and transient scan errors; tracked units that leave the citizen list (dead/off-map) release their extras immediately.

Test plan

Verified live in a mature fort (DF 53.16):

  • Multiple Hauled items tolerated in one inventory
  • Extras attach at pickup (17 concurrent haulers observed carrying 1-4 extras each)
  • fetch collects extras 4-5 tiles beyond the pickup point and delivers them to the destination pile
  • Extras survive transit incl. z-level climbs (re-attach counters engine drops)
  • Extras land on destination pile tiles and stay stocked
  • disable with in-flight extras: all released cleanly, no orphaned in_job items (re-claimed stragglers verified via specific_refs)
  • job.items mutation confirmed unsafe (job cancel + orphan) — avoided entirely
  • Container jobs tracked (barrel/bin/vehicle); moveToContainer verified; primary items merging into container stacks detected as delivered
  • weight auto: per-unit caps verified (e.g. 703 for a weak hauler vs 1206 for a strong one; loads stop at the cap mid-pickup)
  • Orphan sweep clears leaked flags; no error spam (scan errors surface via dfhack.printerr)
  • types pile: cross-type extras attach at pickup and en route and are delivered into the destination pile (observed live: thread riding a corpse haul into a mixed refuse/leather/cloth pile)
  • Wheelbarrow-assisted jobs verified unstealable on live jobs: claimed_for_dest returns false for a wheelbarrow-claimed boulder, true for a normal same-pile claim
  • types pile discrimination on real piles: wood-only pile takes wood/rejects stone, bars piles distinguish metals vs. potash-only vs. soap-only, links-only piles reject everything, forbidden/owned/in-building candidates rejected
  • Mixed-type grab end-to-end: a REMAINS hauler to a refuse pile (temporarily widened to accept stone) swept a BOULDER extra, carried it down 5 z-levels, and dropped it at the destination; zero orphaned in_job flags after
  • test/multihaul.lua: 23 unit tests / 67 checks over the matcher (subtype, material, quality, dye/color, usability, freshness, race vectors, tool buckets, unconfigured-pile semantics) and the candidate filters extra_ok/claimed_for_dest (type modes, weight-cap boundary, destination-container exclusion, wheelbarrow-claim exclusion)

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.
…b 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 <n>' 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
- '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)
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.
'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').
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.
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.
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.
- 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
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.
@ab9rf

ab9rf commented Sep 20, 2026

Copy link
Copy Markdown
Member

we need to discuss whether this should be classified as an armok tool or if instead needs a new category for providing a material change in game mechanics that goes beyond mere automation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants