diff --git a/changelog.txt b/changelog.txt index 0678eabe6..3696de7cd 100644 --- a/changelog.txt +++ b/changelog.txt @@ -16,12 +16,8 @@ Template for new versions: ## New Features -- `gui/design`: add option to draw N-point stars, hollow or filled or inverted, and change the main axis to orient in any direction - ## Fixes -- `gui/design`: prevent line thickness from extending outside the map boundary - ## Misc Improvements ## Removed @@ -38,13 +34,16 @@ Template for new versions: - `gui/spectate`: added "Prefer nicknamed" to the list of options - `gui/mod-manager`: when run in a loaded world, shows a list of active mods -- click to export the list to the clipboard for easy sharing or posting - `gui/blueprint`: now records zone designations +- `gui/design`: add option to draw N-point stars, hollow or filled or inverted, and change the main axis to orient in any direction ## Fixes - `starvingdead`: properly restore to correct enabled state when loading a new game that is different from the first game loaded in this session - `starvingdead`: ensure undead decay does not happen faster than the declared decay rate when saving and loading the game +- `gui/design`: prevent line thickness from extending outside the map boundary ## Misc Improvements - `remove-stress`: also applied to long-term stress, immediately removing stressed and haggard statuses +- `fix/stuck-squad`: don't require a returning army, verbose and quiet options ## Removed diff --git a/docs/fix/stuck-squad.rst b/docs/fix/stuck-squad.rst index 21a2d5048..cf37026ff 100644 --- a/docs/fix/stuck-squad.rst +++ b/docs/fix/stuck-squad.rst @@ -2,25 +2,18 @@ fix/stuck-squad =============== .. dfhack-tool:: - :summary: Allow squads and messengers to rescue lost squads. + :summary: Rescue stranded squads. :tags: fort bugfix military Occasionally, squads that you send out on a mission get stuck on the world map. They lose their ability to navigate and are unable to return to your fortress. -This tool allows a messenger that is returning from a holding or any other of -your squads that is returning from a mission to rescue the lost squad along the -way and bring them home. +This tool brings them back to their senses and redirects them back home. This fix is enabled by default in the DFHack -`control panel `, or you can run it as needed. However, it -is still up to you to send out a messenger or squad that can be tasked with the -rescue. If you have a holding that is linked to your fort, you can send out a -messenger -- you don't have to actually request any workers. Otherwise, you can -send a squad out on a mission with minimal risk, like "Demand one-time tribute". +`control panel `, or you can run it as needed. This tool is integrated with `gui/notify`, so you will get a notification in -the DFHack notification panel when a squad is stuck and there are no squads or -messengers currently out traveling that can rescue them. +the DFHack notification panel when a squad is stuck and hasn't been fixed yet. Note that there might be other reasons why your squad appears missing -- if it got wiped out in combat and nobody survived to report back, for example -- but @@ -31,4 +24,27 @@ Usage :: - fix/stuck-squad + fix/stuck-squad [] + +Fix stuck squads and direct their armies back home. Multiple squads can share +a single army if sent on the same mission, and only armies are counted in the +total. + +Examples +-------- + +``fix/stuck-squad`` + Fix stuck squads and print the number of affected armies. +``fix/stuck-squad -v`` + Same as above, but also print info about armies, etc. + +Options +------- + +``-v``, ``--verbose`` + Print IDs for the affected armies, controllers, and player fort. + Indicate which specific armies were ignored (due to controller not fully + removed). +``-q``, ``--quiet`` + Don't print the number of affected armies if it's zero. Intended for + automatic use. diff --git a/fix/stuck-squad.lua b/fix/stuck-squad.lua index 7bfc739b0..5cce472b5 100644 --- a/fix/stuck-squad.lua +++ b/fix/stuck-squad.lua @@ -1,126 +1,131 @@ +--Rescue stranded squads. (Also contains functions for messing with armies.) --@ module=true -local utils = require('utils') +local argparse = require('argparse') +local plotinfo = df.global.plotinfo --- from observing bugged saves, this condition appears to be unique to stuck armies -local function is_army_stuck(army) - return army.controller_id ~= 0 and not army.controller -end +function new_controller(site_id) --Create army controller aimed at site + local controllers = df.global.world.army_controllers.all + local cid = df.global.army_controller_next_id --- if army is currently camping, we'll need to go up the chain -local function get_top_controller(controller) - if not controller then return end - if controller.master_id == controller.id then return controller end - return df.army_controller.find(controller.master_id) + controllers:insert('#', { + new = true, + id = cid, + site_id = site_id, + pos_x = -1, --DF will assign to site center + pos_y = -1, + year = df.global.cur_year, + year_tick = df.global.cur_year_tick, + master_id = cid, + origin_task_id = -1, + origin_plot_id = -1, + data = {goal_move_to_site = {new = true, flag = {RETURNING_TO_CURRENT_HOME = true}}}, + goal = df.army_controller_goal_type.MOVE_TO_SITE, + }) + df.global.army_controller_next_id = cid+1 + return cid, controllers[#controllers-1] end -local function is_army_valid_and_returning(army) - local controller = get_top_controller(army.controller) - if not controller then return false, false end - if controller.goal == df.army_controller_goal_type.SITE_INVASION then - return true, controller.data.goal_site_invasion.flag.RETURNING_HOME - elseif controller.goal == df.army_controller_goal_type.MAKE_REQUEST then - return true, controller.data.goal_make_request.flag.RETURNING_HOME +function rescue_army(army, site, verbose) --Migrate stranded army to site + site = site or df.world_site.find(plotinfo.site_id) + if not army or not site then + qerror('Invalid army or destination site') + elseif verbose then + print(('Rescuing army #%d to site #%d'):format(army.id, site.id)) end - return false, false -end -local function get_hf_army(hf) - if not hf then return end - return df.army.find(hf.info and hf.info.whereabouts and hf.info.whereabouts.army_id or -1) -end + if df.army_controller.find(army.controller_id) then --Controller still exists + if verbose then + print(('Army controller #%d still exists. Aborting.'):format(army.controller_id)) + end + return false --Don't mess with anything; DF may re-create the army rather than reattach + end --- need to check all squad positions since some members may have died -local function get_squad_army(squad) - if not squad then return end - for _,sp in ipairs(squad.positions) do - local hf = df.historical_figure.find(sp.occupant) - if not hf then goto continue end - local army = get_hf_army(hf) - if army then return army end - ::continue:: + army.controller_id, army.controller = new_controller(site.id) + if verbose then + print(('Attached new controller #%d to army.'):format(army.controller_id)) end + return true end --- called by gui/notify notification -function scan_fort_armies() - local stuck_armies, outbound_army, returning_army = {}, nil, nil - local govt = df.historical_entity.find(df.global.plotinfo.group_id) - if not govt then return stuck_armies, outbound_army, returning_army end +local function get_hf_army(hfid) --Return army ID of HF or -1 + local hf = df.historical_figure.find(hfid) + return hf and hf.info and hf.info.whereabouts and hf.info.whereabouts.army_id or -1 +end - for _,squad_id in ipairs(govt.squads) do - local squad = df.squad.find(squad_id) - local army = get_squad_army(squad) - if not army then goto continue end - if is_army_stuck(army) then - table.insert(stuck_armies, {squad=squad, army=army}) - elseif not returning_army then - local valid, returning = is_army_valid_and_returning(army) - if valid then - if returning then - returning_army = {squad=squad, army=army} - else - outbound_army = {squad=squad, army=army} +function get_fort_armies(govt) --Return a set of all squad armies; squads can share armies + govt = govt or df.historical_entity.find(plotinfo.group_id) + local armies = {} + for _,sqid in ipairs(govt.squads) do --Iterate squads + local squad = df.squad.find(sqid) + if squad then + for _,sp in ipairs(squad.positions) do --Iterate positions + local army = df.army.find(get_hf_army(sp.occupant)) + --HF doesn't update while camping. If it's possible for the army to get + --stuck in that state, we'd probably have to scan all armies to find it. + if army then + armies[army] = true + break --Likely only one valid army per squad end end end - ::continue:: end + return armies +end - if #stuck_armies == 0 then return stuck_armies, nil, nil end +--From observing bugged saves, this condition appears to be unique to stuck armies +local function is_army_stuck(army) + return army and not army.flags.dwarf_mode_preparing and --Let DF handle cancelled missions + army.controller_id ~= 0 and not army.controller +end - -- prefer returning with a messenger if one is readily available - for _,messenger in ipairs(dfhack.units.getUnitsByNobleRole('Messenger')) do - local army = get_hf_army(df.historical_figure.find(messenger.hist_figure_id)) - if not army then goto continue end - local valid, returning = is_army_valid_and_returning(army) - if valid then - if returning then - returning_army = {army=army} - else - outbound_army = {army=army} - end +function scan_fort_armies(govt) --Return a list of all squad armies that are stuck + govt = govt or df.historical_entity.find(plotinfo.group_id) + if not govt then + qerror('No site entity. Is fort loaded?') + end + local stuck = {} + for army in pairs(get_fort_armies(govt)) do --Check each army from the set + if is_army_stuck(army) then + table.insert(stuck, army) end - ::continue:: end - - return stuck_armies, outbound_army, returning_army + return stuck end -local function unstick_armies() - local stuck_armies, outbound_army, returning_army = scan_fort_armies() - if #stuck_armies == 0 then return end - if not returning_army then - local instructions = outbound_army - and ('Please wait for %s to complete their objective and run this command again when they are on their way home.'):format( - outbound_army.squad and dfhack.df2console(dfhack.military.getSquadName(outbound_army.squad.id)) or 'the messenger') - or 'Please send a squad or a messenger out on a mission that will return to the fort, and'.. - ' run this command again when they are on the way home.' - qerror(('%d stuck squad%s found, but no returning squads or messengers are available to rescue them!\n%s'):format( - #stuck_armies, #stuck_armies == 1 and '' or 's', instructions)) - return +function unstick_armies(verbose, quiet_result) --Recover all stuck squads for the current fort + local site = df.world_site.find(plotinfo.site_id) + local govt = df.historical_entity.find(plotinfo.group_id) + if not site or not govt then + qerror('No fort loaded') end - local returning_squad_name = returning_army.squad and dfhack.df2console(dfhack.military.getSquadName(returning_army.squad.id)) or 'the messenger' - for _,stuck in ipairs(stuck_armies) do - print(('fix/stuck-squad: Squad rescue operation underway! %s is rescuing %s'):format( - returning_squad_name, dfhack.military.getSquadName(stuck.squad.id))) - for _,member in ipairs(stuck.army.members) do - local nemesis = df.nemesis_record.find(member.nemesis_id) - if not nemesis or not nemesis.figure then goto continue end - local hf = nemesis.figure - if hf.info and hf.info.whereabouts then - hf.info.whereabouts.army_id = returning_army.army.id - end - utils.insert_sorted(returning_army.army.members, member, 'nemesis_id') - ::continue:: + local stuck = scan_fort_armies(govt) + if not next(stuck) then --No problems + if not quiet_result then + print('No stuck squads.') end - stuck.army.members:resize(0) - utils.insert_sorted(get_top_controller(returning_army.army.controller).assigned_squads, stuck.squad.id) + return 0 + elseif verbose then + print(('Unsticking armies for player site #%d, entity #%d'):format(site.id, govt.id)) + end + local count = 0 + for _,army in ipairs(stuck) do + count = count + (rescue_army(army, site, verbose) and 1 or 0) end + if verbose or not quiet_result then + print(('Rescued %d of %d stuck armies.'):format(count, #stuck)) + end + return count end if dfhack_flags.module then return end -unstick_armies() +local quiet, verbose = false, false +argparse.processArgsGetopt({...}, { + {'q', 'quiet', handler=function() quiet = true end}, + {'v', 'verbose', handler=function() verbose = true end}, +}) + +unstick_armies(verbose, quiet) diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index 76fbee5c1..da1fc5e11 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -94,7 +94,7 @@ COMMANDS_BY_IDX = { {command='fix/stuck-instruments', group='bugfix', mode='repeat', default=true, params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-instruments', ']'}}, {command='fix/stuck-squad', group='bugfix', mode='repeat', default=true, - params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-squad', ']'}}, + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-squad', '-vq', ']'}}, {command='fix/stuck-worship', group='bugfix', mode='repeat', default=true, params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-worship', '-q', ']'}}, {command='fix/noexert-exhaustion', group='bugfix', mode='repeat', default=true, diff --git a/internal/notify/notifications.lua b/internal/notify/notifications.lua index 8af7c2c18..58e6b4c4b 100644 --- a/internal/notify/notifications.lua +++ b/internal/notify/notifications.lua @@ -341,27 +341,20 @@ NOTIFICATIONS_BY_IDX = { desc='Notifies when a squad is stuck on the world map.', default=true, dwarf_fn=function() - local stuck_armies, outbound_army, returning_army = stuck_squad.scan_fort_armies() - if #stuck_armies == 0 then return end - if repeat_util.isScheduled('control-panel/fix/stuck-squad') and (outbound_army or returning_army) then - return - end - return ('%d squad%s need%s rescue'):format( - #stuck_armies, - #stuck_armies == 1 and '' or 's', - #stuck_armies == 1 and 's' or '' - ) + local stuck_armies = stuck_squad.scan_fort_armies() + local n = #stuck_armies + if n == 0 then return end + return ('%d squad %s rescue'):format(n, n == 1 and 'army needs' or 'armies need') end, on_click=function() - local message = 'A squad is lost on the world map and needs rescue!\n\n' .. - 'Please send a messenger to a holding or a squad out on a mission\n' .. - 'that will return to the fort (e.g. a Demand one-time tribute mission,\n' .. - 'but not a Conquer and occupy mission). They will rescue the stuck\n' .. - 'squad on their way home.' - if not repeat_util.isScheduled('control-panel/fix/stuck-squad') then - message = message .. '\n\n' .. - 'Please enable fix/stuck-squad in the DFHack control panel to enable\n'.. - 'missions to rescue stuck squads.' + local message = 'A squad is lost on the world map and needs rescue!\n\n' + if repeat_util.isScheduled('control-panel/fix/stuck-squad') then + message = message .. + 'fix/stuck-squad is enabled, and the squad will be directed home shortly.' + else + message = message .. + 'Please enable fix/stuck-squad in the DFHack control panel or\n' .. + 'run "fix/stuck-squad" manually.' end dlg.showMessage('Rescue stuck squads', message, COLOR_WHITE) end,