Skip to content
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

initial implementation of fix/wildlife #1316

Merged
merged 3 commits into from
Oct 4, 2024
Merged
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
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
70 changes: 70 additions & 0 deletions docs/fix/wildlife.rst
Original file line number Diff line number Diff line change
@@ -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 [<options>]
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.
189 changes: 189 additions & 0 deletions fix/wildlife.lua
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions internal/control-panel/registry.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down