diff --git a/docs/changelog.txt b/docs/changelog.txt index 097b46db0b..c73ec83982 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -55,6 +55,7 @@ Template for new versions: # Future ## New Tools +- `stockflow`: (reinstated) queue manager jobs based on free space in stockpiles - `smooth-movement`: smoothly animate creature, item, and vehicle movement in the fortress viewport - `export-world-map`: Export world map data for GIS and other external tools. diff --git a/docs/plugins/stockflow.rst b/docs/plugins/stockflow.rst index 95eba213c1..a32591d63f 100644 --- a/docs/plugins/stockflow.rst +++ b/docs/plugins/stockflow.rst @@ -3,21 +3,21 @@ stockflow .. dfhack-tool:: :summary: Queue manager jobs based on free space in stockpiles. - :tags: unavailable + :tags: fort auto workorders stockpile -With this plugin, the fortress bookkeeper can tally up free space in specific -stockpiles and queue jobs through the manager to produce items to fill the free +With this plugin, you can tally up free space in specific stockpiles and +automatically queue jobs through the manager to produce items to fill the free space. -When the plugin is enabled, the :kbd:`q` menu of each stockpile will have two -new options: +When the plugin is enabled, selecting a stockpile in the UI will show the +stockflow overlay panel with two options: * :kbd:`j`: Select a job to order, from an interface like the manager's screen. * :kbd:`J`: Cycle between several options for how many such jobs to order. -Whenever the bookkeeper updates stockpile records, new work orders will -be placed on the manager's queue for each such selection, reduced by the -number of identical orders already in the queue. +About once per in-game day, new work orders will be placed on the manager's +queue for each such selection, reduced by the number of identical orders +already in the queue. This plugin is similar to `workflow`, but uses stockpiles to manage job triggers instead of abstract stock quantities. @@ -31,5 +31,3 @@ Usage Display whether the plugin is enabled. ``stockflow list`` List any work order settings for your stockpiles. -``stockflow fast`` - Enqueue orders once per day instead of waiting for the bookkeeper. diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index c584028fba..45290fbc3c 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -124,7 +124,7 @@ if(BUILD_SUPPORTED) #dfhack_plugin(steam-engine steam-engine.cpp) add_subdirectory(smooth-movement) dfhack_plugin(spectate spectate.cpp LINK_LIBRARIES lua) - #dfhack_plugin(stockflow stockflow.cpp LINK_LIBRARIES lua) + dfhack_plugin(stockflow stockflow.cpp LINK_LIBRARIES lua) add_subdirectory(stockpiles) dfhack_plugin(stocks stocks.cpp LINK_LIBRARIES lua) dfhack_plugin(strangemood strangemood.cpp) diff --git a/plugins/lua/stockflow.lua b/plugins/lua/stockflow.lua index dc44d23045..d1880cf310 100644 --- a/plugins/lua/stockflow.lua +++ b/plugins/lua/stockflow.lua @@ -1,11 +1,31 @@ local _ENV = mkmodule('plugins.stockflow') local gui = require "gui" +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') reaction_list = reaction_list or {} saved_orders = saved_orders or {} jobs_to_create = jobs_to_create or {} +local PERSIST_KEY = 'stockflow/orders' + +local function serialize_orders(orders) + local stored = {} + for spid, spec in pairs(orders) do + stored[tostring(spid)] = { + name = spec.name, + order_number = spec.order_number, + trigger_number = spec.trigger_number, + } + end + return stored +end + +local function persist_orders() + dfhack.persistent.saveSiteData(PERSIST_KEY, serialize_orders(saved_orders)) +end + triggers = { {filled = false, divisor = 1, name = "Per empty space"}, {filled = true, divisor = 1, name = "Per stored item"}, @@ -18,12 +38,6 @@ triggers = { {name = "Never"}, } -entry_ints = { - stockpile_id = 1, - order_number = 2, - trigger_number = 3, -} - FirstRow = 3 CenterCol = 38 ExtraLines = 9 @@ -49,8 +63,8 @@ function clear_caches() jobs_to_create = {} end -function trigger_name(cache) - local trigger = triggers[cache.entry.ints[entry_ints.trigger_number]] +function trigger_name(spec) + local trigger = triggers[spec.trigger_number] return trigger and trigger.name or "Never" end @@ -58,7 +72,7 @@ function list_orders() local listed = false for _, spec in pairs(saved_orders) do local num = spec.stockpile.stockpile_number - local name = spec.entry.value + local name = spec.name local trigger = trigger_name(spec) print("Stockpile #"..num, name, trigger) listed = true @@ -66,12 +80,12 @@ function list_orders() if not listed then print("No manager jobs have been set for your stockpiles.") - print("Use j in a stockpile menu to create one...") + print("Select a stockpile and use the stockflow overlay to create one...") end end --- Save the stockpile jobs for later creation. --- Called when the bookkeeper starts updating stockpile records. +-- Gather the stockpile jobs for later creation. +-- Called at the start of each periodic update. function start_bookkeeping() local result = {} for reaction_id, quantity in pairs(check_stockpiles()) do @@ -84,8 +98,8 @@ function start_bookkeeping() jobs_to_create = result end --- Insert any saved jobs. --- Called when the bookkeeper finishes updating stockpile records. +-- Insert the gathered jobs. +-- Called at the end of each periodic update. function finish_bookkeeping() for reaction, amount in pairs(jobs_to_create) do create_orders(reaction_list[reaction].order, amount) @@ -100,65 +114,75 @@ function stockpile_settings(sp) return "No job selected", "" end - return order.entry.value, trigger_name(order) + return order.name, trigger_name(order) end -- Toggle the trigger condition for a stockpile. function toggle_trigger(sp) local saved = saved_orders[sp.id] if saved then - saved.entry.ints[entry_ints.trigger_number] = (saved.entry.ints[entry_ints.trigger_number] % #triggers) + 1 - saved.entry:save() + saved.trigger_number = (saved.trigger_number % #triggers) + 1 + persist_orders() end end function collect_orders() local result = {} - local entries = dfhack.persistent.get_all("stockflow/entry", true) - if entries then - for _, entry in ipairs(entries) do - local spid = entry.ints[entry_ints.stockpile_id] - local stockpile = df.building.find(spid) - if stockpile then - local order_number = entry.ints[entry_ints.order_number] - if reaction_list[order_number] and entry.value == reaction_list[order_number].name then - result[spid] = { - stockpile = stockpile, - entry = entry, - } - else - -- Todo: Search reaction_list for the name. - -- This can happen when loading an old save in a new version. - -- It's even possible that the reaction has been removed. - local found = false - for number, reaction in ipairs(reaction_list) do - if reaction.name == entry.value then - print("Adjusting stockflow entry for stockpile #"..stockpile.stockpile_number..": "..entry.value.." ("..order_number.." => "..number..")") - entry.ints[entry_ints.order_number] = number - entry:save() - result[spid] = { - stockpile = stockpile, - entry = entry, - } - - found = true - break - end + local dirty = false + local stored = dfhack.persistent.getSiteData(PERSIST_KEY) or {} + for spid_str, spec in pairs(stored) do + local spid = tonumber(spid_str) + local stockpile = spid and df.building.find(spid) + if stockpile and not df.building_stockpilest:is_instance(stockpile) then + stockpile = nil + end + if stockpile then + local order_number = spec.order_number + local trigger_number = spec.trigger_number or 1 + if reaction_list[order_number] and spec.name == reaction_list[order_number].name then + result[spid] = { + stockpile = stockpile, + name = spec.name, + order_number = order_number, + trigger_number = trigger_number, + } + else + -- Search reaction_list for the name. + -- This can happen when loading an old save in a new version. + -- It's even possible that the reaction has been removed. + local found = false + for number, reaction in ipairs(reaction_list) do + if reaction.name == spec.name then + print("Adjusting stockflow entry for stockpile #"..stockpile.stockpile_number..": "..spec.name.." ("..tostring(order_number).." => "..number..")") + result[spid] = { + stockpile = stockpile, + name = spec.name, + order_number = number, + trigger_number = trigger_number, + } + + dirty = true + found = true + break end + end - if not found then - print("Unmatched stockflow entry for stockpile #"..stockpile.stockpile_number..": "..entry.value.." ("..order_number..")") - end + if not found then + print("Unmatched stockflow entry for stockpile #"..stockpile.stockpile_number..": "..tostring(spec.name).." ("..tostring(order_number)..")") + dirty = true end - else - -- The stockpile no longer exists. - -- Perhaps it has been deleted, or perhaps this is a different fortress. - -- print("Missing stockflow pile "..spid) - entry:delete() end + else + -- The stockpile no longer exists. + -- Perhaps it has been deleted, or perhaps this is a different fortress. + dirty = true end end + if dirty then + dfhack.persistent.saveSiteData(PERSIST_KEY, serialize_orders(result)) + end + return result end @@ -178,6 +202,7 @@ function reaction_entry(reactions, job_type, values, name) local order = df.manager_order:new() -- These defaults differ from the newly created order's. order:assign{ + frequency = df.workquota_frequency_type.OneTime, job_type = job_type, item_type = -1, item_subtype = -1, @@ -819,7 +844,7 @@ function screen:onRenderBody(dc) dc:string(": Select", COLOR_WHITE) dc:seek(CenterCol, FirstRow + self.page_size + 2) - dc:key("SETUPGAME_SAVE_PROFILE_ABORT"):string(": No order", COLOR_WHITE) + dc:key("CUSTOM_SHIFT_N"):string(": No order", COLOR_WHITE) -- Reaction lines. for _, item in ipairs(self.displayed) do @@ -836,7 +861,7 @@ function screen:onInput(keys) if selected then store_order(self.stockpile, selected.index) end - elseif keys.SETUPGAME_SAVE_PROFILE_ABORT then + elseif keys.CUSTOM_SHIFT_N then self:dismiss() clear_order(self.stockpile) elseif keys.STANDARDSCROLL_UP then @@ -986,10 +1011,9 @@ function screen:refilter() end function clear_order(stockpile) - local saved = saved_orders[stockpile.id] - if saved then - saved.entry:delete() + if saved_orders[stockpile.id] then saved_orders[stockpile.id] = nil + persist_orders() end end @@ -998,23 +1022,17 @@ function store_order(stockpile, order_number) -- print("Setting stockpile #"..stockpile.stockpile_number.." to "..name.." (#"..order_number..")") local saved = saved_orders[stockpile.id] if saved then - saved.entry.value = name - saved.entry.ints[entry_ints.order_number] = order_number - saved.entry:save() + saved.name = name + saved.order_number = order_number else saved_orders[stockpile.id] = { stockpile = stockpile, - entry = dfhack.persistent.save{ - key = "stockflow/entry/"..stockpile.id, - value = name, - ints = { - stockpile.id, - order_number, - 1, - }, - }, + name = name, + order_number = order_number, + trigger_number = 1, } end + persist_orders() end -- Compare the job specification of two orders. @@ -1066,6 +1084,7 @@ end -- Place a new copy of the order onto the manager's queue. function create_orders(order, amount) local new_order = order:new() + new_order.frequency = df.workquota_frequency_type.OneTime amount = math.floor(amount) new_order.amount_left = amount new_order.amount_total = amount @@ -1104,9 +1123,9 @@ end function check_stockpiles(verbose) local result = {} for _, spec in pairs(saved_orders) do - local trigger = triggers[spec.entry.ints[entry_ints.trigger_number]] + local trigger = triggers[spec.trigger_number] if trigger and trigger.divisor then - local reaction = spec.entry.ints[entry_ints.order_number] + local reaction = spec.order_number local filled, empty = check_pile(spec.stockpile, verbose) local amount = math.floor((trigger.filled and filled or empty) / trigger.divisor) result[reaction] = (result[reaction] or 0) + amount @@ -1201,4 +1220,66 @@ function matches_stockpile(item, settings) return true end +-------------------- +-- Overlay +-------------------- + +local function selected_stockpile() + return dfhack.gui.getSelectedStockpile(true) +end + +StockflowOverlay = defclass(StockflowOverlay, overlay.OverlayWidget) +StockflowOverlay.ATTRS{ + desc='Displays and adjusts stockflow work order settings for the selected stockpile.', + default_pos={x=5, y=-7}, + default_enabled=true, + version=1, + viewscreens='dwarfmode/Stockpile/Some/Default', + visible=isEnabled, + frame={w=55, h=4}, +} + +function StockflowOverlay:init() + self:addviews{ + widgets.Panel{ + frame={t=0, l=0, r=0, b=0}, + frame_style=gui.MEDIUM_FRAME, + frame_title='Stockflow', + frame_background=gui.CLEAR_PEN, + subviews={ + widgets.HotkeyLabel{ + frame={t=0, l=0, r=0}, + key='CUSTOM_J', + label=function() + local sp = selected_stockpile() + local name = sp and stockpile_settings(sp) or 'No job selected' + return 'Order: '..name + end, + on_activate=function() + local sp = selected_stockpile() + if sp then select_order(sp) end + end, + }, + widgets.HotkeyLabel{ + frame={t=1, l=0, r=0}, + key='CUSTOM_SHIFT_J', + label=function() + local sp = selected_stockpile() + if not sp then return 'Trigger: Never' end + local _, trigger = stockpile_settings(sp) + if #trigger == 0 then trigger = 'Never' end + return 'Trigger: '..trigger + end, + on_activate=function() + local sp = selected_stockpile() + if sp then toggle_trigger(sp) end + end, + }, + }, + }, + } +end + +OVERLAY_WIDGETS = {stockflow=StockflowOverlay} + return _ENV diff --git a/plugins/stockflow.cpp b/plugins/stockflow.cpp index 524bd16896..a14aafadcc 100644 --- a/plugins/stockflow.cpp +++ b/plugins/stockflow.cpp @@ -4,31 +4,22 @@ * or set AUTOENABLE to true. */ -#include "uicommon.h" #include "LuaTools.h" +#include "PluginManager.h" -#include "df/building_stockpilest.h" -#include "df/job.h" -#include "df/viewscreen_dwarfmodest.h" - -#include "modules/Gui.h" #include "modules/Maps.h" #include "modules/World.h" -using namespace DFHack; -using namespace std; +#include "df/global_objects.h" +#include "df/world.h" -using df::building_stockpilest; +using namespace DFHack; DFHACK_PLUGIN("stockflow"); #define AUTOENABLE false DFHACK_PLUGIN_IS_ENABLED(enabled); -REQUIRE_GLOBAL(gps); REQUIRE_GLOBAL(world); -REQUIRE_GLOBAL(plotinfo); - -bool fast = false; /* * Lua interface. @@ -37,50 +28,18 @@ bool fast = false; class LuaHelper { public: void cycle(color_ostream &out) { - bool found = false; - - if (fast) { - // Ignore the bookkeeper; either gather or enqueue orders every cycle. - found = !bookkeeping; - } else { - // Gather orders when the bookkeeper starts updating stockpile records, - // and enqueue them when the job is done. - for (df::job_list_link* link = &world->jobs.list; link != NULL; link = link->next) { - if (link->item == NULL) continue; - if (link->item->job_type == job_type::UpdateStockpileRecords) { - found = true; - break; - } - } - } - - if (found) { - // Entice the bookkeeper to spend less time update records. - plotinfo->nobles.bookkeeper_precision += plotinfo->nobles.bookkeeper_precision >> 3; - if (!bookkeeping) { - command_method("start_bookkeeping", out); - bookkeeping = true; - } - } else { - // Entice the bookkeeper to update records more often. - plotinfo->nobles.bookkeeper_precision -= plotinfo->nobles.bookkeeper_precision >> 5; - plotinfo->nobles.bookkeeper_cooldown -= plotinfo->nobles.bookkeeper_cooldown >> 2; - if (bookkeeping) { - command_method("finish_bookkeeping", out); - bookkeeping = false; - } - } + // Gather orders and enqueue them in a single pass. This used to be + // gated on the bookkeeper's UpdateStockpileRecords job, but that job + // does not run reliably due to a base game bug. + command_method("start_bookkeeping", out); + command_method("finish_bookkeeping", out); } void init() { - stockpile_id = -1; initialized = false; - bookkeeping = false; } bool reset(color_ostream &out, bool load) { - stockpile_id = -1; - bookkeeping = false; if (load) { return initialized = command_method("initialize_world", out); } else if (initialized) { @@ -93,122 +52,18 @@ class LuaHelper { bool command_method(const char *method, color_ostream &out) { // Calls a lua function with no parameters. - - auto L = Lua::Core::State; - Lua::StackUnwinder top(L); - - if (!lua_checkstack(L, 1)) - return false; - - if (!Lua::PushModulePublic(out, L, "plugins.stockflow", method)) - return false; - - if (!Lua::SafeCall(out, L, 0, 0)) - return false; - - return true; - } - - bool stockpile_method(const char *method, building_stockpilest *sp) { - // Combines the select_order and toggle_trigger method calls, - // because they share the same signature. - auto L = Lua::Core::State; - color_ostream_proxy out(Core::getInstance().getConsole()); - - Lua::StackUnwinder top(L); - - if (!lua_checkstack(L, 2)) - return false; - - if (!Lua::PushModulePublic(out, L, "plugins.stockflow", method)) - return false; - - Lua::Push(L, sp); - - if (!Lua::SafeCall(out, L, 1, 0)) - return false; - - // Invalidate the string cache. - stockpile_id = -1; - - return true; - } - - bool collect_settings(building_stockpilest *sp) { - // Find strings representing the job to order, and the trigger condition. - // There might be a memory leak here; C++ is odd like that. - auto L = Lua::Core::State; - color_ostream_proxy out(Core::getInstance().getConsole()); - - CoreSuspender suspend; - Lua::StackUnwinder top(L); - - if (!lua_checkstack(L, 2)) - return false; - - if (!Lua::PushModulePublic(out, L, "plugins.stockflow", "stockpile_settings")) - return false; - - Lua::Push(L, sp); - - if (!Lua::SafeCall(out, L, 1, 2)) - return false; - - if (!lua_isstring(L, -1)) - return false; - - current_trigger = lua_tostring(L, -1); - lua_pop(L, 1); - - if (!lua_isstring(L, -1)) - return false; - - current_job = lua_tostring(L, -1); - lua_pop(L, 1); - - stockpile_id = sp->id; - - return true; - } - - void draw(building_stockpilest *sp) { - if (sp->id != stockpile_id) { - if (!collect_settings(sp)) { - Core::printerr("Stockflow job collection failed!\n"); - return; - } - } - - auto dims = Gui::getDwarfmodeViewDims(); - int left_margin = dims.menu_x1 + 1; - int x = left_margin; - int y = dims.y2 - 2; // below autodump, automelt, autotrade, stocks, stockpiles - - int links = 0; - links += sp->links.give_to_pile.size(); - links += sp->links.take_from_pile.size(); - links += sp->links.give_to_workshop.size(); - links += sp->links.take_from_workshop.size(); - if (links + 12 >= y) - y += 1; - - OutputHotkeyString(x, y, current_job, "j", true, left_margin, COLOR_WHITE, COLOR_LIGHTRED); - if (*current_trigger) - OutputHotkeyString(x, y, current_trigger, " J", true, left_margin, COLOR_WHITE, COLOR_LIGHTRED); + return Lua::CallLuaModuleFunction(out, "plugins.stockflow", method); } - private: - long stockpile_id; bool initialized; - bool bookkeeping; - const char *current_job; - const char *current_trigger; }; static LuaHelper helper; -#define DELTA_TICKS 600 +// Once per in-game day, more or less. A prime number is used so that +// periodic tools don't all fire on the same tick. +constexpr auto DELTA_TICKS = 1187; DFhackCExport command_result plugin_onupdate(color_ostream &out) { if (!Maps::IsValid()) @@ -225,69 +80,7 @@ DFhackCExport command_result plugin_onupdate(color_ostream &out) { return CR_OK; } - -/* - * Interface hooks - */ -struct stockflow_hook : public df::viewscreen_dwarfmodest { - typedef df::viewscreen_dwarfmodest interpose_base; - - bool handleInput(set *input) { - if (Gui::inRenameBuilding()) - return false; - - building_stockpilest *sp = get_selected_stockpile(); - if (!sp) - return false; - - if (input->count(interface_key::CUSTOM_J)) { - // Select a new order for this stockpile. - if (!helper.stockpile_method("select_order", sp)) { - Core::printerr("Stockflow order selection failed!\n"); - } - - return true; - } else if (input->count(interface_key::CUSTOM_SHIFT_J)) { - // Toggle the order trigger for this stockpile. - if (!helper.stockpile_method("toggle_trigger", sp)) { - Core::printerr("Stockflow trigger toggle failed!\n"); - } - - return true; - } - - return false; - } - - DEFINE_VMETHOD_INTERPOSE(void, feed, (set *input)) { - if (!handleInput(input)) - INTERPOSE_NEXT(feed)(input); - } - - DEFINE_VMETHOD_INTERPOSE(void, render, ()) { - INTERPOSE_NEXT(render)(); - - building_stockpilest *sp = get_selected_stockpile(); - if (sp) - helper.draw(sp); - } -}; - -IMPLEMENT_VMETHOD_INTERPOSE(stockflow_hook, feed); -IMPLEMENT_VMETHOD_INTERPOSE(stockflow_hook, render); - - -static bool apply_hooks(color_ostream &out, bool enabling) { - if (enabling && !gps) { - out.printerr("Stockflow needs graphics.\n"); - return false; - } - - if (!INTERPOSE_HOOK(stockflow_hook, feed).apply(enabling) || !INTERPOSE_HOOK(stockflow_hook, render).apply(enabling)) { - out.printerr("Could not {} stockflow hooks!\n", enabling? "insert": "remove"); - return false; - } - +static bool apply_state(color_ostream &out, bool enabling) { if (!helper.reset(out, enabling && Maps::IsValid())) { out.printerr("Could not reset stockflow world data!\n"); return false; @@ -296,18 +89,13 @@ static bool apply_hooks(color_ostream &out, bool enabling) { return true; } -static command_result stockflow_cmd(color_ostream &out, vector & parameters) { +static command_result stockflow_cmd(color_ostream &out, std::vector & parameters) { bool desired = enabled; if (parameters.size() == 1) { if (parameters[0] == "enable" || parameters[0] == "on" || parameters[0] == "1") { desired = true; - fast = false; } else if (parameters[0] == "disable" || parameters[0] == "off" || parameters[0] == "0") { desired = false; - fast = false; - } else if (parameters[0] == "fast" || parameters[0] == "always" || parameters[0] == "2") { - desired = true; - fast = true; } else if (parameters[0] == "usage" || parameters[0] == "help" || parameters[0] == "?") { return CR_WRONG_USAGE; } else if (parameters[0] == "list") { @@ -331,12 +119,12 @@ static command_result stockflow_cmd(color_ostream &out, vector & parame } if (desired != enabled) { - if (!apply_hooks(out, desired)) { + if (!apply_state(out, desired)) { return CR_FAILURE; } } - out.print("Stockflow is {} {}{}.\n", (desired == enabled)? "currently": "now", desired? "enabled": "disabled", fast? ", in fast mode": ""); + out.print("Stockflow is {} {}.\n", (desired == enabled)? "currently": "now", desired? "enabled": "disabled"); enabled = desired; return CR_OK; } @@ -362,7 +150,7 @@ DFhackCExport command_result plugin_enable(color_ostream& out, bool enable) { /* Accept the "enable stockflow"/"disable stockflow" syntax, where available. */ /* Same as "stockflow enable"/"stockflow disable" except without the status line. */ if (enable != enabled) { - if (!apply_hooks(out, enable)) { + if (!apply_state(out, enable)) { return CR_FAILURE; } @@ -375,7 +163,7 @@ DFhackCExport command_result plugin_enable(color_ostream& out, bool enable) { DFhackCExport command_result plugin_init(color_ostream &out, std::vector &commands) { helper.init(); if (AUTOENABLE) { - if (!apply_hooks(out, true)) { + if (!apply_state(out, true)) { return CR_FAILURE; }