Skip to content

[starvingdead] properly handle state loading #1440

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

Merged
merged 1 commit into from
Apr 25, 2025
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
2 changes: 2 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Template for new versions:
## New Features

## 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

## Misc Improvements

Expand Down
6 changes: 3 additions & 3 deletions docs/starvingdead.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ gradually decay, losing strength, speed, and toughness. After six months,
they collapse upon themselves, never to be reanimated.

Strength lost is proportional to the time until death, all units will have
roughly 10% of each of their attributes' values when close to being removed.
roughly 10% of each of their attributes' values when they succumb to decay.

In any game, this can be a welcome gameplay feature, but it is especially
useful in preventing undead cascades in the caverns in reanimating biomes,
where constant combat can lead to hundreds of undead roaming the caverns and
useful in preventing undead cascades in the caverns in reanimating biomes.
Constant combat can lead to hundreds of undead roaming the caverns and
destroying your FPS.

Usage
Expand Down
23 changes: 13 additions & 10 deletions emigration.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ local unit_link_utils = reqscript('internal/emigration/unit-link-utils')
local GLOBAL_KEY = 'emigration' -- used for state change hooks and persistence

local function get_default_state()
return {enabled=false, last_cycle_tick=0}
return {
enabled=false,
last_cycle_tick=0
}
end

state = state or get_default_state()
Expand Down Expand Up @@ -127,15 +130,15 @@ function checkmigrationnow()
end

local function event_loop()
if state.enabled then
local current_tick = dfhack.world.ReadCurrentTick() + TICKS_PER_YEAR * dfhack.world.ReadCurrentYear()
if current_tick - state.last_cycle_tick < TICKS_PER_MONTH then
local timeout_ticks = state.last_cycle_tick - current_tick + TICKS_PER_MONTH
dfhack.timeout(timeout_ticks, 'ticks', event_loop)
else
checkmigrationnow()
dfhack.timeout(1, 'months', event_loop)
end
if not state.enabled then return end

local current_tick = dfhack.world.ReadCurrentTick() + TICKS_PER_YEAR * dfhack.world.ReadCurrentYear()
if current_tick - state.last_cycle_tick < TICKS_PER_MONTH then
local timeout_ticks = state.last_cycle_tick - current_tick + TICKS_PER_MONTH
dfhack.timeout(timeout_ticks, 'ticks', event_loop)
else
checkmigrationnow()
dfhack.timeout(1, 'months', event_loop)
end
end

Expand Down
217 changes: 121 additions & 96 deletions starvingdead.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,128 +3,153 @@
--@module = true

local argparse = require('argparse')
local utils = require('utils')

local GLOBAL_KEY = 'starvingdead'

starvingDeadInstance = starvingDeadInstance or nil
local function get_default_state()
return {
enabled=false,
decay_rate=1,
death_threshold=6,
last_cycle_tick=0,
}
end

state = state or get_default_state()

function isEnabled()
return starvingDeadInstance ~= nil
return state.enabled
end

local function persist_state()
dfhack.persistent.saveSiteData(GLOBAL_KEY, {
enabled = isEnabled(),
decay_rate = starvingDeadInstance and starvingDeadInstance.decay_rate or 1,
death_threshold = starvingDeadInstance and starvingDeadInstance.death_threshold or 6
})
dfhack.persistent.saveSiteData(GLOBAL_KEY, state)
end

dfhack.onStateChange[GLOBAL_KEY] = function(sc)
if sc == SC_MAP_UNLOADED then
enabled = false
return
end

if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then
return
end

local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {})

if persisted_data.enabled then
starvingDeadInstance = StarvingDead{
decay_rate = persisted_data.decay_rate,
death_threshold = persisted_data.death_threshold
}
end
end

StarvingDead = defclass(StarvingDead)
StarvingDead.ATTRS{
decay_rate = 1,
death_threshold = 6,
}

function StarvingDead:init()
self.timeout_id = nil
-- Percentage goal each attribute should reach before death.
local attribute_goal = 10
self.attribute_decay = (attribute_goal ^ (1 / ((self.death_threshold * 28 / self.decay_rate)))) / 100

self:checkDecay()
print(([[StarvingDead started, checking every %s days and killing off at %s months]]):format(self.decay_rate, self.death_threshold))
end

function StarvingDead:checkDecay()
for _, unit in pairs(df.global.world.units.active) do
if (unit.enemy.undead and not unit.flags1.inactive) then
-- time_on_site is measured in ticks, a month is 33600 ticks.
-- @see https://dwarffortresswiki.org/index.php/Time
for _, attribute in pairs(unit.body.physical_attrs) do
attribute.value = math.floor(attribute.value - (attribute.value * self.attribute_decay))
end

if unit.curse.interaction.time_on_site > (self.death_threshold * 33600) then
unit.animal.vanish_countdown = 1
end
-- threshold each attribute should reach before death.
local ATTRIBUTE_THRESHOLD_PERCENT = 10

local TICKS_PER_DAY = 1200
local TICKS_PER_MONTH = 28 * TICKS_PER_DAY
local TICKS_PER_YEAR = 12 * TICKS_PER_MONTH

local function do_decay()
local decay_exponent = state.decay_rate / (state.death_threshold * 28)
local attribute_decay = (ATTRIBUTE_THRESHOLD_PERCENT ^ decay_exponent) / 100

for _, unit in pairs(df.global.world.units.active) do
if (unit.enemy.undead and not unit.flags1.inactive) then
for _,attribute in pairs(unit.body.physical_attrs) do
attribute.value = math.floor(attribute.value - (attribute.value * attribute_decay))
end

if unit.curse.interaction.time_on_site > (state.death_threshold * TICKS_PER_MONTH) then
unit.animal.vanish_countdown = 1
end
end
end
end
end

self.timeout_id = dfhack.timeout(self.decay_rate, 'days', self:callback('checkDecay'))
local function get_normalized_tick()
return dfhack.world.ReadCurrentTick() + TICKS_PER_YEAR * dfhack.world.ReadCurrentYear()
end

if dfhack_flags.module then
return
timeout_id = timeout_id or nil

local function event_loop()
if not state.enabled then return end

local current_tick = get_normalized_tick()
local ticks_per_cycle = TICKS_PER_DAY * state.decay_rate
local timeout_ticks = ticks_per_cycle

if current_tick - state.last_cycle_tick < ticks_per_cycle then
timeout_ticks = state.last_cycle_tick - current_tick + ticks_per_cycle
else
do_decay()
state.last_cycle_tick = current_tick
persist_state()
end
timeout_id = dfhack.timeout(timeout_ticks, 'ticks', event_loop)
end

local options, args = {
decay_rate = nil,
death_threshold = nil
}, {...}
local function do_enable()
if state.enabled then return end

local positionals = argparse.processArgsGetopt(args, {
{'h', 'help', handler = function() options.help = true end},
{'r', 'decay-rate', hasArg = true, handler=function(arg) options.decay_rate = argparse.positiveInt(arg, 'decay-rate') end },
{'t', 'death-threshold', hasArg = true, handler=function(arg) options.death_threshold = argparse.positiveInt(arg, 'death-threshold') end },
})
state.enabled = true
state.last_cycle_tick = get_normalized_tick()
event_loop()
end

if dfhack_flags.enable then
if dfhack_flags.enable_state then
if starvingDeadInstance then
return
local function do_disable()
if not state.enabled then return end

state.enabled = false
if timeout_id then
dfhack.timeout_active(timeout_id, nil)
timeout_id = nil
end
end

starvingDeadInstance = StarvingDead{}
persist_state()
else
if not starvingDeadInstance then
return
dfhack.onStateChange[GLOBAL_KEY] = function(sc)
if sc == SC_MAP_UNLOADED then
do_disable()
return
end

dfhack.timeout_active(starvingDeadInstance.timeout_id, nil)
starvingDeadInstance = nil
end
else
if not dfhack.isMapLoaded() then
qerror('This script requires a fortress map to be loaded')
end
if sc ~= SC_MAP_LOADED or not dfhack.world.isFortressMode() then
return
end

if positionals[1] == "help" or options.help then
print(dfhack.script_help())
state = get_default_state()
utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state))

event_loop()
end

if dfhack_flags.module then
return
end
end

if positionals[1] == nil then
if starvingDeadInstance then
starvingDeadInstance.decay_rate = options.decay_rate or starvingDeadInstance.decay_rate
starvingDeadInstance.death_threshold = options.death_threshold or starvingDeadInstance.death_threshold
if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then
qerror('This script requires a fortress map to be loaded')
end

print(([[StarvingDead is running, checking every %s days and killing off at %s months]]):format(
starvingDeadInstance.decay_rate, starvingDeadInstance.death_threshold
))
if dfhack_flags.enable then
if dfhack_flags.enable_state then
do_enable()
else
print("StarvingDead is not running!")
do_disable()
end
end
end

local opts = {}
local positionals = argparse.processArgsGetopt({...}, {
{'h', 'help', handler=function() opts.help = true end},
{'r', 'decay-rate', hasArg=true,
handler=function(arg) opts.decay_rate = argparse.positiveInt(arg, 'decay-rate') end },
{'t', 'death-threshold', hasArg=true,
handler=function(arg) opts.death_threshold = argparse.positiveInt(arg, 'death-threshold') end },
})


if positionals[1] == "help" or opts.help then
print(dfhack.script_help())
return
end

if opts.decay_rate then
state.decay_rate = opts.decay_rate
end
if opts.death_threshold then
state.death_threshold = opts.death_threshold
end
persist_state()

if state.enabled then
print(([[StarvingDead is running, decaying undead every %s day%s and killing off at %s month%s]]):format(
state.decay_rate, state.decay_rate == 1 and '' or 's', state.death_threshold, state.death_threshold == 1 and '' or 's'))
else
print(([[StarvingDead is not running, but would decay undead every %s day%s and kill off at %s month%s]]):format(
state.decay_rate, state.decay_rate == 1 and '' or 's', state.death_threshold, state.death_threshold == 1 and '' or 's'))
end