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 @@ -31,6 +31,7 @@ Template for new versions:
## New Features

## Fixes
- `combine`: dyes are combined again, but only when their ``dye_profile`` matches, so mixed dyes no longer revert to a component dye
- `bodyswap`: fix "invalid argument count" when the target unit has no nemesis record
- `fix/loyaltycascade`: guard against citizens that are not historical figures and emit a warning.
- `gui/siegemanager`: fix nil index if there are no siege engines on the map
Expand Down
48 changes: 42 additions & 6 deletions combine.lua
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
-- Combine items in stockpiles into stacks.
--@module = true

local argparse = require('argparse')
local utils = require('utils')

Expand Down Expand Up @@ -183,8 +186,6 @@ local function stack_type_new(type_vals)
end

local function isDye(item)
-- Dyes should not be combined as this will cause bugs when mixing them together
if item:getType() ~= df.item_type.POWDER_MISC then return false end
-- pcall guards items/materials that can't be decoded or lack the flag
local ok, is_dye = pcall(function()
local mat = dfhack.matinfo.decode(item.mat_type, item.mat_index)
Expand All @@ -193,10 +194,29 @@ local function isDye(item)
return ok and is_dye or false
end

local function stacks_add_item(stockpile, stacks, stack_type, item, container)
-- add an item to the matching comp_items table; based on comp_key.
local comp_key = ''
-- produce a fingerprint of an item's dye_profile, which distinguishes mixed
-- dyes (e.g. a blend of two dyes) from their components; they all share the
-- same mat_type/mat_index
local function dye_profile_key(item)
local profile = item.dye_profile
if not profile then
if isDye(item) then
-- merchant dyes can be missing their profile due to a vanilla
-- bug; give each such item its own key so they never combine
return 'unprofiled+' .. item.id
end
return ''
end
local parts = {profile.color_index}
for _,v in ipairs(profile.dye_material) do parts[#parts+1] = v end
for _,v in ipairs(profile.dye_matg) do parts[#parts+1] = v end
for _,v in ipairs(profile.degree) do parts[#parts+1] = v end
for _,v in ipairs(profile.target_index) do parts[#parts+1] = v end
return table.concat(parts, '+')
end

local function make_comp_key(stack_type, item)
local comp_key
if typesThatUseCreatures[df.item_type[stack_type.type_id]] then
if not typesThatUseMaterial[df.item_type[stack_type.type_id]] then
comp_key = ('%s+%s+%s'):format(stack_type.type_id, item.race, item.caste)
Expand All @@ -212,6 +232,15 @@ local function stacks_add_item(stockpile, stacks, stack_type, item, container)
else
comp_key = ('%s+%s+%s'):format(stack_type.type_id, item.mat_type, item.mat_index)
end
if stack_type.type_id == df.item_type.POWDER_MISC then
comp_key = ('%s+%s'):format(comp_key, dye_profile_key(item))
end
return comp_key
end

local function stacks_add_item(stockpile, stacks, stack_type, item, container)
-- add an item to the matching comp_items table; based on comp_key.
local comp_key = make_comp_key(stack_type, item)

if not stack_type.comp_items[comp_key] then
stack_type.comp_items[comp_key] = comp_item_new(comp_key, stack_type)
Expand Down Expand Up @@ -447,7 +476,7 @@ local function stacks_add_items(stockpile, stacks, items, container, ind)
local stack_type = stacks.stack_types[type_id]

-- item type in list of included types?
if stack_type and not item:isSand() and not item:isPlaster() and not isDye(item) and isValidPart(item) then
if stack_type and not item:isSand() and not item:isPlaster() and isValidPart(item) then
if not isRestrictedItem(item) and item.stack_size <= stack_type.max_stack_qty then

stacks_add_item(stockpile, stacks, stack_type, item, container)
Expand Down Expand Up @@ -862,6 +891,13 @@ local function main()

end

if dfhack.internal.IN_TEST then
unit_test_hooks = {
make_comp_key=make_comp_key,
dye_profile_key=dye_profile_key,
}
end

if not dfhack_flags.module then
main()
end
105 changes: 105 additions & 0 deletions test/combine.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
config.target = 'combine'

local combine = reqscript('combine')
local p = combine.unit_test_hooks

local POWDER_MISC = df.item_type.POWDER_MISC
local DRINK = df.item_type.DRINK

local function mock_item(item_type, fields)
local item = {
getType=function() return item_type end,
isCrafted=function() return false end,
}
for k,v in pairs(fields) do item[k] = v end
return item
end

local function mock_dye(color_index, materials)
return mock_item(POWDER_MISC, {
mat_type=0, mat_index=0,
dye_profile={
color_index=color_index,
dye_material=materials,
dye_matg={},
degree={},
target_index={},
},
})
end

function test.dye_same_profile_same_key()
local stack_type = {type_id=POWDER_MISC}
local dye_a = mock_dye(5, {10})
local dye_b = mock_dye(5, {10})
expect.eq(p.make_comp_key(stack_type, dye_a),
p.make_comp_key(stack_type, dye_b))
end

function test.dye_different_color_different_key()
local stack_type = {type_id=POWDER_MISC}
local dye_a = mock_dye(5, {10})
local dye_b = mock_dye(7, {10})
expect.ne(p.make_comp_key(stack_type, dye_a),
p.make_comp_key(stack_type, dye_b))
end

function test.dye_mix_different_key_than_component()
-- a mixed dye shares mat_type/mat_index with its components but has a
-- different dye_profile; it must not be merged into a component stack
local stack_type = {type_id=POWDER_MISC}
local dye_a = mock_dye(5, {10})
local dye_mix = mock_dye(5, {10, 20})
expect.ne(p.make_comp_key(stack_type, dye_a),
p.make_comp_key(stack_type, dye_mix))
end

function test.powder_without_profile()
-- non-dye powders have an empty/unset profile and still merge as before
local stack_type = {type_id=POWDER_MISC}
local p1 = mock_item(POWDER_MISC, {mat_type=1, mat_index=2})
local p2 = mock_item(POWDER_MISC, {mat_type=1, mat_index=2})
expect.eq(p.make_comp_key(stack_type, p1),
p.make_comp_key(stack_type, p2))
end

local function find_dye_mat()
for _, plant in ipairs(df.global.world.raws.plants.all) do
local mtype = plant.material_defs.type.mill
local midx = plant.material_defs.idx.mill
if mtype ~= -1 then
local matinfo = dfhack.matinfo.decode(mtype, midx)
if matinfo and matinfo.material.flags.IS_DYE then
return mtype, midx
end
end
end
end

function test.unprofiled_dye_never_combines()
-- merchant dyes can lose their dye_profile to a vanilla bug; such items
-- must not be merged, not even with each other
local mtype, midx = find_dye_mat()
if not mtype then return end
local stack_type = {type_id=POWDER_MISC}
local d1 = mock_item(POWDER_MISC, {mat_type=mtype, mat_index=midx, id=101})
local d2 = mock_item(POWDER_MISC, {mat_type=mtype, mat_index=midx, id=102})
local d3 = mock_item(POWDER_MISC, {mat_type=mtype, mat_index=midx, id=103,
dye_profile={color_index=1, dye_material={1}, dye_matg={}, degree={},
target_index={}}})
expect.ne(p.make_comp_key(stack_type, d1),
p.make_comp_key(stack_type, d2))
expect.ne(p.make_comp_key(stack_type, d1),
p.make_comp_key(stack_type, d3))
end

function test.dye_key_ignores_other_types()
local stack_type = {type_id=DRINK}
local d1 = mock_item(DRINK, {mat_type=1, mat_index=2})
local d2 = mock_item(DRINK, {mat_type=1, mat_index=2,
dye_profile={color_index=9, dye_material={1},
dye_matg={}, degree={},
target_index={}}})
expect.eq(p.make_comp_key(stack_type, d1),
p.make_comp_key(stack_type, d2))
end