From 998cfd3a84229414deb582b4bc3a68aa743bfd58 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Wed, 16 Sep 2026 17:12:18 +0200 Subject: [PATCH 1/2] gui/settings-manager: preserve new work details Match saved built-in work details by identity instead of raw vector position, then restore custom details after the current built-in prefix. This keeps built-ins added by newer DF versions from being overwritten or truncated by older settings. --- changelog.txt | 1 + gui/settings-manager.lua | 45 ++++++++++-- test/gui/settings-manager.lua | 129 ++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 test/gui/settings-manager.lua diff --git a/changelog.txt b/changelog.txt index 1ed30ae501..412720dc1b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -34,6 +34,7 @@ Template for new versions: ## Fixes - `bodyswap`: fix "invalid argument count" when the target unit has no nemesis record +- `gui/settings-manager`: preserve built-in work details added after saved settings were created - `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/gui/settings-manager.lua b/gui/settings-manager.lua index 140310c2e4..95fae65f9d 100644 --- a/gui/settings-manager.lua +++ b/gui/settings-manager.lua @@ -516,22 +516,53 @@ local function save_work_details() config:write() end +local function apply_work_detail(detail, wd) + local flags = wd.flags or wd.work_detail_flags -- compat for old name + detail.name = wd.name + detail.icon = wd.icon + detail.flags.cannot_be_everybody = flags.cannot_be_everybody + detail.flags.no_modify = flags.no_modify + detail.flags.mode = flags.mode + for i,v in ipairs(wd.allowed_labors) do + detail.allowed_labors[i-1] = v + end +end + local function load_work_details() if not config.data.work_details or #config.data.work_details < 10 then -- not enough data to cover built-in work details return end - li.work_details:resize(#config.data.work_details) - -- keep unit assignments for overwritten indices - for idx, wd in ipairs(config.data.work_details) do - local detail = { + + local saved_builtins, saved_custom = {}, {} + for _,wd in ipairs(config.data.work_details) do + local flags = wd.flags or wd.work_detail_flags + if flags.no_modify then + saved_builtins[('%s\0%s'):format(wd.icon, wd.name)] = wd + else + table.insert(saved_custom, wd) + end + end + + local builtin_count = 0 + for idx = 0, #li.work_details - 1 do + local detail = li.work_details[idx] + if not detail.flags.no_modify then break end + builtin_count = builtin_count + 1 + local wd = saved_builtins[('%s\0%s'):format(detail.icon, detail.name)] + if wd then apply_work_detail(detail, wd) end + end + + li.work_details:resize(builtin_count + #saved_custom) + for idx,wd in ipairs(saved_custom) do + local detail_idx = builtin_count + idx - 1 + li.work_details[detail_idx] = { new=df.work_detail, name=wd.name, icon=wd.icon, - flags=wd.flags or wd.work_detail_flags, -- compat for old name + flags=wd.flags or wd.work_detail_flags, } - li.work_details[idx-1] = detail - local al = li.work_details[idx-1].allowed_labors + local al = li.work_details[detail_idx].allowed_labors for i,v in ipairs(wd.allowed_labors) do al[i-1] = v end diff --git a/test/gui/settings-manager.lua b/test/gui/settings-manager.lua new file mode 100644 index 0000000000..e4263d0088 --- /dev/null +++ b/test/gui/settings-manager.lua @@ -0,0 +1,129 @@ +config = { + mode = 'fortress', + target = 'gui/settings-manager', +} + +local settings_manager = reqscript('gui/settings-manager') + +local function make_vector(entries) + local data = entries or {} + return setmetatable({}, { + __len=function() return #data end, + __index=function(_, key) + if key == 'resize' then + return function(_, size) + for i = #data, size - 1 do data[i+1] = false end + for i = #data, size + 1, -1 do data[i] = nil end + end + end + return type(key) == 'number' and data[key+1] or nil + end, + __newindex=function(_, key, value) + if type(key) ~= 'number' then return end + if type(value) == 'table' and value.new == df.work_detail then + value = { + name=value.name, + icon=value.icon, + flags=value.flags, + allowed_labors={}, + } + end + data[key+1] = value + end, + }) +end + +local function with_work_details(work_details, saved, fn) + local load_fn = settings_manager.WorkDetailsOverlay.ATTRS.load_fn + local li_idx, old_li + for i = 1, 10 do + local name, value = debug.getupvalue(load_fn, i) + if name == 'li' then + li_idx, old_li = i, value + break + end + end + local old_config = settings_manager.config + debug.setupvalue(load_fn, li_idx, {work_details=work_details}) + settings_manager.config = {data={work_details=saved}, write=function() end} + dfhack.with_finalize( + function() + debug.setupvalue(load_fn, li_idx, old_li) + settings_manager.config = old_config + end, + function() fn(load_fn) end) +end + +local function saved_detail(index) + return { + name=('built-in %d'):format(index), + icon=index, + flags={cannot_be_everybody=false, no_modify=true, mode=1}, + allowed_labors={}, + } +end + +local function current_details() + local current = {} + for i = 1, 10 do current[i] = saved_detail(i) end + current[11] = { + name='Siege Operators', + icon=df.work_detail_icon_type.SIEGE_OPERATORS, + flags={cannot_be_everybody=false, no_modify=true, mode=1}, + allowed_labors={}, + } + return current +end + +function test.loading_old_details_preserves_new_builtin() + local saved = {} + for i = 1, 10 do saved[i] = saved_detail(i) end + local work_details = make_vector(current_details()) + + with_work_details(work_details, saved, function(load_fn) + load_fn() + expect.eq(11, #work_details) + if #work_details < 11 then return end + expect.eq('Siege Operators', work_details[10].name) + end) +end + +function test.loading_legacy_work_detail_flags() + local saved = {} + for i = 1, 10 do saved[i] = saved_detail(i) end + saved[1].work_detail_flags = saved[1].flags + saved[1].flags = nil + saved[1].work_detail_flags.mode = 3 + local work_details = make_vector(current_details()) + + with_work_details(work_details, saved, function(load_fn) + load_fn() + expect.eq(3, work_details[0].flags.mode) + end) +end + +function test.loading_old_custom_details_after_new_builtins() + local saved = {} + for i = 1, 10 do saved[i] = saved_detail(i) end + saved[11] = { + name='Custom detail', + icon=1, + flags={cannot_be_everybody=false, no_modify=false, mode=1}, + allowed_labors={}, + } + local current = current_details() + current[12] = { + name='Unsaved custom detail', + icon=2, + flags={cannot_be_everybody=false, no_modify=false, mode=1}, + allowed_labors={}, + } + local work_details = make_vector(current) + + with_work_details(work_details, saved, function(load_fn) + load_fn() + expect.eq(12, #work_details) + expect.eq('Siege Operators', work_details[10].name) + expect.eq('Custom detail', work_details[11].name) + end) +end From 48a31c4d26ad6a87b30084bf9b0492483ae21e49 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Wed, 16 Sep 2026 20:28:03 +0200 Subject: [PATCH 2/2] gui/settings-manager: recompute unit labors after work detail import Importing work details changed each detail's allowed labors but left the units' effective labor flags untouched, so jobs that depended on the newly imported details (e.g. Mechanics and Jeweler's workshops) found no workers until some later action happened to trigger recomputation. Call dfhack.units.setAutomaticProfessions() for all citizens after importing, matching what the game does when work details are applied through the labor UI. Also match saved built-in details by icon when the name no longer matches, so settings exported before a built-in was renamed (by the user or a DF update) still apply, re-read the settings file before each load so files swapped in while the game is running are seen, and skip malformed saved entries instead of erroring on them. --- changelog.txt | 1 + docs/gui/settings-manager.rst | 6 +-- gui/settings-manager.lua | 48 +++++++++++++++---- test/gui/settings-manager.lua | 87 ++++++++++++++++++++++++++++++++++- 4 files changed, 126 insertions(+), 16 deletions(-) diff --git a/changelog.txt b/changelog.txt index 412720dc1b..b21aae2842 100644 --- a/changelog.txt +++ b/changelog.txt @@ -35,6 +35,7 @@ Template for new versions: ## Fixes - `bodyswap`: fix "invalid argument count" when the target unit has no nemesis record - `gui/settings-manager`: preserve built-in work details added after saved settings were created +- `gui/settings-manager`: apply imported work details to renamed built-ins and recompute unit labors so imported settings take effect immediately - `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/docs/gui/settings-manager.rst b/docs/gui/settings-manager.rst index fd5bec8a51..903ed08124 100644 --- a/docs/gui/settings-manager.rst +++ b/docs/gui/settings-manager.rst @@ -50,8 +50,4 @@ Autostart page. There is a similar panel on the Labor -> Work Details page that allows for saving and restoring of work detail definitions. Be aware that work detail assignments to units cannot be saved, so you have to assign the work details to -individual units after you restore the definitions. Another caveat is that DF -doesn't evaluate work detail definitions until a change (any change) is made on -the work details screen. Therefore, after importing work detail definitions, -including auto-loading them for new embarks, you have to go to the work details -page and make a change before your imported work details will take effect. +individual units after you restore the definitions. diff --git a/gui/settings-manager.lua b/gui/settings-manager.lua index 95fae65f9d..e9df7d58b2 100644 --- a/gui/settings-manager.lua +++ b/gui/settings-manager.lua @@ -46,6 +46,8 @@ local function save_difficulty(df_difficulty) end local function load_difficulty(df_difficulty) + -- re-read in case the file changed since the config was first opened + config:read() local difficulty = utils.clone(config.data.difficulty or {}, true) for _, v in pairs(difficulty) do if type(v) == 'table' and v[1] then @@ -398,6 +400,8 @@ local function save_standing_orders() end local function load_standing_orders() + -- re-read in case the file changed since the config was first opened + config:read() for name, val in pairs(config.data.standing_orders or {}) do df.global[name] = val end @@ -517,31 +521,49 @@ local function save_work_details() end local function apply_work_detail(detail, wd) - local flags = wd.flags or wd.work_detail_flags -- compat for old name - detail.name = wd.name - detail.icon = wd.icon + local flags = wd.flags or wd.work_detail_flags or {} -- compat for old name + if wd.name then detail.name = wd.name end + if wd.icon then detail.icon = wd.icon end detail.flags.cannot_be_everybody = flags.cannot_be_everybody detail.flags.no_modify = flags.no_modify detail.flags.mode = flags.mode - for i,v in ipairs(wd.allowed_labors) do + for i,v in ipairs(wd.allowed_labors or {}) do detail.allowed_labors[i-1] = v end end +-- built-in work details are identified by their unique icon; custom details +-- get CUSTOM_* icons (or NONE), so a built-in icon can only belong to a +-- built-in +local function is_builtin_icon(icon) + return type(icon) == 'number' and icon >= 0 and + (icon < df.work_detail_icon_type.CUSTOM_1 or + icon == df.work_detail_icon_type.SIEGE_OPERATORS) +end + local function load_work_details() + -- re-read in case the file changed since the config was first opened + config:read() if not config.data.work_details or #config.data.work_details < 10 then -- not enough data to cover built-in work details return end - local saved_builtins, saved_custom = {}, {} + local saved_builtins, builtin_by_icon, saved_custom = {}, {}, {} for _,wd in ipairs(config.data.work_details) do - local flags = wd.flags or wd.work_detail_flags - if flags.no_modify then + local flags = type(wd) == 'table' and + (wd.flags or wd.work_detail_flags) or nil + if flags and flags.no_modify then saved_builtins[('%s\0%s'):format(wd.icon, wd.name)] = wd - else + if is_builtin_icon(wd.icon) then + -- built-ins may have been renamed by the user or by a DF + -- version update, so fall back to matching by icon + builtin_by_icon[wd.icon] = wd + end + elseif flags then table.insert(saved_custom, wd) end + -- entries without flag data are malformed; skip them end local builtin_count = 0 @@ -549,7 +571,8 @@ local function load_work_details() local detail = li.work_details[idx] if not detail.flags.no_modify then break end builtin_count = builtin_count + 1 - local wd = saved_builtins[('%s\0%s'):format(detail.icon, detail.name)] + local wd = saved_builtins[('%s\0%s'):format(detail.icon, detail.name)] or + builtin_by_icon[detail.icon] if wd then apply_work_detail(detail, wd) end end @@ -563,10 +586,15 @@ local function load_work_details() flags=wd.flags or wd.work_detail_flags, } local al = li.work_details[detail_idx].allowed_labors - for i,v in ipairs(wd.allowed_labors) do + for i,v in ipairs(wd.allowed_labors or {}) do al[i-1] = v end end + -- applying work details through the UI recomputes each unit's effective + -- labors; do the same here so the imported details take effect + for _,unit in ipairs(dfhack.units.getCitizens()) do + dfhack.units.setAutomaticProfessions(unit) + end local scr = dfhack.gui.getDFViewscreen(true) if dfhack.gui.matchFocusString('dwarfmode/Info/LABOR/WORK_DETAILS', scr) then gui.simulateInput(scr, 'LEAVESCREEN') diff --git a/test/gui/settings-manager.lua b/test/gui/settings-manager.lua index e4263d0088..e89825e3b1 100644 --- a/test/gui/settings-manager.lua +++ b/test/gui/settings-manager.lua @@ -45,7 +45,11 @@ local function with_work_details(work_details, saved, fn) end local old_config = settings_manager.config debug.setupvalue(load_fn, li_idx, {work_details=work_details}) - settings_manager.config = {data={work_details=saved}, write=function() end} + settings_manager.config = { + data={work_details=saved}, + read=function() end, + write=function() end, + } dfhack.with_finalize( function() debug.setupvalue(load_fn, li_idx, old_li) @@ -102,6 +106,87 @@ function test.loading_legacy_work_detail_flags() end) end +function test.loading_renamed_builtin_matches_by_icon() + -- a saved built-in whose name was changed (by the user or a DF update) + -- is still identified by its unique built-in icon + local saved = {} + for i = 1, 10 do saved[i] = saved_detail(i) end + saved[3] = { + name='Old Hunters Name', + icon=3, + flags={cannot_be_everybody=false, no_modify=true, mode=3}, + allowed_labors={true, false, true}, + } + local work_details = make_vector(current_details()) + + with_work_details(work_details, saved, function(load_fn) + load_fn() + local detail = work_details[2] + expect.eq('Old Hunters Name', detail.name) + expect.eq(3, detail.flags.mode) + expect.eq(true, detail.allowed_labors[0]) + expect.eq(true, detail.allowed_labors[2]) + end) +end + +function test.builtin_icon_fallback_ignores_custom_icons() + -- a saved no_modify entry with a custom icon must not steal a built-in + local saved = {} + for i = 1, 10 do saved[i] = saved_detail(i) end + saved[3] = { + name='built-in 3', + icon=df.work_detail_icon_type.CUSTOM_1, + flags={cannot_be_everybody=false, no_modify=true, mode=3}, + allowed_labors={true}, + } + local work_details = make_vector(current_details()) + + with_work_details(work_details, saved, function(load_fn) + load_fn() + local detail = work_details[2] + expect.eq('built-in 3', detail.name) + expect.eq(1, detail.flags.mode) + expect.ne(true, detail.allowed_labors[0]) + end) +end + +function test.loading_malformed_entries_are_skipped() + local saved = {} + for i = 1, 10 do saved[i] = saved_detail(i) end + saved[5] = {name='no flags', icon=4} -- no flags/work_detail_flags + saved[6] = 'not a table' + local work_details = make_vector(current_details()) + + with_work_details(work_details, saved, function(load_fn) + load_fn() + -- the two malformed entries are skipped, not recreated as customs + expect.eq(11, #work_details) + expect.eq('Siege Operators', work_details[10].name) + -- unmatched built-ins are left alone + expect.eq('built-in 5', work_details[4].name) + end) +end + +function test.loading_recomputes_unit_labors() + local saved = {} + for i = 1, 10 do saved[i] = saved_detail(i) end + local work_details = make_vector(current_details()) + local citizens = {{id=1}, {id=2}} + local recomputed = {} + mock.patch({ + {dfhack.units, 'getCitizens', function() return citizens end}, + {dfhack.units, 'setAutomaticProfessions', function(unit) + recomputed[unit] = true + end}, + }, function() + with_work_details(work_details, saved, function(load_fn) + load_fn() + expect.true_(recomputed[citizens[1]]) + expect.true_(recomputed[citizens[2]]) + end) + end) +end + function test.loading_old_custom_details_after_new_builtins() local saved = {} for i = 1, 10 do saved[i] = saved_detail(i) end