From ec52b429f769ac075ef78a4b4d999fefed1fa49a Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Thu, 3 Oct 2024 18:31:42 -0700 Subject: [PATCH 1/3] initial implementation of fix/wildlife --- changelog.txt | 1 + docs/fix/wildlife.rst | 63 ++++++++++++ fix/wildlife.lua | 149 ++++++++++++++++++++++++++++ internal/control-panel/registry.lua | 2 + 4 files changed, 215 insertions(+) create mode 100644 docs/fix/wildlife.rst create mode 100644 fix/wildlife.lua 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..b92b23579 --- /dev/null +++ b/docs/fix/wildlife.rst @@ -0,0 +1,63 @@ +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). + +Usage +----- +:: + + fix/wildlife [] + +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. + +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..7ad024f65 --- /dev/null +++ b/fix/wildlife.lua @@ -0,0 +1,149 @@ +--@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 + +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 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 dfhack.units.isDead(unit) or + not dfhack.units.isActive(unit) or + not dfhack.units.isWildlife(unit) or + not unit.flags2.roaming_wilderness_population_source 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 + +unstick_surface_wildlife(opts) 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, From 353871bbb08bb69926b606150a3cfee07d057899 Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Thu, 3 Oct 2024 19:47:34 -0700 Subject: [PATCH 2/3] implement fix/wildlife ignore --- docs/fix/wildlife.rst | 7 +++++++ fix/wildlife.lua | 43 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/docs/fix/wildlife.rst b/docs/fix/wildlife.rst index b92b23579..648f1f4dd 100644 --- a/docs/fix/wildlife.rst +++ b/docs/fix/wildlife.rst @@ -31,12 +31,15 @@ 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 -------- @@ -48,6 +51,10 @@ Examples 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 ------- diff --git a/fix/wildlife.lua b/fix/wildlife.lua index 7ad024f65..cbd5e13b2 100644 --- a/fix/wildlife.lua +++ b/fix/wildlife.lua @@ -61,6 +61,13 @@ local function refund_population(entry) 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 @@ -88,16 +95,18 @@ local function to_key(pop) 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 + 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 dfhack.units.isDead(unit) or - not dfhack.units.isActive(unit) or - not dfhack.units.isWildlife(unit) or - not unit.flags2.roaming_wilderness_population_source or - unit.animal.leave_countdown > 0 - then + 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 @@ -146,4 +155,24 @@ if positionals[1] == 'help' or opts.help then return end -unstick_surface_wildlife(opts) +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 From 5e7a7a01470e74319852dd876ce5a7cb055cee43 Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Fri, 4 Oct 2024 15:59:10 -0700 Subject: [PATCH 3/3] prepare to be called by force (the script) --- fix/wildlife.lua | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/fix/wildlife.lua b/fix/wildlife.lua index cbd5e13b2..bd80f2433 100644 --- a/fix/wildlife.lua +++ b/fix/wildlife.lua @@ -102,6 +102,17 @@ local function is_active_wildlife(unit) 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)