diff --git a/changelog.txt b/changelog.txt index 1ed30ae501..30005b7160 100644 --- a/changelog.txt +++ b/changelog.txt @@ -33,6 +33,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 diff --git a/combine.lua b/combine.lua index cd9e2522f0..dc10c3789c 100644 --- a/combine.lua +++ b/combine.lua @@ -1,3 +1,6 @@ +-- Combine items in stockpiles into stacks. +--@module = true + local argparse = require('argparse') local utils = require('utils') @@ -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) @@ -193,10 +194,30 @@ 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 + -- merchant dyes can lose their profile contents to a vanilla bug (the + -- embedded dye_profile is never absent, but has color_index -1); give + -- each such dye its own key so they never combine + if (not profile or profile.color_index == -1) and isDye(item) then + return 'unprofiled+' .. item.id + end + if not profile then + 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) @@ -212,6 +233,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) @@ -447,7 +477,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) @@ -862,6 +892,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 diff --git a/test/combine.lua b/test/combine.lua new file mode 100644 index 0000000000..e12d8f67dc --- /dev/null +++ b/test/combine.lua @@ -0,0 +1,113 @@ +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. on real items the + -- embedded dye_profile is never absent but has color_index -1 + local mtype, midx = find_dye_mat() + if not mtype then return end + local empty_profile = {color_index=-1, dye_material={}, dye_matg={}, + degree={}, target_index={}} + local stack_type = {type_id=POWDER_MISC} + local d1 = mock_item(POWDER_MISC, {mat_type=mtype, mat_index=midx, id=101, + dye_profile=empty_profile}) + local d2 = mock_item(POWDER_MISC, {mat_type=mtype, mat_index=midx, id=102, + dye_profile=empty_profile}) + local d3 = mock_item(POWDER_MISC, {mat_type=mtype, mat_index=midx, id=103}) + local d4 = mock_item(POWDER_MISC, {mat_type=mtype, mat_index=midx, id=104, + 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)) + expect.ne(p.make_comp_key(stack_type, d1), + p.make_comp_key(stack_type, d4)) +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