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