Merge branch 'develop' into translate-name

develop
Kelly Kinkade 2023-10-14 11:22:59 -05:00 committed by GitHub
commit 2d20e790b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1696 additions and 126 deletions

@ -8,8 +8,8 @@ project(dfhack)
# set up versioning.
set(DF_VERSION "50.11")
set(DFHACK_RELEASE "r1")
set(DFHACK_PRERELEASE FALSE)
set(DFHACK_RELEASE "r2rc1")
set(DFHACK_PRERELEASE TRUE)
set(DFHACK_VERSION "${DF_VERSION}-${DFHACK_RELEASE}")
set(DFHACK_ABI_VERSION 1)

@ -52,22 +52,39 @@ Template for new versions:
# Future
## New Tools
- `spectate`: automatically follow productive dwarves (returned to availability)
- `spectate`: (reinstated) automatically follow dwarves, cycling among interesting ones
- `preserve-tombs`: keep tombs assigned to units when they die
## New Features
- `logistics`: ``automelt`` now optionally supports melting masterworks; click on gear icon on `stockpiles` overlay frame
- `sort`: new search widgets for Info panel tabs, including all "Creatures" subtabs, all "Objects" subtabs, "Tasks", candidate assignment on the "Noble" subtab, and the "Work details" subtab under "Labor"
- `sort`: new search and filter widgets for the "Interrogate" and "Convict" screens under "Justice"
- `sort`: new search widgets for location selection screen (when you're choosing what kind of guildhall or temple to dedicate)
- `sort`: new search widgets for burrow assignment screen and other unit assignment dialogs
- `sort`: new search widgets for artifacts on the world/raid screen
## Fixes
- `zone`: races without specific child or baby names will now get generic child/baby names instead of an empty string
- `zone`: don't show animal assignment link for cages and restraints linked to dungeon zones (which aren't normally assignable)
- `dwarfvet`: fix invalid job id assigned to ``Rest`` job, which could cause crashes on reload
## Misc Improvements
- Translate: will use DF's ``translate_name`` function, if available, instead of the DFHack emulation
- `overlay`: allow ``overlay_onupdate_max_freq_seconds`` to be dynamically set to 0 for a burst of high-frequency updates
- `orders`: ``recheck`` command now only resets orders that have conditions that can be rechecked
- `sort`: added help button for squad assignment search/filter/sort
- `zone`: animals trained for war or hunting are now labeled as such in animal assignment screens
## Documentation
## API
- Translate: will use DF's ``translate_name`` function, if available, instead of the DFHack emulation
## Lua
- added ``GRAY`` color aliases for ``GREY`` colors
- ``utils.search_text``: text search routine (generalized from internal ``widgets.FilteredList`` logic)
## Removed
- ``FILTER_FULL_TEXT``: moved from ``gui.widgets`` to ``utils``; if your full text search preference is lost, please reset it in `gui/control-panel`
# 50.11-r1

@ -684,7 +684,7 @@ Persistent configuration storage
--------------------------------
This api is intended for storing configuration options in the world itself.
It probably should be restricted to data that is world-dependent.
It is intended for data that is world-dependent.
Entries are identified by a string ``key``, but it is also possible to manage
multiple entries with the same key; their identity is determined by ``entry_id``.
@ -717,10 +717,8 @@ Every entry has a mutable string ``value``, and an array of 7 mutable ``ints``.
otherwise the existing one is simply updated.
Returns *entry, did_create_new*
Since the data is hidden in data structures owned by the DF world,
and automatically stored in the save game, these save and retrieval
functions can just copy values in memory without doing any actual I/O.
However, currently every entry has a 180+-byte dead-weight overhead.
The data is kept in memory, so no I/O occurs when getting or saving keys. It is
all written to a json file in the game save directory when the game is saved.
It is also possible to associate one bit per map tile with an entry,
using these two methods:
@ -1611,7 +1609,8 @@ Units module
* ``dfhack.units.getReadableName(unit)``
Returns a string that includes the language name of the unit (if any), the
race of the unit, and any syndrome-given descriptions (such as "necromancer").
race of the unit, whether it is trained for war or hunting, and any
syndrome-given descriptions (such as "necromancer").
* ``dfhack.units.getStressCategory(unit)``
@ -3083,6 +3082,9 @@ environment by the mandatory init file dfhack.lua:
COLOR_LIGHTBLUE, COLOR_LIGHTGREEN, COLOR_LIGHTCYAN, COLOR_LIGHTRED,
COLOR_LIGHTMAGENTA, COLOR_YELLOW, COLOR_WHITE
``COLOR_GREY`` and ``COLOR_DARKGREY`` can also be spelled ``COLOR_GRAY`` and
``COLOR_DARKGRAY``.
* State change event codes, used by ``dfhack.onStateChange``
Available only in the `core context <lua-core-context>`, as is the event itself:
@ -3339,6 +3341,20 @@ utils
Exactly like ``erase_sorted_key``, but if field is specified, takes the key from ``item[field]``.
* ``utils.search_text(text,search_tokens)``
Returns true if all the search tokens are found within ``text``. The text and
search tokens are normalized to lower case and special characters (e.g. ``A``
with a circle on it) are converted to their "basic" forms (e.g. ``a``).
``search_tokens`` can be a string or a table of strings. If it is a string,
it is split into space-separated tokens before matching. The search tokens
are treated literally, so any special regular expression characters do not
need to be escaped. If ``utils.FILTER_FULL_TEXT`` is ``true``, then the
search tokens can match any part of ``text``. If it is ``false``, then the
matches must happen at the beginning of words within ``text``. You can change
the value of ``utils.FILTER_FULL_TEXT`` in `gui/control-panel` on the
"Preferences" tab.
* ``utils.call_with_string(obj,methodname,...)``
Allocates a temporary string object, calls ``obj:method(tmp,...)``, and
@ -5293,12 +5309,11 @@ FilteredList class
------------------
This widget combines List, EditField and Label into a combo-box like
construction that allows filtering the list by subwords of its items.
construction that allows filtering the list.
In addition to passing through all attributes supported by List, it
supports:
:case_sensitive: If ``true``, matching is case sensitive. Defaults to ``false``.
:edit_pen: If specified, used instead of ``cursor_pen`` for the edit field.
:edit_below: If true, the edit field is placed below the list instead of above.
:edit_key: If specified, the edit field is disabled until this key is pressed.
@ -5347,9 +5362,9 @@ Filter behavior:
By default, the filter matches substrings that start at the beginning of a word
(or after any punctuation). You can instead configure filters to match any
substring with a command like::
substring across the full text with a command like::
:lua require('gui.widgets').FILTER_FULL_TEXT=true
:lua require('utils').FILTER_FULL_TEXT=true
TabBar class
------------

@ -135,7 +135,10 @@ The ``overlay.OverlayWidget`` superclass defines the following class attributes:
seconds) that your widget can take to react to changes in information and
not annoy the player. Set to 0 to be called at the maximum rate. Be aware
that running more often than you really need to will impact game FPS,
especially if your widget can run while the game is unpaused.
especially if your widget can run while the game is unpaused. If you change
the value of this attribute dynamically, it may not be noticed until the
previous timeout expires. However, if you need a burst of high-frequency
updates, set it to ``0`` and it will be noticed immediately.
Registering a widget with the overlay framework
***********************************************

@ -147,15 +147,15 @@ Designation options:
Other options:
``--zdown``, ``-d``
``-d``, ``--zdown``
Only designates tiles on the cursor's z-level and below.
``--zup``, ``-u``
``-u``, ``--zup``
Only designates tiles on the cursor's z-level and above.
``--cur-zlevel``, ``-z``
``-z``, ``--cur-zlevel``
Only designates tiles on the same z-level as the cursor.
``--hidden``, ``-h``
``-h``, ``--hidden``
Allows designation of hidden tiles, and picking a hidden tile as the target type.
``--no-auto``, ``-a``
``-a``, ``--no-auto``
No automatic mining mode designation - useful if you want to avoid dwarves digging where you don't want them.
digexp

@ -72,3 +72,7 @@ Options
Causes the command to act upon stockpiles with the given names or numbers
instead of the stockpile that is currently selected in the UI. Note that
the numbers are the stockpile numbers, not the building ids.
``-m``, ``--melt-masterworks``
If specified with a ``logistics add melt`` command, will configure the
stockpile to allow melting of masterworks. By default, masterworks are not
marked for melting, even if they are in an automelt stockpile.

@ -18,12 +18,13 @@ Usage
``orders clear``
Deletes all manager orders in the current embark.
``orders recheck [this]``
Sets the status to ``Checking`` (from ``Active``) for all work orders. if the
"this" option is passed, only sets the status for the workorder whose condition
details page is open. This makes the manager reevaluate its conditions.
This is especially useful for an order that had its conditions met when it
was started, but the requisite items have since disappeared and the workorder
is now generating job cancellation spam.
Sets the status to ``Checking`` (from ``Active``) for all work orders that
have conditions that can be re-checked. If the "this" option is passed,
only sets the status for the workorder whose condition details page is
open. This makes the manager reevaluate its conditions. This is especially
useful for an order that had its conditions met when it was started, but
the requisite items have since disappeared and the workorder is now
generating job cancellation spam.
``orders sort``
Sorts current manager orders by repeat frequency so repeating orders don't
prevent one-time orders from ever being completed. The sorting order is:

@ -0,0 +1,23 @@
preserve-tombs
==============
.. dfhack-tool::
:summary: Preserve tomb assignments when assigned units die.
:tags: fort bugfix
If you find that the tombs you assign to units get unassigned from them when
they die (e.g. your nobles), this tool can help fix that.
Usage
-----
``enable preserve-tombs``
enable the plugin
``preserve-tombs [status]``
check the status of the plugin, and if the plugin is enabled,
lists all currently tracked tomb assignments
``preserve-tombs now``
forces an immediate update of the tomb assignments. This plugin
automatically updates the tomb assignments once every 100 ticks.
This tool runs in the background.

@ -91,3 +91,49 @@ and "ranged combat potential" are explained in detail here:
https://www.reddit.com/r/dwarffortress/comments/163kczo/enhancing_military_candidate_selection_part_3/
"Mental stability" is explained here:
https://www.reddit.com/r/dwarffortress/comments/1617s11/enhancing_military_candidate_selection_part_2/
Info tabs overlay
-----------------
The Info overlay adds search support to many of the fort-wide "Info" panels
(e.g. "Creatures", "Tasks", etc.). When searching for units, you can search by
name (with either English or native language last names), profession, or
special status (like "necromancer"). If there is text in the second column, you
can search for that text as well. This is often a job name or a status, like
"caged".
Interrogation overlay
---------------------
In the interrogation and conviction screens under the "Justice" tab, you can
search for units by name. You can also filter by the classification of the
unit. The classification groups are ordered by how likely a member of that
group is to be involved in a plot. The groups are: All, Risky visitors, Other
visitors, Residents, Citizens, Animals, Deceased, and Others. "Risky" visitors are those who are especially likely to be involved in plots, such as criminals,
necromancers, necromancer experiments, and intelligent undead.
On the interrogations screen, you can also filter units by whether they have
already been interrogated.
Candidates overlay
------------------
When you select the button to choose a candidate to assign to a noble role on
the nobles screen, you can search for units by name, profession, or any of the
skills in which they have achieved at least "novice" level. For example, when
assigning a broker, you can search for "appraisal" to find candidates that have
at least some appraisal skill.
Location selection overlay
--------------------------
When choosing the type of guildhall or temple to dedicate, you can search for
the relevant profession, religion, or deity by name. For temples, you can also
search for the "spheres" associated with the deity or religion, such as
"wealth" or "lies".
World overlay
-------------
Searching is supported for the Artifacts list when viewing the world map (where
you can initiate raids).

@ -100,7 +100,13 @@ Overlay
This plugin provides a panel that appears when you select a stockpile via an
`overlay` widget. You can use it to easily toggle `logistics` plugin features
like autotrade, automelt, or autotrain.
like autotrade, automelt, or autotrain. There are also buttons along the top frame for:
- minimizing the panel (if it is in the way of the vanilla stockpile
configuration widgets)
- showing help for the overlay widget in `gui/launcher` (this page)
- configuring advanced settings for the stockpile, such as whether automelt
will melt masterworks
.. _stockpiles-library:

@ -38,6 +38,9 @@ COLOR_LIGHTMAGENTA = 13
COLOR_YELLOW = 14
COLOR_WHITE = 15
COLOR_GRAY = COLOR_GREY
COLOR_DARKGRAY = COLOR_DARKGREY
-- Events
if dfhack.is_core_context then

@ -14,9 +14,15 @@ CLEAR_PEN = to_pen{tile=dfhack.internal.getAddress('init') and df.global.init.te
TRANSPARENT_PEN = to_pen{tile=0, ch=0}
KEEP_LOWER_PEN = to_pen{ch=32, fg=0, bg=0, keep_lower=true}
local function set_and_get_undo(field, is_set)
local prev_value = df.global.enabler[field]
df.global.enabler[field] = is_set and 1 or 0
return function() df.global.enabler[field] = prev_value end
end
local MOUSE_KEYS = {
_MOUSE_L = function(is_set) df.global.enabler.mouse_lbut = is_set and 1 or 0 end,
_MOUSE_R = function(is_set) df.global.enabler.mouse_rbut = is_set and 1 or 0 end,
_MOUSE_L = curry(set_and_get_undo, 'mouse_lbut'),
_MOUSE_R = curry(set_and_get_undo, 'mouse_rbut'),
_MOUSE_M = true,
_MOUSE_L_DOWN = true,
_MOUSE_R_DOWN = true,
@ -61,12 +67,16 @@ function simulateInput(screen,...)
end
end
end
local undo_fns = {}
for mk, fn in pairs(MOUSE_KEYS) do
if type(fn) == 'function' then
fn(enabled_mouse_keys[mk])
table.insert(undo_fns, fn(enabled_mouse_keys[mk]))
end
end
dscreen._doSimulateInput(screen, keys)
for _, undo_fn in ipairs(undo_fns) do
undo_fn()
end
end
function mkdims_xy(x1,y1,x2,y2)

@ -739,12 +739,6 @@ function EditField:onInput(keys)
end
end
return not not self.key
elseif keys._MOUSE_L_DOWN then
local mouse_x = self:getMousePos()
if mouse_x then
self:setCursor(self.start_pos + mouse_x - (self.text_offset or 0))
return true
end
elseif keys._STRING then
local old = self.text
if keys._STRING == 0 then
@ -795,6 +789,12 @@ function EditField:onInput(keys)
elseif keys.CUSTOM_CTRL_V then
self:insert(dfhack.internal.getClipboardTextCp437())
return true
elseif keys._MOUSE_L_DOWN then
local mouse_x = self:getMousePos()
if mouse_x then
self:setCursor(self.start_pos + mouse_x - (self.text_offset or 0))
return true
end
end
-- if we're modal, then unconditionally eat all the input
@ -2017,12 +2017,9 @@ end
-- Filtered List --
-------------------
FILTER_FULL_TEXT = false
FilteredList = defclass(FilteredList, Widget)
FilteredList.ATTRS {
case_sensitive = false,
edit_below = false,
edit_key = DEFAULT_NIL,
edit_ignore_keys = DEFAULT_NIL,
@ -2172,7 +2169,6 @@ function FilteredList:setFilter(filter, pos)
pos = nil
for i,v in ipairs(self.choices) do
local ok = true
local search_key = v.search_key
if not search_key then
if type(v.text) ~= 'table' then
@ -2187,30 +2183,7 @@ function FilteredList:setFilter(filter, pos)
search_key = table.concat(texts, ' ')
end
end
for _,key in ipairs(tokens) do
key = key:escape_pattern()
if key ~= '' then
search_key = dfhack.toSearchNormalized(search_key)
key = dfhack.toSearchNormalized(key)
if not self.case_sensitive then
search_key = string.lower(search_key)
key = string.lower(key)
end
-- the separate checks for non-space or non-punctuation allows
-- punctuation itself to be matched if that is useful (e.g.
-- filenames or parameter names)
if not FILTER_FULL_TEXT and not search_key:match('%f[^%p\x00]'..key)
and not search_key:match('%f[^%s\x00]'..key) then
ok = false
break
elseif FILTER_FULL_TEXT and not search_key:find(key) then
ok = false
break
end
end
end
if ok then
if utils.search_text(search_key, tokens) then
table.insert(choices, v)
cidx[#choices] = i
if ipos == i then

@ -460,6 +460,32 @@ function erase_sorted(vector,item,field,cmp)
return erase_sorted_key(vector,key,field,cmp)
end
FILTER_FULL_TEXT = false
function search_text(text, search_tokens)
text = dfhack.toSearchNormalized(text)
if type(search_tokens) ~= 'table' then
search_tokens = search_tokens:split()
end
for _,search_token in ipairs(search_tokens) do
if search_token == '' then goto continue end
search_token = dfhack.toSearchNormalized(search_token:escape_pattern())
-- the separate checks for non-space or non-punctuation allows
-- punctuation itself to be matched if that is useful (e.g.
-- filenames or parameter names)
if not FILTER_FULL_TEXT and not text:match('%f[^%p\x00]'..search_token)
and not text:match('%f[^%s\x00]'..search_token) then
return false
elseif FILTER_FULL_TEXT and not text:find(search_token) then
return false
end
::continue::
end
return true
end
-- Calls a method with a string temporary
function call_with_string(obj,methodname,...)
return dfhack.with_temp_object(

@ -89,6 +89,7 @@ using namespace DFHack;
#include "df/viewscreen_new_regionst.h"
#include "df/viewscreen_setupdwarfgamest.h"
#include "df/viewscreen_titlest.h"
#include "df/viewscreen_worldst.h"
#include "df/world.h"
const size_t MAX_REPORTS_SIZE = 3000; // DF clears old reports to maintain this vector size
@ -224,6 +225,11 @@ DEFINE_GET_FOCUS_STRING_HANDLER(legends)
focusStrings.push_back(baseFocus + '/' + screen->page[screen->active_page_index]->header);
}
DEFINE_GET_FOCUS_STRING_HANDLER(world)
{
focusStrings.push_back(baseFocus + '/' + enum_item_key(screen->view_mode));
}
DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode)
{
std::string newFocusString;
@ -240,6 +246,13 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode)
switch(game->main_interface.info.current_mode) {
case df::enums::info_interface_mode_type::CREATURES:
if (game->main_interface.info.creatures.showing_overall_training)
newFocusString += "/OverallTraining";
else if (game->main_interface.info.creatures.showing_activity_details)
newFocusString += "/ActivityDetails";
else if (game->main_interface.info.creatures.adding_trainer)
newFocusString += "/AddingTrainer";
else
newFocusString += '/' + enum_item_key(game->main_interface.info.creatures.current_mode);
break;
case df::enums::info_interface_mode_type::BUILDINGS:
@ -252,6 +265,11 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode)
newFocusString += '/' + enum_item_key(game->main_interface.info.artifacts.mode);
break;
case df::enums::info_interface_mode_type::JUSTICE:
if (game->main_interface.info.justice.interrogating)
newFocusString += "/Interrogating";
else if (game->main_interface.info.justice.convicting)
newFocusString += "/Convicting";
else
newFocusString += '/' + enum_item_key(game->main_interface.info.justice.current_mode);
break;
case df::enums::info_interface_mode_type::WORK_ORDERS:
@ -262,6 +280,14 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode)
else
newFocusString += "/Default";
break;
case df::enums::info_interface_mode_type::ADMINISTRATORS:
if (game->main_interface.info.administrators.choosing_candidate)
newFocusString += "/Candidates";
else if (game->main_interface.info.administrators.assigning_symbol)
newFocusString += "/Symbols";
else
newFocusString += "/Default";
break;
default:
break;
}
@ -556,7 +582,13 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode)
}
if (game->main_interface.location_selector.open) {
newFocusString = baseFocus;
newFocusString += "/LocationSelector";
newFocusString += "/LocationSelector/";
if (game->main_interface.location_selector.choosing_temple_religious_practice)
newFocusString += "Temple";
else if (game->main_interface.location_selector.choosing_craft_guild)
newFocusString += "Guildhall";
else
newFocusString += "Default";
focusStrings.push_back(newFocusString);
}
if (game->main_interface.location_details.open) {

@ -1225,8 +1225,12 @@ string Units::getRaceBabyNameById(int32_t id)
if (id >= 0 && (size_t)id < world->raws.creatures.all.size())
{
df::creature_raw* raw = world->raws.creatures.all[id];
if (raw)
return raw->general_baby_name[0];
if (raw) {
string & baby_name = raw->general_baby_name[0];
if (!baby_name.empty())
return baby_name;
return getRaceReadableNameById(id) + " baby";
}
}
return "";
}
@ -1242,8 +1246,12 @@ string Units::getRaceChildNameById(int32_t id)
if (id >= 0 && (size_t)id < world->raws.creatures.all.size())
{
df::creature_raw* raw = world->raws.creatures.all[id];
if (raw)
return raw->general_child_name[0];
if (raw) {
string & child_name = raw->general_child_name[0];
if (!child_name.empty())
return child_name;
return getRaceReadableNameById(id) + " child";
}
}
return "";
}
@ -1266,7 +1274,14 @@ static string get_caste_name(df::unit* unit) {
}
string Units::getReadableName(df::unit* unit) {
string race_name = isChild(unit) ? getRaceChildName(unit) : get_caste_name(unit);
string race_name = isBaby(unit) ? getRaceBabyName(unit) :
(isChild(unit) ? getRaceChildName(unit) : get_caste_name(unit));
if (race_name.empty())
race_name = getRaceReadableName(unit);
if (isHunter(unit))
race_name = "hunter " + race_name;
if (isWar(unit))
race_name = "war " + race_name;
string name = Translation::TranslateName(getVisibleName(unit));
if (name.empty()) {
name = race_name;

@ -1 +1 @@
Subproject commit aeab463a0d35ac9ff896db840735cabfa12df712
Subproject commit a598bc6770199e9b965e00d0eade3f8400c4be9e

@ -144,6 +144,7 @@ if(BUILD_SUPPORTED)
dfhack_plugin(pathable pathable.cpp LINK_LIBRARIES lua)
#dfhack_plugin(petcapRemover petcapRemover.cpp)
#dfhack_plugin(plants plants.cpp)
dfhack_plugin(preserve-tombs preserve-tombs.cpp)
dfhack_plugin(probe probe.cpp)
dfhack_plugin(prospector prospector.cpp LINK_LIBRARIES lua)
#dfhack_plugin(power-meter power-meter.cpp LINK_LIBRARIES lua)

@ -147,14 +147,14 @@ command_result df_cleanowned (color_ostream &out, vector <string> & parameters)
out.print(
"[%d] %s (wear level %d)",
item->id,
description.c_str(),
DF2CONSOLE(description).c_str(),
item->getWear()
);
df::unit *owner = Items::getOwner(item);
if (owner)
out.print(", owner %s", Translation::TranslateName(&owner->name,false).c_str());
out.print(", owner %s", DF2CONSOLE(Translation::TranslateName(&owner->name,false)).c_str());
if (!dry_run)
{

@ -44,6 +44,7 @@ enum StockpileConfigValues {
STOCKPILE_CONFIG_TRADE = 2,
STOCKPILE_CONFIG_DUMP = 3,
STOCKPILE_CONFIG_TRAIN = 4,
STOCKPILE_CONFIG_MELT_MASTERWORKS = 5,
};
static int get_config_val(PersistentDataItem& c, int index) {
@ -81,6 +82,7 @@ static PersistentDataItem& ensure_stockpile_config(color_ostream& out, int stock
set_config_bool(c, STOCKPILE_CONFIG_TRADE, false);
set_config_bool(c, STOCKPILE_CONFIG_DUMP, false);
set_config_bool(c, STOCKPILE_CONFIG_TRAIN, false);
set_config_bool(c, STOCKPILE_CONFIG_MELT_MASTERWORKS, false);
return c;
}
@ -259,8 +261,8 @@ public:
class MeltStockProcessor : public StockProcessor {
public:
MeltStockProcessor(int32_t stockpile_number, bool enabled, ProcessorStats &stats)
: StockProcessor("melt", stockpile_number, enabled, stats) { }
MeltStockProcessor(int32_t stockpile_number, bool enabled, ProcessorStats &stats, bool melt_masterworks)
: StockProcessor("melt", stockpile_number, enabled, stats), melt_masterworks(melt_masterworks) { }
bool is_designated(color_ostream &out, df::item *item) override {
return item->flags.bits.melt;
@ -294,7 +296,9 @@ public:
}
}
if (item->getQuality() >= df::item_quality::Masterful)
if (!melt_masterworks && item->getQuality() >= df::item_quality::Masterful)
return false;
if (item->flags.bits.artifact)
return false;
return true;
@ -305,6 +309,9 @@ public:
item->flags.bits.melt = 1;
return true;
}
private:
const bool melt_masterworks;
};
class TradeStockProcessor: public StockProcessor {
@ -519,11 +526,12 @@ static void do_cycle(color_ostream& out, int32_t& melt_count, int32_t& trade_cou
int32_t stockpile_number = bld->stockpile_number;
bool melt = get_config_bool(c, STOCKPILE_CONFIG_MELT);
bool melt_masterworks = get_config_bool(c, STOCKPILE_CONFIG_MELT_MASTERWORKS);
bool trade = get_config_bool(c, STOCKPILE_CONFIG_TRADE);
bool dump = get_config_bool(c, STOCKPILE_CONFIG_DUMP);
bool train = get_config_bool(c, STOCKPILE_CONFIG_TRAIN);
MeltStockProcessor melt_stock_processor(stockpile_number, melt, melt_stats);
MeltStockProcessor melt_stock_processor(stockpile_number, melt, melt_stats, melt_masterworks);
TradeStockProcessor trade_stock_processor(stockpile_number, trade, trade_stats);
DumpStockProcessor dump_stock_processor(stockpile_number, dump, dump_stats);
TrainStockProcessor train_stock_processor(stockpile_number, train, train_stats);
@ -555,7 +563,7 @@ static int logistics_getStockpileData(lua_State *L) {
for (auto bld : df::global::world->buildings.other.STOCKPILE) {
int32_t stockpile_number = bld->stockpile_number;
MeltStockProcessor melt_stock_processor(stockpile_number, false, melt_stats);
MeltStockProcessor melt_stock_processor(stockpile_number, false, melt_stats, false);
TradeStockProcessor trade_stock_processor(stockpile_number, false, trade_stats);
DumpStockProcessor dump_stock_processor(stockpile_number, false, dump_stats);
TrainStockProcessor train_stock_processor(stockpile_number, false, train_stats);
@ -581,12 +589,14 @@ static int logistics_getStockpileData(lua_State *L) {
PersistentDataItem &c = entry.second;
bool melt = get_config_bool(c, STOCKPILE_CONFIG_MELT);
bool melt_masterworks = get_config_bool(c, STOCKPILE_CONFIG_MELT_MASTERWORKS);
bool trade = get_config_bool(c, STOCKPILE_CONFIG_TRADE);
bool dump = get_config_bool(c, STOCKPILE_CONFIG_DUMP);
bool train = get_config_bool(c, STOCKPILE_CONFIG_TRAIN);
unordered_map<string, string> config;
config.emplace("melt", melt ? "true" : "false");
config.emplace("melt_masterworks", melt_masterworks ? "true" : "false");
config.emplace("trade", trade ? "true" : "false");
config.emplace("dump", dump ? "true" : "false");
config.emplace("train", train ? "true" : "false");
@ -633,11 +643,13 @@ static unordered_map<string, int> get_stockpile_config(int32_t stockpile_number)
if (watched_stockpiles.count(stockpile_number)) {
PersistentDataItem &c = watched_stockpiles[stockpile_number];
stockpile_config.emplace("melt", get_config_bool(c, STOCKPILE_CONFIG_MELT));
stockpile_config.emplace("melt_masterworks", get_config_bool(c, STOCKPILE_CONFIG_MELT_MASTERWORKS));
stockpile_config.emplace("trade", get_config_bool(c, STOCKPILE_CONFIG_TRADE));
stockpile_config.emplace("dump", get_config_bool(c, STOCKPILE_CONFIG_DUMP));
stockpile_config.emplace("train", get_config_bool(c, STOCKPILE_CONFIG_TRAIN));
} else {
stockpile_config.emplace("melt", false);
stockpile_config.emplace("melt_masterworks", false);
stockpile_config.emplace("trade", false);
stockpile_config.emplace("dump", false);
stockpile_config.emplace("train", false);
@ -666,9 +678,9 @@ static int logistics_getStockpileConfigs(lua_State *L) {
return 1;
}
static void logistics_setStockpileConfig(color_ostream& out, int stockpile_number, bool melt, bool trade, bool dump, bool train) {
DEBUG(status, out).print("entering logistics_setStockpileConfig stockpile_number=%d, melt=%d, trade=%d, dump=%d, train=%d\n",
stockpile_number, melt, trade, dump, train);
static void logistics_setStockpileConfig(color_ostream& out, int stockpile_number, bool melt, bool trade, bool dump, bool train, bool melt_masterworks) {
DEBUG(status, out).print("entering logistics_setStockpileConfig stockpile_number=%d, melt=%d, trade=%d, dump=%d, train=%d, melt_masterworks=%d\n",
stockpile_number, melt, trade, dump, train, melt_masterworks);
if (!find_stockpile(stockpile_number)) {
out.printerr("invalid stockpile number: %d\n", stockpile_number);
@ -677,6 +689,7 @@ static void logistics_setStockpileConfig(color_ostream& out, int stockpile_numbe
auto &c = ensure_stockpile_config(out, stockpile_number);
set_config_bool(c, STOCKPILE_CONFIG_MELT, melt);
set_config_bool(c, STOCKPILE_CONFIG_MELT_MASTERWORKS, melt_masterworks);
set_config_bool(c, STOCKPILE_CONFIG_TRADE, trade);
set_config_bool(c, STOCKPILE_CONFIG_DUMP, dump);
set_config_bool(c, STOCKPILE_CONFIG_TRAIN, train);

@ -151,7 +151,6 @@ function ItemSelection:init()
widgets.FilteredList{
view_id='flist',
frame={t=0, b=0},
case_sensitive=false,
choices=choices,
icon_width=2,
on_submit=self:callback('toggle_group'),

@ -92,13 +92,12 @@ function HospitalZone:assign_spot(unit, unit_pos)
local pos = self:find_spot(unit_pos)
if not pos then return false end
local job = df.new(df.job)
dfhack.job.linkIntoWorld(job)
dfhack.job.linkIntoWorld(job, true)
job.pos.x = pos.x
job.pos.y = pos.y
job.pos.z = pos.z
job.flags.special = true
job.job_type = df.job_type.Rest
job.wait_timer = 1600
local gref = df.new(df.general_ref_unit_workerst)
gref.unit_id = unit.id
job.general_refs:insert('#', gref)

@ -29,6 +29,7 @@ function getStockpileData()
trade=make_stat('trade', stockpile_number, stats, configs),
dump=make_stat('dump', stockpile_number, stats, configs),
train=make_stat('train', stockpile_number, stats, configs),
melt_masterworks=configs[stockpile_number] and configs[stockpile_number].melt_masterworks == 'true',
})
end
table.sort(data, function(a, b) return a.sort_key < b.sort_key end)
@ -41,16 +42,24 @@ local function print_stockpile_data(data)
name_len = math.min(40, math.max(name_len, #sp.name))
end
local has_melt_mastworks = false
print('Designated/designatable items in stockpiles:')
print()
local fmt = '%6s %-' .. name_len .. 's %4s %10s %5s %11s %4s %10s %5s %11s';
print(fmt:format('number', 'name', 'melt', 'melt items', 'trade', 'trade items', 'dump', 'dump items', 'train', 'train items'))
local function uline(len) return ('-'):rep(len) end
print(fmt:format(uline(6), uline(name_len), uline(4), uline(10), uline(5), uline(11), uline(4), uline(10), uline(5), uline(11)))
local function get_enab(stats) return ('[%s]'):format(stats.enabled and 'x' or ' ') end
local function get_enab(stats, ch) return ('[%s]'):format(stats.enabled and (ch or 'x') or ' ') end
local function get_dstat(stats) return ('%d/%d'):format(stats.designated, stats.designated + stats.can_designate) end
for _,sp in ipairs(data) do
print(fmt:format(sp.stockpile_number, sp.name, get_enab(sp.melt), get_dstat(sp.melt), get_enab(sp.trade), get_dstat(sp.trade), get_enab(sp.dump), get_dstat(sp.dump), get_enab(sp.train), get_dstat(sp.train)))
has_melt_mastworks = has_melt_mastworks or sp.melt_masterworks
print(fmt:format(sp.stockpile_number, sp.name, get_enab(sp.melt, sp.melt_masterworks and 'X'), get_dstat(sp.melt),
get_enab(sp.trade), get_dstat(sp.trade), get_enab(sp.dump), get_dstat(sp.dump), get_enab(sp.train), get_dstat(sp.train)))
end
if has_melt_mastworks then
print()
print('An "X" in the "melt" column indicates that masterworks in the stockpile will be melted.')
end
end
@ -101,7 +110,8 @@ local function do_add_stockpile_config(features, opts)
features.melt or config.melt == 1,
features.trade or config.trade == 1,
features.dump or config.dump == 1,
features.train or config.train == 1)
features.train or config.train == 1,
not not opts.melt_masterworks)
end
end
end)
@ -125,6 +135,7 @@ local function process_args(opts, args)
return argparse.processArgsGetopt(args, {
{'h', 'help', handler=function() opts.help = true end},
{'m', 'melt-masterworks', handler=function() opts.melt_masterworks = true end},
{'s', 'stockpile', hasArg=true, handler=function(arg) opts.sp = arg end},
})
end

@ -71,7 +71,7 @@ OrdersOverlay.ATTRS{
default_pos={x=53,y=-6},
default_enabled=true,
viewscreens='dwarfmode/Info/WORK_ORDERS/Default',
frame={w=46, h=4},
frame={w=43, h=4},
}
function OrdersOverlay:init()
@ -99,7 +99,7 @@ function OrdersOverlay:init()
},
widgets.HotkeyLabel{
frame={t=0, l=15},
label='recheck',
label='recheck conditions',
key='CUSTOM_CTRL_K',
auto_width=true,
on_activate=do_recheck,
@ -112,7 +112,7 @@ function OrdersOverlay:init()
on_activate=do_sort,
},
widgets.HotkeyLabel{
frame={t=0, l=31},
frame={t=1, l=28},
label='clear',
key='CUSTOM_CTRL_C',
auto_width=true,
@ -179,10 +179,10 @@ local function set_current_inactive()
end
end
local function is_current_active()
local function can_recheck()
local scrConditions = df.global.game.main_interface.info.work_orders.conditions
local order = scrConditions.wq
return order.status.active
return order.status.active and #order.item_conditions > 0
end
-- -------------------
@ -197,7 +197,7 @@ RecheckOverlay.ATTRS{
default_enabled=true,
viewscreens=focusString,
-- width is the sum of lengths of `[` + `Ctrl+A` + `: ` + button.label + `]`
frame={w=1 + 6 + 2 + 16 + 1, h=3},
frame={w=1 + 6 + 2 + 19 + 1, h=3},
}
local function areTabsInTwoRows()
@ -226,10 +226,10 @@ function RecheckOverlay:init()
widgets.TextButton{
view_id = 'button',
-- frame={t=0, l=0, r=0, h=1}, -- is set in `updateTextButtonFrame()`
label='request re-check',
label='re-check conditions',
key='CUSTOM_CTRL_A',
on_activate=set_current_inactive,
enabled=is_current_active,
enabled=can_recheck,
},
}

@ -433,8 +433,12 @@ end
-- reduces the next call by a small random amount to introduce jitter into the
-- widget processing timings
local function do_update(name, db_entry, now_ms, vs)
if db_entry.next_update_ms > now_ms then return end
local w = db_entry.widget
if w.overlay_onupdate_max_freq_seconds ~= 0 and
db_entry.next_update_ms > now_ms
then
return
end
db_entry.next_update_ms = get_next_onupdate_timestamp(now_ms, w)
if detect_frame_change(w, function() return w:overlay_onupdate(vs) end) then
if register_trigger_lock_screen(w:overlay_trigger(), name) then

@ -3,6 +3,7 @@ local _ENV = mkmodule('plugins.sort')
local gui = require('gui')
local overlay = require('plugins.overlay')
local setbelief = reqscript('modtools/set-belief')
local textures = require('gui.textures')
local utils = require('utils')
local widgets = require('gui.widgets')
@ -275,29 +276,29 @@ local function get_ranged_skill_effectiveness_rating(unit)
return get_rating(ranged_skill_effectiveness(unit), 0, 800000, 72, 52, 31, 11)
end
local function make_sort_by_ranged_skill_effectiveness_desc(list)
local function make_sort_by_ranged_skill_effectiveness_desc()
return function(unit_id_1, unit_id_2)
if unit_id_1 == unit_id_2 then return 0 end
local unit1 = df.unit.find(unit_id_1)
local unit2 = df.unit.find(unit_id_2)
if not unit1 then return -1 end
if not unit2 then return 1 end
local rating1 = ranged_skill_effectiveness(unit1, list)
local rating2 = ranged_skill_effectiveness(unit2, list)
local rating1 = ranged_skill_effectiveness(unit1)
local rating2 = ranged_skill_effectiveness(unit2)
if rating1 == rating2 then return sort_by_name_desc(unit_id_1, unit_id_2) end
return utils.compare(rating2, rating1)
end
end
local function make_sort_by_ranged_skill_effectiveness_asc(list)
local function make_sort_by_ranged_skill_effectiveness_asc()
return function(unit_id_1, unit_id_2)
if unit_id_1 == unit_id_2 then return 0 end
local unit1 = df.unit.find(unit_id_1)
local unit2 = df.unit.find(unit_id_2)
if not unit1 then return -1 end
if not unit2 then return 1 end
local rating1 = ranged_skill_effectiveness(unit1, list)
local rating2 = ranged_skill_effectiveness(unit2, list)
local rating1 = ranged_skill_effectiveness(unit1)
local rating2 = ranged_skill_effectiveness(unit2)
if rating1 == rating2 then return sort_by_name_desc(unit_id_1, unit_id_2) end
return utils.compare(rating1, rating2)
end
@ -630,10 +631,6 @@ SquadAssignmentOverlay.ATTRS{
viewscreens='dwarfmode/UnitSelector/SQUAD_FILL_POSITION',
version='2',
frame={w=38, h=31},
frame_style=gui.FRAME_PANEL,
frame_background=gui.CLEAR_PEN,
autoarrange_subviews=true,
autoarrange_gap=1,
}
-- allow initial spacebar or two successive spacebars to fall through and
@ -660,7 +657,14 @@ function SquadAssignmentOverlay:init()
})
end
self:addviews{
local main_panel = widgets.Panel{
frame={l=0, r=0, t=0, b=0},
frame_style=gui.FRAME_PANEL,
frame_background=gui.CLEAR_PEN,
autoarrange_subviews=true,
autoarrange_gap=1,
}
main_panel:addviews{
widgets.EditField{
view_id='search',
frame={l=0},
@ -939,6 +943,26 @@ function SquadAssignmentOverlay:init()
on_change=function() self:refresh_list() end,
},
}
local button_pen_left = dfhack.pen.parse{fg=COLOR_CYAN,
tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')}
local button_pen_right = dfhack.pen.parse{fg=COLOR_CYAN,
tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')}
local help_pen_center = dfhack.pen.parse{
tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')}
self:addviews{
main_panel,
widgets.Label{
frame={t=0, r=1, w=3},
text={
{tile=button_pen_left},
{tile=help_pen_center},
{tile=button_pen_right},
},
on_click=function() dfhack.run_command('gui/launcher', 'sort ') end,
},
}
end
local function normalize_search_key(search_key)
@ -1261,6 +1285,13 @@ end
OVERLAY_WIDGETS = {
squad_assignment=SquadAssignmentOverlay,
squad_annotation=SquadAnnotationOverlay,
info=require('plugins.sort.info').InfoOverlay,
candidates=require('plugins.sort.info').CandidatesOverlay,
interrogation=require('plugins.sort.info').InterrogationOverlay,
location_selector=require('plugins.sort.locationselector').LocationSelectorOverlay,
unit_selector=require('plugins.sort.unitselector').UnitSelectorOverlay,
worker_assignment=require('plugins.sort.unitselector').WorkerAssignmentOverlay,
world=require('plugins.sort.world').WorldOverlay,
}
dfhack.onStateChange[GLOBAL_KEY] = function(sc)

@ -0,0 +1,514 @@
local _ENV = mkmodule('plugins.sort.info')
local gui = require('gui')
local sortoverlay = require('plugins.sort.sortoverlay')
local widgets = require('gui.widgets')
local utils = require('utils')
local info = df.global.game.main_interface.info
local administrators = info.administrators
local creatures = info.creatures
local justice = info.justice
local objects = info.artifacts
local tasks = info.jobs
local work_details = info.labor.work_details
-- these sort functions attempt to match the vanilla info panel sort behavior, which
-- is not quite the same as the rest of DFHack. For example, in other DFHack sorts,
-- we'd always sort by name descending as a secondary sort. To match vanilla sorting,
-- if the primary sort is ascending, the secondary name sort will also be ascending.
--
-- also note that vanilla sorts are not stable, so there might still be some jitter
-- if the player clicks one of the vanilla sort widgets after searching
local function sort_by_name_desc(a, b)
return a.sort_name < b.sort_name
end
local function sort_by_name_asc(a, b)
return a.sort_name > b.sort_name
end
local function sort_by_prof_desc(a, b)
if a.profession_list_order1 == b.profession_list_order1 then
return sort_by_name_desc(a, b)
end
return a.profession_list_order1 < b.profession_list_order1
end
local function sort_by_prof_asc(a, b)
if a.profession_list_order1 == b.profession_list_order1 then
return sort_by_name_asc(a, b)
end
return a.profession_list_order1 > b.profession_list_order1
end
local function sort_by_job_name_desc(a, b)
if a.job_sort_name == b.job_sort_name then
return sort_by_name_desc(a, b)
end
return a.job_sort_name < b.job_sort_name
end
local function sort_by_job_name_asc(a, b)
if a.job_sort_name == b.job_sort_name then
-- use descending tertiary sort for visual stability
return sort_by_name_desc(a, b)
end
return a.job_sort_name > b.job_sort_name
end
local function sort_by_job_desc(a, b)
if not not a.jb == not not b.jb then
return sort_by_job_name_desc(a, b)
end
return not not a.jb
end
local function sort_by_job_asc(a, b)
if not not a.jb == not not b.jb then
return sort_by_job_name_asc(a, b)
end
return not not b.jb
end
local function sort_by_stress_desc(a, b)
if a.stress == b.stress then
return sort_by_name_desc(a, b)
end
return a.stress > b.stress
end
local function sort_by_stress_asc(a, b)
if a.stress == b.stress then
return sort_by_name_asc(a, b)
end
return a.stress < b.stress
end
local function get_sort()
if creatures.sorting_cit_job then
return creatures.sorting_cit_job_is_ascending and sort_by_job_asc or sort_by_job_desc
elseif creatures.sorting_cit_stress then
return creatures.sorting_cit_stress_is_ascending and sort_by_stress_asc or sort_by_stress_desc
elseif creatures.sorting_cit_nameprof_doing_prof then
return creatures.sorting_cit_nameprof_is_ascending and sort_by_prof_asc or sort_by_prof_desc
else
return creatures.sorting_cit_nameprof_is_ascending and sort_by_name_asc or sort_by_name_desc
end
end
local function get_unit_search_key(unit)
return ('%s %s %s'):format(
dfhack.units.getReadableName(unit), -- last name is in english
dfhack.units.getProfessionName(unit),
dfhack.TranslateName(unit.name, false, true)) -- get untranslated last name
end
local function get_cri_unit_search_key(cri_unit)
return ('%s %s'):format(
cri_unit.un and get_unit_search_key(cri_unit.un) or '',
cri_unit.job_sort_name)
end
local function get_race_name(raw_id)
local raw = df.creature_raw.find(raw_id)
if not raw then return end
return raw.name[1]
end
local function get_trainer_search_key(unit)
if not unit then return end
return ('%s %s'):format(dfhack.TranslateName(unit.name), dfhack.units.getProfessionName(unit))
end
-- get name in both dwarvish and English
local function get_artifact_search_key(artifact)
return ('%s %s'):format(dfhack.TranslateName(artifact.name), dfhack.TranslateName(artifact.name, true))
end
local function work_details_search(vec, data, text, incremental)
if work_details.selected_work_detail_index ~= data.selected then
data.saved_original = nil
data.selected = work_details.selected_work_detail_index
end
sortoverlay.single_vector_search(
{get_search_key_fn=get_unit_search_key},
vec, data, text, incremental)
end
local function restore_allocated_data(vec, data)
if not data.saved_visible or not data.saved_original then return end
for _,elem in ipairs(data.saved_original) do
if not utils.linear_index(data.saved_visible, elem) then
vec:insert('#', elem)
end
end
end
local function serialize_skills(unit)
if not unit or not unit.status or not unit.status.current_soul then
return ''
end
local skills = {}
for _, skill in ipairs(unit.status.current_soul.skills) do
if skill.rating > 0 then -- ignore dabbling
table.insert(skills, df.job_skill[skill.id])
end
end
return table.concat(skills, ' ')
end
local function get_candidate_search_key(cand)
if not cand.un then return end
return ('%s %s'):format(
get_unit_search_key(cand.un),
serialize_skills(cand.un))
end
-- ----------------------
-- InfoOverlay
--
InfoOverlay = defclass(InfoOverlay, sortoverlay.SortOverlay)
InfoOverlay.ATTRS{
default_pos={x=64, y=8},
viewscreens='dwarfmode/Info',
frame={w=40, h=4},
}
function InfoOverlay:init()
self:addviews{
widgets.BannerPanel{
view_id='panel',
frame={l=0, t=0, r=0, h=1},
visible=self:callback('get_key'),
subviews={
widgets.EditField{
view_id='search',
frame={l=1, t=0, r=1},
label_text="Search: ",
key='CUSTOM_ALT_S',
on_change=function(text) self:do_search(text) end,
},
},
},
}
local CRI_UNIT_VECS = {
CITIZEN=creatures.cri_unit.CITIZEN,
PET=creatures.cri_unit.PET,
OTHER=creatures.cri_unit.OTHER,
DECEASED=creatures.cri_unit.DECEASED,
}
for key,vec in pairs(CRI_UNIT_VECS) do
self:register_handler(key, vec,
curry(sortoverlay.single_vector_search,
{
get_search_key_fn=get_cri_unit_search_key,
get_sort_fn=get_sort
}),
curry(restore_allocated_data, vec))
end
self:register_handler('JOBS', tasks.cri_job,
curry(sortoverlay.single_vector_search, {get_search_key_fn=get_cri_unit_search_key}),
curry(restore_allocated_data, tasks.cri_job))
self:register_handler('PET_OT', creatures.atk_index,
curry(sortoverlay.single_vector_search, {get_search_key_fn=get_race_name}))
self:register_handler('PET_AT', creatures.trainer,
curry(sortoverlay.single_vector_search, {get_search_key_fn=get_trainer_search_key}))
self:register_handler('WORK_DETAILS', work_details.assignable_unit, work_details_search)
for idx,name in ipairs(df.artifacts_mode_type) do
if idx < 0 then goto continue end
self:register_handler(name, objects.list[idx],
curry(sortoverlay.single_vector_search, {get_search_key_fn=get_artifact_search_key}))
::continue::
end
end
function InfoOverlay:get_key()
if info.current_mode == df.info_interface_mode_type.CREATURES then
if creatures.current_mode == df.unit_list_mode_type.PET then
if creatures.showing_overall_training then
return 'PET_OT'
elseif creatures.adding_trainer then
return 'PET_AT'
end
end
return df.unit_list_mode_type[creatures.current_mode]
elseif info.current_mode == df.info_interface_mode_type.JOBS then
return 'JOBS'
elseif info.current_mode == df.info_interface_mode_type.ARTIFACTS then
return df.artifacts_mode_type[objects.mode]
elseif info.current_mode == df.info_interface_mode_type.LABOR then
if info.labor.mode == df.labor_mode_type.WORK_DETAILS then
return 'WORK_DETAILS'
end
end
end
local function resize_overlay(self)
local sw = dfhack.screen.getWindowSize()
local overlay_width = math.min(40, sw-(self.frame_rect.x1 + 30))
if overlay_width ~= self.frame.w then
self.frame.w = overlay_width
return true
end
end
local function is_tabs_in_two_rows()
return dfhack.screen.readTile(64, 6, false).ch == 0
end
local function get_panel_offsets()
local tabs_in_two_rows = is_tabs_in_two_rows()
local shift_right = info.current_mode == df.info_interface_mode_type.ARTIFACTS or
info.current_mode == df.info_interface_mode_type.LABOR
local l_offset = (not tabs_in_two_rows and shift_right) and 4 or 0
local t_offset = 1
if tabs_in_two_rows then
t_offset = shift_right and 0 or 3
end
if info.current_mode == df.info_interface_mode_type.JOBS then
t_offset = t_offset - 1
end
return l_offset, t_offset
end
function InfoOverlay:updateFrames()
local ret = resize_overlay(self)
local l, t = get_panel_offsets()
local frame = self.subviews.panel.frame
if frame.l == l and frame.t == t then return ret end
frame.l, frame.t = l, t
return true
end
function InfoOverlay:onRenderBody(dc)
InfoOverlay.super.onRenderBody(self, dc)
if self:updateFrames() then
self:updateLayout()
end
if self.refresh_search then
self.refresh_search = nil
self:do_search(self.subviews.search.text)
end
end
function InfoOverlay:onInput(keys)
if keys._MOUSE_L and self:get_key() == 'WORK_DETAILS' then
self.refresh_search = true
end
return InfoOverlay.super.onInput(self, keys)
end
-- ----------------------
-- CandidatesOverlay
--
CandidatesOverlay = defclass(CandidatesOverlay, sortoverlay.SortOverlay)
CandidatesOverlay.ATTRS{
default_pos={x=54, y=8},
viewscreens='dwarfmode/Info/ADMINISTRATORS/Candidates',
frame={w=27, h=3},
}
function CandidatesOverlay:init()
self:addviews{
widgets.BannerPanel{
view_id='panel',
frame={l=0, t=0, r=0, h=1},
subviews={
widgets.EditField{
view_id='search',
frame={l=1, t=0, r=1},
label_text="Search: ",
key='CUSTOM_ALT_S',
on_change=function(text) self:do_search(text) end,
},
},
},
}
self:register_handler('CANDIDATE', administrators.candidate,
curry(sortoverlay.single_vector_search, {get_search_key_fn=get_candidate_search_key}),
curry(restore_allocated_data, administrators.candidate))
end
function CandidatesOverlay:get_key()
if administrators.choosing_candidate then
return 'CANDIDATE'
end
end
function CandidatesOverlay:updateFrames()
local t = is_tabs_in_two_rows() and 2 or 0
local frame = self.subviews.panel.frame
if frame.t == t then return end
frame.t = t
return true
end
function CandidatesOverlay:onRenderBody(dc)
CandidatesOverlay.super.onRenderBody(self, dc)
if self:updateFrames() then
self:updateLayout()
end
end
-- ----------------------
-- InterrogationOverlay
--
InterrogationOverlay = defclass(InterrogationOverlay, sortoverlay.SortOverlay)
InterrogationOverlay.ATTRS{
default_pos={x=47, y=10},
viewscreens='dwarfmode/Info/JUSTICE',
frame={w=27, h=9},
}
function InterrogationOverlay:init()
self:addviews{
widgets.Panel{
view_id='panel',
frame={l=0, t=4, h=5, r=0},
frame_background=gui.CLEAR_PEN,
frame_style=gui.FRAME_MEDIUM,
visible=self:callback('get_key'),
subviews={
widgets.EditField{
view_id='search',
frame={l=0, t=0, r=0},
label_text="Search: ",
key='CUSTOM_ALT_S',
on_change=function(text) self:do_search(text) end,
},
widgets.ToggleHotkeyLabel{
view_id='include_interviewed',
frame={l=0, t=1, w=23},
key='CUSTOM_SHIFT_I',
label='Interviewed:',
options={
{label='Include', value=true, pen=COLOR_GREEN},
{label='Exclude', value=false, pen=COLOR_RED},
},
visible=function() return justice.interrogating end,
on_change=function() self:do_search(self.subviews.search.text, true) end,
},
widgets.CycleHotkeyLabel{
view_id='subset',
frame={l=0, t=2, w=28},
key='CUSTOM_SHIFT_F',
label='Show:',
options={
{label='All', value='all', pen=COLOR_GREEN},
{label='Risky visitors', value='risky', pen=COLOR_RED},
{label='Other visitors', value='visitors', pen=COLOR_LIGHTRED},
{label='Residents', value='residents', pen=COLOR_YELLOW},
{label='Citizens', value='citizens', pen=COLOR_CYAN},
{label='Animals', value='animals', pen=COLOR_BLUE},
{label='Deceased or missing', value='deceased', pen=COLOR_MAGENTA},
{label='Others', value='others', pen=COLOR_GRAY},
},
on_change=function() self:do_search(self.subviews.search.text, true) end,
},
},
},
}
self:register_handler('INTERROGATING', justice.interrogation_list,
curry(sortoverlay.flags_vector_search,
{
get_search_key_fn=get_unit_search_key,
get_elem_id_fn=function(unit) return unit.id end,
matches_filters_fn=self:callback('matches_filters'),
},
justice.interrogation_list_flag))
self:register_handler('CONVICTING', justice.conviction_list,
curry(sortoverlay.single_vector_search,
{
get_search_key_fn=get_unit_search_key,
matches_filters_fn=self:callback('matches_filters'),
}))
end
function InterrogationOverlay:reset()
InterrogationOverlay.super.reset(self)
self.subviews.include_interviewed:setOption(true, false)
self.subviews.subset:setOption('all')
end
function InterrogationOverlay:get_key()
if justice.interrogating then
return 'INTERROGATING'
elseif justice.convicting then
return 'CONVICTING'
end
end
local RISKY_PROFESSIONS = utils.invert{
df.profession.THIEF,
df.profession.MASTER_THIEF,
df.profession.CRIMINAL,
}
local function is_risky(unit)
if RISKY_PROFESSIONS[unit.profession] or RISKY_PROFESSIONS[unit.profession2] then
return true
end
if dfhack.units.getReadableName(unit):endswith('necromancer') then return true end
return not dfhack.units.isAlive(unit) -- detect intelligent undead
end
function InterrogationOverlay:matches_filters(unit, flag)
if justice.interrogating then
local include_interviewed = self.subviews.include_interviewed:getOptionValue()
if not include_interviewed and flag == 2 then return false end
end
local subset = self.subviews.subset:getOptionValue()
if subset == 'all' then
return true
elseif dfhack.units.isDead(unit) or not dfhack.units.isActive(unit) then
return subset == 'deceased'
elseif dfhack.units.isInvader(unit) or dfhack.units.isOpposedToLife(unit)
or unit.flags2.visitor_uninvited or unit.flags4.agitated_wilderness_creature
then
return subset == 'others'
elseif dfhack.units.isVisiting(unit) then
local risky = is_risky(unit)
return (subset == 'risky' and risky) or (subset == 'visitors' and not risky)
elseif dfhack.units.isAnimal(unit) then
return subset == 'animals'
elseif dfhack.units.isCitizen(unit) then
return subset == 'citizens'
elseif unit.flags2.roaming_wilderness_population_source then
return subset == 'others'
end
return subset == 'residents'
end
function InterrogationOverlay:render(dc)
local sw = dfhack.screen.getWindowSize()
local info_panel_border = 31 -- from edges of panel to screen edges
local info_panel_width = sw - info_panel_border
local info_panel_center = info_panel_width // 2
local panel_x_offset = (info_panel_center + 5) - self.frame_rect.x1
local frame_w = math.min(panel_x_offset + 37, info_panel_width - 56)
local panel_l = panel_x_offset
local panel_t = is_tabs_in_two_rows() and 4 or 0
if self.frame.w ~= frame_w or
self.subviews.panel.frame.l ~= panel_l or
self.subviews.panel.frame.t ~= panel_t
then
self.frame.w = frame_w
self.subviews.panel.frame.l = panel_l
self.subviews.panel.frame.t = panel_t
self:updateLayout()
end
InterrogationOverlay.super.render(self, dc)
end
return _ENV

@ -0,0 +1,89 @@
local _ENV = mkmodule('plugins.sort.locationselector')
local sortoverlay = require('plugins.sort.sortoverlay')
local widgets = require('gui.widgets')
local location_selector = df.global.game.main_interface.location_selector
-- ----------------------
-- LocationSelectorOverlay
--
LocationSelectorOverlay = defclass(LocationSelectorOverlay, sortoverlay.SortOverlay)
LocationSelectorOverlay.ATTRS{
default_pos={x=48, y=7},
viewscreens='dwarfmode/LocationSelector',
frame={w=26, h=1},
}
local function add_spheres(hf, spheres)
if not hf then return end
for _, sphere in ipairs(hf.info.spheres.spheres) do
spheres[sphere] = true
end
end
local function stringify_spheres(spheres)
local strs = {}
for sphere in pairs(spheres) do
table.insert(strs, df.sphere_type[sphere])
end
return table.concat(strs, ' ')
end
local function get_religion_string(religion_id, religion_type)
if religion_id == -1 then return end
local entity
local spheres = {}
if religion_type == 0 then
entity = df.historical_figure.find(religion_id)
add_spheres(entity, spheres)
elseif religion_type == 1 then
entity = df.historical_entity.find(religion_id)
if entity then
for _, deity in ipairs(entity.relations.deities) do
add_spheres(df.historical_figure.find(deity), spheres)
end
end
end
if not entity then return end
return ('%s %s'):format(dfhack.TranslateName(entity.name, true), stringify_spheres(spheres))
end
local function get_profession_string(profession)
return df.profession[profession]:gsub('_', ' ')
end
function LocationSelectorOverlay:init()
self:addviews{
widgets.BannerPanel{
frame={l=0, t=0, r=0, h=1},
visible=self:callback('get_key'),
subviews={
widgets.EditField{
view_id='search',
frame={l=1, t=0, r=1},
label_text="Search: ",
key='CUSTOM_ALT_S',
on_change=function(text) self:do_search(text) end,
},
},
},
}
self:register_handler('TEMPLE', location_selector.valid_religious_practice_id,
curry(sortoverlay.flags_vector_search, {get_search_key_fn=get_religion_string},
location_selector.valid_religious_practice))
self:register_handler('GUILDHALL', location_selector.valid_craft_guild_type,
curry(sortoverlay.single_vector_search, {get_search_key_fn=get_profession_string}))
end
function LocationSelectorOverlay:get_key()
if location_selector.choosing_temple_religious_practice then
return 'TEMPLE'
elseif location_selector.choosing_craft_guild then
return 'GUILDHALL'
end
end
return _ENV

@ -0,0 +1,179 @@
local _ENV = mkmodule('plugins.sort.sortoverlay')
local overlay = require('plugins.overlay')
local utils = require('utils')
local function copy_to_lua_table(vec)
local tab = {}
for k,v in ipairs(vec) do
tab[k+1] = v
end
return tab
end
-- ----------------------
-- SortOverlay
--
SortOverlay = defclass(SortOverlay, overlay.OverlayWidget)
SortOverlay.ATTRS{
default_enabled=true,
hotspot=true,
overlay_onupdate_max_freq_seconds=0,
-- subclasses expected to provide default_pos, viewscreens (single string), and frame
-- viewscreens should be the top-level scope within which the search widget state is maintained
-- once the player leaves that scope, widget state will be reset
}
function SortOverlay:init()
self.state = {}
self.handlers = {}
-- subclasses expected to provide an EditField widget with view_id='search'
end
function SortOverlay:register_handler(key, vec, search_fn, cleanup_fn)
self.handlers[key] = {
vec=vec,
search_fn=search_fn,
cleanup_fn=cleanup_fn
}
end
-- handles reset and clean up when the player exits the handled scope
function SortOverlay:overlay_onupdate()
if self.overlay_onupdate_max_freq_seconds == 0 and
not dfhack.gui.matchFocusString(self.viewscreens, dfhack.gui.getDFViewscreen(true))
then
for key,data in pairs(self.state) do
local cleanup_fn = safe_index(self.handlers, key, 'cleanup_fn')
if cleanup_fn then
cleanup_fn(data)
end
end
self:reset()
self.overlay_onupdate_max_freq_seconds = 300
end
end
function SortOverlay:reset()
self.state = {}
self.subviews.search:setText('')
self.subviews.search:setFocus(false)
end
-- returns the current context key for dereferencing the handler
-- subclasses must override
function SortOverlay:get_key()
return nil
end
-- handles saving/restoring search strings when the player moves between different contexts
function SortOverlay:onRenderBody(dc)
if next(self.state) then
local key = self:get_key()
if self.state.cur_key ~= key then
self.state.cur_key = key
local prev_text = key and ensure_key(self.state, key).prev_text or ''
self.subviews.search:setText(prev_text)
self:do_search(self.subviews.search.text, true)
end
end
self.overlay_onupdate_max_freq_seconds = 0
SortOverlay.super.onRenderBody(self, dc)
end
function SortOverlay:onInput(keys)
if keys._MOUSE_R and self.subviews.search.focus and self:get_key() then
self.subviews.search:setFocus(false)
return true
end
return SortOverlay.super.onInput(self, keys)
end
function SortOverlay:do_search(text, force_full_search)
if not force_full_search and not next(self.state) and text == '' then return end
-- the EditField state is guaranteed to be consistent with the current
-- context since when clicking to switch tabs, onRenderBody is always called
-- before this text_input callback, even if a key is pressed before the next
-- graphical frame would otherwise be printed. if this ever becomes untrue,
-- then we can add an on_char handler to the EditField that also checks for
-- context transitions.
local key = self:get_key()
if not key then return end
local prev_text = ensure_key(self.state, key).prev_text
-- some screens reset their contents between context switches; regardless,
-- a switch back to the context should results in an incremental search
local incremental = not force_full_search and prev_text and text:startswith(prev_text)
local handler = self.handlers[key]
handler.search_fn(handler.vec, self.state[key], text, incremental)
self.state[key].prev_text = text
end
local function filter_vec(fns, flags_vec, vec, text, erase_fn)
if fns.matches_filters_fn or text ~= '' then
local search_tokens = text:split()
for idx = #vec-1,0,-1 do
local flag = flags_vec and flags_vec[idx] or nil
local search_key = fns.get_search_key_fn(vec[idx], flag)
if (search_key and not utils.search_text(search_key, search_tokens)) or
(fns.matches_filters_fn and not fns.matches_filters_fn(vec[idx], flag))
then
erase_fn(idx)
end
end
end
end
function single_vector_search(fns, vec, data, text, incremental)
vec = utils.getval(vec)
if not data.saved_original then
data.saved_original = copy_to_lua_table(vec)
data.saved_original_size = #vec
elseif not incremental then
vec:assign(data.saved_original)
vec:resize(data.saved_original_size)
end
filter_vec(fns, nil, vec, text, function(idx) vec:erase(idx) end)
data.saved_visible = copy_to_lua_table(vec)
data.saved_visible_size = #vec
if fns.get_sort_fn then
table.sort(data.saved_visible, fns.get_sort_fn())
vec:assign(data.saved_visible)
vec:resize(data.saved_visible_size)
end
end
-- doesn't support sorting since nothing that uses this needs it yet
function flags_vector_search(fns, flags_vec, vec, data, text, incremental)
local get_elem_id_fn = fns.get_elem_id_fn or function(elem) return elem end
flags_vec, vec = utils.getval(flags_vec), utils.getval(vec)
if not data.saved_original then
-- we save the sizes since trailing nils get lost in the lua -> vec assignment
data.saved_original = copy_to_lua_table(vec)
data.saved_original_size = #vec
data.saved_flags = copy_to_lua_table(flags_vec)
data.saved_flags_size = #flags_vec
data.saved_idx_map = {}
for idx,elem in ipairs(data.saved_original) do
data.saved_idx_map[get_elem_id_fn(elem)] = idx -- 1-based idx
end
else -- sync flag changes to saved vector
for idx,elem in ipairs(vec) do -- 0-based idx
data.saved_flags[data.saved_idx_map[get_elem_id_fn(elem)]] = flags_vec[idx]
end
end
if not incremental then
vec:assign(data.saved_original)
vec:resize(data.saved_original_size)
flags_vec:assign(data.saved_flags)
flags_vec:resize(data.saved_flags_size)
end
filter_vec(fns, flags_vec, vec, text, function(idx)
vec:erase(idx)
flags_vec:erase(idx)
end)
end
return _ENV

@ -0,0 +1,101 @@
local _ENV = mkmodule('plugins.sort.unitselector')
local sortoverlay = require('plugins.sort.sortoverlay')
local widgets = require('gui.widgets')
local unit_selector = df.global.game.main_interface.unit_selector
-- ----------------------
-- UnitSelectorOverlay
--
UnitSelectorOverlay = defclass(UnitSelectorOverlay, sortoverlay.SortOverlay)
UnitSelectorOverlay.ATTRS{
default_pos={x=62, y=6},
viewscreens='dwarfmode/UnitSelector',
frame={w=31, h=1},
handled_screens=DEFAULT_NIL,
}
local function get_unit_id_search_key(unit_id)
local unit = df.unit.find(unit_id)
if not unit then return end
return ('%s %s %s'):format(
dfhack.units.getReadableName(unit), -- last name is in english
dfhack.units.getProfessionName(unit),
dfhack.TranslateName(unit.name, false, true)) -- get untranslated last name
end
function UnitSelectorOverlay:init()
self:addviews{
widgets.BannerPanel{
frame={l=0, t=0, r=0, h=1},
visible=self:callback('get_key'),
subviews={
widgets.EditField{
view_id='search',
frame={l=1, t=0, r=1},
label_text="Search: ",
key='CUSTOM_ALT_S',
on_change=function(text) self:do_search(text) end,
},
},
},
}
-- pen, pit, chain, and cage assignment are handled by dedicated screens
-- squad fill position screen has a specialized overlay
-- we *could* add search functionality to vanilla screens for pit and cage,
-- but then we'd have to handle the itemid vector
self.handled_screens = self.handled_screens or {
ZONE_BEDROOM_ASSIGNMENT='already',
ZONE_OFFICE_ASSIGNMENT='already',
ZONE_DINING_HALL_ASSIGNMENT='already',
ZONE_TOMB_ASSIGNMENT='already',
OCCUPATION_ASSIGNMENT='selected',
BURROW_ASSIGNMENT='selected',
SQUAD_KILL_ORDER='selected',
}
for name,flags_vec in pairs(self.handled_screens) do
self:register_handler(name, unit_selector.unid,
curry(sortoverlay.flags_vector_search, {get_search_key_fn=get_unit_id_search_key},
unit_selector[flags_vec]))
end
end
function UnitSelectorOverlay:get_key()
local key = df.unit_selector_context_type[unit_selector.context]
if self.handled_screens[key] then
return key
end
end
function UnitSelectorOverlay:onRenderBody(dc)
UnitSelectorOverlay.super.onRenderBody(self, dc)
if self.refresh_search then
self.refresh_search = nil
self:do_search(self.subviews.search.text)
end
end
function UnitSelectorOverlay:onInput(keys)
if keys._MOUSE_L then
self.refresh_search = true
end
return UnitSelectorOverlay.super.onInput(self, keys)
end
-- ----------------------
-- WorkerAssignmentOverlay
--
WorkerAssignmentOverlay = defclass(WorkerAssignmentOverlay, UnitSelectorOverlay)
WorkerAssignmentOverlay.ATTRS{
default_pos={x=6, y=6},
viewscreens='dwarfmode/UnitSelector',
frame={w=31, h=1},
handled_screens={WORKER_ASSIGNMENT='selected'},
}
return _ENV

@ -0,0 +1,88 @@
local _ENV = mkmodule('plugins.sort.world')
local sortoverlay = require('plugins.sort.sortoverlay')
local widgets = require('gui.widgets')
-- ----------------------
-- WorldOverlay
--
WorldOverlay = defclass(WorldOverlay, sortoverlay.SortOverlay)
WorldOverlay.ATTRS{
default_pos={x=-18, y=2},
viewscreens='world/ARTIFACTS',
frame={w=40, h=1},
}
local function get_world_artifact_search_key(artifact, rumor)
local search_key = ('%s %s'):format(dfhack.TranslateName(artifact.name, true),
dfhack.items.getDescription(artifact.item, 0))
if rumor then
local hf = df.historical_figure.find(rumor.hfid)
if hf then
search_key = ('%s %s %s'):format(search_key,
dfhack.TranslateName(hf.name),
dfhack.TranslateName(hf.name, true))
end
local ws = df.world_site.find(rumor.stid)
if ws then
search_key = ('%s %s'):format(search_key,
dfhack.TranslateName(ws.name, true))
end
else
local hf = df.historical_figure.find(artifact.holder_hf)
if hf then
local unit = df.unit.find(hf.unit_id)
if unit then
search_key = ('%s %s'):format(search_key,
dfhack.units.getReadableName(unit))
end
end
end
return search_key
end
local function cleanup_artifact_vectors(data)
local vs_world = dfhack.gui.getDFViewscreen(true)
vs_world.artifact:assign(data.saved_original)
vs_world.artifact:resize(data.saved_original_size)
vs_world.artifact_arl:assign(data.saved_flags)
vs_world.artifact_arl:resize(data.saved_flags_size)
end
function WorldOverlay:init()
self:addviews{
widgets.BannerPanel{
frame={l=0, t=0, r=0, h=1},
visible=self:callback('get_key'),
subviews={
widgets.EditField{
view_id='search',
frame={l=1, t=0, r=1},
label_text="Search: ",
key='CUSTOM_ALT_S',
on_change=function(text) self:do_search(text) end,
},
},
},
}
self:register_handler('ARTIFACTS',
function() return dfhack.gui.getDFViewscreen(true).artifact end,
curry(sortoverlay.flags_vector_search,
{
get_search_key_fn=get_world_artifact_search_key,
get_elem_id_fn=function(artifact_record) return artifact_record.id end,
},
function() return dfhack.gui.getDFViewscreen(true).artifact_arl end),
cleanup_artifact_vectors)
end
function WorldOverlay:get_key()
local scr = dfhack.gui.getDFViewscreen(true)
if scr.view_mode == df.world_view_mode_type.ARTIFACTS then
return 'ARTIFACTS'
end
end
return _ENV

@ -4,6 +4,7 @@ local argparse = require('argparse')
local gui = require('gui')
local logistics = require('plugins.logistics')
local overlay = require('plugins.overlay')
local textures = require('gui.textures')
local widgets = require('gui.widgets')
local STOCKPILES_DIR = 'dfhack-config/stockpiles'
@ -262,6 +263,45 @@ local function do_export()
export_view = export_view and export_view:raise() or StockpilesExportScreen{}:show()
end
--------------------
-- ConfigModal
--------------------
ConfigModal = defclass(ConfigModal, gui.ZScreenModal)
ConfigModal.ATTRS{
focus_path='stockpiles_config',
on_close=DEFAULT_NIL,
}
function ConfigModal:init()
local sp = dfhack.gui.getSelectedStockpile(true)
local cur_setting = false
if sp then
local config = logistics.logistics_getStockpileConfigs(sp.stockpile_number)[1]
cur_setting = config.melt_masterworks == 1
end
self:addviews{
widgets.Window{
frame={w=35, h=10},
frame_title='Advanced logistics settings',
subviews={
widgets.ToggleHotkeyLabel{
view_id='melt_masterworks',
frame={l=0, t=0},
key='CUSTOM_M',
label='Melt masterworks',
initial_option=cur_setting,
},
},
},
}
end
function ConfigModal:onDismiss()
self.on_close{melt_masterworks=self.subviews.melt_masterworks:getOptionValue()}
end
--------------------
-- MinimizeButton
--------------------
@ -368,9 +408,7 @@ function StockpilesOverlay:init()
view_id='main',
frame_style=gui.MEDIUM_FRAME,
frame_background=gui.CLEAR_PEN,
visible=function()
return not self.minimized
end,
visible=function() return not self.minimized end,
subviews={
-- widgets.HotkeyLabel{
-- frame={t=0, l=0},
@ -439,14 +477,40 @@ function StockpilesOverlay:init()
},
}
local button_pen_left = dfhack.pen.parse{fg=COLOR_CYAN,
tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')}
local button_pen_right = dfhack.pen.parse{fg=COLOR_CYAN,
tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')}
local help_pen_center = dfhack.pen.parse{
tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')}
local configure_pen_center = dfhack.pen.parse{
tile=curry(textures.tp_control_panel, 10) or nil, ch=15} -- gear/masterwork symbol
self:addviews{
main_panel, MinimizeButton{
main_panel,
MinimizeButton{
frame={t=0, r=9},
get_minimized_fn=function()
return self.minimized
end,
get_minimized_fn=function() return self.minimized end,
on_click=self:callback('toggleMinimized'),
},
widgets.Label{
frame={t=0, r=5, w=3},
text={
{tile=button_pen_left},
{tile=configure_pen_center},
{tile=button_pen_right},
},
on_click=function() ConfigModal{on_close=self:callback('on_custom_config')}:show() end,
},
widgets.Label{
frame={t=0, r=1, w=3},
text={
{tile=button_pen_left},
{tile=help_pen_center},
{tile=button_pen_right},
},
on_click=function() dfhack.run_command('gui/launcher', 'stockpiles ') end,
},
}
end
@ -475,7 +539,16 @@ function StockpilesOverlay:toggleLogisticsFeature(feature)
-- logical xor
logistics.logistics_setStockpileConfig(config.stockpile_number,
(feature == 'melt') ~= (config.melt == 1), (feature == 'trade') ~= (config.trade == 1),
(feature == 'dump') ~= (config.dump == 1), (feature == 'train') ~= (config.train == 1))
(feature == 'dump') ~= (config.dump == 1), (feature == 'train') ~= (config.train == 1),
config.melt_masterworks == 1)
end
function StockpilesOverlay:on_custom_config(custom)
local sp = dfhack.gui.getSelectedStockpile(true)
if not sp then return end
local config = logistics.logistics_getStockpileConfigs(sp.stockpile_number)[1]
logistics.logistics_setStockpileConfig(config.stockpile_number,
config.melt == 1, config.trade == 1, config.dump == 1, config.train == 1, custom.melt_masterworks)
end
function StockpilesOverlay:toggleMinimized()

@ -961,9 +961,13 @@ CageChainOverlay.ATTRS{
local function is_valid_building()
local bld = dfhack.gui.getSelectedBuilding(true)
return bld and bld:getBuildStage() == bld:getMaxBuildStage() and
(bld:getType() == df.building_type.Cage or
bld:getType() == df.building_type.Chain)
if not bld or bld:getBuildStage() ~= bld:getMaxBuildStage() then return false end
local bt = bld:getType()
if bt ~= df.building_type.Cage and bt ~= df.building_type.Chain then return false end
for _,zone in ipairs(bld.relations) do
if zone.type == df.civzone_type.Dungeon then return false end
end
return true
end
local function is_cage_selected()

@ -1036,11 +1036,15 @@ static command_result orders_sort_command(color_ostream & out)
static command_result orders_recheck_command(color_ostream & out)
{
for (auto it : world->manager_orders)
{
size_t count = 0;
for (auto it : world->manager_orders) {
if (it->item_conditions.size() && it->status.bits.active) {
++count;
it->status.bits.active = false;
it->status.bits.validated = false;
}
}
out << "Re-checking conditions for " << count << " manager orders." << std::endl;
return CR_OK;
}

@ -0,0 +1,287 @@
#include "Debug.h"
#include "PluginManager.h"
#include "MiscUtils.h"
#include <string>
#include <vector>
#include <unordered_map>
#include <algorithm>
#include <utility>
#include <cstdint>
#include "modules/Units.h"
#include "modules/Buildings.h"
#include "modules/Persistence.h"
#include "modules/EventManager.h"
#include "modules/World.h"
#include "modules/Translation.h"
#include "df/world.h"
#include "df/unit.h"
#include "df/building.h"
#include "df/building_civzonest.h"
using namespace DFHack;
using namespace df::enums;
// <BOILERPLATE>
DFHACK_PLUGIN("preserve-tombs");
DFHACK_PLUGIN_IS_ENABLED(is_enabled);
REQUIRE_GLOBAL(world);
static const std::string CONFIG_KEY = std::string(plugin_name) + "/config";
static PersistentDataItem config;
static int32_t cycle_timestamp;
static constexpr int32_t cycle_freq = 100;
enum ConfigValues {
CONFIG_IS_ENABLED = 0,
};
static std::unordered_map<int32_t, int32_t> tomb_assignments;
namespace DFHack {
DBG_DECLARE(preservetombs, config, DebugCategory::LINFO);
DBG_DECLARE(preservetombs, cycle, DebugCategory::LINFO);
DBG_DECLARE(preservetombs, event, DebugCategory::LINFO);
}
static int get_config_val(PersistentDataItem &c, int index) {
if (!c.isValid())
return -1;
return c.ival(index);
}
static bool get_config_bool(PersistentDataItem &c, int index) {
return get_config_val(c, index) == 1;
}
static void set_config_val(PersistentDataItem &c, int index, int value) {
if (c.isValid())
c.ival(index) = value;
}
static void set_config_bool(PersistentDataItem &c, int index, bool value) {
set_config_val(c, index, value ? 1 : 0);
}
static bool assign_to_tomb(int32_t unit_id, int32_t building_id);
static void update_tomb_assignments(color_ostream& out);
void onUnitDeath(color_ostream& out, void* ptr);
static command_result do_command(color_ostream& out, std::vector<std::string>& params);
DFhackCExport command_result plugin_init(color_ostream &out, std::vector <PluginCommand> &commands) {
commands.push_back(PluginCommand(
plugin_name,
"Preserve tomb assignments when assigned units die.",
do_command));
return CR_OK;
}
static command_result do_command(color_ostream& out, std::vector<std::string>& params) {
if (!Core::getInstance().isWorldLoaded()) {
out.printerr("Cannot use %s without a loaded world.\n", plugin_name);
return CR_FAILURE;
}
if (params.size() == 0 || params[0] == "status") {
out.print("%s is currently %s\n", plugin_name, is_enabled ? "enabled" : "disabled");
if (is_enabled) {
out.print("tracked tomb assignments:\n");
std::for_each(tomb_assignments.begin(), tomb_assignments.end(), [&out](const auto& p){
auto& [unit_id, building_id] = p;
auto* unit = df::unit::find(unit_id);
std::string name = unit ? Translation::TranslateName(&unit->name) : "UNKNOWN UNIT" ;
out.print("%s (id %d) -> building %d\n", name.c_str(), unit_id, building_id);
});
}
return CR_OK;
}
if (params[0] == "now") {
if (!is_enabled) {
out.printerr("Cannot update %s when not enabled", plugin_name);
return CR_FAILURE;
}
CoreSuspender suspend;
update_tomb_assignments(out);
out.print("Updated tomb assignments\n");
return CR_OK;
}
return CR_WRONG_USAGE;
}
// event listener
EventManager::EventHandler assign_tomb_handler(onUnitDeath, 0);
DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) {
if (!Core::getInstance().isWorldLoaded()) {
out.printerr("Cannot enable %s without a loaded world.\n", plugin_name);
return CR_FAILURE;
}
if (enable != is_enabled) {
is_enabled = enable;
DEBUG(config,out).print("%s from the API; persisting\n",
is_enabled ? "enabled" : "disabled");
set_config_bool(config, CONFIG_IS_ENABLED, is_enabled);
if (enable) {
EventManager::registerListener(EventManager::EventType::UNIT_DEATH, assign_tomb_handler, plugin_self);
update_tomb_assignments(out);
}
else {
tomb_assignments.clear();
EventManager::unregisterAll(plugin_self);
}
} else {
DEBUG(config,out).print("%s from the API, but already %s; no action\n",
is_enabled ? "enabled" : "disabled",
is_enabled ? "enabled" : "disabled");
}
return CR_OK;
}
DFhackCExport command_result plugin_shutdown (color_ostream &out) {
DEBUG(config,out).print("shutting down %s\n", plugin_name);
// PluginManager handles unregistering our handler from EventManager,
// so we don't have to do that here
return CR_OK;
}
DFhackCExport command_result plugin_load_data (color_ostream &out) {
cycle_timestamp = 0;
config = World::GetPersistentData(CONFIG_KEY);
if (!config.isValid()) {
DEBUG(config,out).print("no config found in this save; initializing\n");
config = World::AddPersistentData(CONFIG_KEY);
set_config_bool(config, CONFIG_IS_ENABLED, is_enabled);
}
is_enabled = get_config_bool(config, CONFIG_IS_ENABLED);
DEBUG(config,out).print("loading persisted enabled state: %s\n",
is_enabled ? "true" : "false");
return CR_OK;
}
DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) {
if (event == DFHack::SC_WORLD_UNLOADED) {
tomb_assignments.clear();
if (is_enabled) {
DEBUG(config,out).print("world unloaded; disabling %s\n",
plugin_name);
is_enabled = false;
}
EventManager::unregisterAll(plugin_self);
}
return CR_OK;
}
DFhackCExport command_result plugin_onupdate(color_ostream &out) {
if (is_enabled && world->frame_counter - cycle_timestamp >= cycle_freq)
update_tomb_assignments(out);
return CR_OK;
}
// </BOILERPLATE>
// On unit death - check if we assigned them a tomb
//
//
void onUnitDeath(color_ostream& out, void* ptr) {
// input is void* that contains the unit id
int32_t unit_id = reinterpret_cast<std::intptr_t>(ptr);
// check if unit was assigned a tomb in life
auto it = tomb_assignments.find(unit_id);
if (it == tomb_assignments.end()) return;
// assign that unit to their previously assigned tomb in life
int32_t building_id = it->second;
if (!assign_to_tomb(unit_id, building_id)) {
WARN(event, out).print("Unit %d died - but failed to assign them back to their tomb %d\n", unit_id, building_id);
return;
}
// success, print status update and remove assignment from our memo-list
INFO(event, out).print("Unit %d died - assigning them back to their tomb\n", unit_id);
tomb_assignments.erase(it);
}
// Update tomb assignments
//
//
static void update_tomb_assignments(color_ostream &out) {
cycle_timestamp = world->frame_counter;
// check tomb civzones for assigned units
for (auto* bld : world->buildings.other.ZONE_TOMB) {
auto* tomb = virtual_cast<df::building_civzonest>(bld);
if (!tomb || !tomb->flags.bits.exists) continue;
if (!tomb->assigned_unit) continue;
if (Units::isDead(tomb->assigned_unit)) continue; // we only care about living units
auto it = tomb_assignments.find(tomb->assigned_unit_id);
if (it == tomb_assignments.end()) {
tomb_assignments.emplace(tomb->assigned_unit_id, tomb->id);
DEBUG(cycle, out).print("%s new tomb assignment, unit %d to tomb %d\n",
plugin_name, tomb->assigned_unit_id, tomb->id);
}
else if (it->second != tomb->id) {
DEBUG(cycle, out).print("%s tomb assignment to %d changed, (old: %d, new: %d)\n",
plugin_name, tomb->assigned_unit_id, it->second, tomb->id);
it->second = tomb->id;
}
}
// now check our civzones for unassignment / deleted zone
std::erase_if(tomb_assignments,[&](const auto& p){
auto &[unit_id, building_id] = p;
const int tomb_idx = binsearch_index(world->buildings.other.ZONE_TOMB, building_id);
if (tomb_idx == -1) {
DEBUG(cycle, out).print("%s tomb missing: %d - removing\n", plugin_name, building_id);
return true;
}
const auto tomb = virtual_cast<df::building_civzonest>(world->buildings.other.ZONE_TOMB[tomb_idx]);
if (!tomb || !tomb->flags.bits.exists) {
DEBUG(cycle, out).print("%s tomb missing: %d - removing\n", plugin_name, building_id);
return true;
}
if (tomb->assigned_unit_id != unit_id) {
DEBUG(cycle, out).print("%s unit %d unassigned from tomb %d - removing\n", plugin_name, unit_id, building_id);
return true;
}
return false;
});
}
// ASSIGN UNIT TO TOMB
//
//
static bool assign_to_tomb(int32_t unit_id, int32_t building_id) {
df::unit* unit = df::unit::find(unit_id);
if (!unit || !Units::isDead(unit)) return false;
const int tomb_idx = binsearch_index(world->buildings.other.ZONE_TOMB, building_id);
if (tomb_idx == -1) return false;
df::building_civzonest* tomb = virtual_cast<df::building_civzonest>(world->buildings.other.ZONE_TOMB[tomb_idx]);
if (!tomb || tomb->assigned_unit) return false;
Buildings::setOwner(tomb, unit);
return true;
}

@ -1217,7 +1217,6 @@ command_result df_strangemood (color_ostream &out, vector <string> & parameters)
ref->setID(unit->id);
job->general_refs.push_back(ref);
unit->job.current_job = job;
job->wait_timer = 0;
// Generate the artifact's name
if (type == mood_type::Fell || type == mood_type::Macabre)

@ -1 +1 @@
Subproject commit 28bcd6e313ea6f87ffd805c8cf40360da5f21509
Subproject commit 6166bb73dc9ae19a51780ecf026d92f2fffd277f