diff --git a/CMakeLists.txt b/CMakeLists.txt index c09fc6b71..5ec38b28a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/docs/changelog.txt b/docs/changelog.txt index 81bf3b1e6..0248707d9 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -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 diff --git a/docs/dev/Lua API.rst b/docs/dev/Lua API.rst index 49f0b3633..fd56a11ed 100644 --- a/docs/dev/Lua API.rst +++ b/docs/dev/Lua API.rst @@ -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 `, 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 ------------ diff --git a/docs/dev/overlay-dev-guide.rst b/docs/dev/overlay-dev-guide.rst index 54e200700..b5b6cf0e3 100644 --- a/docs/dev/overlay-dev-guide.rst +++ b/docs/dev/overlay-dev-guide.rst @@ -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 *********************************************** diff --git a/docs/plugins/dig.rst b/docs/plugins/dig.rst index 8d5c99536..2055a7572 100644 --- a/docs/plugins/dig.rst +++ b/docs/plugins/dig.rst @@ -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 diff --git a/docs/plugins/logistics.rst b/docs/plugins/logistics.rst index a65571484..d9aeee067 100644 --- a/docs/plugins/logistics.rst +++ b/docs/plugins/logistics.rst @@ -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. diff --git a/docs/plugins/orders.rst b/docs/plugins/orders.rst index acb25fe99..32708b4c8 100644 --- a/docs/plugins/orders.rst +++ b/docs/plugins/orders.rst @@ -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: diff --git a/docs/plugins/preserve-tombs.rst b/docs/plugins/preserve-tombs.rst new file mode 100644 index 000000000..2f01162c6 --- /dev/null +++ b/docs/plugins/preserve-tombs.rst @@ -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. diff --git a/docs/plugins/sort.rst b/docs/plugins/sort.rst index 31ab2d3fc..4350a4cb0 100644 --- a/docs/plugins/sort.rst +++ b/docs/plugins/sort.rst @@ -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). diff --git a/docs/plugins/stockpiles.rst b/docs/plugins/stockpiles.rst index 249a27d13..7d4ec050b 100644 --- a/docs/plugins/stockpiles.rst +++ b/docs/plugins/stockpiles.rst @@ -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: diff --git a/library/lua/dfhack.lua b/library/lua/dfhack.lua index 8ea5e9dac..059c008d2 100644 --- a/library/lua/dfhack.lua +++ b/library/lua/dfhack.lua @@ -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 diff --git a/library/lua/gui.lua b/library/lua/gui.lua index bb29124a5..4ee3eba4a 100644 --- a/library/lua/gui.lua +++ b/library/lua/gui.lua @@ -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) diff --git a/library/lua/gui/widgets.lua b/library/lua/gui/widgets.lua index 05d237f35..f12ecff5d 100644 --- a/library/lua/gui/widgets.lua +++ b/library/lua/gui/widgets.lua @@ -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 diff --git a/library/lua/utils.lua b/library/lua/utils.lua index 3883439f1..fb41835da 100644 --- a/library/lua/utils.lua +++ b/library/lua/utils.lua @@ -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( diff --git a/library/modules/Gui.cpp b/library/modules/Gui.cpp index 58763262f..500676983 100644 --- a/library/modules/Gui.cpp +++ b/library/modules/Gui.cpp @@ -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,7 +246,14 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode) switch(game->main_interface.info.current_mode) { case df::enums::info_interface_mode_type::CREATURES: - newFocusString += '/' + enum_item_key(game->main_interface.info.creatures.current_mode); + 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: newFocusString += '/' + enum_item_key(game->main_interface.info.buildings.mode); @@ -252,7 +265,12 @@ 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: - newFocusString += '/' + enum_item_key(game->main_interface.info.justice.current_mode); + 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: if (game->main_interface.info.work_orders.conditions.open) @@ -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) { diff --git a/library/modules/Units.cpp b/library/modules/Units.cpp index ebfd13b5e..a9d361706 100644 --- a/library/modules/Units.cpp +++ b/library/modules/Units.cpp @@ -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; diff --git a/library/xml b/library/xml index aeab463a0..a598bc677 160000 --- a/library/xml +++ b/library/xml @@ -1 +1 @@ -Subproject commit aeab463a0d35ac9ff896db840735cabfa12df712 +Subproject commit a598bc6770199e9b965e00d0eade3f8400c4be9e diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index c730a7655..be690edf1 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -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) diff --git a/plugins/cleanowned.cpp b/plugins/cleanowned.cpp index 3fece8bfe..c13f7826c 100644 --- a/plugins/cleanowned.cpp +++ b/plugins/cleanowned.cpp @@ -147,14 +147,14 @@ command_result df_cleanowned (color_ostream &out, vector & 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) { diff --git a/plugins/logistics.cpp b/plugins/logistics.cpp index 86f65d351..d4d2f0afe 100644 --- a/plugins/logistics.cpp +++ b/plugins/logistics.cpp @@ -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 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 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); diff --git a/plugins/lua/buildingplan/itemselection.lua b/plugins/lua/buildingplan/itemselection.lua index 9dfd0cc69..4b8ee73d8 100644 --- a/plugins/lua/buildingplan/itemselection.lua +++ b/plugins/lua/buildingplan/itemselection.lua @@ -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'), diff --git a/plugins/lua/dwarfvet.lua b/plugins/lua/dwarfvet.lua index a976b91c8..2bda976b7 100644 --- a/plugins/lua/dwarfvet.lua +++ b/plugins/lua/dwarfvet.lua @@ -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) diff --git a/plugins/lua/logistics.lua b/plugins/lua/logistics.lua index 0231ce593..2f260cc59 100644 --- a/plugins/lua/logistics.lua +++ b/plugins/lua/logistics.lua @@ -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 diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index 102580fab..0eeb327fc 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -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, }, } diff --git a/plugins/lua/overlay.lua b/plugins/lua/overlay.lua index 9751561a8..d8b3c4f81 100644 --- a/plugins/lua/overlay.lua +++ b/plugins/lua/overlay.lua @@ -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 diff --git a/plugins/lua/sort.lua b/plugins/lua/sort.lua index 6d8c8a298..9be0848c5 100644 --- a/plugins/lua/sort.lua +++ b/plugins/lua/sort.lua @@ -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) diff --git a/plugins/lua/sort/info.lua b/plugins/lua/sort/info.lua new file mode 100644 index 000000000..1f15d643b --- /dev/null +++ b/plugins/lua/sort/info.lua @@ -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 diff --git a/plugins/lua/sort/locationselector.lua b/plugins/lua/sort/locationselector.lua new file mode 100644 index 000000000..d91c536ac --- /dev/null +++ b/plugins/lua/sort/locationselector.lua @@ -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 diff --git a/plugins/lua/sort/sortoverlay.lua b/plugins/lua/sort/sortoverlay.lua new file mode 100644 index 000000000..18b4877b3 --- /dev/null +++ b/plugins/lua/sort/sortoverlay.lua @@ -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 diff --git a/plugins/lua/sort/unitselector.lua b/plugins/lua/sort/unitselector.lua new file mode 100644 index 000000000..a904d2f29 --- /dev/null +++ b/plugins/lua/sort/unitselector.lua @@ -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 diff --git a/plugins/lua/sort/world.lua b/plugins/lua/sort/world.lua new file mode 100644 index 000000000..a4cf6c0e5 --- /dev/null +++ b/plugins/lua/sort/world.lua @@ -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 diff --git a/plugins/lua/stockpiles.lua b/plugins/lua/stockpiles.lua index 4707c97ad..f25205a9c 100644 --- a/plugins/lua/stockpiles.lua +++ b/plugins/lua/stockpiles.lua @@ -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() diff --git a/plugins/lua/zone.lua b/plugins/lua/zone.lua index 13182cef1..eb984093b 100644 --- a/plugins/lua/zone.lua +++ b/plugins/lua/zone.lua @@ -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() diff --git a/plugins/orders.cpp b/plugins/orders.cpp index a84f14172..4b4c0ed4f 100644 --- a/plugins/orders.cpp +++ b/plugins/orders.cpp @@ -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) - { - it->status.bits.active = false; - it->status.bits.validated = false; + 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; } diff --git a/plugins/preserve-tombs.cpp b/plugins/preserve-tombs.cpp new file mode 100644 index 000000000..be560e1ce --- /dev/null +++ b/plugins/preserve-tombs.cpp @@ -0,0 +1,287 @@ +#include "Debug.h" +#include "PluginManager.h" +#include "MiscUtils.h" + +#include +#include +#include +#include +#include +#include + +#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; + + +// +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 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& params); + +DFhackCExport command_result plugin_init(color_ostream &out, std::vector &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& 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; +} +// + + + + +// 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(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(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(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(world->buildings.other.ZONE_TOMB[tomb_idx]); + if (!tomb || tomb->assigned_unit) return false; + + Buildings::setOwner(tomb, unit); + return true; +} diff --git a/plugins/strangemood.cpp b/plugins/strangemood.cpp index b300eb795..88d1df071 100644 --- a/plugins/strangemood.cpp +++ b/plugins/strangemood.cpp @@ -1217,7 +1217,6 @@ command_result df_strangemood (color_ostream &out, vector & 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) diff --git a/scripts b/scripts index 28bcd6e31..6166bb73d 160000 --- a/scripts +++ b/scripts @@ -1 +1 @@ -Subproject commit 28bcd6e313ea6f87ffd805c8cf40360da5f21509 +Subproject commit 6166bb73dc9ae19a51780ecf026d92f2fffd277f