Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 12 additions & 9 deletions docs/idle-crafting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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).
95 changes: 92 additions & 3 deletions idle-crafting.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string,integer>
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<string,integer>
---@param item df.item
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
--
Expand Down
146 changes: 146 additions & 0 deletions test/idle-crafting.lua
Original file line number Diff line number Diff line change
@@ -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