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