diff --git a/autolabor-artisans.lua b/autolabor-artisans.lua index 4bf95145e8..c9401dcbf9 100644 --- a/autolabor-artisans.lua +++ b/autolabor-artisans.lua @@ -14,9 +14,8 @@ influences output quality. Examples:: local artisan_labors = { "CARPENTER", - "DETAIL", + "ENGRAVER", "MASON", - "ARCHITECT", "ANIMALTRAIN", "LEATHER", "WEAVER", diff --git a/gui/settings-manager.lua b/gui/settings-manager.lua index 140310c2e4..edc6bcd9d0 100644 --- a/gui/settings-manager.lua +++ b/gui/settings-manager.lua @@ -501,7 +501,47 @@ local function clone_wd_flags(flags) } end +-- the autolabor plugin provides the labor automation controls; it may be +-- unavailable if the plugin binary is not loaded +local ok, autolabor_plugin = pcall(require, 'plugins.autolabor') +if not ok or not autolabor_plugin.autolabor_getMode then + autolabor_plugin = nil +end + +local MODE_LEGACY = 0 +local MODE_MODERN = 1 +local MODE_MONITOR = 2 + +local function automation_active() + return autolabor_plugin and autolabor_plugin.isEnabled() +end + +local function automation_opt() + return autolabor_plugin.isEnabled() and + autolabor_plugin.autolabor_getMode() or 'off' +end + +local function modern_active() + return automation_active() and + autolabor_plugin.autolabor_getMode() == MODE_MODERN +end + +local function monitor_active() + return automation_active() and + autolabor_plugin.autolabor_getMode() == MODE_MONITOR +end + +-- true while an engine that rewrites labors/work details is running; +-- monitor mode leaves work details alone, so save/load stays available +local function managed_active() + return automation_active() and not monitor_active() +end + local function save_work_details() + if managed_active() then + dfhack.printerr('not saving work details while labor automation is active') + return + end local details = {} for idx, wd in ipairs(li.work_details) do local detail = { @@ -517,6 +557,12 @@ local function save_work_details() end local function load_work_details() + -- loading deletes the plugin-managed 'auto:' details out from under the + -- running tool (and they are meaningless in legacy mode anyway) + if managed_active() then + dfhack.printerr('not loading work details while labor automation is active') + return + end if not config.data.work_details or #config.data.work_details < 10 then -- not enough data to cover built-in work details return @@ -549,10 +595,10 @@ end WorkDetailsOverlay = defclass(WorkDetailsOverlay, ImportExportAutoOverlay) WorkDetailsOverlay.ATTRS { - desc='Adds buttons to the work details screen for saving and restoring settings.', + desc='Adds buttons to the work details screen for saving and restoring settings and for controlling labor automation.', default_pos={x=80, y=-5}, viewscreens='dwarfmode/Info/LABOR/WORK_DETAILS/Default', - frame={w=35, h=5}, + frame={w=50, h=5}, save_label='Save work details', load_label='Load work details', auto_label='Load for new embarks:', @@ -570,6 +616,157 @@ function WorkDetailsOverlay:init() self.subviews.load.frame.w = 25 self.subviews.load_flash.frame.t = 1 self.subviews.load_flash.frame.l = 10 + if not autolabor_plugin then return end + self:addviews{ + widgets.CycleHotkeyLabel{ + view_id='labor_mode', + frame={t=0, l=0}, + key='CUSTOM_SHIFT_M', + label='Labor automation:', + options={ + {label='off', value='off'}, + {label='legacy (autolabor)', value=MODE_LEGACY}, + {label='modern (labormanager)', value=MODE_MODERN}, + {label='monitor (warnings only)', value=MODE_MONITOR}, + }, + initial_option=automation_opt(), + on_change=function(val) + if val == 'off' then + autolabor_plugin.setEnabled(false) + else + autolabor_plugin.autolabor_setMode(val) + if not autolabor_plugin.isEnabled() then + autolabor_plugin.setEnabled(true) + end + end + end, + }, + widgets.Label{ + view_id='status_line1', + frame={t=1, l=0}, + text={{text=function() + if not autolabor_plugin.isEnabled() then + return 'Labor automation is off.' + elseif autolabor_plugin.autolabor_getMode() == MODE_LEGACY then + return 'Legacy mode: edits here have no effect.' + elseif autolabor_plugin.autolabor_getMode() == MODE_MONITOR then + return 'Monitor mode: no labor management.' + end + return "Modern mode: 'auto:' details are managed." + end}}, + }, + widgets.Label{ + view_id='status_line2', + frame={t=2, l=0}, + text='Manual edits will be overwritten.', + visible=modern_active, + }, + widgets.Label{ + view_id='priority_label', + frame={t=3, l=0, w=9}, + text='Priority:', + visible=modern_active, + }, + widgets.Slider{ + view_id='balance_slider', + frame={t=3, l=10, w=36}, + num_stops=select('#', autolabor_plugin.autolabor_getBalanceStops()), + get_idx_fn=function() return autolabor_plugin.autolabor_getBalance() + 1 end, + on_change=function(idx) autolabor_plugin.autolabor_setBalance(idx - 1) end, + visible=modern_active, + }, + widgets.Label{ + view_id='balance_name', + frame={t=4, l=0}, + text={{text=function() + local names = {autolabor_plugin.autolabor_getBalanceStops()} + return (names[autolabor_plugin.autolabor_getBalance() + 1] or '') + .. ' (staffing <-> skills)' + end}}, + visible=modern_active, + }, + widgets.Label{ + view_id='role_counts', + frame={t=5, l=0}, + text={{text=function() + if monitor_active() then + local n = autolabor_plugin.autolabor_getStarvingJobs() + return ('%d starving job%s'):format(n, n == 1 and '' or 's') + end + return autolabor_plugin.autolabor_getStatus() + end}}, + visible=function() return modern_active() or monitor_active() end, + }, + } +end + +-- packs the visible rows tightly and shrinks the frame to fit; called on +-- every render so mode changes made elsewhere are reflected immediately +function WorkDetailsOverlay:updateAutomationLayout() + if not autolabor_plugin then return end + local on = automation_active() + local modern = modern_active() + local monitor = monitor_active() + local managed = managed_active() + local s = self.subviews + -- the save/load feature is unusable while a managing engine runs + -- (monitor mode doesn't touch work details, so it stays available); + -- hide it entirely so the overlay stays small. assert this every + -- render since the 'saved'/'loaded' flash timer can set a button + -- visible again + s.save.visible = not managed + s.load.visible = not managed + s.auto.visible = not managed + if managed then + s.save_flash.visible = false + s.load_flash.visible = false + end + + local sig = ('%s:%s:%s'):format(on, modern, monitor) + if self.automation_sig == sig then return end + self.automation_sig = sig + + local t = 0 + local function place(v) + v.frame.t = t + t = t + 1 + end + place(s.labor_mode) + place(s.status_line1) + if modern then + place(s.status_line2) + s.priority_label.frame.t = t + s.balance_slider.frame.t = t + t = t + 1 + place(s.balance_name) + place(s.role_counts) + elseif monitor then + place(s.role_counts) + end + if not managed then + t = t + 1 + place(s.save) + s.save_flash.frame.t = s.save.frame.t + place(s.load) + s.load_flash.frame.t = s.load.frame.t + place(s.auto) + end + self.frame.h = t + 2 + self:updateLayout() +end + +function WorkDetailsOverlay:onRenderFrame(dc, rect) + self:updateAutomationLayout() + WorkDetailsOverlay.super.onRenderFrame(self, dc, rect) + -- keep the mode selector truthful if the state changes elsewhere + -- (console commands, control panel, another overlay) + if autolabor_plugin then + local opt = automation_opt() + local cycle = self.subviews.labor_mode + if cycle:getOptionValue() ~= opt then + cycle:setOption(opt) + end + end end OVERLAY_WIDGETS = { diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index 0759ed398c..c77b35d6df 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -25,7 +25,8 @@ COMMANDS_BY_IDX = { {command='autofarm threshold 150 grass_tail_pig', group='automation', mode='run', desc='Enable if you usually farm pig tails for the clothing industry.'}, {command='autofish', group='automation', mode='enable'}, - --{command='autolabor', group='automation', mode='enable'}, -- hide until it works better + {command='autolabor', group='automation', mode='enable', + desc='Automatically assign labors. Runs in the mode selected on the work details screen or with the mode command (legacy autolabor, modern labormanager, or starvation-only monitor).'}, {command='automilk', help_command='workorder', group='automation', mode='repeat', desc='Automatically milk creatures that are ready for milking.', params={'--time', '14', '--timeUnits', 'days', '--command', '[', 'workorder', '"{\\"job\\":\\"MilkCreature\\",\\"item_conditions\\":[{\\"condition\\":\\"AtLeast\\",\\"value\\":2,\\"flags\\":[\\"empty\\"],\\"item_type\\":\\"BUCKET\\"}]}"', ']'}}, diff --git a/internal/notify/notifications.lua b/internal/notify/notifications.lua index 108fe92f97..f4fe56f814 100644 --- a/internal/notify/notifications.lua +++ b/internal/notify/notifications.lua @@ -9,6 +9,12 @@ local repeat_util = require('repeat-util') local stuck_squad = reqscript('fix/stuck-squad') local warn_stranded = reqscript('warn-stranded') +-- may be nil if the autolabor plugin binary is not loaded +local ok, autolabor_plugin = pcall(require, 'plugins.autolabor') +if not ok or not autolabor_plugin.autolabor_getStarvingJobs then + autolabor_plugin = nil +end + local CONFIG_FILE = 'dfhack-config/notify.json' local buildings = df.global.world.buildings @@ -711,6 +717,30 @@ NOTIFICATIONS_BY_IDX = { dwarf_fn=curry(injured_units, for_injured, 'injured citizen'), on_click=curry(zoom_to_next, for_injured), }, + { + name='labor_starvation', + desc='Warns when job postings stay unclaimed for more than a day while labormanager (or monitor mode) is active.', + default=true, + dwarf_fn=function() + if not autolabor_plugin then return end + local count, job, ticks = autolabor_plugin.autolabor_getStarvingJobs() + if count > 0 then + local msg = ('task starvation: %d unclaimed job%s'):format( + count, count == 1 and '' or 's') + if job then + msg = ('%s (oldest: %s, %d ticks)'):format(msg, job, ticks) + end + return {{text=msg, pen=COLOR_LIGHTRED}} + end + end, + on_click=function() + if not autolabor_plugin then return end + local count, _, _, x, y, z = autolabor_plugin.autolabor_getStarvingJobs() + if count > 0 and x then + dfhack.gui.revealInDwarfmodeMap(xyz2pos(x, y, z), true) + end + end, + }, { name='suffocation_adv', desc='Shows a suffocation bar when you are drowning or breathless.', diff --git a/test/autolabor.lua b/test/autolabor.lua new file mode 100644 index 0000000000..063073708b --- /dev/null +++ b/test/autolabor.lua @@ -0,0 +1,74 @@ +config = { + mode = 'fortress', +} + +local autolabor = require('plugins.autolabor') + +local function loaded() + expect.true_(autolabor.autolabor_getMode ~= nil, + 'autolabor plugin is not loaded') +end + +-- regression: DF releases may add or remove unit_labor enum items, and +-- the engines' static labor data must cover every generated labor type +function test.labor_coverage() + loaded() + local gaps = autolabor.autolabor_checkLaborCoverage() + expect.eq(0, #gaps, + 'uncovered labor types: ' .. table.concat(gaps, ', ')) +end + +-- regression: DF releases may add or remove job_type enum items; every +-- generated job type must have an entry in the job to labor table +function test.job_coverage() + loaded() + local gaps = autolabor.autolabor_checkJobCoverage() + expect.eq(0, #gaps, + 'uncovered job types: ' .. table.concat(gaps, ', ')) +end + +function test.mode_api() + loaded() + local mode = autolabor.autolabor_getMode() + expect.true_(mode == 0 or mode == 1 or mode == 2, + 'unexpected mode ' .. tostring(mode)) +end + +function test.mode_roundtrip() + loaded() + local orig = autolabor.autolabor_getMode() + for _, mode in ipairs{0, 1, 2} do + autolabor.autolabor_setMode(mode) + expect.eq(mode, autolabor.autolabor_getMode()) + end + autolabor.autolabor_setMode(orig) +end + +function test.balance_api() + loaded() + expect.eq(5, select('#', autolabor.autolabor_getBalanceStops())) + local orig = autolabor.autolabor_getBalance() + expect.true_(orig >= 0 and orig < 5, + 'unexpected balance ' .. tostring(orig)) + autolabor.autolabor_setBalance(0) + expect.eq(0, autolabor.autolabor_getBalance()) + autolabor.autolabor_setBalance(4) + expect.eq(4, autolabor.autolabor_getBalance()) + autolabor.autolabor_setBalance(orig) +end + +function test.idle_reserve_api() + loaded() + local orig = autolabor.autolabor_getIdleReserve() + autolabor.autolabor_setIdleReserve(0) + expect.eq(0, autolabor.autolabor_getIdleReserve()) + autolabor.autolabor_setIdleReserve(50) + expect.eq(50, autolabor.autolabor_getIdleReserve()) + autolabor.autolabor_setIdleReserve(orig) +end + +function test.starving_jobs() + loaded() + local n = autolabor.autolabor_getStarvingJobs() + expect.true_(type(n) == 'number' and n >= 0) +end