diff --git a/autosave.lua b/autosave.lua new file mode 100644 index 0000000000..2c2e9db95a --- /dev/null +++ b/autosave.lua @@ -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 diff --git a/changelog.txt b/changelog.txt index b492b6d2ec..c0f4d2148d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -27,6 +27,7 @@ Template for new versions: # Future ## New Tools +- `autosave`: automatically save the game every N minutes of real time ## New Features diff --git a/docs/autosave.rst b/docs/autosave.rst new file mode 100644 index 0000000000..0765028be7 --- /dev/null +++ b/docs/autosave.rst @@ -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 + 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 `` + 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. diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index 0759ed398c..84b21de980 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -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', ']'}}, diff --git a/test/autosave.lua b/test/autosave.lua new file mode 100644 index 0000000000..947a5940f4 --- /dev/null +++ b/test/autosave.lua @@ -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