diff --git a/changelog.txt b/changelog.txt index b58d36042..f635ae76c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -27,6 +27,7 @@ Template for new versions: # Future ## New Tools +- `fix/wildlife`: prevent wildlife from getting stuck when trying to exit the map. This fix needs to be enabled manually in `gui/control-panel` on the Bug Fixes tab since not all players want this bug to be fixed. ## New Features diff --git a/docs/fix/wildlife.rst b/docs/fix/wildlife.rst new file mode 100644 index 000000000..648f1f4dd --- /dev/null +++ b/docs/fix/wildlife.rst @@ -0,0 +1,70 @@ +fix/wildlife +============ + +.. dfhack-tool:: + :summary: Moves stuck wildlife off the map so new waves can enter. + :tags: fort bugfix animals + +This tool identifies wildlife that is trying to leave the map but has gotten +stuck. The stuck creatures will be moved off the map so that new waves of +wildlife can enter. When removing stuck wildlife, their regional population +counters are correctly incremented, just as if they had successfully left the +map on their own. + +Dwarf Fortress manages wildlife in "waves". A small group of creatures of a +species that has population associated with a local region enters the map, +wanders around for a while (or aggressively attacks you if it is an agitated +group), and then leaves the map. Any members of the group that successfully +leave the map will get added back to the regional population. + +The trouble, though, is that the group sometimes gets stuck when attempting to +leave. A new wave cannot enter until the previous group has been destroyed or +has left the map, so wildlife activity effectively completely halts. This is DF +:bug:`12921`. + +You can run this script without parameters to immediately remove stuck +wildlife, or you can enable it in the `gui/control-panel` on the Bug Fixes tab +to monitor and manage wildlife in the background. When enabled from the control +panel, it will monitor for stuck wildlife and remove wildlife after it has been +stuck for 7 days. + +Unlike most bugfixes, this one is not enabled by default since some players +like to keep wildlife around for creative purposes (e.g. for intentionally +stalling wildlife waves or for controlled startling of friendly necromancers). +These players can selectively ignore the wildlife they want to keep captive +before they enable `fix/wildlife`. + +Usage +----- +:: + + fix/wildlife [] + fix/wildlife ignore [unit ID] + +Examples +-------- + +``fix/wildlife`` + Remove any wildlife that is currently trying to leave the map but has not + yet succeeded. +``fix/wildlife --week`` + Remove wildlife that has been stuck for at least a week. The command must + be run periodically with this option so it can discover newly stuck + wildlife and remove wildlife when timeouts expire. +``fix/wildlife ignore`` + Disconnect the selected unit from its wildlife population so it doesn't + block new wildlife from entering the map, but keep the unit on the map. + This unit will not be touched by future invocations of this tool. + +Options +------- + +``-n``, ``--dry-run`` + Print out which creatures are stuck but take no action. +``-w``, ``--week`` + Discover newly stuck units and associate the current in-game time with + them. Units that were discovered on a previous invocation where this + parameter was specified will be removed if that time was at least a week + ago. +``-q``, ``--quiet`` + Don't print the number of affected units if no units were affected. diff --git a/fix/wildlife.lua b/fix/wildlife.lua new file mode 100644 index 000000000..bd80f2433 --- /dev/null +++ b/fix/wildlife.lua @@ -0,0 +1,189 @@ +--@module = true + +local argparse = require('argparse') +local exterminate = reqscript('exterminate') + +local GLOBAL_KEY = 'fix/wildlife' + +DEBUG = DEBUG or false + +stuck_creatures = stuck_creatures or {} + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if (sc == SC_MAP_UNLOADED or sc == SC_MAP_LOADED) and + dfhack.world.isFortressMode() + then + stuck_creatures = {} + end +end + +local function print_summary(opts, unstuck) + if not next(unstuck) then + if not opts.quiet then + print('No stuck wildlife found') + return + end + end + local prefix = opts.week and (GLOBAL_KEY .. ': ') or '' + local msg_txt = opts.dry_run and '' or 'no longer ' + for _,entry in pairs(unstuck) do + if entry.count == 1 then + print(('%s%d %s is %sblocking new waves of wildlife'):format( + prefix, + entry.count, + entry.known and dfhack.units.getRaceReadableNameById(entry.race) or 'hidden creature', + msg_txt)) + else + print(('%s%d %s are %sblocking new waves of wildlife'):format( + prefix, + entry.count, + entry.known and dfhack.units.getRaceNamePluralById(entry.race) or 'hidden creatures', + msg_txt)) + end + end +end + +local function refund_population(entry) + local epop = entry.pop + for _,population in ipairs(df.global.world.populations) do + local wpop = population.population + if population.quantity < 10000001 and + wpop.region_x == epop.region_x and + wpop.region_y == epop.region_y and + wpop.feature_idx == epop.feature_idx and + wpop.cave_id == epop.cave_id and + wpop.site_id == epop.site_id and + wpop.population_idx == epop.population_idx + then + population.quantity = math.min(population.quantity + entry.count, population.quantity_max) + break + end + end +end + +-- refund unit to population and ensure it doesn't get picked up by unstick_surface_wildlife in the future +local function detach_unit(unit) + unit.flags2.roaming_wilderness_population_source = false + unit.flags2.roaming_wilderness_population_source_not_a_map_feature = false + refund_population{race=unit.race, pop=unit.animal.population, known=true, count=1} +end + +local TICKS_PER_DAY = 1200 +local TICKS_PER_WEEK = TICKS_PER_DAY * 7 +local TICKS_PER_MONTH = 28 * TICKS_PER_DAY +local TICKS_PER_SEASON = 3 * TICKS_PER_MONTH +local TICKS_PER_YEAR = 4 * TICKS_PER_SEASON + +local WEEK_BEFORE_EOY_TICKS = TICKS_PER_YEAR - TICKS_PER_WEEK + +-- update stuck_creatures records and check timeout +-- we only enter this function if the unit's leave_countdown has already expired +-- returns true if the unit has timed out +local function check_timeout(opts, unit, week_ago_ticks) + if not opts.week then return true end + if not stuck_creatures[unit.id] then + stuck_creatures[unit.id] = df.global.cur_year_tick + return false + end + local timestamp = stuck_creatures[unit.id] + return timestamp < week_ago_ticks or + (timestamp > df.global.cur_year_tick and timestamp > WEEK_BEFORE_EOY_TICKS) +end + +local function to_key(pop) + return ('%d:%d:%d:%d:%d:%d'):format( + pop.region_x, pop.region_y, pop.feature_idx, pop.cave_id, pop.site_id, pop.population_idx) +end + +local function is_active_wildlife(unit) + return not dfhack.units.isDead(unit) and + dfhack.units.isActive(unit) and + dfhack.units.isWildlife(unit) and + unit.flags2.roaming_wilderness_population_source +end + +-- called by force for the "Wildlife" event +function free_all_wildlife(include_hidden) + for _,unit in ipairs(df.global.world.units.active) do + if is_active_wildlife(unit) and + (include_hidden or not dfhack.units.isHidden(unit)) + then + detach_unit(unit) + end + end +end + +local function unstick_surface_wildlife(opts) + local unstuck = {} + local week_ago_ticks = math.max(0, df.global.cur_year_tick - TICKS_PER_WEEK) + for _,unit in ipairs(df.global.world.units.active) do + if not is_active_wildlife(unit) or unit.animal.leave_countdown > 0 then + goto skip + end + if not check_timeout(opts, unit, week_ago_ticks) then + goto skip + end + local pop = unit.animal.population + local unstuck_entry = ensure_key(unstuck, to_key(pop), {race=unit.race, pop=pop, known=false, count=0}) + unstuck_entry.known = unstuck_entry.known or not dfhack.units.isHidden(unit) + unstuck_entry.count = unstuck_entry.count + 1 + if not opts.dry_run then + stuck_creatures[unit.id] = nil + exterminate.killUnit(unit, exterminate.killMethod.DISINTEGRATE) + end + ::skip:: + end + for _,entry in pairs(unstuck) do + refund_population(entry) + end + print_summary(opts, unstuck) +end + +if dfhack_flags.module then + return +end + +if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then + qerror('needs a loaded fortress map to work') +end + +local opts = { + dry_run=false, + help=false, + quiet=false, + week=false, +} + +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler = function() opts.help = true end}, + {'n', 'dry-run', handler = function() opts.dry_run = true end}, + {'w', 'week', handler = function() opts.week = true end}, + {'q', 'quiet', handler = function() opts.quiet = true end}, +}) + +if positionals[1] == 'help' or opts.help then + print(dfhack.script_help()) + return +end + +if positionals[1] == 'ignore' then + local unit + local unit_id = positionals[2] and argparse.nonnegativeInt(positionals[2], 'unit_id') + if unit_id then + unit = df.unit.find(unit_id) + else + unit = dfhack.gui.getSelectedUnit(true) + end + if not unit then + qerror('please select a unit or pass a unit ID on the commandline') + end + if not is_active_wildlife(unit) then + qerror('selected unit is not blocking new waves of wildlife; nothing to do') + end + detach_unit(unit) + if not opts.quiet then + print(('%s will now be ignored by fix/wildlife'):format(dfhack.units.getReadableName(unit))) + end +else + unstick_surface_wildlife(opts) +end diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index 7dca4d2b0..aa35e5ae5 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -93,6 +93,8 @@ COMMANDS_BY_IDX = { params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-worship', '-q', ']'}}, {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', + params={'--time', '2', '--timeUnits', 'days', '--command', '[', 'fix/wildlife', '-wq', ']'}}, {command='flask-contents', help_command='tweak', group='bugfix', mode='tweak', default=true, desc='Displays flask contents in the item name, similar to barrels and bins.'}, {command='named-codices', help_command='tweak', group='bugfix', mode='tweak', default=true,