From 5b8d35e4403e485bf2dc3340260f520cbdfa55d3 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Tue, 15 Sep 2026 02:13:17 +0200 Subject: [PATCH] fix/stuck-written-materials: new tool to free written materials stuck in jobs Codices, quires, and scrolls can be left with the in_job flag set after visitors join the fort or squads return from missions, making them unusable. Check each written item against the live job list, taking container chains into account, and clear the flag and stale job and activity references when no job actually claims the item. Scheduled daily by the control panel alongside the other bug fixes. fixes DFHack/dfhack#3572 --- changelog.txt | 1 + docs/fix/stuck-written-materials.rst | 25 +++ fix/stuck-written-materials.lua | 127 +++++++++++++++ internal/control-panel/registry.lua | 2 + test/fix/stuck-written-materials.lua | 230 +++++++++++++++++++++++++++ 5 files changed, 385 insertions(+) create mode 100644 docs/fix/stuck-written-materials.rst create mode 100644 fix/stuck-written-materials.lua create mode 100644 test/fix/stuck-written-materials.lua diff --git a/changelog.txt b/changelog.txt index b492b6d2ec..9e7ba9f91d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -27,6 +27,7 @@ Template for new versions: # Future ## New Tools +- `fix/stuck-written-materials`: free written materials that are stuck in a non-existent job ## New Features diff --git a/docs/fix/stuck-written-materials.rst b/docs/fix/stuck-written-materials.rst new file mode 100644 index 0000000000..d4603f5850 --- /dev/null +++ b/docs/fix/stuck-written-materials.rst @@ -0,0 +1,25 @@ +fix/stuck-written-materials +=========================== + +.. dfhack-tool:: + :summary: Allow bugged written materials to be interacted with again. + :tags: fort bugfix items + +Fixes books, quires, and scrolls that are stuck permanently in a job that no +longer exists. This can happen, for example, when a visitor that was reading +or carrying a written work joins the fortress, or when squads return from +missions with written materials. + +This works around the same family of issues as `fix/stuck-instruments` +(:bug:`9485`), and should be run if you notice any written materials that +cannot be hauled or interacted with. + + +Usage +----- + +``fix/stuck-written-materials`` + Fixes item data for all stuck written materials on the map. +``fix/stuck-written-materials -n``, ``fix/stuck-written-materials --dry-run`` + List how many written materials would be fixed without performing the + action. diff --git a/fix/stuck-written-materials.lua b/fix/stuck-written-materials.lua new file mode 100644 index 0000000000..56875af6f8 --- /dev/null +++ b/fix/stuck-written-materials.lua @@ -0,0 +1,127 @@ +-- Fixes written materials that are stuck in a non-existent job +--@module = true + +local argparse = require('argparse') +local utils = require('utils') + +local quire_subtype, scroll_subtype + +function is_written_material(item) + if df.item_bookst:is_instance(item) then + return true + end + if not df.item_toolst:is_instance(item) then + return false + end + local subtype = item:getSubtype() + return subtype == quire_subtype or subtype == scroll_subtype +end + +local function get_ids_of_items_in_jobs() + local ids = {} + for _, job in utils.listpairs(df.global.world.jobs.list) do + for _, item_ref in ipairs(job.items) do + if item_ref.item then + ids[item_ref.item.id] = true + end + end + end + return ids +end + +function is_in_live_job(item, ids) + -- contents of a container are flagged in_job when the container itself + -- is attached to a job + while item do + if ids[item.id] then return true end + item = dfhack.items.getContainer(item) + end + return false +end + +local function remove_dead_refs(item) + for i = #item.specific_refs - 1, 0, -1 do + local ref = item.specific_refs[i] + -- the jobs these refs point at may have already been deleted, so the + -- ref itself must not be dereferenced; just remove it + if ref.type == df.specific_ref_type.JOB then + ref:delete() + item.specific_refs:erase(i) + end + end +end + +function fixWrittenMaterials(opts) + quire_subtype = dfhack.items.findSubtype('TOOL:ITEM_TOOL_QUIRE') + scroll_subtype = dfhack.items.findSubtype('TOOL:ITEM_TOOL_SCROLL') + + local fixed = 0 + local in_job_ids = get_ids_of_items_in_jobs() + + for _, vec in ipairs{df.global.world.items.other.BOOK, + df.global.world.items.other.TOOL} do + for _, item in ipairs(vec) do + if not is_written_material(item) then goto continue end + + local stuck = false + + -- written materials can keep references to activity events that no + -- longer exist, e.g. when a visitor that was reading or carrying + -- them joins the fort (bug 9485) + for i = #item.general_refs - 1, 0, -1 do + local ref = item.general_refs[i] + if ref:getType() == df.general_ref_type.ACTIVITY_EVENT and + not df.activity_entry.find(ref.activity_id) then + if not opts.dry_run then + ref:delete() + item.general_refs:erase(i) + end + stuck = true + end + end + + -- they can also be left with the in_job flag set while no actual + -- job references them + if item.flags.in_job and not is_in_live_job(item, in_job_ids) then + if not opts.dry_run then + remove_dead_refs(item) + item.flags.in_job = false + end + stuck = true + end + + if stuck then + print(dfhack.df2console(('Found stuck written material: %s'):format( + dfhack.items.getDescription(item, 0, true)))) + fixed = fixed + 1 + end + + ::continue:: + end + end + + if fixed > 0 or opts.dry_run then + print(("%s %d stuck written material(s)."):format( + opts.dry_run and "Found" or "Fixed", + fixed + )) + end +end + +if dfhack_flags.module then + return +end + +local opts = {} + +local positionals = argparse.processArgsGetopt({...}, { + { 'h', 'help', handler = function() opts.help = true end }, + { 'n', 'dry-run', handler = function() opts.dry_run = true end }, +}) + +if positionals[1] == 'help' or opts.help then + print(dfhack.script_help()) + return +end + +fixWrittenMaterials(opts) diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index 0759ed398c..c7ff2e1747 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -103,6 +103,8 @@ COMMANDS_BY_IDX = { params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-squad', ']'}}, {command='fix/stuck-worship', group='bugfix', mode='repeat', default=true, params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-worship', '-q', ']'}}, + {command='fix/stuck-written-materials', group='bugfix', mode='repeat', default=true, + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-written-materials', ']'}}, {command='fix/noexert-exhaustion', group='bugfix', mode='repeat', default=true, params={'--time', '439', '--timeUnits', 'ticks', '--command', '[', 'fix/noexert-exhaustion', ']'}}, {command='fix/wildlife', group='bugfix', mode='repeat', diff --git a/test/fix/stuck-written-materials.lua b/test/fix/stuck-written-materials.lua new file mode 100644 index 0000000000..273e6e4549 --- /dev/null +++ b/test/fix/stuck-written-materials.lua @@ -0,0 +1,230 @@ +config.target = 'fix/stuck-written-materials' + +local swm = reqscript('fix/stuck-written-materials') + +local QUIRE_SUBTYPE, SCROLL_SUBTYPE = 100, 200 +local ACTIVITY_EVENT, JOB_REF = 61, 2 + +local mock_df, mock_items, mock_print +local live_activities + +-- emulates a df vector: 0-based indexing, # gives the element count +local function mock_vector(data) + local vec = {_data = data or {}} + return setmetatable(vec, { + __index = function(self, k) + if k == 'erase' then + return function(_, i) table.remove(self._data, i + 1) end + end + if type(k) == 'number' then return self._data[k + 1] end + end, + __newindex = function(self, k, v) self._data[k + 1] = v end, + __len = function(self) return #self._data end, + __ipairs = function(self) + local i = -1 + return function() + i = i + 1 + local v = self._data[i + 1] + if v ~= nil then return i, v end + end + end, + }) +end + +local function mock_item(id, class, subtype) + return { + id = id, + _class = class, + flags = {in_job = false}, + general_refs = mock_vector(), + specific_refs = mock_vector(), + container = nil, + getSubtype = function(self) return self._subtype end, + _subtype = subtype, + } +end + +local function mock_book(id) + return mock_item(id, 'book') +end + +local function mock_tool(id, subtype) + return mock_item(id, 'tool', subtype) +end + +local function mock_activity_ref(activity_id) + return { + activity_id = activity_id, + getType = function() return ACTIVITY_EVENT end, + delete = mock.func(), + } +end + +local function mock_job_ref(job) + return { + type = JOB_REF, + data = {job = job}, + delete = mock.func(), + } +end + +local function mock_other_ref() + return { + type = 0, + getType = function() return 0 end, + delete = mock.func(), + } +end + +local function mock_job(item_refs) + return {items = mock_vector(item_refs)} +end + +local function linked_list(jobs) + local head = {} + local link = head + for _, job in ipairs(jobs) do + link.next = {item = job} + link = link.next + end + return head +end + +local book_items, tool_items, jobs + +config.wrapper = function(test_fn) + book_items = mock_vector() + tool_items = mock_vector() + jobs = {} + live_activities = {} + + mock_df = { + item_bookst = {is_instance = function(_, item) return item._class == 'book' end}, + item_toolst = {is_instance = function(_, item) return item._class == 'tool' end}, + general_ref_type = {ACTIVITY_EVENT = ACTIVITY_EVENT}, + specific_ref_type = {JOB = JOB_REF}, + activity_entry = {find = function(id) return live_activities[id] end}, + global = {world = { + items = {other = {BOOK = book_items, TOOL = tool_items}}, + jobs = {list = linked_list(jobs)}, + }}, + } + mock_items = { + findSubtype = function(name) + if name == 'TOOL:ITEM_TOOL_QUIRE' then return QUIRE_SUBTYPE end + if name == 'TOOL:ITEM_TOOL_SCROLL' then return SCROLL_SUBTYPE end + end, + getContainer = function(item) return item.container end, + getDescription = function() return 'mock item' end, + } + mock_print = mock.func() + + mock.patch({{swm, 'df', mock_df}, + {swm.dfhack, 'items', mock_items}, + {swm.dfhack, 'df2console', function(s) return s end}, + {swm, 'print', mock_print}}, + test_fn) +end + +local function add_job(items) + local job = mock_job(items) + table.insert(jobs, job) + mock_df.global.world.jobs.list = linked_list(jobs) + return job +end + +function test.no_stuck_items() + swm.fixWrittenMaterials({}) + expect.eq(0, mock_print.call_count) +end + +function test.clears_stuck_in_job_flag() + local book = mock_book(1) + book.flags.in_job = true + book_items[0] = book + swm.fixWrittenMaterials({}) + expect.false_(book.flags.in_job) + expect.str_find('Fixed 1 stuck written material', mock_print.call_args[2][1]) +end + +function test.removes_dead_job_ref() + local book = mock_book(1) + book.flags.in_job = true + local ref = mock_job_ref({}) + book.specific_refs[0] = ref + book_items[0] = book + swm.fixWrittenMaterials({}) + expect.false_(book.flags.in_job) + expect.eq(1, ref.delete.call_count) + expect.eq(0, #book.specific_refs) +end + +function test.removes_dead_activity_ref() + local book = mock_book(1) + local dead_ref = mock_activity_ref(42) + local live_ref = mock_activity_ref(7) + live_activities[7] = {} + book.general_refs[0] = dead_ref + book.general_refs[1] = live_ref + book_items[0] = book + swm.fixWrittenMaterials({}) + expect.eq(1, dead_ref.delete.call_count) + expect.eq(0, live_ref.delete.call_count) + expect.eq(1, #book.general_refs) + expect.eq(live_ref, book.general_refs[0]) +end + +function test.ignores_item_in_live_job() + local book = mock_book(1) + book.flags.in_job = true + book_items[0] = book + add_job({{item = book}}) + swm.fixWrittenMaterials({}) + expect.true_(book.flags.in_job) + expect.eq(0, mock_print.call_count) +end + +function test.ignores_item_whose_container_is_in_live_job() + local bag = mock_item(2, 'tool') + local book = mock_book(1) + book.flags.in_job = true + book.container = bag + book_items[0] = book + add_job({{item = bag}}) + swm.fixWrittenMaterials({}) + expect.true_(book.flags.in_job) + expect.eq(0, mock_print.call_count) +end + +function test.handles_quire_and_scroll() + local quire = mock_tool(1, QUIRE_SUBTYPE) + local scroll = mock_tool(2, SCROLL_SUBTYPE) + quire.flags.in_job = true + scroll.flags.in_job = true + tool_items[0] = quire + tool_items[1] = scroll + swm.fixWrittenMaterials({}) + expect.false_(quire.flags.in_job) + expect.false_(scroll.flags.in_job) +end + +function test.ignores_unrelated_tool() + local item = mock_tool(1, 999) + item.flags.in_job = true + tool_items[0] = item + swm.fixWrittenMaterials({}) + expect.true_(item.flags.in_job) + expect.eq(0, mock_print.call_count) +end + +function test.dry_run_reports_without_changing() + local book = mock_book(1) + book.flags.in_job = true + local ref = mock_job_ref({}) + book.specific_refs[0] = ref + book_items[0] = book + swm.fixWrittenMaterials({dry_run = true}) + expect.true_(book.flags.in_job) + expect.eq(0, ref.delete.call_count) + expect.str_find('Found 1 stuck written material', mock_print.call_args[2][1]) +end