Skip to content

Improve fix/stuck-squad #1453

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
40 changes: 28 additions & 12 deletions docs/fix/stuck-squad.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <gui/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 <gui/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
Expand All @@ -31,4 +24,27 @@ Usage

::

fix/stuck-squad
fix/stuck-squad [<options>]

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.
193 changes: 99 additions & 94 deletions fix/stuck-squad.lua
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is a controller_id of 0 excluded, specifically?

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)
2 changes: 1 addition & 1 deletion internal/control-panel/registry.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 12 additions & 19 deletions internal/notify/notifications.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down