Skip to content
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
152 changes: 152 additions & 0 deletions autosave.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
-- Automatically save the game on a real-time schedule.
--@module = true
--@enable = true

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

local GLOBAL_KEY = 'autosave' -- used for state change hooks and persistence
local CONFIG_FILE_PATH = 'dfhack-config/autosave.json'

local DEFAULT_INTERVAL_MINUTES = 30

-- how often (in rendered frames) to check whether a save is due. rendered
-- frames keep ticking while the game is paused, so the interval stays in
-- real time
local POLL_FRAMES = 100

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

state = state or get_default_state()
config = config or json.open(CONFIG_FILE_PATH)

function isEnabled()
return state.enabled
end

local function persist_state()
dfhack.persistent.saveSiteData(GLOBAL_KEY, state)
end

local function get_interval_minutes()
return config.data.interval_minutes or DEFAULT_INTERVAL_MINUTES
end

-- a save is in flight from when it is requested until the saver finishes;
-- save_progress.substage keeps its final value (Finishing) after completion
local function is_save_in_progress()
local main = df.global.plotinfo.main
return main.autosave_request or
(main.save_progress.substage >= df.save_substage.Initializing and
main.save_progress.substage < df.save_substage.Finishing)
end

local function save_now()
dfhack.run_script('quicksave')
end

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

if dfhack.persistent.getUnsavedSeconds() >= get_interval_minutes() * 60 and
dfhack.isMapLoaded() and dfhack.world.isFortressMode() and
not is_save_in_progress() then
save_now()
end

timeout_id = dfhack.timeout(POLL_FRAMES, 'frames', event_loop)
end

local function do_enable()
if state.enabled then return end
state.enabled = true
event_loop()
end

local function do_disable()
if not state.enabled then return end
state.enabled = false
if timeout_id then
dfhack.timeout_active(timeout_id, nil) -- cancel callback
timeout_id = nil
end
end

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

if sc ~= SC_MAP_LOADED then
return
end

state = get_default_state()
utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state))
event_loop()
end

local function status()
print(('autosave is %s'):format(state.enabled and 'enabled' or 'disabled'))
local interval = get_interval_minutes()
print(('autosave interval: %s minute%s'):format(
interval, interval == 1 and '' or 's'))
if dfhack.isMapLoaded() then
local unsaved_min = dfhack.persistent.getUnsavedSeconds() // 60
print(('time since last save: %d minute%s'):format(
unsaved_min, unsaved_min == 1 and '' or 's'))
end
end

if dfhack_flags.module then
return
end

if dfhack_flags.enable then
if dfhack_flags.enable_state then
do_enable()
else
do_disable()
end
persist_state()
end

local help = false
local positionals = argparse.processArgsGetopt({...}, {
{'h', 'help', handler=function() help = true end},
})

local command = table.remove(positionals, 1)
if help or command == 'help' then
print(dfhack.script_help())
return
end

if not command or command == 'status' then
status()
elseif command == 'set' then
local minutes = tonumber(positionals[1])
if not minutes or minutes <= 0 then
qerror('interval must be a positive number of minutes')
end
config.data.interval_minutes = minutes
config:write()
print(('autosave interval set to %s minute%s'):format(
minutes, minutes == 1 and '' or 's'))
elseif command == 'now' then
if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then
qerror('a fortress must be loaded to save')
end
if is_save_in_progress() then
qerror('a save is already in progress')
end
save_now()
else
qerror(('unrecognized command: "%s"'):format(command))
end
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
- `autosave`: automatically save the game every N minutes of real time

## New Features

Expand Down
37 changes: 37 additions & 0 deletions docs/autosave.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
autosave
========

.. dfhack-tool::
:summary: Automatically save the game on a real-time schedule.
:tags: fort gameplay

When enabled, ``autosave`` periodically checks how much real time has passed
since the game was last saved (or loaded), and runs `quicksave` once the
configured interval has elapsed.

Unlike the vanilla seasonal autosaves, the interval is measured in real time
rather than game time, and it keeps counting even while the game is paused.
The interval applies globally across all your forts and worlds, while the
enabled state is remembered per fort.

Usage
-----

::

enable autosave
autosave [status]
autosave set <minutes>
autosave now

``autosave`` or ``autosave status``
Show whether autosave is enabled, the configured interval, and how much
time has passed since the last save.
``autosave set <minutes>``
Set how often the game is saved, in minutes of real time. The default is
30 minutes.
``autosave now``
Save the game immediately.

You can also enable and disable ``autosave`` on the Automation tab of the
DFHack control panel.
1 change: 1 addition & 0 deletions internal/control-panel/registry.lua
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ COMMANDS_BY_IDX = {
desc='Automatically milk creatures that are ready for milking.',
params={'--time', '14', '--timeUnits', 'days', '--command', '[', 'workorder', '"{\\"job\\":\\"MilkCreature\\",\\"item_conditions\\":[{\\"condition\\":\\"AtLeast\\",\\"value\\":2,\\"flags\\":[\\"empty\\"],\\"item_type\\":\\"BUCKET\\"}]}"', ']'}},
{command='autonestbox', group='automation', mode='enable'},
{command='autosave', group='automation', mode='enable'},
{command='autoshear', help_command='workorder', group='automation', mode='repeat',
desc='Automatically shear creatures that are ready for shearing.',
params={'--time', '14', '--timeUnits', 'days', '--command', '[', 'workorder', 'ShearCreature', ']'}},
Expand Down
57 changes: 57 additions & 0 deletions test/autosave.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
config = {
mode = 'fortress',
target = 'autosave',
}

local autosave = reqscript('autosave')

-- saving can take a while for large forts
local SAVE_TIMEOUT_FRAMES = 6000

local function wait_for_save()
-- the unsaved time counter resets when a save completes, so it decreasing
-- below the value captured before triggering the save means it finished
local before = dfhack.persistent.getUnsavedSeconds()
return function()
return dfhack.persistent.getUnsavedSeconds() < before
end
end

config.wrapper = function(test_fn)
-- dfhack.run_script is patched during tests to use a test-local script
-- env, but enable/disable go through dfhack.enable_script and act on the
-- real env, so drive the real env directly with run_script_with_env
local orig_interval = autosave.config.data.interval_minutes
dfhack.enable_script('autosave', false)
local ok, err = pcall(test_fn)
dfhack.enable_script('autosave', false)
dfhack.run_script_with_env(nil, 'autosave', {}, 'set', tostring(orig_interval or 30))
if not ok then error(err) end
end

function test.now_saves_game()
-- wait a few seconds so that the unsaved time is distinguishably positive
delay_until(function()
return dfhack.persistent.getUnsavedSeconds() >= 3
end, 1000)
dfhack.run_script('autosave', 'now')
delay_until(wait_for_save(), SAVE_TIMEOUT_FRAMES)
end

function test.save_fires_when_enabled()
-- wait a few seconds so that the unsaved time is distinguishably positive
-- and its reset on save is observable
delay_until(function()
return dfhack.persistent.getUnsavedSeconds() >= 3
end, 1000)
dfhack.run_script_with_env(nil, 'autosave', {}, 'set', '0.001')
dfhack.enable_script('autosave', true)
delay_until(wait_for_save(), SAVE_TIMEOUT_FRAMES)
end

function test.no_save_when_disabled()
local before = dfhack.persistent.getUnsavedSeconds()
-- poll interval is 100 frames; wait well past it
delay(500)
expect.true_(dfhack.persistent.getUnsavedSeconds() >= before)
end