From 5bd2bc18d2a58b8f7ec7710de47754012bcfc345 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Tue, 15 Sep 2026 04:39:27 +0200 Subject: [PATCH] idle-crafting: add cloth, silk, and yarn crafts (#5851) The tool only supported bone, horn, shell, rock, and totem crafting. Cloth crafts require clothesmaking labor and come in three material categories the game distinguishes via job.material_category plus a job_item flags2 bit: plant-fiber cloth (flags2.plant), silk (flags2.silk), and yarn (flags2.yarn), matching the pattern strangemood uses for mood cloth requirements. For workshops with input stockpile links, cloth in linked piles is counted by category and folded into the weighted random job choice. For workshops without links, clothesmaking is tried after stonecrafting and bone carving when usable cloth exists on the map. Workshop profiles that block clothesmaking now correctly report invalid only when all three supported labors are blocked, and the overlay toggle enables workshops where clothesmaking is the only permitted craft labor. --- changelog.txt | 1 + docs/idle-crafting.rst | 21 +++--- idle-crafting.lua | 95 ++++++++++++++++++++++++++- test/idle-crafting.lua | 146 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 test/idle-crafting.lua diff --git a/changelog.txt b/changelog.txt index b492b6d2ec..403650ac04 100644 --- a/changelog.txt +++ b/changelog.txt @@ -29,6 +29,7 @@ Template for new versions: ## New Tools ## New Features +- `idle-crafting`: support cloth, silk, and yarn crafts ## Fixes - `bodyswap`: fix "invalid argument count" when the target unit has no nemesis record diff --git a/docs/idle-crafting.rst b/docs/idle-crafting.rst index 6eeb571e7f..d1b94c75f5 100644 --- a/docs/idle-crafting.rst +++ b/docs/idle-crafting.rst @@ -58,12 +58,15 @@ jobs and assign them to idle dwarves who have a need for crafting objects. This script respects the setting for permitted general work order labors from the "Workers" tab. -For workshops without input stockpile links, bone carving and stonecrafting are -supported, with stonecrafting being the default option. Thus, to designate a -workshop for bone carving, disable the stonecrafting labor while keeping the -bone carving labor enabled. - -For workshops with input stockpile links, the creation of totems, shell crafts, -and horn crafts are supported as well. In this case, the choice of job is made -randomly based on the resources available in the input stockpiles (respecting -the permitted labors from the workshop profile). +For workshops without input stockpile links, bone carving, stonecrafting, and +clothesmaking (cloth, silk, and yarn crafts) are supported. Stonecrafting is +tried first, then bone carving, then clothesmaking if usable cloth exists +anywhere on the map. Thus, to designate a workshop for bone carving, disable +the stonecrafting labor while keeping the bone carving labor enabled. To +designate a workshop for cloth crafts, disable both stonecrafting and bone +carving. + +For workshops with input stockpile links, the creation of totems, shell +crafts, horn crafts, and cloth crafts are supported as well. In this case, the +choice of job is made randomly based on the resources available in the input +stockpiles (respecting the permitted labors from the workshop profile). diff --git a/idle-crafting.lua b/idle-crafting.lua index e5d25751bf..0a9e760870 100644 --- a/idle-crafting.lua +++ b/idle-crafting.lua @@ -79,7 +79,7 @@ function makeTotem(unit, workshop) return dfhack.job.addWorker(job, unit) end ----make totem at specified workshop +---make horn crafts at specified workshop ---@param unit df.unit ---@param workshop df.building_workshopst ---@return boolean @@ -177,6 +177,67 @@ function makeRockCraft(unit, workshop) return dfhack.job.addWorker(job, unit) end +-- the game distinguishes plant-fiber cloth, silk cloth, and yarn cloth crafts by +-- the job's material_category; the matching job_item flags2 bit differs in name +local CLOTH_ITEM_FLAG = { cloth = 'plant', silk = 'silk', yarn = 'yarn' } + +---which cloth craft material category a cloth item belongs to +---@param item df.item +---@return 'cloth'|'silk'|'yarn'|nil +local function cloth_category(item) + local mat = dfhack.matinfo.decode(item) + if not mat then return nil end + if mat.material.flags.SILK then return 'silk' end + if mat.material.flags.YARN then return 'yarn' end + if mat.material.flags.THREAD_PLANT then return 'cloth' end +end + +---make cloth crafts at specified workshop +---@param unit df.unit +---@param workshop df.building_workshopst +---@param category 'cloth'|'silk'|'yarn' +---@return boolean +function makeClothCraft(unit, workshop, category) + local job = dfhack.job.createLinked() + job.job_type = df.job_type.MakeCrafts + job.mat_type = -1 + job.material_category[category] = true + + local jitem = df.job_item:new() + jitem.item_type = df.item_type.CLOTH + jitem.mat_type = -1 + jitem.mat_index = -1 + jitem.quantity = 1 + jitem.vector_id = df.job_item_vector_id.CLOTH + jitem.flags2[CLOTH_ITEM_FLAG[category]] = true + job.job_items.elements:insert('#', jitem) + + dfhack.job.assignToWorkshop(job, workshop) + return dfhack.job.addWorker(job, unit) +end + +---count available cloth in play by craft category (for workshops without links) +---cloth is never equipped, so in_inventory items are stored in containers and +---still fetchable by jobs +---@return table +local function cloth_categories_in_play() + local counts = {} + for _, item in ipairs(df.global.world.items.other.CLOTH) do + local flags = item.flags + if not flags.in_job and not flags.forbid and not flags.garbage_collect + and not flags.hidden and not flags.removed and not flags.dump + and not flags.owned and not flags.hostile and not flags.trader + and not flags.in_building and not flags.encased + and not flags.on_fire then + local category = cloth_category(item) + if category then + counts[category] = (counts[category] or 0) + 1 + end + end + end + return counts +end + ---categorize and count crafting materials (for Craftsdwarf's workshop) ---@param tab table ---@param item df.item @@ -193,6 +254,11 @@ local function categorize_craft(tab,item) end elseif df.item_boulderst:is_instance(item) then tab['boulder'] = (tab['boulder'] or 0) + 1 + elseif df.item_clothst:is_instance(item) then + local category = cloth_category(item) + if category then + tab[category] = (tab[category] or 0) + 1 + end end end @@ -270,6 +336,7 @@ end local CraftObject = df.need_type['CraftObject'] local BONE_CARVE = df.unit_labor['BONE_CARVE'] local STONE_CRAFT = df.unit_labor['STONE_CRAFT'] +local CLOTHESMAKER = df.unit_labor['CLOTHESMAKER'] ---negative crafting focus penalty ---@generic T @@ -331,12 +398,19 @@ function select_crafting_job(workshop) tab['horn'] = nil tab['shell'] = nil end + if blocked_labors[CLOTHESMAKER] then + tab['cloth'] = nil + tab['silk'] = nil + tab['yarn'] = nil + end local material = weightedChoice(tab) if material == 'bone' then return makeBoneCraft elseif material == 'skull' then return makeTotem elseif material == 'horn' then return makeHornCrafts elseif material == 'shell' then return makeShellCraft elseif material == 'boulder' then return makeRockCraft + elseif CLOTH_ITEM_FLAG[material] then + return function(unit, ws) return makeClothCraft(unit, ws, material) end else return nil end @@ -371,8 +445,14 @@ local function processUnit(workshop, idx, unit_id) if not success and workshop.profile.blocked_labors[BONE_CARVE] == false then success = makeBoneCraft(unit, workshop) end + if not success and workshop.profile.blocked_labors[CLOTHESMAKER] == false then + local category = weightedChoice(cloth_categories_in_play()) + if category then + success = makeClothCraft(unit, workshop, category) + end + end if not success then - dfhack.printerr('idle-crafting: profile allows neither bone carving nor stonecrafting') + dfhack.printerr('idle-crafting: profile allows neither bone carving, stonecrafting, nor clothesmaking') end else local craftItem = select_crafting_job(workshop) @@ -397,7 +477,8 @@ end local function invalidProfile(workshop) local profile = workshop.profile return (#profile.permitted_workers > 0) or - (profile.blocked_labors[BONE_CARVE] and profile.blocked_labors[STONE_CRAFT]) + (profile.blocked_labors[BONE_CARVE] and profile.blocked_labors[STONE_CRAFT] + and profile.blocked_labors[CLOTHESMAKER]) end -- try to catch units that currently don't have a job and send them to satisfy @@ -612,6 +693,14 @@ OVERLAY_WIDGETS = { idlecrafting = IdleCraftingOverlay } +if dfhack.internal.IN_TEST then + unit_test_hooks = { + cloth_category=cloth_category, + cloth_categories_in_play=cloth_categories_in_play, + categorize_craft=categorize_craft, + } +end + -- -- commandline interface -- diff --git a/test/idle-crafting.lua b/test/idle-crafting.lua new file mode 100644 index 0000000000..db8b8e1547 --- /dev/null +++ b/test/idle-crafting.lua @@ -0,0 +1,146 @@ +config.target = 'idle-crafting' + +local ic = reqscript('idle-crafting') +local p = ic.unit_test_hooks + +local MakeCrafts = df.job_type.MakeCrafts +local CLOTH = df.item_type.CLOTH +local CLOTH_VEC = df.job_item_vector_id.CLOTH +local CLOTHESMAKER = df.unit_labor.CLOTHESMAKER +local STONE_CRAFT = df.unit_labor.STONE_CRAFT +local BONE_CARVE = df.unit_labor.BONE_CARVE + +local function fake_matinfo(flag) + local flags = {} + if flag then flags[flag] = true end + return {material={flags=flags}} +end + +local function patched_jobs(fn) + local made = {} + mock.patch({{dfhack.job, 'createLinked', + function() local j = df.job:new(); table.insert(made, j); return j end}, + {dfhack.job, 'assignToWorkshop', mock.func()}, + {dfhack.job, 'addWorker', function() return true end}}, + function() fn(made) end) +end + +function test.cloth_crafts_plant() + patched_jobs(function(made) + expect.true_(ic.makeClothCraft({}, {}, 'cloth')) + local job = made[1] + expect.eq(job.job_type, MakeCrafts) + expect.true_(job.material_category.cloth) + expect.false_(job.material_category.silk) + expect.false_(job.material_category.yarn) + local jitem = job.job_items.elements[0] + expect.eq(jitem.item_type, CLOTH) + expect.eq(jitem.vector_id, CLOTH_VEC) + expect.eq(jitem.quantity, 1) + expect.eq(jitem.mat_type, -1) + expect.eq(jitem.mat_index, -1) + expect.true_(jitem.flags2.plant) + expect.false_(jitem.flags2.silk) + expect.false_(jitem.flags2.yarn) + end) +end + +function test.cloth_crafts_silk() + patched_jobs(function(made) + expect.true_(ic.makeClothCraft({}, {}, 'silk')) + local job = made[1] + expect.true_(job.material_category.silk) + expect.false_(job.material_category.cloth) + local jitem = job.job_items.elements[0] + expect.true_(jitem.flags2.silk) + expect.false_(jitem.flags2.plant) + expect.false_(jitem.flags2.yarn) + end) +end + +function test.cloth_crafts_yarn() + patched_jobs(function(made) + expect.true_(ic.makeClothCraft({}, {}, 'yarn')) + local job = made[1] + expect.true_(job.material_category.yarn) + expect.false_(job.material_category.cloth) + local jitem = job.job_items.elements[0] + expect.true_(jitem.flags2.yarn) + expect.false_(jitem.flags2.plant) + expect.false_(jitem.flags2.silk) + end) +end + +function test.cloth_category_classification() + local item = df.item_clothst:new() + mock.patch({{dfhack.matinfo, 'decode', + function() return fake_matinfo('THREAD_PLANT') end}}, + function() expect.eq(p.cloth_category(item), 'cloth') end) + mock.patch({{dfhack.matinfo, 'decode', + function() return fake_matinfo('SILK') end}}, + function() expect.eq(p.cloth_category(item), 'silk') end) + mock.patch({{dfhack.matinfo, 'decode', + function() return fake_matinfo('YARN') end}}, + function() expect.eq(p.cloth_category(item), 'yarn') end) + -- neither silk, yarn, nor plant-derived: not a usable craft cloth + mock.patch({{dfhack.matinfo, 'decode', + function() return fake_matinfo() end}}, + function() expect.nil_(p.cloth_category(item)) end) + mock.patch({{dfhack.matinfo, 'decode', function() return nil end}}, + function() expect.nil_(p.cloth_category(item)) end) +end + +local function mock_workshop(blocked_labors) + return { + profile = { + links = {take_from_pile={{id=1}}}, + blocked_labors = blocked_labors or {}, + }, + contained_items = {}, + } +end + +-- pairs of {item, mat} so the real df objects stay field-free +local function with_stockpile_items(entries, fn) + local items, mats = {}, {} + for i, entry in ipairs(entries) do + items[i] = entry.item + mats[entry.item] = entry.mat + end + mock.patch({{dfhack.buildings, 'getStockpileContents', + function() return items end}, + {dfhack.matinfo, 'decode', function(item) return mats[item] end}}, + fn) +end + +local function cloth_entry(mat) + return {item=df.item_clothst:new(), mat=mat} +end + +function test.select_picks_cloth_category() + with_stockpile_items({cloth_entry(fake_matinfo('SILK'))}, function() + patched_jobs(function(made) + local job_fn = ic.select_crafting_job(mock_workshop()) + expect.true_(job_fn ~= nil) + expect.true_(job_fn({}, {})) + local job = made[1] + expect.true_(job.material_category.silk) + expect.true_(job.job_items.elements[0].flags2.silk) + end) + end) +end + +function test.select_respects_blocked_clothesmaking() + with_stockpile_items({cloth_entry(fake_matinfo('THREAD_PLANT'))}, function() + local ws = mock_workshop({[CLOTHESMAKER]=true}) + expect.nil_(ic.select_crafting_job(ws)) + end) +end + +function test.select_cloth_unaffected_by_other_blocked_labors() + with_stockpile_items({cloth_entry(fake_matinfo('THREAD_PLANT'))}, function() + local ws = mock_workshop({[STONE_CRAFT]=true, [BONE_CARVE]=true}) + local job_fn = ic.select_crafting_job(ws) + expect.true_(job_fn ~= nil) + end) +end